docs(articles): SEO titles, intros and a section preview image for From Magento to Symfony
en/articles/magento-to-symfony/data/doctrine.md
+4
−0
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() 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(ordoctrine:migrations:diff) generates a class inmigrations/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:migrateon deploy is the equivalent ofsetup:upgradefor the schema.doctrine:schema:validatechecks that the mapping is correct and matches the database.- Test and demo data:
doctrine/doctrine-fixtures-bundleor Zenstruck Foundry, commanddoctrine: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.