docs(articles): From Magento to Symfony, a requalification guide grouped into sections
uk/articles/magento-to-symfony/data/doctrine.md
новий файл +189
−0
1
+
# Doctrine ORM
2
+
3
+
Найважча частина переходу, бо змінюється сама модель роботи з даними. Magento-модель схожа на Active Record: об'єкт знає, як себе зберегти, а `save()` одразу виконує SQL. Doctrine — це Data Mapper з Unit of Work: сутність — звичайний PHP-об'єкт без знання про БД, а EntityManager відстежує зміни всіх завантажених сутностей і записує їх разом при `flush()`.
4
+
5
+
| Аспект | Magento | Doctrine |
6
+
|---|---|---|
7
+
| Файли на одну таблицю | Model, ResourceModel, Collection, Api/Data інтерфейс, Repository, db_schema.xml | Entity, Repository, міграція |
8
+
| Дані об'єкта | масив $_data, getData('title') | типізовані private властивості |
9
+
| Збереження | save() одразу пише рядок | persist() реєструє, flush() пише всі зміни |
10
+
| Оновлення | load, setX, save | завантажили, змінили, flush. persist для вже керованої сутності не потрібен |
11
+
| Зв'язки | лише id-колонки, join у колекції вручну | ManyToOne, OneToMany, ManyToMany з лінивим завантаженням |
12
+
| Той самий рядок двічі | два різні об'єкти | той самий об'єкт (identity map) |
13
+
| Схема БД | декларативна db_schema.xml | мапінг атрибутами + версіоновані міграції |
14
+
| Сирий SQL | ResourceConnection | DBAL Connection |
15
+
16
+
## Сутність
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
+
// геттери…
62
+
}
63
+
```
64
+
65
+
Сутність не зобов'язана мати сетер на кожне поле. Обов'язкове передається в конструктор, зміни стану оформлюються методами з назвою дії (`deactivate()`, `rename()`). Генератор `bin/console make:entity` створює геттери й сетери — для старту нормально, потім прибирайте зайве.
66
+
67
+
## Запити: Collection → QueryBuilder
68
+
69
+
**Magento** · колекція
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** · метод репозиторію
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
+
У QueryBuilder і DQL пишуть імена властивостей і класів, а не колонок і таблиць: `q.isActive`, а не `is_active`. Прості запити є готові: `find($id)`, `findOneBy(['slug' => $slug])`, `findBy([...], ['position' => 'ASC'])`.
110
+
111
+
## Збереження: 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() для $other не потрібен: вона вже керована
135
+
136
+
$this->em->flush(); // один INSERT і один UPDATE в одній транзакції
137
+
```
138
+
139
+
:::danger subtitle="flush() — не save()"
140
+
`flush()` записує всі зміни всіх сутностей, які знає UnitOfWork, а не одну передану. Якщо десь вище по стеку змінили іншу сутність і не хотіли її зберігати, вона теж запишеться. У Doctrine ORM 3 виклик `flush($entity)` прибрали, тож старий код, що покладався на «часткове» збереження, треба переглядати, а не просто видаляти аргумент.
141
+
142
+
Хто відповідає за flush, вирішують на рівні застосунку: зазвичай це контролер, обробник повідомлення або команда, тобто точка входу, а не репозиторій глибоко всередині.
143
+
:::
144
+
145
+
## Міграції замість db_schema.xml
146
+
147
+
- Змінили мапінг → `bin/console make:migration` (або `doctrine:migrations:diff`) генерує клас у `migrations/` з SQL різниці між мапінгом і поточною БД.
148
+
- Перегляньте згенерований SQL перед комітом: перейменування колонки diff бачить як DROP + ADD.
149
+
- `doctrine:migrations:migrate` на деплої — аналог `setup:upgrade` для схеми.
150
+
- `doctrine:schema:validate` перевіряє, що мапінг коректний і збігається з БД.
151
+
- Тестові й демо-дані — `doctrine/doctrine-fixtures-bundle` або Zenstruck Foundry, команда `doctrine:fixtures:load`.
152
+
153
+
## Зв'язки і N+1
154
+
155
+
Зв'язки ліниві: `$question->getCategory()->getName()` у циклі по 50 питаннях дасть 50 додаткових запитів. Профайлер покаже це на вкладці Doctrine. Рішення — забрати зв'язок одним запитом:
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
+
У двосторонньому зв'язку в БД записується лише owning side (сторона з `ManyToOne` / `JoinColumn`). Додавання в колекцію `$category->getQuestions()->add($q)` без виставлення `$q->category` нічого не збереже, тому методи-хелпери оновлюють обидві сторони.
169
+
170
+
## Масова обробка
171
+
172
+
**Symfony** · імпорт/перерахунок без витоку пам'яті
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
+
Identity map тримає кожну завантажену сутність, доки не викличете `clear()`. Після `clear()` усі раніше отримані об'єкти від'єднані: їхні зміни більше не збережуться, а зв'язки на них треба перечитати. Для по-справжньому масових змін швидше працювати через DBAL або DQL `UPDATE`.