docs(articles): English translation of From Magento to Symfony
en/articles/magento-to-symfony/data/doctrine.md
new file +189
โ0
1
+
# Doctrine ORM
2
+
3
+
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()`.
4
+
5
+
| Aspect | Magento | Doctrine |
6
+
|---|---|---|
7
+
| Files per table | Model, ResourceModel, Collection, Api/Data interface, Repository, db_schema.xml | Entity, Repository, migration |
8
+
| Object data | $_data array, getData('title') | typed private properties |
9
+
| Saving | save() writes the row immediately | persist() registers, flush() writes all changes |
10
+
| Updating | load, setX, save | load, change, flush. persist is not needed for an already managed entity |
11
+
| Relations | id columns only, joins in the collection by hand | ManyToOne, OneToMany, ManyToMany with lazy loading |
12
+
| Same row twice | two different objects | the same object (identity map) |
13
+
| DB schema | declarative db_schema.xml | attribute mapping + versioned migrations |
14
+
| Raw SQL | ResourceConnection | DBAL Connection |
15
+
16
+
## Entity
17
+
18
+
**Symfony** ยท src/Entity/Question.php
19
+
20
+
```php
21
+
use Doctrine\ORM\Mapping as ORM;
22
+
23
+
#[ORM\Entity(repositoryClass: QuestionRepository::class)]
24
+
#[ORM\Table(name: 'faq_question')]
25
+
class Question
26
+
{
27
+
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
28
+
private ?int $id = null;
29
+
30
+
#[ORM\Column(length: 255, unique: true)]
31
+
private string $slug;
32
+
33
+
#[ORM\Column]
34
+
private bool $isActive = true;
35
+
36
+
#[ORM\Column]
37
+
private int $position = 0;
38
+
39
+
#[ORM\Column]
40
+
private \DateTimeImmutable $createdAt;
41
+
42
+
public function __construct(
43
+
#[ORM\Column(length: 255)]
44
+
private string $title,
45
+
#[ORM\Column(type: Types::TEXT)]
46
+
private string $answer,
47
+
#[ORM\ManyToOne(inversedBy: 'questions')]
48
+
#[ORM\JoinColumn(nullable: false)]
49
+
private Category $category,
50
+
)
51
+
{
52
+
$this->slug = (new AsciiSlugger())->slug($title)->lower()->toString();
53
+
$this->createdAt = new \DateTimeImmutable();
54
+
}
55
+
56
+
public function deactivate(): void
57
+
{
58
+
$this->isActive = false;
59
+
}
60
+
61
+
// gettersโฆ
62
+
}
63
+
```
64
+
65
+
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.
66
+
67
+
## Queries: Collection โ QueryBuilder
68
+
69
+
**Magento** ยท collection
70
+
71
+
```php
72
+
$collection = $this->collectionFactory->create()
73
+
->addFieldToFilter('category_id', $categoryId)
74
+
->addFieldToFilter('is_active', 1)
75
+
->setOrder('position', 'ASC')
76
+
->setPageSize(20);
77
+
78
+
foreach ($collection as $question) {
79
+
echo $question->getData('title');
80
+
}
81
+
```
82
+
83
+
**Symfony** ยท repository method
84
+
85
+
```php
86
+
/** @extends ServiceEntityRepository<Question> */
87
+
class QuestionRepository extends ServiceEntityRepository
88
+
{
89
+
public function __construct(ManagerRegistry $registry)
90
+
{
91
+
parent::__construct($registry, Question::class);
92
+
}
93
+
94
+
/** @return list<Question> */
95
+
public function findActiveInCategory(Category $category): array
96
+
{
97
+
return $this->createQueryBuilder('q')
98
+
->andWhere('q.category = :category')
99
+
->andWhere('q.isActive = true')
100
+
->setParameter('category', $category)
101
+
->orderBy('q.position', 'ASC')
102
+
->setMaxResults(20)
103
+
->getQuery()
104
+
->getResult();
105
+
}
106
+
}
107
+
```
108
+
109
+
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'])`.
110
+
111
+
## Saving: save() โ persist() + flush()
112
+
113
+
**Magento**
114
+
115
+
```php
116
+
$question = $this->questionFactory->create();
117
+
$question->setTitle($title);
118
+
$question->setCategoryId($categoryId);
119
+
$this->questionRepository->save($question);
120
+
121
+
$other = $this->questionRepository->getById(7);
122
+
$other->setIsActive(false);
123
+
$this->questionRepository->save($other);
124
+
```
125
+
126
+
**Symfony**
127
+
128
+
```php
129
+
$question = new Question($title, $answer, $category);
130
+
$this->em->persist($question);
131
+
132
+
$other = $this->questions->find(7);
133
+
$other->deactivate();
134
+
// persist() is not needed for $other: it is already managed
135
+
136
+
$this->em->flush(); // one INSERT and one UPDATE in a single transaction
137
+
```
138
+
139
+
:::danger subtitle="flush() is not save()"
140
+
`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.
141
+
142
+
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.
143
+
:::
144
+
145
+
## Migrations instead of db_schema.xml
146
+
147
+
- 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.
148
+
- Review the generated SQL before committing: diff sees a column rename as DROP + ADD.
149
+
- `doctrine:migrations:migrate` on deploy is the equivalent of `setup:upgrade` for the schema.
150
+
- `doctrine:schema:validate` checks that the mapping is correct and matches the database.
151
+
- Test and demo data: `doctrine/doctrine-fixtures-bundle` or Zenstruck Foundry, command `doctrine:fixtures:load`.
152
+
153
+
## Relations and N+1
154
+
155
+
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:
156
+
157
+
**Symfony** ยท fetch join
158
+
159
+
```php
160
+
return $this->createQueryBuilder('q')
161
+
->addSelect('c')
162
+
->join('q.category', 'c')
163
+
->andWhere('q.isActive = true')
164
+
->getQuery()
165
+
->getResult();
166
+
```
167
+
168
+
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.
169
+
170
+
## Bulk processing
171
+
172
+
**Symfony** ยท import/recalculation without a memory leak
173
+
174
+
```php
175
+
$i = 0;
176
+
$query = $this->questions->createQueryBuilder('q')->getQuery();
177
+
178
+
foreach ($query->toIterable() as $question) {
179
+
$question->recalculateRank();
180
+
if (++$i % 200 === 0) {
181
+
$this->em->flush();
182
+
$this->em->clear();
183
+
}
184
+
}
185
+
$this->em->flush();
186
+
$this->em->clear();
187
+
```
188
+
189
+
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`.