Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Serializer Bundle Laravel Package

anzusystems/serializer-bundle

AnzuSerializer is a fast, lightweight Symfony serializer bundle. Inject the Serializer to turn DTOs into JSON and deserialize JSON into objects or collections. Configure via attributes: custom DateTime formats, enums, Uuid, value objects, entity ID handlers, and more.

View on GitHub
Deep Wiki
Context7

Getting Started

Three commands and one config file and you have a working serializer:

composer require anzusystems/serializer-bundle
# config/packages/anzu_systems_serializer.yaml
anzu_systems_serializer:
  date_format: 'Y-m-d\TH:i:s.u\Z'

There is nothing to register — the bundle ships its own DI extension and the Serializer service is autowirable. Inject it and you are done:

use AnzuSystems\SerializerBundle\Serializer;

final readonly class ArticleController
{
    public function __construct(private Serializer $serializer) {}

    #[Route('/articles/{id}', methods: ['GET'])]
    public function detail(Article $article): JsonResponse
    {
        return new JsonResponse($this->serializer->serialize($article));
    }
}

Where to look first, in this order:

  1. AnzuSystems\SerializerBundle\Attributes\Serialize — the single attribute that drives everything. Read every constructor argument before writing a DTO; most confusion comes from not knowing type, handler, serializedName, discriminatorMap, orderBy exist.
  2. AnzuSystems\SerializerBundle\Handler\Handlers\ — the built-in handlers (BasicHandler, DateTimeHandler, EnumHandler, ObjectHandler, UuidHandler, EntityIdHandler, ArrayStringHandler). This namespace is the de facto API reference; it tells you exactly what is auto-resolved and what is not.
  3. Metadata and SerializationContext — you will touch these the moment you write a custom handler.
  4. SerializerModelDescriber — only if you use NelmioApiDocBundle, but it is the reason your OpenAPI spec stays honest.

First real use case: don't try to serialize your Doctrine entities on day one. Pick one read endpoint, define a small DTO with #[Serialize] on its properties, and serialize that. It gets you the full loop (attributes → getters → JSON) in ten minutes, and it immediately exposes the two things that will bite you later: the getter/setter naming requirement and the constructor rule.

Then verify the round trip before writing more DTOs:

$json = $serializer->serialize($dto);
$back = $serializer->deserialize($json, Dto::class);

If deserialization fails, the problem is almost always a missing setter or a missing type: on an iterable, not the serializer.

Implementation Patterns

DTOs as the boundary, not entities

The package is at its best when it owns a dedicated DTO layer. Entities leak persistence concerns (lazy proxies, inverse sides, circular references) and the serializer will faithfully try to walk them. Define request/response DTOs, keep entities out of the JSON, and use EntityIdHandler when you genuinely need to reference persistence:

#[Serialize(handler: EntityIdHandler::class)]
private User $author;                 // single id in, single id out

#[Serialize(handler: EntityIdHandler::class, type: Author::class, orderBy: ['position' => Criteria::ASC])]
protected Collection $authors;        // ordered list of ids

This is the highest-leverage pattern in the bundle for API work: it gives you ID-based payloads (JSON:API-ish, cheap to cache, no accidental N+1 graph serialization) without hand-writing array_map(fn($a) => $a->getId(), ...) in every DTO.

Attributes as the schema

Treat #[Serialize] as your serialization contract and keep it declarative. The attribute covers most of what you would otherwise put in a normalizer:

#[Serialize(serializedName: 'stats')]         // rename in output
private UserStats $decorated;

#[Serialize(type: 'd.m.Y H:i:s')]             // per-property date format
private DateTimeImmutable $publishedAt;

#[Serialize(type: DummyDto::class)]           // required for iterables
private array $itemsArray;

#[Serialize(handler: ArrayStringHandler::class)]
private array $tags;                           // 'a, b,c' <-> ['a','b','c']

#[Serialize]                                   // virtual property, getter only
public function getViolations(): Collection

The virtual-property trick is worth internalizing: a getter with #[Serialize] and no matching property lets you compute derived values (getViolations(), getFullName()) without polluting the domain object with a fake field.

Polymorphism via discriminatorMap

Any list of mixed types needs a discriminator, otherwise deserialization has no way to pick a class:

#[Serialize(discriminatorMap: ['person' => Person::class, 'machine' => Machine::class])]
private Collection $items;

The JSON carries {"type": "person", ...} (or whatever key the handler expects) and the serializer instantiates the right class. Plan this in advance — retrofitting a discriminator onto an existing payload format is a breaking change for clients.

Custom handlers for value objects

Anything that isn't a scalar, a DateTimeInterface, a Uuid, an EnumInterface or a ValueObjectInterface needs a handler. Write one handler per value-object type, then let it self-register:

final class GeolocationHandler extends AbstractHandler
{
    public function serialize(mixed $value, Metadata $metadata, SerializationContext $context): array
    {
        return ['lat' => $value->getLatitude(), 'lon' => $value->getLongitude()];
    }

    public function deserialize(mixed $value, Metadata $metadata): Geolocation
    {
        return new Geolocation((float) $value['lat'], (float) $value['lon']);
    }

    public static function supportsSerialize(mixed $value): bool
    {
        return $value instanceof Geolocation;
    }

    public static function supportsDeserialize(mixed $value, string $type): bool
    {
        return is_a($type, Geolocation::class, true) && is_array($value);
    }
}

With the static supports* methods you never write handler: GeolocationHandler::class again — every Geolocation property is handled automatically. If two handlers claim the same type, use getPriority() (remember BasicHandler is 10 and ObjectHandler is -1, so pick a number in between deliberately).

Batch handlers: the reason to pick this over Symfony's serializer

This is the pattern that earns its keep on a real API. A handler that hits the database does it once per collection item by default; BatchSerializeHandlerInterface collapses that to one query for the whole collection:

final class AuthorHandler extends AbstractHandler implements BatchSerializeHandlerInterface
{
    public function prepareSerializeBatch(SerializationContext $context, BatchItem ...$items): void
    {
        $ids = array_filter(array_map(fn (BatchItem $i) => $i->value, $items), 'is_int');
        foreach ($this->authorRepository->findByIds($ids) as $author) {
            $this->authors[$author->getId()] = $author;
        }
    }
}

BatchDeserializeHandlerInterface::prepareDeserializeBatch() is the mirror image on the way in, and the README makes a strong promise: it runs before the first deserialize() call for that object, constructor arguments included. That means your handler can do all its fetching there and afterwards only read Doctrine's identity map — which is exactly how EntityIdHandler avoids a query for an ID that doesn't exist.

Practical guidance: turn this on for the two or three handlers that actually touch I/O (author, user, tag), and leave the rest alone. Batching is a performance escape hatch, not an architectural default.

ContainerParam instead of hard-coded class names

When a DTO lives in a shared package but the concrete class lives in the app:

anzu_systems_serializer:
  parameter_bag:
    AnzuSystems\Contracts\Entity\AbstractUser: App\Entity\User
#[Serialize(handler: EntityIdHandler::class, type: new ContainerParam(AbstractUser::class))]
protected Collection $users;

Use this whenever a bundle or shared contract needs to point at an app class. It also makes the DTO testable with a stub implementation.

Keep Nelmio docs in sync via describe()

If you have NelmioApiDocBundle, the model describer auto-registers and reflects Symfony annotations plus DocBlock titles. For each custom handler, override describe() so the spec shows an object rather than a bare string:

$description['type'] = 'object';
$description['properties'] = [new Property(['property' => 'lat', 'type' => 'float'])];
$description[SerializerModelDescriber::NESTED_CLASS] = 'App\Entity\User';
$description['items'][SerializerModelDescriber::NESTED_CLASS] = 'App\Entity\User';

The payoff here is real: the OpenAPI spec becomes generated truth instead of a hand-maintained YAML file that drifts from the DTOs.

Test the round trip, not the internals

For every DTO, one test asserting deserialize(serialize($dto)) equals the original covers attributes, handler resolution, date formats, and nested types in a handful of lines. Add a fixture JSON per DTO for the "client sent this shape" case — that is where discriminator and type: mistakes show up, and nowhere else.

Gotchas and Tips

Every serialized property needs a public getter and setter, named conventionally. getEmail/setEmail, and isEnabled for booleans. This is the single most common first-hour failure. Serialize on a private property with no accessors silently does nothing useful.

No required constructor parameters. Deserialization instantiates without arguments. If your DTO needs invariants, use a public static factory and the documented instantiation path, or keep the constructor argument-free and validate afterwards.

Iterables need type: (or discriminatorMap:). Without it, deserialization of array/Collection properties has no idea what to build. Serialization may work fine, which makes this a nasty "works on the way out, fails on the way in" bug.

JSON only. Do not plan on XML, CSV or form encoding. If you need them, this is not the bundle.

Automatic resolution has hard requirements. DateTimeInterface, Uuid, enums and value objects only auto-handle if they implement EnumInterface / ValueObjectInterface or are the Symfony Uuid type. Everything else needs #[Serialize(handler: ...)] or a supports* registration.

Batch state is a singleton problem. Handlers live in the container, so on FrankenPHP/RoadRunner/Swoole a private array $authors on the handler survives the response. Keep the cache in a store that resets between runs, and make prepareSerializeBatch() idempotent — it can be called several times per request when a payload contains more than one collection.

Batching has sharp edges: only array and Doctrine\Common\Collections\Collection are prepared (generators and plain iterators are skipped to avoid double traversal); only handlers forced via #[Serialize(handler: ...)] are batched, not auto-resolved ones; nested collections are prepared once per parent item; values arrive with duplicates and nulls, filtering is your job.

EntityIdHandler depends on Doctrine's identity map. Deserializing an ID that doesn't exist will not cost a query — but it also means you may not get an error where you expect one. Validate existence at the boundary (Symfony validator on the DTO) rather than relying on the serializer.

Config precedence on dates: the global date_format is the default, #[Serialize(type: '...')] on a property overrides it. Mixing both in one DTO is legal but makes debugging harder — pick one convention per payload family.

orderBy takes a Doctrine Criteria direction, not a string. ['position' => Criteria::ASC] is the shape; 'ASC' is the mistake.

Debugging workflow when something is null in the output: (1) does the property have #[Serialize]; (2) does the getter follow the naming convention; (3) is the value actually set (lazy proxy / inverse side); (4) for deserialization, does the setter exist and is type: present. That order resolves the vast majority of issues without touching the source.

Extension points worth knowing about beyond handlers: AbstractHandler::describe() for OpenAPI, getPriority() for handler disambiguation, ContainerParam for type indirection, NESTED_CLASS for reusing another class's description. There is no event system or normalizer chain here — the handler is the extension point.

Maturity risk, stated plainly. Zero stars, zero dependents, and a single-vendor bundle means you are the QA team. Mitigate cheaply:

  • Wrap AnzuSystems\SerializerBundle\Serializer behind your own thin interface in the app, so swapping to Symfony's serializer or JMS later is a one-class change rather than a grep across the codebase.
  • Pin the version in composer.json and treat minor upgrades as reviewable diffs.
  • Keep the round-trip test suite described above — it is what lets you upgrade with confidence.
  • Read the Handler\Handlers\ namespace once, end to end. It is short, and it is the most accurate documentation this package has.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc