wizbii/json-serializer
A lightweight PHP library that turns objects into clean, predictable JSON using attributes and annotations. Configure property names, nested objects, arrays, and date formats with minimal boilerplate, and get consistent API responses in Laravel or any PHP project.
The wizbii/json-serializer-bundle is a thin Symfony integration around the wizbii/json-serializer library. It wires the library's serializer into the container so you can annotate plain PHP objects (DTOs, value objects) with attributes and let the bundle handle hydration/dehydration.
Install and register
composer require wizbii/json-serializer-bundle
If you use Symfony Flex, the bundle is registered automatically. Otherwise add it to config/bundles.php:
return [
// ...
Wizbii\JsonSerializerBundle\WizbiiJsonSerializerBundle::class => ['all' => true],
];
First thing to read
Look at the bundle's Resources/config/services.yaml (or services.php) first — it tells you the service ID and the public alias you should autowire. Then look at the library's own Serializer class and any #[Serializable]-style attribute classes, since the bundle adds almost nothing on top of the library besides DI plumbing.
First use case
Model an API response as a readonly DTO and hydrate it from a JSON payload:
use Wizbii\JsonSerializer\Model\Serializable;
use Wizbii\JsonSerializer\Attribute\JsonField;
#[Serializable]
final class UserResponse
{
public function __construct(
#[JsonField('id')] public readonly string $id,
#[JsonField('email')] public readonly string $email,
#[JsonField('created_at')] public readonly \DateTimeImmutable $createdAt,
) {}
}
public function __construct(private Serializer $serializer) {}
public function __invoke(string $json): UserResponse
{
return $this->serializer->deserialize($json, UserResponse::class);
}
Inject the serializer by autowiring the interface/class the bundle registers. That is the day-to-day loop: declare a DTO, annotate it, call deserialize() inbound and serialize() outbound.
Treat DTOs as the unit of contract, not Eloquent/Doctrine entities. The serializer is happiest when the object graph is plain PHP with typed constructor args or typed public properties. Keep persistence entities out of the wiring layer; map Entity → DTO → JSON and JSON → DTO → Entity.
Constructor hydration for readonly DTOs. Prefer promoting constructor arguments — the serializer can inspect parameter types to build nested objects, arrays of objects, and enums without extra config. Property-based hydration requires non-readonly setters and is easy to break silently.
Nested and collection types. Use docblock generics for elements the runtime cannot infer:
/** @var list<OrderLineResponse> */
#[JsonField('lines')] public readonly array $lines;
Without the @var, you usually get stdClass objects inside arrays rather than hydrated DTOs. Same for array<string, FooResponse> for maps.
Custom type handlers / transformers. For value objects with a non-trivial string representation (money, UUIDs, slugs), implement a serializer side rather than adding static factory methods on the domain type. Register it via the bundle's service tag (check the bundle's config for the exact tag name — typically wizbii_json_serializer.serializer) and keep the domain object free of framework awareness.
Use it at the HTTP boundary, not as a general-purpose normalizer. The serializer is designed for request/response payloads where you own both sides. Don't route Symfony's ObjectNormalizer jobs (form data, fixtures, doctrine proxies) through it — that's symfony/serializer's job and mixing the two causes confusing "unknown property" errors.
Compose with API clients. It pairs well with PSR-18/symfony/http-client: call the client, json_decode(..., true) only if you need to inspect shape, then immediately hand the raw string to Serializer::deserialize(). Avoiding the intermediate array decode is what makes the bundle worth having over hand-rolled mapping.
Testing pattern. Unit-test the round trip: serialize(deserialize($json)) should equal $json (byte-for-byte only if your field ordering and whitespace match — otherwise compare decoded structures). Fakes for the serializer are usually worse than the real one; instantiate the real serializer in tests since it has no I/O.
Very low adoption — pin the version and vendor the docs. With no dependents and near-zero stars, expect sparse examples and no Stack Overflow coverage. composer.lock is your friend; treat minor version bumps as potential breaking changes and re-run your serialization tests after every update.
Attribute vs annotation. Older releases may still accept docblock annotations for BC. If you are on PHP 8.1+ and attributes are ignored, check that the library version in composer.lock actually ships the attribute classes — a stale lock is a common cause of "silently un-serialized" properties.
Silent field dropping. Serializers that are attribute-driven often ignore unannotated properties rather than throwing. If a field disappears from output, the cause is almost always a missing #[JsonField] (or whatever the bundle names its property attribute), not a bug in the value. Verify with a minimal round-trip test before debugging deeper.
Date and enum handling. \DateTimeInterface and backed enums usually work out of the box, but the format (ISO-8601 vs custom) and the direction (serialize vs deserialize) may differ. Pin the expected format in a test; do not assume DateTimeImmutable round-trips identically to DateTime.
Nullability and defaults. Prefer explicit ?Type and default values over optional constructor args. Reflection-based hydration treats "missing" and "null" differently depending on the version, and a missing property can throw a TypeError that is hard to trace back to the JSON payload.
Extension points are the escape hatch. Custom normalizers/handlers registered through the bundle's tag are the sanctioned way to handle types the library does not know. Avoid monkey-patching DTOs with __serialize/__unserialize — those bypass the serializer's own traversal and break nested-object handling.
Debugging tip. When deserialization fails, json_decode the payload into an array and var_dump it alongside your DTO definition. The library's error messages are often generic (Cannot deserialize...), but the mismatch is nearly always one of: field name casing, missing @var for array elements, or an enum/value type the serializer has no handler for.
How can I help you explore Laravel packages today?