Doctrine ORM

Doctrine ORM

The hardest part of the move, because the very model of working with data changes. A Magento model resembles Active Record: the object knows how to save itself, and save() runs SQL right away. Doctrine is a Data Mapper with a Unit of Work: an entity is a plain PHP object that knows nothing about the database, and the EntityManager tracks changes to all loaded entities and writes them together on flush().

Aspect Magento Doctrine
Files per table Model, ResourceModel, Collection, Api/Data interface, Repository, db_schema.xml Entity, Repository, migration
Object data $_data array, getData('title') typed private properties
Saving save() writes the row immediately persist() registers, flush() writes all changes
Updating load, setX, save load, change, flush. persist is not needed for an already managed entity
Relations id columns only, joins in the collection by hand ManyToOne, OneToMany, ManyToMany with lazy loading
Same row twice two different objects the same object (identity map)
DB schema declarative db_schema.xml attribute mapping + versioned migrations
Raw SQL ResourceConnection DBAL Connection

Entity

Symfony · src/Entity/Question.php

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: QuestionRepository::class)]
#[ORM\Table(name: 'faq_question')]
class Question
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255, unique: true)]
    private string $slug;

    #[ORM\Column]
    private bool $isActive = true;

    #[ORM\Column]
    private int $position = 0;

    #[ORM\Column]
    private \DateTimeImmutable $createdAt;

    public function __construct(
        #[ORM\Column(length: 255)]
        private string $title,
        #[ORM\Column(type: Types::TEXT)]
        private string $answer,
        #[ORM\ManyToOne(inversedBy: 'questions')]
        #[ORM\JoinColumn(nullable: false)]
        private Category $category,
    )
    {
        $this->slug = (new AsciiSlugger())->slug($title)->lower()->toString();
        $this->createdAt = new \DateTimeImmutable();
    }

    public function deactivate(): void
    {
        $this->isActive = false;
    }

    // getters…
}

An entity doesn't have to have a setter for every field. Required data goes into the constructor, and state changes are methods named after the action (deactivate(), rename()). The bin/console make:entity generator creates getters and setters — fine for a start, then remove what you don't need.

Queries: Collection → QueryBuilder

Magento · collection

$collection = $this->collectionFactory->create()
    ->addFieldToFilter('category_id', $categoryId)
    ->addFieldToFilter('is_active', 1)
    ->setOrder('position', 'ASC')
    ->setPageSize(20);

foreach ($collection as $question) {
    echo $question->getData('title');
}

Symfony · repository method

/** @extends ServiceEntityRepository<Question> */
class QuestionRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Question::class);
    }

    /** @return list<Question> */
    public function findActiveInCategory(Category $category): array
    {
        return $this->createQueryBuilder('q')
            ->andWhere('q.category = :category')
            ->andWhere('q.isActive = true')
            ->setParameter('category', $category)
            ->orderBy('q.position', 'ASC')
            ->setMaxResults(20)
            ->getQuery()
            ->getResult();
    }
}

In QueryBuilder and DQL you use property and class names, not column and table names: q.isActive, not is_active. Simple queries come ready-made: find($id), findOneBy(['slug' => $slug]), findBy([...], ['position' => 'ASC']).

Saving: save() → persist() + flush()

Magento

$question = $this->questionFactory->create();
$question->setTitle($title);
$question->setCategoryId($categoryId);
$this->questionRepository->save($question);

$other = $this->questionRepository->getById(7);
$other->setIsActive(false);
$this->questionRepository->save($other);

Symfony

$question = new Question($title, $answer, $category);
$this->em->persist($question);

$other = $this->questions->find(7);
$other->deactivate();
// persist() is not needed for $other: it is already managed

$this->em->flush();   // one INSERT and one UPDATE in a single transaction
flush() is not save()

flush() writes all changes to all entities the UnitOfWork knows about, not just the one you passed. If some other entity was changed somewhere up the stack and you didn't mean to save it, it gets written too. Doctrine ORM 3 removed flush($entity), so old code that relied on "partial" saving has to be reviewed, not just stripped of the argument.

Who is responsible for flush is decided at the application level: usually it's the controller, the message handler or the command, i.e. the entry point, not a repository deep inside.

Migrations instead of db_schema.xml

  • Changed the mapping → bin/console make:migration (or doctrine:migrations:diff) generates a class in migrations/ with the SQL difference between the mapping and the current database.
  • Review the generated SQL before committing: diff sees a column rename as DROP + ADD.
  • doctrine:migrations:migrate on deploy is the equivalent of setup:upgrade for the schema.
  • doctrine:schema:validate checks that the mapping is correct and matches the database.
  • Test and demo data: doctrine/doctrine-fixtures-bundle or Zenstruck Foundry, command doctrine:fixtures:load.

Relations and N+1

Relations are lazy: $question->getCategory()->getName() in a loop over 50 questions makes 50 extra queries. The profiler shows this on the Doctrine tab. The fix is to fetch the relation in one query:

Symfony · fetch join

return $this->createQueryBuilder('q')
    ->addSelect('c')
    ->join('q.category', 'c')
    ->andWhere('q.isActive = true')
    ->getQuery()
    ->getResult();

In a bidirectional relation only the owning side (the side with ManyToOne / JoinColumn) is written to the database. Adding to the collection with $category->getQuestions()->add($q) without setting $q->category saves nothing, so helper methods update both sides.

Bulk processing

Symfony · import/recalculation without a memory leak

$i = 0;
$query = $this->questions->createQueryBuilder('q')->getQuery();

foreach ($query->toIterable() as $question) {
    $question->recalculateRank();
    if (++$i % 200 === 0) {
        $this->em->flush();
        $this->em->clear();
    }
}
$this->em->flush();
$this->em->clear();

The identity map holds every loaded entity until you call clear(). After clear() all previously fetched objects are detached: their changes will no longer be saved, and relations pointing to them have to be re-read. For truly bulk changes it's faster to work through DBAL or a DQL UPDATE.