Что такое ленивая загрузка (Lazy Loading) в Doctrine ORM?

«Что такое ленивая загрузка (Lazy Loading) в Doctrine ORM?» — вопрос из категории Symfony, который задают на 24% собеседований PHP Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

Ленивая загрузка (Lazy Loading) в Doctrine — это стратегия загрузки связанных сущностей, при которой они не извлекаются из базы данных сразу при запросе основной сущности, а только в момент первого обращения к этому отношению. Это реализуется с помощью прокси-объектов.

Как это работает:

  1. При загрузке сущности User Doctrine создаёт для свойства $orders (коллекция) специальный прокси-объект, а не выполняет SQL-запрос.
  2. При первом вызове $user->getOrders()->count() или итерации по коллекции прокси «просыпается» и выполняет запрос к БД для загрузки реальных данных.

Пример с атрибутами в Symfony/Doctrine:

// src/Entity/User.php
#[ORMEntity]
class User
{
    #[ORMId, ORMGeneratedValue, ORMColumn]
    private ?int $id = null;

    #[ORMOneToMany(targetEntity: Order::class, mappedBy: 'user')]
    // fetch: 'LAZY' установлен по умолчанию для OneToMany и ManyToMany
    private Collection $orders;

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

    public function getOrders(): Collection
    {
        return $this->orders;
    }
}

// В коде контроллера или сервиса
$user = $entityManager->find(User::class, 1);
// На этом этапе запроса для заказов НЕТ

foreach ($user->getOrders() as $order) {
    // ЗДЕСЬ выполняется SQL-запрос типа:
    // SELECT * FROM orders WHERE user_id = 1
    echo $order->getProductName();
}

Критически важные нюансы и проблема N+1:

  • Прокси: Doctrine использует сгенерированные классы-прокси, которые наследуют вашу сущность и перехватывают обращения к геттерам.
  • Проблема N+1: Если вы загрузите 100 пользователей в цикле и для каждого обратитесь к его заказам, Doctrine выполнит 1 запрос для пользователей + 100 отдельных запросов для заказов. Это убийственно для производительности.
  • Решение — жадная загрузка (Eager Fetching) или DQL JOIN:
    // Использование DQL с JOIN FETCH
    $dql = "SELECT u, o FROM AppEntityUser u JOIN u.orders o WHERE u.id = :id";
    $user = $entityManager->createQuery($dql)->setParameter('id', 1)->getSingleResult();
    // Или через QueryBuilder
    $user = $entityManager->getRepository(User::class)
        ->createQueryBuilder('u')
        ->leftJoin('u.orders', 'o')
        ->addSelect('o')
        ->where('u.id = :id')
        ->setParameter('id', 1)
        ->getQuery()
        ->getSingleResult();

    Теперь и пользователь, и его заказы загружены одним запросом.

Ленивая загрузка — удобная стратегия по умолчанию, но требует осознанного управления запросами в местах, где загружаются коллекции.