symfony/serializer
The Symfony Serializer component converts object graphs and data structures to and from arrays, JSON, XML, and other formats, handling normalization, denormalization, and encoding for flexible data exchange.
The first thing to understand is that symfony/serializer is a read-only split of the symfony/symfony monorepo. Bugs and PRs belong in the monorepo, and Packagist "dependents" here are meaningless — you almost certainly get this transitively (via symfony/http-kernel, api-platform, spatie/laravel-data, or a queue/webhook SDK), not as a deliberate install. Check composer why symfony/serializer before you add it explicitly.
In a Laravel app you don't get the framework's autowiring, so wire it yourself in a service provider:
// app/Providers/SerializerServiceProvider.php
$this->app->singleton(Serializer::class, function () {
$encoders = [new JsonEncoder()];
$normalizers = [
new DateTimeNormalizer([DateTimeNormalizer::FORMAT_KEY => \DateTimeInterface::RFC3339]),
new BackedEnumNormalizer(),
new UidNormalizer(),
new ObjectNormalizer(
null,
new CamelCaseToSnakeCaseNameConverter(),
null,
new ReflectionExtractor(), // PropertyInfo is required for typed DTOs
),
];
return new Serializer($normalizers, $encoders);
});
Minimum viable install:
composer require symfony/serializer symfony/property-access symfony/property-info
ObjectNormalizer needs PropertyAccess; without PropertyInfo it will silently normalize arrays instead of typed DTOs and you'll wonder why your Address object came back as array.
First use case: don't try to replace Eloquent's toArray() or JsonResource. The serializer earns its keep at the edges of the app — inbound webhook payloads, third-party API responses, queue message envelopes, and CSV/XML imports. A good first exercise is a single DTO you denormalize() from a webhook body:
$event = $serializer->denormalize(
$request->json()->all(),
StripeChargeSucceeded::class,
'json',
[AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => false]
);
Read, in this order: the component docs page (symfony.com/doc/current/components/serializer.html), the Serializer class constructor signature, then AbstractObjectNormalizer context constants — those constants are the real API surface.
DTO hydration at the boundary. The winning pattern is: never let request arrays reach your domain. Each external contract gets a readonly DTO with promoted constructor properties, and the serializer is the only thing that touches the raw array.
final readonly class ChargeSucceeded
{
/** @param LineItem[] $items */
public function __construct(
public string $id,
#[SerializedName('created_at')]
public \DateTimeImmutable $createdAt,
public array $items,
public ?string $coupon = null,
) {}
}
Constructor argument names are the wire format unless a name converter or #[SerializedName] intervenes, so DTO property names are your integration contract — design them deliberately.
Strict mode for untrusted input. AbstractObjectNormalizer::ALLOW_EXTRA_ATTRIBUTES => false turns unknown keys into ExtraAttributesException. Combined with COLLECT_DENORMALIZATION_ERRORS => true, you can surface every bad field in one pass instead of chasing them one 400 at a time:
try {
$dto = $serializer->denormalize($payload, Foo::class, 'json', [
AbstractObjectNormalizer::ALLOW_EXTRA_ATTRIBUTES => false,
AbstractObjectNormalizer::COLLECT_DENORMALIZATION_ERRORS => true,
]);
} catch (PartialDenormalizationException $e) {
// $e->getErrors() : NotNormalizableValueException[]
// each has getPath() like 'items[2].amount' and getExpectedTypes()
}
That exception list maps almost 1:1 onto a Laravel ValidationException, which is the cleanest bridge between the two worlds.
Groups as API-version switches. #[Groups(['v2', 'admin'])] on properties plus ['groups' => ['v2']] in context is how you expose different shapes from one DTO. Use it for outbound normalization of your own resources rather than for inbound denormalization — inbound groups are the area with the most historical bugs (see Gotchas).
Polymorphism via #[DiscriminatorMap]. For webhook families (charge.succeeded, charge.refunded), put a discriminator on an abstract base class and let one denormalize($payload, WebhookEvent::class) dispatch to the right subclass. This is the single feature that most justifies the dependency over hand-rolled match statements.
Context layering. Build context arrays in config, not inline: a base context (name converter behaviour, date format, depth limits), a strict-input context, and a lenient-output context. Merge them per call site. Serializer itself implements NormalizerInterface, so a custom normalizer can hold a Serializer reference and delegate nested payloads — use NormalizerAwareTrait/DenormalizerAwareTrait (or the *AwareInterface autoconfigure interfaces) rather than constructor-injecting the serializer and creating a circular graph.
Custom normalizers. Implement NormalizerInterface and DenormalizerInterface and — critically — getSupportedTypes():
public function getSupportedTypes(?string $format): array
{
return [Money::class => true]; // true = result is cacheable per type
}
Return '*' => false only for genuinely catch-all normalizers; marking them cacheable poisons the metadata/type cache and produces confusing "support" mismatches. Register custom normalizers before ObjectNormalizer in the chain.
Formats. JsonEncoder and CsvEncoder cover ~95% of Laravel work. CSV is genuinely useful (imports, bank exports) but the no_headers / csv_headers interaction has been buggy — pre-1.0 of your integration, encode and decode a fixture in a test. XmlEncoder needs symfony/xml and has its own dialect rules (xml_root_node_name, decoder_ignored_node_types); treat its output as one-way unless you control both ends.
Where to actually install it. If your only need is "array to object," consider spatie/laravel-data with the Symfony serializer normalizers enabled, or plain constructor mapping. Bring in the serializer bare when you need the encoder matrix (JSON/CSV/XML/YAML), discriminator maps, or you're consuming a Symfony/API Platform service and want identical semantics on both sides.
Version pinning is not optional here. Releases ship weekly, and the changelogs are dense with behavioural fixes to denormalization semantics: SerializedName being ignored when groups are present, discriminator attributes on nested maps, allow_extra_attributes=false falsely flagging renamed keys, scalar collection element enforcement, is/has/can accessor naming. Pin an exact minor (8.1.*) and re-run your payload fixture suite on every bump. Note that 5.4 got a security release — 5.4 is done; target 6.4 LTS or the current 7.x/8.x line.
Pin phpdocumentor/reflection-docblock. A conflict with >= 6 was patched because PhpDocTypeHelper (used for Foo[] array typing) breaks against it. If array-of-DTO denormalization suddenly returns array<array> instead of array<Foo>, look there first.
getSupportedTypes() is the modern contract; supportsNormalization() is the legacy path. Mixing a new-style getSupportedTypes() with an old-style supportsNormalization() that disagrees produces "no normalizer found" for a type you can clearly see handled. Keep them consistent, and remember getSupportedTypes() is consulted first.
Attributes only work where reflection can see them. Attribute discovery on non-public members has had compile-time bugs; if #[Groups]/#[Ignore] on a private property seems dead, check the accessor. #[Ignore] on a getter vs. a same-name property has also been a fix-worthy bug — when ignoring a field, verify with a normalize() fixture rather than assuming.
Name converters cut both ways. CamelCaseToSnakeCaseNameConverter affects both normalize and denormalize, so a snake_case API response becomes camelCase properties automatically — but a #[SerializedName] wins over the converter, and denormalizing with a converter plus ALLOW_EXTRA_ATTRIBUTES => false historically produced false "extra attribute" errors when input format was misdetected. Always pass an explicit $format argument to denormalize(); passing null is where several of these bugs lived.
Enums and dates need explicit normalizers. BackedEnumNormalizer and DateTimeNormalizer are not in ObjectNormalizer. Without them you get NotNormalizableValueException on perfectly valid DTOs. DateTimeNormalizer wants DateTimeImmutable — mutable DateTime is more fragile. In 7.4+ you can force the timezone on denormalization; before that, incoming offsets are honoured and can silently shift your stored timestamps.
SKIP_NULL_VALUES, PRESERVE_EMPTY_OBJECTS, CIRCULAR_REFERENCE_HANDLER, and max-depth are context keys, not constructor options. They differ per call, so a "why is my JSON missing fields" bug is usually someone else's context leaking in. Centralize context construction.
Performance: cache the metadata. Reflection-based extraction is the hot path. In Laravel, warm the metadata cache at deploy (an artisan command or php artisan optimize hook) using a CacheClassMetadataFactory over a persistent cache store. Without it, first-request-per-worker pays the full reflection cost and you'll see it in p95 on large DTOs. Also avoid ALLOW_EXTRA_ATTRIBUTES => false on huge payloads unless you need it — it forces full key-set comparison.
Debugging technique. When denormalization fails unhelpfully, do it in two steps: $serializer->decode($json, 'json') to confirm the array shape, then denormalize($array, Foo::class, null, [...]) with COLLECT_DENORMALIZATION_ERRORS and dump getPath() / getExpectedTypes() per error. Nine times out of ten the failure is a constructor arg name mismatch — the message says "type error," the reality is the key wasn't found so the default null hit a non-nullable property (MissingConstructorArgumentsException is the tell).
Uninitialized typed properties. SKIP_UNINITIALIZED_VALUES exists for a reason; without it, normalizing an entity with a never-set typed property throws. This collides with Laravel models where attributes are hydrated lazily — do not point ObjectNormalizer directly at Eloquent models; map to DTOs first.
Extension points worth knowing: NormalizerAwareInterface, DenormalizerAwareInterface, ContextAwareNormalizerInterface, NameConverterInterface (including MetadataAwareNameConverter for per-property mapping), ClassDiscriminatorResolverInterface, and Serializer::setMaxDepthHandler(). The serializer is designed to be composed — a chain of five small normalizers beats one 400-line supportsX() monster, and it makes the "which normalizer swallowed my field" question answerable.
How can I help you explore Laravel packages today?