Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 117 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# PHP-Jackson

[![PHP Tests](https://github.com/tcds-io/php-jackson/actions/workflows/tests.yml/badge.svg)](https://github.com/tcds-io/php-jackson/actions/workflows/tests.yml)

###### A lightweight, flexible object serializer for PHP, inspired by [Jackson](https://github.com/FasterXML/jackson).

It provides strong typing, JSON ↔ object mapping, generics support, array/object shapes, custom type mappers, and detailed error tracing.
Expand All @@ -21,9 +23,13 @@ It provides strong typing, JSON ↔ object mapping, generics support, array/obje
- [Map Example](#map-example)
- [Array Shape Example](#array-shape-example)
- [Object Shape Example](#object-shape-example)
- [Renaming JSON keys with `#[JsonProperty]`](#-renaming-json-keys-with-jsonproperty)
- [Custom Type Mappers](#-custom-type-mappers)
- [Using Custom Mappers with External Context](#using-custom-mappers-with-external-context)
- [Pinning a Mapper on the Class with `#[JsonMapper]`](#pinning-a-mapper-on-the-class-with-jsonmapper)
- [Date Handling](#-date-handling)
- [Error Handling](#-error-handling)
- [Development](#-development)
- [Summary](#-summary)

---
Expand Down Expand Up @@ -53,9 +59,9 @@ PHP Jackson offers first-class integrations for popular PHP frameworks and tools
Each integration extends the core mapper with framework-specific features for a smoother development experience.

Official Plugins:
- <a href="https://github.com/tcds-io/php-jackson-laravel" target="_blank" rel="noopener noreferrer">Laravel <small>↗</small></a>
- <a href="https://github.com/tcds-io/php-jackson-symfony" target="_blank" rel="noopener noreferrer">Symfony <small>↗</small></a>
- <a href="https://github.com/tcds-io/php-jackson-guzzle" target="_blank" rel="noopener noreferrer">Guzzle <small>↗</small></a>
- <a href="https://github.com/tcds-io/php-jackson-laravel" target="_blank" rel="noopener noreferrer">Laravel <small>↗</small></a> — controller injection, JSON responses, request error handling, and Eloquent casts
- <a href="https://github.com/tcds-io/php-jackson-symfony" target="_blank" rel="noopener noreferrer">Symfony <small>↗</small></a> — controller argument resolvers, JSON responses, and configurable request error handling
- <a href="https://github.com/tcds-io/php-jackson-guzzle" target="_blank" rel="noopener noreferrer">Guzzle <small>↗</small></a> — typed HTTP client with request DTO mapping and async response parsing

## 🔧 Basic Usage

Expand Down Expand Up @@ -170,6 +176,8 @@ $json = $mapper->writeValue($object);

## 📚 Generic Types (`list<T>`, `map<K,V>`, shapes)

The `generic()` and `shape()` helper functions are loaded by Composer through `php-better-generics`.

### List example

```php
Expand Down Expand Up @@ -237,16 +245,55 @@ $object->position instanceof LatLng

---

## 🏷️ Renaming JSON keys with `#[JsonProperty]`

PHP-Jackson maps JSON keys to PHP names 1:1 by default. When the wire format
uses a different naming convention (snake_case, kebab-case, etc.), pin the
JSON key on the constructor parameter (or property) with `#[JsonProperty]`:

```php
use Tcds\Io\Jackson\Node\JsonProperty;

readonly class User
{
public function __construct(
#[JsonProperty('first_name')] public string $firstName,
#[JsonProperty('last_name')] public string $lastName,
public int $age,
) {}
}
```

The attribute is honored on **both** directions:

```php
$mapper = new JsonObjectMapper();

$user = $mapper->readValue(User::class, '{"first_name":"Arthur","last_name":"Dent","age":42}');
// User { firstName: "Arthur", lastName: "Dent", age: 42 }

$mapper->writeValue($user);
// {"first_name":"Arthur","last_name":"Dent","age":42}
```

Error traces and the `expected` payload on `UnableToParseValue` use the wire
key — the one users will recognize from the JSON they are sending — not the
PHP identifier.

---

## 🧩 Custom Type Mappers

Custom mappers are useful when object construction depends on complex logic or external data:

```php
use Tcds\Io\Jackson\ArrayObjectMapper;

$mapper = new ArrayObjectMapper(
typeMappers: [
LatLng::class => [
'reader' => fn (string $value) => new LatLng(...explode(',', $value)),
'writer' => fn (LatLng $value) => sprintf("%s, %s", $value->lat, $value->lng),
'reader' => fn(string $data) => new LatLng(...explode(',', $data)),
'writer' => fn(LatLng $data) => sprintf("%s, %s", $data->lat, $data->lng),
]
]
);
Expand Down Expand Up @@ -275,20 +322,68 @@ and serialize back into:
### Using Custom Mappers with External Context

```php
use Tcds\Io\Jackson\ArrayObjectMapper;

$mapper = new ArrayObjectMapper(
typeMappers: [
User::class => [
'reader' => fn () => Auth::user(),
'writer' => fn (User $user) => [
'id' => $user->id,
'name' => $user->name,
'reader' => fn() => Auth::user(),
'writer' => fn(User $data) => [
'id' => $data->id,
'name' => $data->name,
// 'email' intentionally omitted
],
]
]
);
```

Mapper closures can receive any of the named arguments used internally by PHP-Jackson:

```php
fn(mixed $data, string $type, ObjectMapper $mapper, array $path): mixed
```

Use only the parameters you need; `ReflectionFunction::call()` binds them by name.

---

### Pinning a Mapper on the Class with `#[JsonMapper]`

If a class always wants the same custom (de)serialization, declare it once on
the class itself instead of registering it on every mapper instance:

```php
use Tcds\Io\Jackson\Node\JsonMapper;

#[JsonMapper(reader: MoneyReader::class, writer: MoneyWriter::class)]
readonly class Money
{
public function __construct(public int $cents) {}
}
```

The `reader` and `writer` accept any of:

- a class string of an implementation of `Reader` / `Writer` (instance is
built with a no-arg constructor),
- a class string of `StaticReader` / `StaticWriter` (no instance — the static
`read` / `write` is called),
- a class string of any class with a matching `__invoke` (treated as a
`MapperClosure`),
- an instance of `Reader` / `Writer` (PHP 8.1 `new` in attribute initializers),
- a `Closure` matching `MapperClosure`, when constructing `JsonMapper`
programmatically (PHP attribute literals can't carry closures).

**Resolution order on every read/write:**

1. `#[JsonMapper]` attribute on the target class — declaration site, wins
2. `typeMappers` constructor argument
3. default reader/writer

That is, an explicit class-level mapper cannot be silently overridden by
mapper-instance config — the class itself is the canonical source.

---

## 🕒 Date Handling
Expand Down Expand Up @@ -335,6 +430,16 @@ This makes debugging extremely easy.

---

## 🔧 Development

```bash
composer install
composer tests # runs cs:check + phpstan + phpunit
composer cs:fix # auto-fix code style
```

---

## ✅ Summary

You can:
Expand All @@ -344,5 +449,7 @@ You can:
- Merge missing fields using `readValueWith`
- Write objects → JSON/arrays via `writeValue`
- Use generics (`list<T>`, `map<K,V>`, shapes)
- Register custom mappers for any class
- Rename wire keys per field with `#[JsonProperty('snake_case')]`
- Register custom mappers for any class via `typeMappers` or pin them on the
class itself with `#[JsonMapper(reader: …, writer: …)]`
- Rely on strong error tracing with full path information
10 changes: 4 additions & 6 deletions tests/Fixture/Money.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@

use Tcds\Io\Jackson\Node\JsonMapper;

#[
JsonMapper(
reader: MoneyReader::class,
writer: MoneyWriter::class,
)
]
#[JsonMapper(
reader: MoneyReader::class,
writer: MoneyWriter::class,
)]
readonly class Money
{
public function __construct(public int $cents)
Expand Down
Loading