You are browsing a version that has not yet been released.

Example Usage in an API Context

APIs typically exchange entities as plain data, e.g. as JSON. This page shows how to configure the hydrator for that purpose: associations and embeddables are represented as nested arrays, enums and dates as scalar values, and the keys of the data use snake case.

The Entities

The example uses a blog post with an enum, a date, an author and tags. The author has an address, which is an embeddable. Getters and setters are omitted for brevity:

namespace Application\Entity;use DateTimeImmutable;use Doctrine\Common\Collections\ArrayCollection;use Doctrine\Common\Collections\Collection;use Doctrine\ORM\Mapping as ORM;enum PostStatus: string{    case Draft = 'draft';    case Published = 'published';}#[ORM\Entity]class BlogPost{    #[ORM\Id]    #[ORM\Column(type: 'integer')]    #[ORM\GeneratedValue]    private ?int $id = null;    #[ORM\Column(type: 'string')]    private ?string $title = null;    #[ORM\Column(type: 'string', enumType: PostStatus::class)]    private PostStatus $status = PostStatus::Draft;    #[ORM\Column(type: 'datetime_immutable', nullable: true)]    private ?DateTimeImmutable $publishedAt = null;    #[ORM\ManyToOne(targetEntity: User::class)]    private ?User $author = null;    #[ORM\ManyToMany(targetEntity: Tag::class)]    private Collection $tags;    public function __construct()    {        $this->tags = new ArrayCollection();    }    // … getters and setters, addTags() and removeTags() …}#[ORM\Entity]class User{    #[ORM\Id]    #[ORM\Column(type: 'integer')]    #[ORM\GeneratedValue]    private ?int $id = null;    #[ORM\Column(type: 'string')]    private ?string $name = null;    #[ORM\Column(type: 'string')]    private ?string $password = null;    #[ORM\Embedded(class: Address::class)]    private Address $address;    public function __construct()    {        $this->address = new Address();    }    // … getters and setters …}#[ORM\Embeddable]class Address{    #[ORM\Column(type: 'string', nullable: true)]    private ?string $postalCode = null;    #[ORM\Column(type: 'string', nullable: true)]    private ?string $city = null;    // … getters and setters …}#[ORM\Entity]class Tag{    #[ORM\Id]    #[ORM\Column(type: 'integer')]    #[ORM\GeneratedValue]    private ?int $id = null;    #[ORM\Column(type: 'string')]    private ?string $name = null;    // … getters and setters …}

Configuring the Hydrator

The hydrator for the API is configured once, e.g. in a factory:

use Doctrine\Laminas\Hydrator\AssociationExtraction;use Doctrine\Laminas\Hydrator\DoctrineObject as DoctrineHydrator;use Doctrine\Laminas\Hydrator\DoctrineTypeConverter;use Doctrine\Laminas\Hydrator\EmbeddableExtraction;use Doctrine\Laminas\Hydrator\Filter\PropertyName;use Doctrine\ORM\EntityManagerInterface;use Laminas\Hydrator\NamingStrategy\UnderscoreNamingStrategy;function createApiHydrator(EntityManagerInterface $entityManager): DoctrineHydrator{    // Extract enums as their backing values and dates as strings    $hydrator = new DoctrineHydrator(        $entityManager,        typeConverter: new DoctrineTypeConverter(extractEnums: true, extractDateTimes: true),    );    // Represent associated entities and embeddables as nested arrays    $hydrator->setAssociationExtraction(AssociationExtraction::Array);    $hydrator->setEmbeddableExtraction(EmbeddableExtraction::Array);    // Use snake case keys, e.g. "published_at"    $hydrator->setNamingStrategy(new UnderscoreNamingStrategy());    // Never expose the passwords of users    $hydrator->getAssociationHydrator(User::class)        ->addFilter('password', new PropertyName(['password']));    return $hydrator;}

The type converter, the naming strategy and the representation of associations and embeddables apply to associated entities as well. Filters and strategies are configured per class, as the filter for users above, see Configuring Associated Entities.

Reading Entities

To return an entity, extract it and encode the data:

$hydrator = createApiHydrator($entityManager);$blogPost = $entityManager->find(BlogPost::class, 5);echo json_encode($hydrator->extract($blogPost), JSON_THROW_ON_ERROR);

This results in:

{    "id": 5,    "title": "Doctrine Laminas Hydrator 4.0",    "status": "published",    "published_at": "2026-10-29T14:30:00+01:00",    "author": {        "id": 2,        "name": "Jane",        "address": {            "postal_code": "20095",            "city": "Hamburg"        }    },    "tags": [        {"id": 3, "name": "php"},        {"id": 8, "name": "doctrine"}    ]}

Only the associations of the blog post are extracted as arrays, the associations of the author would be extracted as identifiers. To extract more levels, pass the maximum depth to setAssociationExtraction(), see Extracting Associations.

When extracting lists of entities, fetch the associations you extract along with the entities, so they are not loaded one by one:

$blogPosts = $entityManager->createQueryBuilder()    ->select('post', 'author', 'tag')    ->from(BlogPost::class, 'post')    ->leftJoin('post.author', 'author')    ->leftJoin('post.tags', 'tag')    ->getQuery()    ->getResult();echo json_encode(array_map($hydrator->extract(...), $blogPosts), JSON_THROW_ON_ERROR);

See Performance Considerations for details.

Writing Entities

Unlike when extracting, filters do not apply when hydrating: every field of the entity contained in the data is written, including its identifier and its associations. Moreover, if the data contains an identifier, the hydrator loads the entity with that identifier and hydrates it instead of the given one. Therefore, only pass the fields clients are allowed to change, and validate them, e.g. using laminas-inputfilter:

$input = json_decode((string) $request->getBody(), true, flags: JSON_THROW_ON_ERROR);// Only accept the fields clients are allowed to change$data = array_intersect_key($input, array_flip(['title', 'status', 'published_at', 'author', 'tags']));

Creating an entity hydrates a new instance:

$blogPost = $hydrator->hydrate($data, new BlogPost());$entityManager->persist($blogPost);$entityManager->flush();

with a request like:

{    "title": "Doctrine Laminas Hydrator 4.0",    "status": "draft",    "author": 2,    "tags": [3, 8]}

The status is converted to PostStatus::Draft based on the enumType of its mapping, and the author and the tags are loaded by their identifiers. Identifiers are accepted for associations regardless of the association extraction, so clients can reference existing entities without sending their data.

Updating an entity hydrates the existing instance. Only the fields contained in the data are written, so the same code works for partial updates:

$blogPost = $entityManager->find(BlogPost::class, $id);$hydrator->hydrate($data, $blogPost);$entityManager->flush();

Note that a collection given in the data replaces the elements of the collection: "tags": [3] removes all other tags of the blog post. To only add elements, use a DisallowRemove* strategy, see Working with Associations.

Nested Data

The hydrator also accepts nested arrays for associations, in the format they are extracted with AssociationExtraction::Array. A nested array is hydrated into the referenced entity, so a request like "author": {"id": 2, "name": "Joe"} renames the user with the identifier 2. Only accept nested data for associations if clients are allowed to change the associated entities, and restrict their fields as well. Otherwise, accept identifiers only.

Embeddables are hydrated from nested arrays as they are extracted, writing only the given fields. For example, updating a user with {"address": {"city": "Berlin"}} changes the city and keeps the postal code. If you prefer flat data, e.g. "address__city", use EmbeddableExtraction::Flattened instead, see Embeddables as Arrays.

Invalid Data

Values that cannot be converted cause exceptions while hydrating, e.g. a ValueError for an invalid enum value, or an exception of DateTimeImmutable for an invalid date. Validate the data before hydrating it to return a meaningful error response to the client.