Is EntityManager Necessary?

Dmytro Bichenko
4 min readJan 24, 2025

--

In the PHP world, it has become a tradition that there are two main frameworks: Symfony and Laravel. The others, unfortunately (or fortunately), settle for scraps, holding the status of niche or trailing players.

Symfony is the standard for enterprise applications, while Laravel is best suited for rapid prototyping and development. We’ll talk about the first one.

It’s no secret that Symfony is a strong adaptation of Java’s Spring Framework, while Hibernate inspires Doctrine. Symfony has been more or less successfully adapted, causing no significant conflicts, but the case is different with Doctrine. I’m talking about ORM and the hero of this article — the EntityManager.

Basics of EntityManager

EntityManager and Doctrine are based on three key design patterns: Unit of Work, Data Mapper, and Identity Map. Let’s take a closer look at the first one.

Unit of Work is a design pattern often used in programming, especially in the context of working with databases. Its primary goal is to manage transactions and minimize the number of database operations by combining multiple changes into a single transaction.

In simple terms, the idea is this: we work with batches, and instead of writing data to the database after every change, we wait until several changes accumulate and then perform them all at once.

Practically speaking, for each entity, we either mark it for change tracking (or not, if the entity is fetched from the database), and at the moment of flush(), a change set is calculated and sent to the database.

Implementation Issues in PHP

The implementation of this pattern in PHP leaves much to be desired. This is due to the characteristics of standard PHP applications, where each request is a separate program with a short lifecycle. If we exclude long-running processes (e.g., workers and daemons), we end up with a significant overhead during initialization that simply cannot pay off within the lifespan of a process.

The exception to this is batch processing — in such cases, this concept fits perfectly. But in typical scenarios, developers often ignore this aspect and simply use flush wherever they feel it’s necessary to save and modify data.

Here’s an interesting fact: In Java, despite its long-running nature, batch mode in Hibernate is not the default behavior. It is only enabled under certain conditions (e.g., the primary key must be known before insertion — hello, auto-increments and MySQL). By default, persist works like insert.

What’s the Alternative?

There’s an approach that can slightly improve the situation: use a single flush for each request.

Essentially, this would emulate the @Transactional annotation in Spring, allowing you to control the "boundaries" of the area where database changes should occur. Within Symfony, this can be implemented as follows:

  1. Create an @Transactional annotation.
  2. Make listener for kernel.controller event and check if the method has this annotation.
  3. Make listener for kernel.response or kernel.view (it depends on what sort of application u have —` kernel.view` works when u are rendering twig template) events to verify whether the annotation was applied in the controller method. If it was, execute flush.
<?php

namespace App\Annotation;

use Attribute;

/**
* @Annotation
* @Target({"METHOD"})
*/
#[Attribute(Attribute::TARGET_METHOD)]
class Transactional
{
}
<?php

namespace App\EventListener;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityManager;
use App\Annotation\Transactional;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpFoundation\Response;
use Doctrine\Common\Annotations\Reader;

class TransactionalListener
{
private EntityManagerInterface $entityManager;
private Reader $annotationReader;
private bool $isTransactionalMethod = false;

public function __construct(EntityManagerInterface $entityManager, Reader $annotationReader)
{
$this->entityManager = $entityManager;
$this->annotationReader = $annotationReader;
}

public function onKernelController(ControllerEvent $event): void
{
$controller = $event->getController();

if (is_array($controller)) {
[$object, $method] = $controller;

$reflectionMethod = new \ReflectionMethod($object, $method);
$annotation = $this->annotationReader->getMethodAnnotation($reflectionMethod, Transactional::class);

if ($annotation !== null) {
$this->isTransactionalMethod = true;
}
}
}

public function onKernelView(ViewEvent $event): void
{
if ($this->isTransactionalMethod) {
try {
$this->entityManager->flush();
} catch (\Throwable $e) {
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Transaction failed', $e);
}
}
}

public function onKernelResponse(ResponseEvent $event): void
{
if ($this->isTransactionalMethod) {
$this->entityManager->clear();
$this->isTransactionalMethod = false;
}
}
}
services:
App\EventListener\TransactionalListener:
arguments:
$annotationReader: '@annotation_reader'
tags:
- { name: kernel.event_listener, event: kernel.controller, method: onKernelController }
- { name: kernel.event_listener, event: kernel.view, method: onKernelView }
- { name: kernel.event_listener, event: kernel.response, method: onKernelResponse }

Pitfalls

  1. Error handling. You need to monitor attempts to call flush manually carefully. Some conventions are must-have + good code review process.
  2. Immediate persistence requirements. Sometimes, data must be written to the database immediately, and waiting isn’t an option. This requires additional “hacks,” such as using two separate EntityManagers with different connections to avoid issues with “dead” connections (e.g., if an “instant” write fails and the connection closes, it can break the flow of the “main” write).
  3. Auto-increments. When inserting a record with an auto-increment ID, the ID becomes available only after the query is executed. If this ID is needed immediately (e.g., for a response in a CRUD POST request), it creates a problem. The solution is to use entities with predefined keys instead of auto-increments for external access.

Or Just Skip EntityManager?

Another approach is to abandon EntityManager and ORM altogether in favor of DBAL. This would be faster and provide more control but would require writing various mappers and DTOs to shuttle data between layers. While this involves a lot of boilerplate, it might not be a critical issue depending on the system.

In my opinion, using a single “stream” for writing appears cleaner and could serve as an alternative to the “classic” approach. But things are as they are.

--

--

Dmytro Bichenko
Dmytro Bichenko

Written by Dmytro Bichenko

Entrepreneur passionate about development.

No responses yet