Как работать с ассоциациями / отношениями Doctrine
Дата обновления перевода 2025-08-25
Как работать с ассоциациями / отношениями Doctrine
Screencast
Предпочитаете видео-туториалы? Посмотрите серию скринкастов Mastering Doctrine Relations
Существует два основных типа отношений / ассоциаций:
ManyToOne/OneToMany-
Наиболее распространённые отношения, отображённые в DB с помощью столбца с
foreign key (например, столбца
category_idв таблицеproduct). На самом деле, это один тип ассоциации, рассматриваемый с двух разных сторон отношений. ManyToMany- Использует промежуточную таблицу и нужен, когда обе стороны отношений могут иметь множество с другой стороны (например, "ученики" и "предметы": каждый ученик во многих предметах, и каждый класс имеет множество учеников).
Для начала, вам нужно определить, какое отношение использовать. Если обе стороны
отношений будут содержать множество с другой стороны (например, "ученики" и
"предметы"), то вам нужно использовать отношение ManyToMany. В других случаях,
вам скорее нужен ManyToOne.
Tip
Также существуют отношения OneToOne (например, один User
имеет один Profile и наоборот). На практике, их использование схоже
с ManyToOne.
Ассоциация ManyToOne / OneToMany
Представьте, что каждый продукт в вашем приложении принадлежит только к одной
конкретной категории. В этом случае, вам понадобится класс Category, и способ
связать объект Product с объектом Category.
Начните с создания сущности Category:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
$ php bin/console make:entity Category
Новое имя свойства (нажмите <return>, чтобы перестать добавлять поля):
> name
Тип поля (введите ?, чтобы увидеть все типы) [string]:
> string
Длина поля [255]:
> 255
Может ли это поле быть null в базе данных (nullable) (да/нет) [no]:
> no
Новое имя свойства (нажмите <return>, чтобы перестать добавлять поля):
>
(нажмите enter снова, чтобы закончить)
Это сгенерирует новый класс entity (сущности):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// src/Entity/Category.php
namespace App\Entity;
// ...
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private $id;
#[ORM\Column]
private string $name;
// ... геттеры и сеттеры
}
Tip
Начиная с MakerBundle: v1.57.0 - Вы можете передавать --with-uuid или
--with-ulid в make:entity. Используя преимущества Компонента Uid от Symfony,
создается сущность с типом id в виде :ref:36319edbd88bb79c860425caa7b04b07332c704dint``.
Отображение отношения ManyToOne
В этом примере, каждая категория может быть ассоциирована со многими продуктами. Но каждый продукт может быть ассоциирован только с одной категорией. Это отношение можно подытожить так: многие продукты к одной категории (или, равнозначно - одна категория ко многим продуктам).
С точки зрения entity Product - это отношения многие-к-одному. С точки зрения
сущности Category - это отношения один-ко-многим.
Чтобы отобразить это в DB, для начала создайте свойство category в класее Product
с атрибутом ManyToOne. Вы можете сделать это вручную или используя команду make:entity,
которая задаст несколько вопросов о вашем отношении. Если вы не знаете, что ответить, не волнуйтесь!
Можно поменять настройки позже:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
$ php bin/console make:entity
Имя класса сущности для создания или обновления (например, BraveChef):
> Product
Новое имя свойства (нажмите <return>, чтобы перестать добавлять поля):
> category
Тип поля (введите ?, чтобы увидеть все типы) [string]:
> relation
К какому классу должна относиться эта сущность?:
> Category
Какой тип отношений? [ManyToOne, OneToMany, ManyToMany, OneToOne]:
> ManyToOne
Может ли свойство Product.category быть null (nullable)? (да/нет) [yes]:
> no
Хотите ли вы добавить новое свойство в Категорию, чтобы вы могли иметь доступ
или обновлять объекты Продуктов из него - например, $category->getProducts()? (да/нет) [yes]:
> yes
Новое имя поля внутри Категории [products]:
> products
Хотите ли вы автоматически удалять ненужные объекты App\Entity\Product
(orphanRemoval)? (да/нет) [no]:
> no
Новое имя свойства (нажмите <return>, чтобы перестать добавлять поля):
>
(нажмите enter снова, чтобы закончить)
Это внесло изменения в две сущности. Сначала добавилось свойство category
в сущность Product (и методы getters/setters):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
// src/Entity/Product.php
namespace App\Entity;
// ...
class Product
{
// ...
#[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'products')]
private Category $category;
public function getCategory(): ?Category
{
return $this->category;
}
public function setCategory(?Category $category): self
{
$this->category = $category;
return $this;
}
}
Это отображение ManyToOne является обязательным. Оно говорит Doctrine
использовать колонку category_id таблицы product, чтобы соотнести каждую запись в этой
таблице с записью в таблице category.
Далее, так как один объект Category будет относиться ко многим объектам
Product, команда make:entity также добавит свойство products к классу
Category, который будет содержать эти объекты:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
// src/Entity/Category.php
namespace App\Entity;
// ...
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
class Category
{
// ...
#[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category')]
private Collection $products;
public function __construct()
{
$this->products = new ArrayCollection();
}
/**
* @return Collection<int, Product>
*/
public function getProducts(): Collection
{
return $this->products;
}
// addProduct() и removeProduct() также были добавлены
}
Отображение ManyToOne, показанное ранее, обязательно. Но, отношение
OneToMany - необязательно: добавляйте его только если вы хотите иметь
доступ к products, которые связаны с category (это один из вопросов, который
make:entity задаёт вам). В этом примере, будет полезно
иметь возможность вызвать $category->getProducts(). Если вы не хотите этого,
то вам также не нужна настройка inversedBy или mappedBy.
Ваша DB настроена! Теперь, выполните миграции, как обычно:
1 2
$ php bin/console doctrine:migrations:diff
$ php bin/console doctrine:migrations:migrate
Благодрая отношениям, это создаст столбец с foreign key category_id в
таблице product. Doctrine готова сохранять наши отношения!
Сохранение связанных сущностей
Теперь вы можете увидеть этот новый код в действии! Представьте, что вы внутри контроллера:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
// src/Controller/ProductController.php
namespace App\Controller;
// ...
use App\Entity\Category;
use App\Entity\Product;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ProductController extends AbstractController
{
#[Route('/product', name: 'product')]
public function index(EntityManagerInterface $entityManager): Response
{
$category = new Category();
$category->setName('Computer Peripherals');
$product = new Product();
$product->setName('Keyboard');
$product->setPrice(19.99);
$product->setDescription('Ergonomic and stylish!');
// относит этот продукт к категории
$product->setCategory($category);
$entityManager->persist($category);
$entityManager->persist($product);
$entityManager->flush();
return new Response(
'Saved new product with id: '.$product->getId()
.' and new category with id: '.$category->getId()
);
}
}
Когда вы переходите в /product, к таблицам category и product добавляется
одна строчка. Столбец product.category_id для нового product устанавливается, как
id новой category. Doctrine управляет сохранением этих отношений за вас:
Если вы новичок в ORM, то это самый сложный концепт: вам нужно перестать думать
о вашей DB, а вместо этого думать только о ваших объектах. Вместо установки числового
id категории в Product, вы устанавливаете весь объект Category. Doctrine
заботится обо всём остальном при сохранении.
Извлечение связанных объектов
Когда вам надо вернуть ассоциированные объекты, ваш ход работы выглядит так же,
как и раньше. Вначале, вызовите объект $product, а потом получите доступ к
связанному с ним объекту Category:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// src/Controller/ProductController.php
namespace App\Controller;
use App\Entity\Product;
// ...
class ProductController extends AbstractController
{
public function show(ProductRepository $productRepository, int $id): Response
{
$product = $productRepository->find($id);
// ...
$categoryName = $product->getCategory()->getName();
// ...
}
}
В этом примере, вы вначале запрашиваете объект Product, основываясь на его
id. Это запускает запрос только для данных продукта и насыщает (hydrates) объект
$product. Позже, когда вы вызовете $product->getCategory()->getName(),
Doctrine молча создаст второй запрос, чтобы найти Category, которая связана с этим
Product. Она подготавливает объект $category и возвращает его вам.
Важно то, что у вас есть доступ к category, связанной с product, но данные category на самом деле не запрашиваются, пока вы не спросите о них (т.е. "ленивая загрузка").
Так как мы отобразили необязательную сторону OneToMany, то вы также
можете запросить в обратном направлении:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// src/Controller/ProductController.php
// ...
class ProductController extends AbstractController
{
public function showProducts(CategoryRepository $categoryRepository, int $id): Response
{
$category = $categoryRepository->find($id);
$products = $category->getProducts();
// ...
}
}
В этом случае, происходят те же вещи: вы вначале запрашиваете один объект
Category. Далее, только когда (и если) вы запрашиваете products,
Doctrine делает второй запрос, чтобы получить связанные объекты Product.
Этого дополнительного запроса можно избежать, добавив JOIN.
Объединение связанных записей
В вышеописанных примерах, было сделано два запроса - один к оригинальному
объекту (например, Category) и один к связанному(ым) объекту(ам),
(например объектам Product).
Tip
Помните, что вы можете увидеть все запросы, сделанные во время запроса с помощью панели инструментов веб-отладки (web debug toolbar).
Конечно, если вы заранее знаете, что вам понадобится получить доступ к обоим
объектам, вы можете избежать ворого запроса, путём создания join (объединения) в
оригинальном запросе. Добавьте следующий метод к классу ProductRepository:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// src/Repository/ProductRepository.php
// ...
class ProductRepository extends ServiceEntityRepository
{
public function findOneByIdJoinedToCategory(int $productId): ?Product
{
$entityManager = $this->getEntityManager();
$query = $entityManager->createQuery(
'SELECT p, c
FROM App\Entity\Product p
INNER JOIN p.category c
WHERE p.id = :id'
)->setParameter('id', $productId);
return $query->getOneOrNullResult();
}
}
Это всё равно вернёт массив объектов Product. Но теперь, когда вы
вызываете $product->getCategory() и используете эти данные, второй
запрос не создаётся.
Теперь вы можете использовать этот метод в вашем контроллере, чтобы создать
запрос к объекту Product и связанному с ним Category с помощью
единого запроса:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// src/Controller/ProductController.php
// ...
class ProductController extends AbstractController
{
public function show(ProductRepository $productRepository, int $id): Response
{
$product = $productRepository->findOneByIdJoinedToCategory($id);
$category = $product->getCategory();
// ...
}
}
Установка информации с обратной стороны
До этого момента вы обновляли отношения, вызывая $product->setCategory($category).
Это не случайно! Каждое отношение имеет две стороны: в этом примере Product.category
- это владеющая сторона, а Category.products - обратная сторона.
Для обновления отношения в DB, вам нужно установить отношение на владеющей стороне.
Владеющая сторона - всегда та, где уствновлена связь ManyToOne
(для отношений ManyToMany вы можете выбрать какая сторона будет владеющей).
Значит ли это, что невозможно вызвать $category->addProduct() или
$category->removeProduct() для обновления DB? На самом деле, это возможно
благодаря умному коду, который сгенерировала команда make:entity :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
// src/Entity/Category.php
// ...
class Category
{
// ...
public function addProduct(Product $product): self
{
if (!$this->products->contains($product)) {
$this->products[] = $product;
$product->setCategory($this);
}
return $this;
}
}
Ключевым является код $product->setCategory($this), который обновляет
владеющую сторону. Теперь, когда вы сохраняетесь, отношения будут обновляться в DB.
Что на счёт удаления Product из Category? Комана make:entity
также сгенерировала метод removeProduct():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
// src/Entity/Category.php
namespace App\Entity;
// ...
class Category
{
// ...
public function removeProduct(Product $product): self
{
if ($this->products->contains($product)) {
$this->products->removeElement($product);
// установить владеющую сторону как null (если ещё не изменена)
if ($product->getCategory() === $this) {
$product->setCategory(null);
}
}
return $this;
}
}
Благодаря этому, если вы вызовете $category->removeProduct($product), category_id
в этом Product будет установлен null в DB.
Warning
Пожалуйста, имейте в виду, что обратная сторона может быть связана с большим количеством записей.
То есть может быть большое количество товаров с одной и той же категорией.
В этом случае $this->products->contains($product) может привести к нежелательным запросам к базе данных
и очень большому потреблению памяти, с риском возникновения трудноотлаживаемых ошибок «Out of memory».
Поэтому убедитесь, нужна ли вам обратная сторона, и проверьте, может ли сгенерированный код привести к подобным проблемам.
Но, вместо установки category_id как null, что, если вы хотите, чтобы Product
был удалён, если он станет "сиротой" (orphan) (т.е. без Category)? Чтобы выбрать такое
поведение, используйте опцию orphanRemoval внутри Category:
1 2 3 4 5 6
// src/Entity/Category.php
// ...
#[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category', orphanRemoval: true)]
private array $products;
Благодаря этому, если Product удаляется из Category, он будет
полностью удалён из базы данных.
Больше информации об ассоциациях
Этот раздел был вступлением к одному распространённому типу отношений entity, отношению один-ко-многим. Для более подробных деталей и примеров того, как использовать другие типы отношений (например, один-к-одному, многие-ко-многим), смотрите документацию об Association Mapping Doctrine.
Note
Если вы используете аннотации, вам понадобится добавить ко всем
аннотациям префикс @ORM\ (например, @ORM\OneToMany), который
не упомянут в документации Doctrine.