The Serializer

Normalizers and encoders, serializing an object graph, and the attributes that stop a cycle or a runaway depth.

Syntax$serializer->serialize(mixed $data, string $format, array $context = []): string

The Serializer turns objects into strings and strings back into objects. It works in two steps. A normalizer converts an object into an array of scalars and nested arrays. An encoder turns that array into a format: JSON, XML, CSV, YAML. Deserializing runs the pair in reverse — decode, then denormalize.

Using it

Inject SerializerInterface. It has two methods: serialize($data, $format, $context) returns a string, and deserialize($data, $type, $format, $context) returns an object of $type. The last argument is the context, an array of options that every normalizer in the chain can read. Each option has a string key and a matching constant holding that same string, so 'groups' and AbstractNormalizer::GROUPS are interchangeable.

Object graphs

A relation becomes a nested array, and the nesting continues as far as the objects do. Two things go wrong. A cycle — article to author, author back to articles — throws CircularReferenceException, unless the context sets circular_reference_handler to a callable that returns something flat, usually the id. Depth on its own is limited with #[MaxDepth(n)] on the property, which the normalizer only consults when the context sets enable_max_depth to true. #[Ignore] removes a property in both directions.

What trips people up

Serializing an entity loads whatever it reaches. Hand the serializer a collection of rows that each carry a lazy relation and you get one query per row. Fetch-join the data first, or serialize a shape built for the response.

Example

Example · php
// src/Api/ArticlePayload.php
declare(strict_types=1);

namespace App\Api;

use App\Entity\Article;
use App\Entity\Author;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
use Symfony\Component\Serializer\SerializerInterface;

final class ArticlePayload
{
    // Type-hinted on the interface and injected, so this class can be
    // tested with any serializer.
    public function __construct(private readonly SerializerInterface $serializer)
    {
    }

    public function toJson(Article $article): string
    {
        // Shorthand: plain string keys.
        return $this->serializer->serialize($article, 'json', [
            'enable_max_depth' => true,
            'circular_reference_handler' => static fn (Article|Author $related): ?int => $related->getId(),
        ]);
    }

    public function toJsonLonghand(Article $article): string
    {
        // Longhand: the constants hold exactly those strings, so this is the
        // same array as above.
        return $this->serializer->serialize($article, 'json', [
            AbstractObjectNormalizer::ENABLE_MAX_DEPTH => true,
            AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => static fn (Article|Author $related): ?int => $related->getId(),
        ]);
    }
}

// Output: toJson() on article 7 returns
// {"id":7,"title":"Routing","author":{"id":3,"name":"Ada","articles":[7]}}
// The handler replaced the nested article object with its id.

When to use it

  • Turn an entity and its author into one JSON document for a detail endpoint.
  • Stop a two-way relation from throwing a circular reference error during serialization.
  • Update a managed entity straight from a PATCH body without writing a setter call per field.
  • Give a money or date-range value object a stable JSON shape across the whole API.

More examples

Ignore and MaxDepth on the entity

These two attributes are declarative limits that travel with the class, so every endpoint gets them without repeating context options. <code>#[Ignore]</code> is the one to reach for on anything the outside world must never see, because it holds in both directions: a client cannot set it by sending the key either. <code>#[MaxDepth(1)]</code> stops the author's articles from expanding into full objects, but only when the calling context enables max depth.

Example · php
// src/Entity/Author.php
declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\Component\Serializer\Attribute\MaxDepth;

#[ORM\Entity]
class Author
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 120)]
    public string $name = '';

    // Never leaves the application, and never arrives from a client either.
    #[ORM\Column(type: 'text')]
    #[Ignore]
    public string $internalNotes = '';

    /** @var Collection<int, Article> */
    #[ORM\OneToMany(targetEntity: Article::class, mappedBy: 'author')]
    #[MaxDepth(1)]
    private Collection $articles;

    public function __construct()
    {
        $this->articles = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    /** @return Collection<int, Article> */
    public function getArticles(): Collection
    {
        return $this->articles;
    }
}

Deserializing into an existing object

This is how a PATCH or PUT endpoint updates a row without copying field by field. <code>object_to_populate</code> tells the normalizer to set values on an object you already have, which for a Doctrine-managed entity means the change is tracked and <code>flush()</code> alone saves it. Pair it with input groups so a client cannot overwrite fields that are not theirs to set.

Example · php
// src/Api/ArticleUpdater.php
declare(strict_types=1);

namespace App\Api;

use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;

final class ArticleUpdater
{
    public function __construct(
        private readonly SerializerInterface $serializer,
        private readonly EntityManagerInterface $entityManager,
    ) {
    }

    public function applyJson(Article $article, string $json): Article
    {
        $this->serializer->deserialize($json, Article::class, 'json', [
            // Writes into the object you pass instead of building a new one.
            AbstractNormalizer::OBJECT_TO_POPULATE => $article,
            // Shorthand for the same option: 'object_to_populate' => $article,
        ]);

        // The entity is already managed by Doctrine, so there is no persist()
        // to make; flush() writes the change.
        $this->entityManager->flush();

        return $article;
    }
}

A normalizer for a value object

The default object normalizer would expose a value object's internals as whatever its accessors happen to be named. A dedicated normalizer decides the wire shape once, for every endpoint. Implementing <code>NormalizerInterface</code> is enough for the class to be registered automatically; no configuration file is involved. <code>getSupportedTypes()</code> lets the serializer cache its decisions, and returning <code>true</code> means the answer never depends on the context.

Example · php
// src/Serializer/MoneyNormalizer.php
declare(strict_types=1);

namespace App\Serializer;

use App\Money;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

final class MoneyNormalizer implements NormalizerInterface
{
    /**
     * @param Money $data
     *
     * @return array{amount: int, currency: string}
     */
    public function normalize(mixed $data, ?string $format = null, array $context = []): array
    {
        return [
            'amount' => $data->minorUnits,
            'currency' => $data->currency,
        ];
    }

    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
    {
        return $data instanceof Money;
    }

    /**
     * @return array<string, bool|null>
     */
    public function getSupportedTypes(?string $format): array
    {
        return [Money::class => true];
    }
}

Discussion

  • Be the first to comment on this lesson.