Enhanced your Symfony Application Performance with Doctrine Custom Hydrators and DTOs

Vandeth Tho
10 min readJan 30, 2024

--

In the world of web development, particularly when using PHP and frameworks like Symfony, performance optimization is a crucial aspect. One effective method to boost performance is through the use of Data Transfer Objects (DTOs). In this blog, we’ll dive into creating a custom hydrator in Doctrine and Symfony to generate DTOs, aiming to enhance performance significantly.

What is DTOs ?

Before we proceed, let’s clarify what a DTO is. A DTO is an object that aggregates data and is typically used to transfer data between software application subsystems. Unlike entities that are often tied to database schema, DTOs are simpler objects, designed for specific tasks like transferring data over a network.

Why Using Custom Hydrators for DTOs?

Doctrine, the default ORM for Symfony, is highly efficient but can be optimized further. The typical Doctrine approach is to load complete entities, which can be resource-intensive, especially when dealing with large datasets. By using custom hydrators to generate DTOs, we can load only the necessary data, reducing memory usage and improving execution time.

Step-by-Step Implementation

For this blog, we will use this GitHub repository: https://github.com/vandetho/blog_app

1. Setting Up the Environment

Make sure you have Symfony and Doctrine installed. You can set up a new Symfony project and include Doctrine by using Composer:

composer require symfony/orm-pack property-access phpdocumentor/reflection-docblock
composer require symfony/maker-bundle --dev

2. Creating the Entity

Create our entities:

App\Entity\Blog :

<?php
declare(strict_types=1);

namespace App\Entity;

use App\Repository\BlogRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

/**
* Class Blog
*
* @package App\Entity
* @author Vandeth THO <thovandeth@gmail.com>
*/
#[ORM\Entity(repositoryClass: BlogRepository::class)]
class Blog
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

/**
* @var string|null
*/
#[ORM\Column(length: 255, unique: true, nullable: true)]
private ?string $title = null;

/**
* @var string|null
*/
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $content = null;

/**
* @var string|null
*/
#[ORM\Column(length: 255)]
private ?string $state = null;

#[ORM\ManyToOne(inversedBy: 'blogs')]
private ?User $user = null;

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

public function getTitle(): ?string
{
return $this->title;
}

public function setTitle(?string $title): Blog
{
$this->title = $title;
return $this;
}

public function getContent(): ?string
{
return $this->content;
}

public function setContent(?string $content): Blog
{
$this->content = $content;
return $this;
}

public function getState(): ?string
{
return $this->state;
}

public function setState(?string $state): Blog
{
$this->state = $state;
return $this;
}

public function getUser(): ?User
{
return $this->user;
}

public function setUser(?User $user): static
{
$this->user = $user;

return $this;
}
}

App\Entity\User :

<?php

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: UserRepository::class)]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private ?string $username = null;

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

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

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

public function getUsername(): ?string
{
return $this->username;
}

public function setUsername(string $username): static
{
$this->username = $username;

return $this;
}

/**
* @return Collection<int, Blog>
*/
public function getBlogs(): Collection
{
return $this->blogs;
}

public function addBlog(Blog $blog): static
{
if (!$this->blogs->contains($blog)) {
$this->blogs->add($blog);
$blog->setUser($this);
}

return $this;
}

public function removeBlog(Blog $blog): static
{
if ($this->blogs->removeElement($blog)) {
// set the owning side to null (unless already changed)
if ($blog->getUser() === $this) {
$blog->setUser(null);
}
}

return $this;
}
}

3. Creating the DTO

Create DTOs that represents the data you want to transfer.

App\DTO\Blog :

<?php
declare(strict_types=1);

namespace App\DTO;

/**
* Class Blog
* @package App\DTO
* @author Vandeth THO <thovandeth@gmail.com>
*/
class Blog
{

/**
* @var int|null
*/
public ?int $id = null;

/**
* @var string|null
*/
public ?string $title = null;

/**
* @var string|null
*/
public ?string $content = null;

/**
* @var string|null
*/
public ?string $state = null;

/**
* @var User|null
*/
public ?User $user = null;
}

App\DTO\User :

<?php
declare(strict_types=1);

namespace App\DTO;

class User
{
public ?int $id = null;

public ?string $username = null;
}

3. Implementing the Custom Hydrator

Create a custom hydrator in Doctrine. This hydrator will be responsible for converting the result set into your DTO, in here, I use my DTO class as custom hydrator reference when register in config/packages/doctrine.yaml:

App\Hydrator\AbstractHydrator :

<?php
declare(strict_types=1);

namespace App\Hydrator;

use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Internal\Hydration\AbstractHydrator as BaseAbstractHydrator;
use Exception;
use JsonException;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessor;
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
use Symfony\Component\PropertyInfo\Type;

/**
* Class AbstractHydrator
* @package App\Hydrator
* @author Vandeth THO <thovandeth@gmail.com>
*/
class AbstractHydrator extends BaseAbstractHydrator
{
/**
* @var PropertyAccessor
*/
protected PropertyAccessor $propertyAccessor;

/**
* @var PropertyInfoExtractor
*/
protected PropertyInfoExtractor $propertyInfo;

/**
* AbstractHydrator constructor.
*
* @param EntityManagerInterface $em
* @param string $dtoClass
*/
public function __construct(EntityManagerInterface $em, private readonly string $dtoClass)
{
parent::__construct($em);
$this->propertyAccessor = PropertyAccess::createPropertyAccessorBuilder()
->enableMagicMethods()
->getPropertyAccessor();
$phpDocExtractor = new PhpDocExtractor();
$reflectionExtractor = new ReflectionExtractor();
$listExtractors = [$reflectionExtractor];
$typeExtractors = [$phpDocExtractor, $reflectionExtractor];
$descriptionExtractors = [$phpDocExtractor];
$accessExtractors = [$reflectionExtractor];
$propertyInitializableExtractors = [$reflectionExtractor];
$this->propertyInfo = new PropertyInfoExtractor(
$listExtractors,
$typeExtractors,
$descriptionExtractors,
$accessExtractors,
$propertyInitializableExtractors
);
}

/**
* @param string $key
* @param mixed $value
* @param string|null $dtoClass
* @return mixed
* @throws JsonException
*/
protected function getValue(string $key, mixed $value, string $dtoClass = null): mixed
{
$types = $this->propertyInfo->getTypes($dtoClass, $key);
if (is_array($types) && count($types) > 0) {
if (
$types[0]->getBuiltinType() === Type::BUILTIN_TYPE_OBJECT
&&
in_array($types[0]->getClassName(), [
DateTime::class,
DateTimeImmutable::class,
], true)) {
$class = $types[0]->getClassName();

return new $class($value);
}
if ($types[0]->getBuiltinType() === Type::BUILTIN_TYPE_ARRAY) {
return json_decode($value, true, 512, JSON_THROW_ON_ERROR);
}
}

return $value;
}


/**
* @inheritDoc
* @return array
* @throws \Doctrine\DBAL\Exception
* @throws Exception
*/
protected function hydrateAllData(): array
{
$results = [];
foreach ($this->_stmt->fetchAllAssociative() as $row) {
$this->hydrateRowData($row, $results);
}

return $results;
}


/**
* @param array $row
* @param array $result
* @return void
* @throws Exception
*/
protected function hydrateRowData(array $row, array &$result): void
{
$dto = new $this->dtoClass();
$class = null;
foreach ($row as $key => $value) {
if (null !== $finalValue = $value) {
$properties = explode('_', $this->_rsm->getScalarAlias($key));
if (count($properties) > 0) {
if (count($properties) === 1) {
if ($this->propertyAccessor->isWritable($dto, $properties[0])) {
$finalValue = $this->getValue($properties[0], $finalValue, $this->dtoClass);
$this->propertyAccessor->setValue($dto, $properties[0], $finalValue);
}
continue;
}
$alias = [];
$path = '';
$count = count($properties) - 1;
foreach ($properties as $property) {
$alias[] = $property;
$path = implode('.', $alias);
if (null === $types = $this->propertyInfo->getTypes($this->dtoClass, $path)) {
$previous = $alias;
unset($previous[count($alias) - 1]);
if (null !== $previousType = $this->propertyInfo->getTypes($this->dtoClass, implode('.', $previous))) {
$types = $this->propertyInfo->getTypes($previousType[0]->getClassName(), $property);
}
}
if (is_array($types)
&& isset($types[0])
&& $types[0]->getBuiltinType() === Type::BUILTIN_TYPE_OBJECT
&& $this->propertyAccessor->getValue($dto, $path) === null
&& !in_array($types[0]->getClassName(), [
DateTimeInterface::class,
DateTime::class,
DateTimeImmutable::class,
], true)
) {
$class = $types[0]->getClassName();
$this->propertyAccessor->setValue($dto, $path, new $class());
}
}
$finalValue = $this->getValue($properties[$count], $finalValue, $class);
$this->propertyAccessor->setValue($dto, $path, $finalValue);
}
}
}
$result[] = $dto;
}
}

App\Hydrator\BlogHydrator :

<?php
declare(strict_types=1);


namespace App\Hydrator;

use App\DTO\Blog;
use Doctrine\ORM\EntityManagerInterface;

/**
* Class BlogHydrator
* @package App\Hydrator
* @author Vandeth THO <thovandeth@gmail.com>
*/
class BlogHydrator extends AbstractHydrator
{
/**
* BlogHydrator constructor.
*
* @param EntityManagerInterface $em
* @param string $dtoClass
*/
public function __construct(EntityManagerInterface $em, string $dtoClass = Blog::class)
{
parent::__construct($em, $dtoClass);
}
}

App\Hydrator\UserHydrator :

<?php
declare(strict_types=1);


namespace App\Hydrator;

use App\DTO\User;
use Doctrine\ORM\EntityManagerInterface;

/**
* Class UserHydrator
* @package App\Hydrator
* @author Vandeth THO <thovandeth@gmail.com>
*/
class UserHydrator extends AbstractHydrator
{
/**
* UserHydrator constructor.
*
* @param EntityManagerInterface $em
* @param string $dtoClass
*/
public function __construct(EntityManagerInterface $em, string $dtoClass = User::class)
{
parent::__construct($em, $dtoClass);
}
}

By creating an abstract class that will later be inherited, it allows you to easy create a new custom hydrator for each DTO you have.

4. Registering the Custom Hydrator

Register the custom hydrator with Doctrine in your Symfony configuration:

doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'

# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'

profiling_collect_backtrace: '%kernel.debug%'
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
hydrators:
App\DTO\Blog: App\Hydrator\BlogHydrator
App\DTO\User: App\Hydrator\UserHydrator
# All custom hydrators will be registered here

when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'

when@prod:
doctrine:
orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool

framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

5. Update our repository

Let update our BlogRepository to be able automatically call our custom hydrator:

App\Repository\BlogRepository:

<?php
declare(strict_types=1);

namespace App\Repository;

use App\Entity\Blog;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\Persistence\ManagerRegistry;

/**
* Class BlogRepository
* @package App\Repository
* @author Vandeth THO <thovandeth@gmail.com>
*
* @extends ServiceEntityRepository<Blog>
*
* @method Blog|null find($id, $lockMode = null, $lockVersion = null)
* @method Blog|null findOneBy(array $criteria, array $orderBy = null)
* @method Blog[] findAll()
* @method Blog[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class BlogRepository extends ServiceEntityRepository
{
/**
* BlogRepository constructor.
*
* @param ManagerRegistry $registry
* @param string $dtoClass
*/
public function __construct(
ManagerRegistry $registry,
private readonly string $dtoClass = \App\DTO\Blog::class
){
parent::__construct($registry, Blog::class);
}

/**
* @return array|\App\DTO\Blog[]
*/
public function findAsDTO(): array
{
return $this->createQueryBuilder('b')
->select([
'b.id',
'b.title',
'b.content',
'b.state',
// The custom hydrator will automatically initialise User DTO
'u.id as user_id',
'u.username as user_username',
])
->innerJoin('b.user', 'u')
->getQuery()
->getResult($this->dtoClass);
}

/**
* @param int $id
* @return \App\DTO\Blog|null
*
* @throws NonUniqueResultException
*/
public function findByIdAsDTO(int $id): ?\App\DTO\Blog
{
return $this->createQueryBuilder('b')
->select([
'b.id',
'b.title',
'b.content',
'b.state',
// The custom hydrator will automatically initialise User DTO
'u.id as user_id',
'u.username as user_username',
])
->innerJoin('b.user', 'u')
->where('b.id = :id')
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult($this->dtoClass);
}

/**
* @return Blog
*/
public function create(): Blog
{
return new $this->_entityName;
}

/**
* Save an object in the database
*
* @param Blog $blog
* @param bool $andFlush tell the manager whether the object needs to be flush or not
*/
public function save(Blog $blog, bool $andFlush = true): void
{
$this->persist($blog);
if ($andFlush) {
$this->flush();
}
}

/**
* @param Blog $blog
*
* @return void
*/
public function persist(Blog $blog): void
{
$this->getEntityManager()->persist($blog);
}

/**
* @param Blog $blog
* @return void
*/
public function remove(Blog $blog): void
{
$this->getEntityManager()->remove($blog);
}

/**
* @param Blog $blog
*/
public function reload(Blog $blog): void
{
$this->getEntityManager()->refresh($blog);
}

/**
* Flushes all changes to object that have been queued up too now to the database.
* This effectively synchronizes the in-memory state of managed objects with the
* database.
*
* @return void
*/
public function flush(): void
{
$this->getEntityManager()->flush();
}
}

As you can see, we use mysql alias as for our ManyToOne relation, and use underscores to separate our class name and its property name. Our custom hydrator will automatically initialize the nested DTO.

App\Repository\UserRepository :

<?php

namespace App\Repository;

use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
* @extends ServiceEntityRepository<User>
*
* @method User|null find($id, $lockMode = null, $lockVersion = null)
* @method User|null findOneBy(array $criteria, array $orderBy = null)
* @method User[] findAll()
* @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}

/**
* @return User
*/
public function create(): User
{
return new $this->_entityName;
}

/**
* Save an object in the database
*
* @param User $blog
* @param bool $andFlush tell the manager whether the object needs to be flush or not
*/
public function save(User $blog, bool $andFlush = true): void
{
$this->persist($blog);
if ($andFlush) {
$this->flush();
}
}

/**
* @param User $blog
*
* @return void
*/
public function persist(User $blog): void
{
$this->getEntityManager()->persist($blog);
}

/**
* @param User $blog
* @return void
*/
public function remove(User $blog): void
{
$this->getEntityManager()->remove($blog);
}

/**
* @param User $blog
*/
public function reload(User $blog): void
{
$this->getEntityManager()->refresh($blog);
}

/**
* Flushes all changes to object that have been queued up too now to the database.
* This effectively synchronizes the in-memory state of managed objects with the
* database.
*
* @return void
*/
public function flush(): void
{
$this->getEntityManager()->flush();
}
}

6. Testing the repository

In here , we will write a unit test to test our hydrator:

<?php
declare(strict_types=1);

namespace App\Tests\Repository;

use App\DTO\Blog;
use App\DTO\User as UserDTO;
use App\Entity\User;
use App\Repository\BlogRepository;
use App\Repository\UserRepository;
use App\Workflow\State\BlogState;
use Doctrine\ORM\NonUniqueResultException;
use Faker\Factory;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class BlogRepositoryTest extends KernelTestCase
{
private BlogRepository $blogRepository;
private UserRepository $userRepository;

protected function setUp(): void
{
$this->blogRepository = self::getContainer()->get(BlogRepository::class);
$this->userRepository = self::getContainer()->get(UserRepository::class);
}

/**
* @throws NonUniqueResultException
*/
public function testCreateBlog(): int
{

$faker = Factory::create();
$user = new User();
$user->setUsername($faker->userName());
$this->userRepository->save($user);

$title = $faker->sentence();
$content = $faker->paragraph();
$blog = $this->blogRepository->create();
$blog->setTitle($title);
$blog->setContent($content);
$blog->setState(BlogState::NEW_BLOG);
$blog->setUser($user);
$this->blogRepository->save($blog);

$dto = $this->blogRepository->findByIdAsDTO($blog->getId());
$this->assertNotNull($dto);
$this->assertSame(get_class($dto), Blog::class);
$this->assertSame(get_class($dto->user), UserDTO::class);
$this->assertSame($dto->user->username, $user->getUsername());
return $dto->id;
}
}

Benefits

By using a custom hydrator to generate DTOs, you significantly reduce the overhead of loading full entities. This approach can lead to:

  • Reduced Memory Usage: Loading only necessary data means less memory consumption.
  • Improved Performance: Less data processing translates to faster response times.
  • Clearer Code Structure: DTOs provide a clear separation of concerns, making the code more maintainable.

You can find a complete code in this GitHub repository: https://github.com/vandetho/blog_app.git

Implementing custom hydrators in Doctrine within Symfony is a powerful technique for optimizing performance. By leveraging DTOs, we can create more efficient, scalable, and maintainable web applications. This approach is particularly beneficial in applications dealing with large datasets and complex domain logic.

Remember, while this method enhances performance, it’s essential to assess its impact on your specific application and use it judiciously. Happy coding!

--

--

Vandeth Tho

Passionate about technology and innovation, I'm a problem solver. Skilled in coding, design, and collaboration, I strive to make a positive impact.