diff --git a/README.md b/README.md
index ebf5fdd..f347424 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# PHP-Jackson
+[](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.
@@ -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)
---
@@ -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:
-- Laravel ↗
-- Symfony ↗
-- Guzzle ↗
+- Laravel ↗ — controller injection, JSON responses, request error handling, and Eloquent casts
+- Symfony ↗ — controller argument resolvers, JSON responses, and configurable request error handling
+- Guzzle ↗ — typed HTTP client with request DTO mapping and async response parsing
## 🔧 Basic Usage
@@ -170,6 +176,8 @@ $json = $mapper->writeValue($object);
## 📚 Generic Types (`list`, `map`, shapes)
+The `generic()` and `shape()` helper functions are loaded by Composer through `php-better-generics`.
+
### List example
```php
@@ -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),
]
]
);
@@ -275,13 +322,15 @@ 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
],
]
@@ -289,6 +338,52 @@ $mapper = new ArrayObjectMapper(
);
```
+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
@@ -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:
@@ -344,5 +449,7 @@ You can:
- Merge missing fields using `readValueWith`
- Write objects → JSON/arrays via `writeValue`
- Use generics (`list`, `map`, 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
diff --git a/tests/Fixture/Money.php b/tests/Fixture/Money.php
index 84ee961..a77f8e1 100644
--- a/tests/Fixture/Money.php
+++ b/tests/Fixture/Money.php
@@ -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)