⚠️ This bundle is currently under active development and is not yet ready for production use. Expect breaking changes between releases.
A Symfony bundle that generates optimised, build-time normalizer classes for the Symfony Serializer component.
Instead of relying on the generic reflection-based ObjectNormalizer at runtime, this bundle analyses your classes at compile time and writes plain PHP normalizer classes tailored to each model. The result is a faster serializer with zero runtime reflection overhead.
Normalizing and denormalizing a single Post (with a nested User and Address) 200 000 times in a local environment:
| Operation | Symfony ObjectNormalizer (before) |
Generated (after) | Performance gain |
|---|---|---|---|
| Normalize | 4 208 ms | 250 ms | ~17× faster (~94.1% reduction) |
| Denormalize | 6 076 ms | 324 ms | ~18× faster (~94.5% reduction) |
The benchmark was produced with:
<?php
$start = microtime(true);
for ($i = 0; $i < 200_000; $i++) {
$data = $this->serializer->normalize($post);
}
$elapsed = microtime(true) - $start;
dump("Time: " . round($elapsed * 1000) . " ms");
$start = microtime(true);
for ($i = 0; $i < 200_000; $i++) {
$this->serializer->denormalize($data, Post::class);
}
$elapsed = microtime(true) - $start;
dd("Time: " . round($elapsed * 1000) . " ms");- Configure the bundle with a PSR-4 map: each namespace prefix points at the directory whose
*.phpfiles define the model classes you want generated normalizers for. - On container compilation (e.g.
cache:clearorcache:warmup), the bundle:- Autodetects concrete PHP classes under those directories: for each file, it derives the fully qualified class name from the path relative to the configured directory and the namespace prefix, then includes every concrete class (it skips interfaces, traits, enums, and abstract classes).
- Inspects each class's properties, constructor parameters, and getter methods via reflection and property-info extractors to build rich
ClassMetadata. - Generates a dedicated PHP normalizer class per model using nikic/php-parser and writes it to the configured cache directory.
- Registers every generated normalizer as a Symfony service tagged with
serializer.normalizerat priority 200 (well aboveObjectNormalizer's -1000), so they are used first.
Generated normalizers are plain, human-readable PHP classes — you can inspect them in your cache directory at any time.
composer require remcosmitsdev/buildable-serializer-bundle:dev-masterRegister the bundle in config/bundles.php if it is not picked up automatically by Symfony Flex:
return [
// ...
RemcoSmitsDev\BuildableSerializerBundle\BuildableSerializerBundle::class => ['all' => true],
];Create config/packages/buildable_serializer.yaml:
buildable_serializer:
normalizers:
# PSR-4 map of namespace-prefix => directory configuration.
# Value can be a simple directory path or an object with 'path' and optional 'exclude'.
paths:
# Simple string: scans all PHP files recursively
'App\Model': '%kernel.project_dir%/src/Model'
# With single exclude pattern
'App\Entity':
path: '%kernel.project_dir%/src/Entity'
exclude: '*Repository.php'
# With multiple exclude patterns
'App\Dto':
path: '%kernel.project_dir%/src/Dto'
exclude:
- '*Helper.php'
- '*Test.php'
# Toggle individual serializer features in the generated normalizers.
features:
groups: true # Emit group-filtering logic.
max_depth: true # Emit max-depth checking logic.
circular_reference: true # Emit circular-reference detection logic.
skip_null_values: true # Emit logic to skip null-valued properties.
preserve_empty_objects: true # Emit logic to preserve empty objects as {} (JSON) instead of [].
context: true # Emit logic to merge #[Context] attribute values.
attributes: true # Emit attribute-allowlist filtering logic (AbstractNormalizer::ATTRIBUTES).
ignored_attributes: true # Emit ignored-attribute denylist filtering logic (AbstractNormalizer::IGNORED_ATTRIBUTES).
strict_types: true # Prepend declare(strict_types=1); to every file.
denormalizers:
paths:
'App\Dto': '%kernel.project_dir%/src/Dto'
features:
groups: true # Emit group-filtering logic.
attributes: true # Emit attribute-allowlist filtering logic (AbstractNormalizer::ATTRIBUTES).
ignored_attributes: true # Emit ignored-attribute denylist filtering logic (AbstractNormalizer::IGNORED_ATTRIBUTES).
strict_types: true # Prepend declare(strict_types=1); to every file.All options are optional and fall back to the defaults shown above.
Put your PHP classes in the directories listed under paths, using namespaces that match the configured prefixes (PSR-4). The bundle discovers them automatically; no extra attribute is required on the class.
<?php
class User
{
#[Groups(["user:read", "user:list"])]
private int $id;
#[Groups(["user:read", "user:list"])]
private string $firstName;
#[Groups(["user:read", "user:list"])]
private string $lastName;
#[Groups(["user:read"])]
#[SerializedName("email_address")]
private string $email;
#[Groups(["user:read"])]
private ?Address $address = null;
#[Ignore]
private string $passwordHash = "";
#[Groups(["user:read"])]
private bool $active = true;
public function __construct(
int $id,
string $firstName,
string $lastName,
string $email,
) {
$this->id = $id;
$this->firstName = $firstName;
$this->lastName = $lastName;
$this->email = $email;
}
public function getId(): int
{
return $this->id;
}
public function getFirstName(): string
{
return $this->firstName;
}
public function getLastName(): string
{
return $this->lastName;
}
public function getEmail(): string
{
return $this->email;
}
public function getAddress(): ?Address
{
return $this->address;
}
public function setAddress(?Address $address): void
{
$this->address = $address;
}
public function getPasswordHash(): string
{
return $this->passwordHash;
}
public function setPasswordHash(string $hash): void
{
$this->passwordHash = $hash;
}
public function isActive(): bool
{
return $this->active;
}
public function setActive(bool $active): void
{
$this->active = $active;
}
}
class Post
{
#[Groups(["post:read", "post:list"])]
private int $id;
#[Groups(["post:read", "post:list"])]
private string $title;
#[Groups(["post:read"])]
private string $content;
#[Groups(["post:read", "post:list"])]
#[MaxDepth(1)]
private User $author;
/**
* The Context attribute allows passing custom context to nested normalizers.
* Here we specify a custom date format for this property.
*/
#[Groups(["post:read", "post:list"])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private DateTimeImmutable $createdAt;
/**
* Context can also be group-specific. This applies a different date format
* only when serializing with the "post:api" group.
*/
#[Groups(["post:read", "post:list", "post:api"])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i:s'], groups: ["post:read", "post:list"])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'c'], groups: ["post:api"])]
private DateTimeImmutable $updatedAt;
public function __construct(
int $id,
string $title,
string $content,
User $author,
) {
$this->id = $id;
$this->title = $title;
$this->content = $content;
$this->author = $author;
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): int
{
return $this->id;
}
public function getTitle(): string
{
return $this->title;
}
public function getContent(): string
{
return $this->content;
}
public function getAuthor(): User
{
return $this->author;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
}
class Address
{
#[Groups(["address:read", "user:read"])]
public string $street;
#[Groups(["address:read", "user:read"])]
public string $city;
#[Groups(["address:read", "user:read"])]
#[SerializedName("postal_code")]
public string $postalCode;
#[Groups(["address:read", "user:read"])]
public string $country;
public function __construct(
string $street,
string $city,
string $postalCode,
string $country,
) {
$this->street = $street;
$this->city = $city;
$this->postalCode = $postalCode;
$this->country = $country;
}
public function getStreet(): string
{
return $this->street;
}
public function getCity(): string
{
return $this->city;
}
public function getPostalCode(): string
{
return $this->postalCode;
}
public function getCountry(): string
{
return $this->country;
}
}php bin/console cache:clear
# or
php bin/console cache:warmupThat's it. The Symfony Serializer will now use the generated normalizers automatically — no other code changes are required.
The bundle writes one normalizer per model into the configured /var/cache/%kernel.environment%. For the models above it produces:
UserNormalizer.php
<?php
/**
* @generated
*
* Normalizer for \App\Model\User.
*
* THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
*/
final class N67c521d5_UserNormalizer implements NormalizerInterface, GeneratedNormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
/**
* @param \App\Model\User $object
* @param array<string, mixed> $context
*
* @return array<string, mixed>
*/
public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
{
$objectHash = spl_object_hash($object);
$context['circular_reference_limit_counters'] ??= [];
if (isset($context['circular_reference_limit_counters'][$objectHash])) {
$limit = (int) ($context[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT] ?? 1);
if ($context['circular_reference_limit_counters'][$objectHash] >= $limit) {
if (isset($context[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER])) {
return $context[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER]($object, $format, $context);
}
throw new CircularReferenceException(sprintf('A circular reference has been detected when serializing the object of class "%s" (configured limit: %d).', 'App\Model\User', $limit));
}
++$context['circular_reference_limit_counters'][$objectHash];
} else {
$context['circular_reference_limit_counters'][$objectHash] = 1;
}
$attributes = $context[AbstractNormalizer::ATTRIBUTES] ?? null;
$attributesLookup = null;
if ($attributes !== null) {
$attributesLookup = [];
foreach ((array) $attributes as $k => $v) {
$attributesLookup[is_string($k) ? $k : $v] = true;
}
}
$ignoredAttributes = $context[AbstractNormalizer::IGNORED_ATTRIBUTES] ?? null;
$ignoredAttributesLookup = null;
if ($ignoredAttributes !== null) {
$ignoredAttributesLookup = array_fill_keys((array) $ignoredAttributes, true);
}
$groups = (array) ($context[AbstractNormalizer::GROUPS] ?? []);
$groupsLookup = array_fill_keys($groups, true);
$skipNullValues = (bool) ($context[AbstractObjectNormalizer::SKIP_NULL_VALUES] ?? false);
$data = [];
if ($ignoredAttributesLookup === null || !isset($ignoredAttributesLookup['id'])) {
if ($attributesLookup === null || isset($attributesLookup['id'])) {
if ($groups === [] || isset($groupsLookup['user:read']) || isset($groupsLookup['user:list'])) {
$data['id'] = $object->getId();
}
}
}
if ($ignoredAttributesLookup === null || !isset($ignoredAttributesLookup['firstName'])) {
if ($attributesLookup === null || isset($attributesLookup['firstName'])) {
if ($groups === [] || isset($groupsLookup['user:read']) || isset($groupsLookup['user:list'])) {
$data['firstName'] = $object->getFirstName();
}
}
}
if ($ignoredAttributesLookup === null || !isset($ignoredAttributesLookup['lastName'])) {
if ($attributesLookup === null || isset($attributesLookup['lastName'])) {
if ($groups === [] || isset($groupsLookup['user:read']) || isset($groupsLookup['user:list'])) {
$data['lastName'] = $object->getLastName();
}
}
}
if ($ignoredAttributesLookup === null || !isset($ignoredAttributesLookup['email'])) {
if ($attributesLookup === null || isset($attributesLookup['email'])) {
if ($groups === [] || isset($groupsLookup['user:read'])) {
$data['email_address'] = $object->getEmail();
}
}
}
if ($ignoredAttributesLookup === null || !isset($ignoredAttributesLookup['address'])) {
if ($attributesLookup === null || isset($attributesLookup['address'])) {
if ($groups === [] || isset($groupsLookup['user:read'])) {
$_val = $object->getAddress();
if ($_val !== null) {
$_childContext = !isset($attributes) ? $context : (is_array($attributes['address'] ?? null) ? array_replace($context, [AbstractNormalizer::ATTRIBUTES => $attributes['address']]) : array_diff_key($context, [AbstractNormalizer::ATTRIBUTES => null]));
$data['address'] = $this->normalizer->normalize($_val, $format, $_childContext);
} elseif (!$skipNullValues) {
$data['address'] = null;
}
}
}
}
if ($ignoredAttributesLookup === null || !isset($ignoredAttributesLookup['active'])) {
if ($attributesLookup === null || isset($attributesLookup['active'])) {
if ($groups === [] || isset($groupsLookup['user:read'])) {
$data['active'] = $object->isActive();
}
}
}
if ($data === [] && (bool) ($context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] ?? false)) {
return new \ArrayObject();
}
return $data;
}
/**
* @param array<string, mixed> $context
*/
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
return $data instanceof User;
}
/**
* @return array<class-string|'*'|'object'|string, bool|null>
*/
public function getSupportedTypes(?string $format): array
{
return ['App\Model\User' => true];
}
}PostNormalizer.php
<?php
/**
* @generated
*
* Normalizer for \App\Model\Post.
*
* THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
*/
final class N67c521d5_PostNormalizer implements NormalizerInterface, GeneratedNormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
/**
* @param \App\Model\Post $object
* @param array<string, mixed> $context
*
* @return array<string, mixed>
*/
public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
{
$objectHash = spl_object_hash($object);
$context['circular_reference_limit_counters'] ??= [];
if (isset($context['circular_reference_limit_counters'][$objectHash])) {
$limit = (int) ($context[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT] ?? 1);
if ($context['circular_reference_limit_counters'][$objectHash] >= $limit) {
if (isset($context[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER])) {
return $context[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER]($object, $format, $context);
}
throw new CircularReferenceException(sprintf('A circular reference has been detected when serializing the object of class "%s" (configured limit: %d).', 'App\Model\Post', $limit));
}
++$context['circular_reference_limit_counters'][$objectHash];
} else {
$context['circular_reference_limit_counters'][$objectHash] = 1;
}
$attributes = $context[AbstractNormalizer::ATTRIBUTES] ?? null;
$attributesLookup = null;
if ($attributes !== null) {
$attributesLookup = [];
foreach ((array) $attributes as $k => $v) {
$attributesLookup[is_string($k) ? $k : $v] = true;
}
}
$ignoredAttributes = $context[AbstractNormalizer::IGNORED_ATTRIBUTES] ?? null;
$ignoredAttributesLookup = null;
if ($ignoredAttributes !== null) {
$ignoredAttributesLookup = array_fill_keys((array) $ignoredAttributes, true);
}
$groups = (array) ($context[AbstractNormalizer::GROUPS] ?? []);
$groupsLookup = array_fill_keys($groups, true);
$skipNullValues = (bool) ($context[AbstractObjectNormalizer::SKIP_NULL_VALUES] ?? false);
$data = [];
if ($ignoredAttributesLookup === null || !isset($ignoredAttributesLookup['id'])) {
if ($attributesLookup === null || isset($attributesLookup['id'])) {
if ($groups === [] || isset($groupsLookup['post:read']) || isset($groupsLookup['post:list'])) {
$data['id'] = $object->getId();
}
}
}
if ($ignoredAttributesLookup === null || !isset($ignoredAttributesLookup['title'])) {
if ($attributesLookup === null || isset($attributesLookup['title'])) {
if ($groups === [] || isset($groupsLookup['post:read']) || isset($groupsLookup['post:list'])) {
$data['title'] = $object->getTitle();
}
}
}
if ($ignoredAttributesLookup === null || !isset($ignoredAttributesLookup['content'])) {
if ($attributesLookup === null || isset($attributesLookup['content'])) {
if ($groups === [] || isset($groupsLookup['post:read'])) {
$data['content'] = $object->getContent();
}
}
}
if ($ignoredAttributesLookup === null || !isset($ignoredAttributesLookup['author'])) {
if ($attributesLookup === null || isset($attributesLookup['author'])) {
if ($groups === [] || isset($groupsLookup['post:read']) || isset($groupsLookup['post:list'])) {
$_depthKey = sprintf(AbstractObjectNormalizer::DEPTH_KEY_PATTERN, 'App\Model\Post', 'author');
$_currentDepth = (int) ($context[$_depthKey] ?? 0);
// max-depth: author (limit=1)
if ($_currentDepth < 1) {
$context[$_depthKey] = $_currentDepth + 1;
$_childContext = !isset($attributes) ? $context : (is_array($attributes['author'] ?? null) ? array_replace($context, [AbstractNormalizer::ATTRIBUTES => $attributes['author']]) : array_diff_key($context, [AbstractNormalizer::ATTRIBUTES => null]));
$data['author'] = $this->normalizer->normalize($object->getAuthor(), $format, $_childContext);
}
}
}
}
if ($ignoredAttributesLookup === null || !isset($ignoredAttributesLookup['createdAt'])) {
if ($attributesLookup === null || isset($attributesLookup['createdAt'])) {
if ($groups === [] || isset($groupsLookup['post:read']) || isset($groupsLookup['post:list'])) {
$_childContext = !isset($attributes) ? array_merge($context, ['datetime_format' => 'Y-m-d']) : (is_array($attributes['createdAt'] ?? null) ? array_replace(array_merge($context, ['datetime_format' => 'Y-m-d']), [AbstractNormalizer::ATTRIBUTES => $attributes['createdAt']]) : array_diff_key(array_merge($context, ['datetime_format' => 'Y-m-d']), [AbstractNormalizer::ATTRIBUTES => null]));
$data['createdAt'] = $this->normalizer->normalize($object->getCreatedAt(), $format, $_childContext);
}
}
}
if ($data === [] && (bool) ($context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] ?? false)) {
return new \ArrayObject();
}
return $data;
}
/**
* @param array<string, mixed> $context
*/
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
return $data instanceof Post;
}
/**
* @return array<class-string|'*'|'object'|string, bool|null>
*/
public function getSupportedTypes(?string $format): array
{
return ['App\Model\Post' => true];
}
}AddressNormalizer.php
<?php
/**
* @generated
*
* Normalizer for \App\Model\Address.
*
* THIS FILE IS AUTO-GENERATED. DO NOT EDIT MANUALLY.
*/
final class N67c521d5_AddressNormalizer implements NormalizerInterface, GeneratedNormalizerInterface
{
/**
* @param \App\Model\Address $object
* @param array<string, mixed> $context
*
* @return array<string, mixed>
*/
public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
{
$attributes = $context[AbstractNormalizer::ATTRIBUTES] ?? null;
$attributesLookup = null;
if ($attributes !== null) {
$attributesLookup = [];
foreach ((array) $attributes as $k => $v) {
$attributesLookup[is_string($k) ? $k : $v] = true;
}
}
$ignoredAttributes = $context[AbstractNormalizer::IGNORED_ATTRIBUTES] ?? null;
$ignoredAttributesLookup = null;
if ($ignoredAttributes !== null) {
$ignoredAttributesLookup = array_fill_keys((array) $ignoredAttributes, true);
}
$groups = (array) ($context[AbstractNormalizer::GROUPS] ?? []);
$groupsLookup = array_fill_keys($groups, true);
$skipNullValues = (bool) ($context[AbstractObjectNormalizer::SKIP_NULL_VALUES] ?? false);
$data = [];
if ($ignoredAttributesLookup === null || !isset($ignoredAttributesLookup['street'])) {
if ($attributesLookup === null || isset($attributesLookup['street'])) {
if ($groups === [] || isset($groupsLookup['address:read']) || isset($groupsLookup['user:read'])) {
$data['street'] = $object->street;
}
}
}
if ($ignoredAttributesLookup === null || !isset($ignoredAttributesLookup['city'])) {
if ($attributesLookup === null || isset($attributesLookup['city'])) {
if ($groups === [] || isset($groupsLookup['address:read']) || isset($groupsLookup['user:read'])) {
$data['city'] = $object->city;
}
}
}
if ($ignoredAttributesLookup === null || !isset($ignoredAttributesLookup['postalCode'])) {
if ($attributesLookup === null || isset($attributesLookup['postalCode'])) {
if ($groups === [] || isset($groupsLookup['address:read']) || isset($groupsLookup['user:read'])) {
$data['postal_code'] = $object->postalCode;
}
}
}
if ($ignoredAttributesLookup === null || !isset($ignoredAttributesLookup['country'])) {
if ($attributesLookup === null || isset($attributesLookup['country'])) {
if ($groups === [] || isset($groupsLookup['address:read']) || isset($groupsLookup['user:read'])) {
$data['country'] = $object->country;
}
}
}
if ($data === [] && (bool) ($context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] ?? false)) {
return new \ArrayObject();
}
return $data;
}
/**
* @param array<string, mixed> $context
*/
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
return $data instanceof Address;
}
/**
* @return array<class-string|'*'|'object'|string, bool|null>
*/
public function getSupportedTypes(?string $format): array
{
return ['App\Model\Address' => true];
}
}A few things worth noting in the generated output:
#[Ignore]—$passwordHashis completely absent fromUserNormalizer; the field is never read or written.#[SerializedName]—$emailis emitted asemail_addressand$postalCodeaspostal_code.#[MaxDepth(1)]—PostNormalizerwraps theauthorproperty in a depth-counter guard so that nestedUserobjects are not serialized beyond one level.- Nullable object property —
addressinUserNormalizeruses a dedicated branch: it only callsnormalize()when the value is non-null, and falls back tonull(or skips entirely whenSKIP_NULL_VALUESis set). NormalizerAwareInterface—UserNormalizerandPostNormalizerreceive the parent normalizer viaNormalizerAwareTraitso they can delegate nested objects.AddressNormalizerhas no nested objects and therefore does not need it.
| Feature | Default | Description |
|---|---|---|
groups |
true |
Honours the groups serialization context key |
max_depth |
true |
Enforces the max_depth context constraint |
circular_reference |
true |
Detects and handles circular object references |
skip_null_values |
true |
Omits null properties when the context flag is set |
preserve_empty_objects |
true |
Returns \ArrayObject (JSON {}) instead of [] for empty results when the preserve_empty_objects context flag is set |
context |
true |
Merges property-specific context from #[Context] attributes |
attributes |
true |
Honours the AbstractNormalizer::ATTRIBUTES context key (normalizer & denormalizer) |
ignored_attributes |
true |
Honours the AbstractNormalizer::IGNORED_ATTRIBUTES context key (normalizer & denormalizer) |
Disabling a feature you don't need produces leaner, faster generated code.
The bundle supports the following Symfony Serializer attributes:
| Attribute | Description |
|---|---|
#[Groups] |
Define serialization groups for properties |
#[SerializedName] |
Customize the serialized property name |
#[Ignore] |
Exclude a property from serialization |
#[MaxDepth] |
Limit serialization depth for nested objects |
#[Context] |
Pass custom context to nested normalizers |
In addition to the attributes listed above, the generated normalizers honour the following context keys at runtime (matching Symfony's built-in normalizers):
| Context key | Source | Description |
|---|---|---|
AbstractNormalizer::GROUPS |
groups feature |
Restrict the output to properties belonging to the given groups. |
AbstractNormalizer::ATTRIBUTES |
attributes feature |
Allowlist of PHP property names to include during (de)normalization. An empty array produces an empty result. When a nested object is listed as an array-map value (e.g. ['id', 'author' => ['name']]), the sub-array is forwarded as the child's ATTRIBUTES context, limiting which of the nested object's properties are processed. When omitted or null, all properties are included. |
AbstractNormalizer::IGNORED_ATTRIBUTES |
ignored_attributes feature |
Denylist of PHP property names to skip during (de)normalization. Unlike ATTRIBUTES, this list is applied at every level of nested structures — properties named in it are excluded wherever they appear in the object graph. An empty array or null disables the filter entirely. |
AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT / CIRCULAR_REFERENCE_HANDLER |
circular_reference feature |
Control the circular-reference guard's limit and fallback handler. |
AbstractObjectNormalizer::SKIP_NULL_VALUES |
skip_null_values feature |
When true, properties whose value is null are omitted from the output. |
AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS |
preserve_empty_objects feature |
When true and the normalized result would otherwise be an empty array ([]), an \ArrayObject is returned instead so the value is encoded as an empty JSON object ({}). |
Denormalizer note:
AbstractNormalizer::ATTRIBUTESis checked against PHP property names in the generatedpopulate()phase. Properties whose PHP name is absent from the allowlist are skipped regardless of what keys are present in the input payload. For constructor parameters, a parameter not in the allowlist falls back to its declared default value instead of reading from the input data. TheGROUPScontext key is not applied in the generatedpopulate()phase — it is forwarded to any nested delegated denormalizer calls but has no effect on top-level scalar property population.Denormalizer note:
AbstractNormalizer::IGNORED_ATTRIBUTESworks as the mirror image ofATTRIBUTESduring denormalization. Properties whose PHP name appears in the denylist are skipped in thepopulate()phase; for constructor parameters, a listed parameter falls back to its declared default value instead of reading from the input data. When bothATTRIBUTESandIGNORED_ATTRIBUTESare present in the context,IGNORED_ATTRIBUTEStakes precedence — a property must pass the denylist check before the allowlist check is even evaluated.
The #[Context] attribute allows you to pass custom serialization context to nested normalizers. This is particularly useful for customizing how nested objects (like DateTimeImmutable) are serialized.
<?php
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
class Post
{
// Simple context - always applied
#[Groups(["post:read"])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private DateTimeImmutable $createdAt;
// Normalization-specific context
#[Groups(["post:read"])]
#[Context(normalizationContext: [DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private DateTimeImmutable $publishedAt;
// Group-specific context - different formats for different groups
#[Groups(["post:read", "post:api"])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i:s'], groups: ["post:read"])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'c'], groups: ["post:api"])]
private DateTimeImmutable $updatedAt;
}The #[Context] attribute supports:
context: Common context applied to both normalization and denormalizationnormalizationContext: Context applied only during normalization (serialization)denormalizationContext: Context applied only during denormalization (deserialization)groups: Optional groups to conditionally apply the context (the attribute is repeatable)
When multiple #[Context] attributes are present with different groups, the appropriate context is merged based on the active serialization groups.
- PHP 8.1.*
- Symfony 6.4.*
MIT — see LICENSE for details.