Транзакции и скорость Doctrine
Пока записей мало, Doctrine работает незаметно. С ростом базы появляются вопросы: как выполнить несколько изменений как одно целое, как обработать миллион строк и почему страница делает двести запросов.
Транзакции
flush() сам оборачивает свои запросы в транзакцию. Когда вызовов flush() несколько или есть прямые запросы, транзакцию задают явно:
$em->wrapInTransaction(function (EntityManagerInterface $em) use ($from, $to, $amount) {
$from->withdraw($amount);
$to->deposit($amount);
$em->persist(new Transfer($from, $to, $amount));
});Если внутри возникнет исключение, все изменения отменятся.
Блокировка строки
Два одновременных списания могут прочитать один и тот же баланс. Блокировка заставляет второй запрос подождать:
use Doctrine\DBAL\LockMode;
$em->wrapInTransaction(function (EntityManagerInterface $em) use ($id, $amount) {
$account = $em->find(Account::class, $id, LockMode::PESSIMISTIC_WRITE);
$account->withdraw($amount);
});Большие таблицы
findAll() для миллиона записей исчерпает память: Doctrine хранит каждый загруженный объект. Записи перебирают по одной и время от времени очищают память:
$query = $em->createQuery('SELECT u FROM App\Entity\User u');
foreach ($query->toIterable() as $index => $user) {
$user->recalculateRating();
if ($index % 200 === 0) {
$em->flush();
$em->clear();
}
}
$em->flush();clear() заставляет Doctrine «забыть» загруженные объекты.
Массовое изменение одним запросом
Когда объекты не нужны, быстрее изменить строки прямо в базе:
$em->createQuery('UPDATE App\Entity\Post p SET p.published = false WHERE p.createdAt < :date')
->setParameter('date', new \DateTimeImmutable('-2 years'))
->execute();Только нужные данные
Для списков и отчётов объекты избыточны. Массивы загружаются быстрее:
$rows = $this->createQueryBuilder('p')
->select('p.id, p.title, COUNT(c.id) AS comments')
->leftJoin('p.comments', 'c')
->groupBy('p.id')
->getQuery()
->getArrayResult();Индексы
Столбцы, по которым ищут и сортируют, должны иметь индекс. Его описывают атрибутом над сущностью:
#[ORM\Entity]
#[ORM\Index(name: 'post_created_idx', columns: ['created_at'])]
#[ORM\Index(name: 'post_status_date_idx', columns: ['status', 'created_at'])]
class Post
{
// …
}После этого создайте миграцию. Столбцы связей ManyToOne получают индекс автоматически.
Прямой SQL
Сложный отчёт проще написать на SQL:
$rows = $em->getConnection()->executeQuery(
'SELECT DATE(created_at) AS day, COUNT(*) AS total FROM post WHERE created_at >= :from GROUP BY day',
['from' => '2026-01-01'],
)->fetchAllAssociative();Значения по-прежнему передаются параметрами.
Где искать причину
Вкладка Doctrine в профилировщике показывает все запросы страницы, их время и число одинаковых. Сто одинаковых запросов — признак проблемы N+1: связь нужно загрузить через leftJoin() и addSelect().
Частая ошибка
После $em->clear() все загруженные ранее объекты становятся «отсоединёнными»: Doctrine больше не следит за ними, и их изменения не сохранятся. Если объект — например, текущий пользователь — нужен после очистки, загрузите его заново через find().