DEV Community

Cover image for Symfony Serializer at the Boundary: DTOs In, Entities Never Out
Gabriel Anhaia
Gabriel Anhaia

Posted on

Symfony Serializer at the Boundary: DTOs In, Entities Never Out


You add one field to a Doctrine entity. A passwordHash column, internal, never meant for anyone outside the app. Two weeks later a security researcher emails: your /api/users/42 endpoint returns it. Nobody wrote code to expose it. The controller just did return $this->json($user) and the serializer walked every public getter it could find.

That's the failure mode of serializing entities directly. The entity is shaped for the database: every column, every relation, every getter. The serializer doesn't know which of those are meant for the wire. So it ships all of them, and your persistence layer becomes your public contract by accident.

The fix is a boundary. DTOs come in from the request, DTOs go out in the response, and the entity never touches the serializer. Here's how to hold that line in Symfony.

Why entities leak

An entity answers to Doctrine. It has a surrogate primary key, foreign keys, lazy-loaded collections, an updatedAt you set with a lifecycle callback. None of that is part of what a client should see.

<?php
// src/Entity/User.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

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

    #[ORM\Column(length: 180, unique: true)]
    private string $email;

    #[ORM\Column]
    private string $passwordHash;

    #[ORM\Column(length: 255)]
    private string $displayName;

    #[ORM\OneToMany(
        targetEntity: Order::class,
        mappedBy: 'user'
    )]
    private Collection $orders;

    // getters for every one of these...
}
Enter fullscreen mode Exit fullscreen mode

Hand that to $this->json($user) and you get passwordHash on the wire, plus a lazy load of every order the moment the serializer reads getOrders(). One JSON call, an N+1 query storm, and a leaked credential hash. The serializer did exactly what it was built to do. You pointed it at the wrong object.

The output DTO

Build a class that describes the response, and nothing else. It has the fields you want public, in the shape you want them, with no Doctrine annotations and no behavior.

<?php
// src/Api/Dto/UserResponse.php
namespace App\Api\Dto;

final readonly class UserResponse
{
    public function __construct(
        public int $id,
        public string $email,
        public string $displayName,
    ) {
    }

    public static function fromEntity(User $user): self
    {
        return new self(
            id: $user->getId(),
            email: $user->getEmail(),
            displayName: $user->getDisplayName(),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

There is no passwordHash here, so there is no way to leak it. Adding a column to User next month changes nothing about the API. To expose a new field you edit the DTO on purpose, which is the whole point. The wire shape is a decision, not a side effect.

The controller reads clean:

<?php
// src/Controller/UserController.php
#[Route('/api/users/{id}', methods: ['GET'])]
public function show(User $user): JsonResponse
{
    return $this->json(UserResponse::fromEntity($user));
}
Enter fullscreen mode Exit fullscreen mode

Groups: one DTO, several views

Sometimes the same resource needs a public view and an admin view. Instead of two DTO classes, tag fields with serialization groups and choose the group per endpoint.

<?php
// src/Api/Dto/UserResponse.php
namespace App\Api\Dto;

use Symfony\Component\Serializer\Attribute\Groups;

final readonly class UserResponse
{
    public function __construct(
        #[Groups(['user:public', 'user:admin'])]
        public int $id,

        #[Groups(['user:public', 'user:admin'])]
        public string $email,

        #[Groups(['user:admin'])]
        public string $internalNote,
    ) {
    }
}
Enter fullscreen mode Exit fullscreen mode

The controller names the group it wants. Anything outside that group stays home.

<?php
return $this->json($dto, context: [
    'groups' => ['user:public'],
]);
Enter fullscreen mode Exit fullscreen mode

Call it with user:public and internalNote never serializes. Call it with user:admin from an admin-only route and it does. Watch the default, though: forget the groups context entirely and the serializer ignores the groups and ships every property, internalNote included. Groups only filter when you pass them, so make passing the group non-optional at the boundary. For fields that must never leak under any group, reach for #[Ignore], which does fail closed.

Ignore what should never ship

For a field that must never cross the wire under any group, don't rely on remembering to omit it. Mark it with #[Ignore] so the serializer refuses to touch it.

<?php
use Symfony\Component\Serializer\Attribute\Ignore;

final class InvoiceResponse
{
    public function __construct(
        public string $number,
        public int $amountCents,
        #[Ignore]
        public string $internalLedgerRef,
    ) {
    }
}
Enter fullscreen mode Exit fullscreen mode

internalLedgerRef is available to your code but invisible to the serializer. This is the belt to groups' suspenders: groups decide which view a field belongs to, #[Ignore] says "no view, ever." Use it for anything that would be a real incident if it leaked.

Circular references: the entity trap you inherit

Circular references are the reason "just serialize the entity" blows up even when security isn't in play. User has orders, each Order has a user, and the serializer recurses until it hits its depth limit and throws CircularReferenceException.

DTOs sidestep this by construction: a UserResponse holds an array of OrderSummary, and OrderSummary holds an order id, not a back-reference to the user. The cycle can't form because you never modeled it.

<?php
final readonly class UserResponse
{
    public function __construct(
        public int $id,
        public string $email,
        /** @var list<OrderSummary> */
        public array $orders,
    ) {
    }
}

final readonly class OrderSummary
{
    public function __construct(
        public int $id,
        public string $status,
    ) {
    }
}
Enter fullscreen mode Exit fullscreen mode

If you're mid-migration and still handing an entity to the serializer somewhere, Symfony gives you an escape hatch: a circular reference handler that returns an identifier instead of recursing.

<?php
$context = [
    AbstractObjectNormalizer::CIRCULAR_REFERENCE_HANDLER =>
        fn (object $o) => method_exists($o, 'getId')
            ? $o->getId()
            : spl_object_id($o),
];
Enter fullscreen mode Exit fullscreen mode

Treat that as a bridge, not a destination. The real fix is that response DTOs don't have cycles to handle.

Input DTOs: the same wall, other direction

The boundary runs both ways. On the way in, don't let the serializer hydrate an entity straight from request JSON. That's mass assignment: a client sends {"role": "admin"} and the deserializer happily calls setRole(). Deserialize into an input DTO instead, validate it, then map to the entity yourself.

<?php
// src/Api/Dto/CreateUserRequest.php
namespace App\Api\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final readonly class CreateUserRequest
{
    public function __construct(
        #[Assert\NotBlank, Assert\Email]
        public string $email,

        #[Assert\NotBlank, Assert\Length(min: 12)]
        public string $password,
    ) {
    }
}
Enter fullscreen mode Exit fullscreen mode

With Symfony's #[MapRequestPayload], the framework deserializes and validates before your controller runs. You get a typed, checked DTO or a 422, never a half-built entity.

<?php
#[Route('/api/users', methods: ['POST'])]
public function create(
    #[MapRequestPayload] CreateUserRequest $req,
    UserService $users,
): JsonResponse {
    $user = $users->register($req->email, $req->password);

    return $this->json(
        UserResponse::fromEntity($user),
        status: 201,
    );
}
Enter fullscreen mode Exit fullscreen mode

The client can send role, id, isVerified, whatever it wants. CreateUserRequest has two properties, so the extra keys go nowhere. The entity is built by UserService from validated primitives, on your terms.

Where the mapping lives

Keep the fromEntity() and the reverse mapping out of the controller and out of the entity. A small mapper or a factory method on the DTO is enough for most apps. What matters is that one layer owns the translation between the persistence shape and the wire shape, and that neither side knows about the other. The entity doesn't import the DTO. The DTO doesn't carry Doctrine attributes. The serializer only ever sees the DTO.

When that wall holds, you can rename a column, split a table, or swap Doctrine for something else, and the API contract doesn't move. When it doesn't hold, every schema change is a potential breaking change for every client, and you find out in production.

What's the worst thing you've seen leak out of a return $this->json($entity)? Drop it in the comments.


The serializer is a boundary tool, and boundaries are exactly where a decoupled architecture earns its keep. Keeping DTOs at the edge is the same move you make for queues, events, and every other seam where the framework wants to reach into your domain: persistence shape on one side, wire shape on the other, a mapper between them. Decoupled PHP is the book I wrote about drawing those lines and keeping them drawn as the app grows.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)