A lightweight, flexible object serializer for PHP, inspired by Jackson.
It provides strong typing, JSON β object mapping, generics support, array/object shapes, custom type mappers, and detailed error tracing.
- Overview
- Integrations
- Installation
- Basic Usage
- Deserializing from JSON
- Merging Additional Data (
readValueWith) - Serializing Objects
- Generic Types (
list<t> map<k, v> shapes) - Renaming JSON keys with
#[JsonProperty] - Custom Type Mappers
- Date Handling
- Error Handling
- Development
- Summary
Main components:
- JsonObjectMapper β handles JSON strings at the boundary.
- ArrayObjectMapper β handles associative arrays at the boundary.
- Type Mappers β custom readers/writers for specific classes.
- Generic Types β support for
list<T>,map<K,V>, shapes, etc. - Date Handling β built-in support for DateTime and Carbon.
- Error Reporting β typed exceptions with full trace paths.
composer require tcds-io/php-jacksonPHP 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 β β 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
use Tcds\Io\Jackson\JsonObjectMapper;
$mapper = new JsonObjectMapper();
$address = $mapper->readValue(Address::class, $json);Equivalent array version:
use Tcds\Io\Jackson\ArrayObjectMapper;
$mapper = new ArrayObjectMapper();
$address = $mapper->readValue(Address::class, $dataArray);$json = <<<JSON
{
"street": "Ocean avenue",
"number": "100",
"main": "true",
"place": {
"city": "Rio de Janeiro",
"country": "Brazil",
"position": { "lat": "-26.9013", "lng": "-48.6655" }
}
}
JSON;
$mapper = new JsonObjectMapper();
$address = $mapper->readValue(Address::class, $json);The resulting object matches:
new Address(
street: 'Ocean avenue',
number: 100,
main: true,
place: new Place(
city: 'Rio de Janeiro',
country: 'Brazil',
position: new LatLng(lat: -26.9013, lng: -48.6655),
),
);Merging data is useful when the incoming payload does not contain all required values and those values must be completed from another source:
$partial = <<<JSON
{
"street": "Ocean avenue",
"number": "100",
"main": "true"
}
JSON;
$address = $mapper->readValueWith(
Address::class,
$partial,
[
'place' => [
'city' => "Rio de Janeiro",
'country' => "Brazil",
'position' => [
'lat' => -26.9013,
'lng' => -48.6655,
]
]
]
);Array output:
$mapper = new ArrayObjectMapper();
$array = $mapper->writeValue($object);JSON output:
$mapper = new JsonObjectMapper();
$json = $mapper->writeValue($object);The generic() and shape() helper functions are loaded by Composer through php-better-generics.
$list = $mapper->readValue('list<LatLng>', $json);Using generic():
$type = generic('list', [LatLng::class]);
$list = $mapper->readValue($type, $json);$type = generic('map', ['string', Address::class]);
$result = $mapper->readValue($type, [
'main' => Address::mainData(),
'other' => Address::otherData(),
]);$type = shape('array', [
'type' => AccountType::class,
'position' => LatLng::class,
]);Produces:
[
'type' => AccountType::CHECKING,
'position' => new LatLng(...),
]$type = shape('object', [
'type' => AccountType::class,
'position' => LatLng::class
]);Produces a stdClass:
$object->type === AccountType::CHECKING
$object->position instanceof LatLngPHP-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]:
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:
$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 mappers are useful when object construction depends on complex logic or external data:
use Tcds\Io\Jackson\ArrayObjectMapper;
$mapper = new ArrayObjectMapper(
typeMappers: [
LatLng::class => [
'reader' => fn(string $data) => new LatLng(...explode(',', $data)),
'writer' => fn(LatLng $data) => sprintf("%s, %s", $data->lat, $data->lng),
]
]
);This allows:
"position" => "-26.9013, -48.6655"to become:
new LatLng(-26.9013, -48.6655)and serialize back into:
"position" => "-26.9013, -48.6655"use Tcds\Io\Jackson\ArrayObjectMapper;
$mapper = new ArrayObjectMapper(
typeMappers: [
User::class => [
'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:
fn(mixed $data, string $type, ObjectMapper $mapper, array $path): mixedUse only the parameters you need; ReflectionFunction::call() binds them by name.
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:
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 staticread/writeis called), - a class string of any class with a matching
__invoke(treated as aMapperClosure), - an instance of
Reader/Writer(PHP 8.1newin attribute initializers), - a
ClosurematchingMapperClosure, when constructingJsonMapperprogrammatically (PHP attribute literals can't carry closures).
Resolution order on every read/write:
#[JsonMapper]attribute on the target class β declaration site, winstypeMappersconstructor argument- 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.
PHP-Jackson provides built-in support for:
- DateTime
- DateTimeImmutable
- Carbon
- CarbonImmutable
- DateTimeInterface
Dates are serialized and deserialized using ISO-8601 strings:
[
'datetime' => '2025-10-22T11:21:31+00:00'
]When parsing fails, the library throws:
Properties:
$e->trace; // ['address','place','position']
$e->expected; // expected type description
$e->given; // actual given valueExample message:
Unable to parse value at .address.place.position
This makes debugging extremely easy.
composer install
composer tests # runs cs:check + phpstan + phpunit
composer cs:fix # auto-fix code styleYou can:
- Read JSON β typed objects via
JsonObjectMapper - Read arrays β typed objects via
ArrayObjectMapper - Merge missing fields using
readValueWith - Write objects β JSON/arrays via
writeValue - Use generics (
list<T>,map<K,V>, shapes) - Rename wire keys per field with
#[JsonProperty('snake_case')] - Register custom mappers for any class via
typeMappersor pin them on the class itself with#[JsonMapper(reader: β¦, writer: β¦)] - Rely on strong error tracing with full path information