Doctrine ORM: Padroneggiare le relazioni in Symfony
Guida completa alle relazioni Doctrine ORM in Symfony. OneToMany, ManyToMany, strategie di caricamento e ottimizzazione delle prestazioni con esempi pratici.

Le relazioni tra entità costituiscono la spina dorsale di qualsiasi applicazione Symfony che utilizza Doctrine ORM. Una solida comprensione dei diversi tipi di relazione, delle strategie di caricamento e delle insidie sulle prestazioni permette di costruire applicazioni robuste ed efficienti. Questa guida copre i pattern essenziali per gestire le relazioni Doctrine in modo efficace.
Doctrine utilizza il concetto di lato proprietario e lato inverso. Comprendere questa distinzione previene molti bug legati alla persistenza delle relazioni.
Tipi di relazioni in Doctrine
Doctrine offre quattro tipi di relazioni tra entità: OneToOne, OneToMany, ManyToOne e ManyToMany. Ogni tipo risponde a un'esigenza di business specifica e presenta caratteristiche di prestazioni proprie.
Relazione ManyToOne: il caso più comune
La relazione ManyToOne rappresenta il legame di database più frequente. Più entità su un lato si relazionano a una singola entità sull'altro lato. Questa relazione è sempre il lato proprietario dell'associazione.
// A comment belongs to a single article
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CommentRepository::class)]
class Comment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'text')]
private string $content;
// ManyToOne: many comments → one article
// This entity owns the relationship (foreign key stored here)
#[ORM\ManyToOne(targetEntity: Article::class, inversedBy: 'comments')]
#[ORM\JoinColumn(nullable: false)] // Relationship is required
private Article $article;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getArticle(): Article
{
return $this->article;
}
public function setArticle(Article $article): self
{
$this->article = $article;
return $this;
}
}L'entità Comment contiene la chiave esterna article_id. Doctrine si occupa della persistenza in modo automatico: modificare $comment->setArticle($article) crea il legame nel database.
Relazione OneToMany: il lato inverso
La relazione OneToMany rappresenta il lato inverso di una ManyToOne. Permette di navigare dall'entità «uno» verso le entità «molti». Questa relazione non è mai il lato proprietario e non contiene chiave esterna.
// An article has multiple comments
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\Column(type: 'text')]
private string $content;
// OneToMany: one article → many comments
// Inverse side: mappedBy points to the owning entity's property
#[ORM\OneToMany(
targetEntity: Comment::class,
mappedBy: 'article',
cascade: ['persist', 'remove'], // Cascade operations
orphanRemoval: true // Remove orphaned comments
)]
private Collection $comments;
public function __construct()
{
// Initialize collection in the constructor
$this->comments = new ArrayCollection();
}
/**
* @return Collection<int, Comment>
*/
public function getComments(): Collection
{
return $this->comments;
}
public function addComment(Comment $comment): self
{
if (!$this->comments->contains($comment)) {
$this->comments->add($comment);
// CRITICAL: synchronize the owning side
$comment->setArticle($this);
}
return $this;
}
public function removeComment(Comment $comment): self
{
if ($this->comments->removeElement($comment)) {
// orphanRemoval handles deletion
}
return $this;
}
}I metodi addComment() e removeComment() garantiscono la coerenza bidirezionale. Senza la riga $comment->setArticle($this), la relazione non verrebbe persistita correttamente.
Dimenticare di sincronizzare entrambi i lati di una relazione bidirezionale è l'errore più comune. È necessario modificare sempre il lato proprietario per garantire la persistenza.
Relazione ManyToMany: associazioni multiple
La relazione ManyToMany collega più entità su entrambi i lati. Doctrine crea automaticamente una tabella di unione. Un lato deve essere designato come proprietario tramite l'attributo inversedBy.
// An article can have multiple tags
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
// ... other properties
// ManyToMany: owning side (inversedBy)
// Join table: article_tag (auto-generated)
#[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')]
#[ORM\JoinTable(name: 'article_tag')] // Explicit table name
private Collection $tags;
public function __construct()
{
$this->tags = new ArrayCollection();
}
/**
* @return Collection<int, Tag>
*/
public function getTags(): Collection
{
return $this->tags;
}
public function addTag(Tag $tag): self
{
if (!$this->tags->contains($tag)) {
$this->tags->add($tag);
// Owning side: no need to sync other side for persistence
// but recommended for in-memory consistency
$tag->addArticle($this);
}
return $this;
}
public function removeTag(Tag $tag): self
{
if ($this->tags->removeElement($tag)) {
$tag->removeArticle($this);
}
return $this;
}
}// A tag can belong to multiple articles
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: TagRepository::class)]
class Tag
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 50, unique: true)]
private string $name;
// Inverse side: mappedBy points to the owner
#[ORM\ManyToMany(targetEntity: Article::class, mappedBy: 'tags')]
private Collection $articles;
public function __construct()
{
$this->articles = new ArrayCollection();
}
/**
* @return Collection<int, Article>
*/
public function getArticles(): Collection
{
return $this->articles;
}
public function addArticle(Article $article): self
{
if (!$this->articles->contains($article)) {
$this->articles->add($article);
}
return $this;
}
public function removeArticle(Article $article): self
{
$this->articles->removeElement($article);
return $this;
}
}Per una relazione ManyToMany con dati aggiuntivi (data di associazione, ordine, ecc.) conviene trasformarla in due relazioni ManyToOne che puntano a un'entità intermedia.
Pronto a superare i tuoi colloqui su Symfony?
Pratica con i nostri simulatori interattivi, flashcards e test tecnici.
Strategie di caricamento e prestazioni
Il caricamento delle relazioni è il punto critico delle prestazioni in Doctrine. Esistono tre strategie: LAZY (predefinita), EAGER ed EXTRA_LAZY.
Lazy Loading: caricamento su richiesta
Il caricamento pigro recupera le relazioni solo al primo accesso. Questa strategia predefinita evita query inutili, ma può creare il problema N+1.
// Demonstrating the N+1 problem
namespace App\Repository;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ArticleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
// N+1 PROBLEM: one query per article for comments
public function findAllWithLazyComments(): array
{
// Query 1: SELECT * FROM article
$articles = $this->findAll();
// In view or service:
// foreach ($articles as $article) {
// $article->getComments(); // Query N: SELECT * FROM comment WHERE article_id = ?
// }
return $articles;
}
}Per 100 articoli, questo codice genera 101 query SQL: una per gli articoli e poi una per ciascun articolo per caricare i commenti.
Eager Loading con join
L'eager loading risolve il problema N+1 recuperando le relazioni nella stessa query tramite join.
// Optimized loading with joins
namespace App\Repository;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ArticleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
// SOLUTION: explicit join with fetch join
public function findAllWithComments(): array
{
return $this->createQueryBuilder('a')
// LEFT JOIN loads articles even without comments
->leftJoin('a.comments', 'c')
// addSelect includes comments in the result
->addSelect('c')
// Sort by comment creation date
->orderBy('c.createdAt', 'DESC')
->getQuery()
->getResult();
// Single SQL query with JOIN
}
// Loading multiple relationships
public function findAllWithCommentsAndTags(): array
{
return $this->createQueryBuilder('a')
->leftJoin('a.comments', 'c')
->addSelect('c')
->leftJoin('a.tags', 't')
->addSelect('t')
->leftJoin('c.author', 'ca') // Comment author join
->addSelect('ca')
->getQuery()
->getResult();
}
}Utilizzare addSelect() dopo ogni leftJoin() è essenziale. Senza addSelect(), Doctrine esegue il join ma non carica le entità correlate.
Extra Lazy: ottimizzare le collezioni grandi
La strategia EXTRA_LAZY ottimizza le operazioni su collezioni grandi senza caricare tutti gli elementi.
// Category with potentially thousands of products
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 100)]
private string $name;
// EXTRA_LAZY: optimizes count(), contains(), slice()
#[ORM\OneToMany(
targetEntity: Product::class,
mappedBy: 'category',
fetch: 'EXTRA_LAZY' // Does not load all products
)]
private Collection $products;
public function __construct()
{
$this->products = new ArrayCollection();
}
// count() executes SELECT COUNT(*) instead of loading collection
public function getProductCount(): int
{
return $this->products->count();
// SQL: SELECT COUNT(*) FROM product WHERE category_id = ?
}
// contains() checks existence without loading everything
public function hasProduct(Product $product): bool
{
return $this->products->contains($product);
// SQL: SELECT 1 FROM product WHERE id = ? AND category_id = ?
}
// slice() loads only a portion
public function getRecentProducts(int $limit = 5): array
{
return $this->products->slice(0, $limit);
// SQL: SELECT * FROM product WHERE category_id = ? LIMIT 5
}
}EXTRA_LAZY evita di caricare migliaia di entità per una semplice verifica o conteggio.
È opportuno applicare EXTRA_LAZY a collezioni potenzialmente grandi (oltre 100 elementi) in cui le operazioni count(), contains() o slice() sono frequenti.
Cascade e gestione del ciclo di vita
Le opzioni cascade automatizzano la propagazione delle operazioni alle entità correlate. Tre opzioni principali: persist, remove e orphanRemoval.
Cascade Persist: persistenza automatica
Cascade persist salva automaticamente le nuove entità correlate durante il flush.
// Order with cascaded order lines
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: OrderRepository::class)]
#[ORM\Table(name: '`order`')] // order is a SQL reserved word
class Order
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 50)]
private string $reference;
// cascade persist: OrderLines are persisted with Order
#[ORM\OneToMany(
targetEntity: OrderLine::class,
mappedBy: 'order',
cascade: ['persist'] // Automatically persist lines
)]
private Collection $lines;
public function __construct()
{
$this->lines = new ArrayCollection();
$this->reference = 'ORD-' . uniqid();
}
public function addLine(OrderLine $line): self
{
if (!$this->lines->contains($line)) {
$this->lines->add($line);
$line->setOrder($this);
}
return $this;
}
}// Order creation with cascade persist
namespace App\Service;
use App\Entity\Order;
use App\Entity\OrderLine;
use App\Entity\Product;
use Doctrine\ORM\EntityManagerInterface;
class OrderService
{
public function __construct(
private readonly EntityManagerInterface $em
) {}
public function createOrder(array $cartItems): Order
{
$order = new Order();
foreach ($cartItems as $item) {
$line = new OrderLine();
$line->setProduct($item['product']);
$line->setQuantity($item['quantity']);
$line->setUnitPrice($item['product']->getPrice());
// addLine synchronizes the relationship
$order->addLine($line);
}
// Only Order is explicitly persisted
// OrderLines are persisted automatically (cascade)
$this->em->persist($order);
$this->em->flush();
return $order;
}
}Cascade Remove e OrphanRemoval
Cascade remove elimina le entità correlate. OrphanRemoval va oltre eliminando anche le entità staccate dalla collezione.
// Blog post with images
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: BlogPostRepository::class)]
class BlogPost
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\OneToMany(
targetEntity: Image::class,
mappedBy: 'blogPost',
cascade: ['persist', 'remove'], // Delete images with post
orphanRemoval: true // Also delete detached images
)]
private Collection $images;
public function __construct()
{
$this->images = new ArrayCollection();
}
public function removeImage(Image $image): self
{
if ($this->images->removeElement($image)) {
// orphanRemoval: image will be deleted on flush
// Without orphanRemoval: image would remain in DB without blogPost
}
return $this;
}
public function clearImages(): self
{
// Clear collection → all images will be deleted
$this->images->clear();
return $this;
}
}La differenza chiave: cascade: ['remove'] elimina le entità correlate solo quando l'entità padre viene eliminata. orphanRemoval: true elimina anche le entità rimosse dalla collezione.
Query DQL avanzate per le relazioni
DQL (Doctrine Query Language) offre la massima flessibilità per query che coinvolgono relazioni complesse.
Filtraggio sulle relazioni
// Advanced queries on relationships
namespace App\Repository;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ArticleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
// Articles with at least one comment
public function findWithComments(): array
{
return $this->createQueryBuilder('a')
->innerJoin('a.comments', 'c') // INNER JOIN excludes articles without comments
->addSelect('c')
->groupBy('a.id')
->getQuery()
->getResult();
}
// Articles by tag with comment count
public function findByTagWithCommentCount(string $tagName): array
{
return $this->createQueryBuilder('a')
->select('a', 'COUNT(c.id) as commentCount')
->leftJoin('a.comments', 'c')
->innerJoin('a.tags', 't')
->where('t.name = :tagName')
->setParameter('tagName', $tagName)
->groupBy('a.id')
->orderBy('commentCount', 'DESC')
->getQuery()
->getResult();
}
// Recent articles with comment authors
public function findRecentWithAuthors(\DateTimeInterface $since): array
{
return $this->createQueryBuilder('a')
->leftJoin('a.comments', 'c')
->addSelect('c')
->leftJoin('c.author', 'u') // Join on comment author
->addSelect('u')
->where('a.publishedAt > :since')
->setParameter('since', $since)
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult();
}
}Subquery e aggregazioni
// Complex queries with subqueries
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
// Most active users (by article count)
public function findMostActiveAuthors(int $limit = 10): array
{
return $this->createQueryBuilder('u')
->select('u', 'COUNT(a.id) as articleCount')
->leftJoin('u.articles', 'a')
->where('a.status = :published')
->setParameter('published', 'published')
->groupBy('u.id')
->having('COUNT(a.id) > 0') // HAVING to filter after GROUP BY
->orderBy('articleCount', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
// Users with articles having more than 10 comments
public function findAuthorsWithPopularArticles(): array
{
// DQL subquery
$em = $this->getEntityManager();
$subQuery = $em->createQueryBuilder()
->select('IDENTITY(a2.author)')
->from('App\Entity\Article', 'a2')
->leftJoin('a2.comments', 'c2')
->groupBy('a2.id')
->having('COUNT(c2.id) > 10')
->getDQL();
return $this->createQueryBuilder('u')
->where('u.id IN (' . $subQuery . ')')
->getQuery()
->getResult();
}
}Inizia a praticare!
Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.
Best practice e pattern avanzati
Inizializzazione corretta delle collezioni
Le collezioni vanno sempre inizializzate nel costruttore per evitare errori di tipo.
// Proper relationship initialization
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AuthorRepository::class)]
class Author
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\OneToMany(targetEntity: Book::class, mappedBy: 'author')]
private Collection $books;
#[ORM\ManyToMany(targetEntity: Genre::class)]
private Collection $favoriteGenres;
// ALWAYS initialize in constructor
public function __construct()
{
$this->books = new ArrayCollection();
$this->favoriteGenres = new ArrayCollection();
}
// Utility method to check if collection is loaded
public function areBooksLoaded(): bool
{
return $this->books->isInitialized();
}
}Evitare i riferimenti circolari
Le relazioni bidirezionali possono creare loop infiniti durante la serializzazione.
// Handling circular references
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\MaxDepth;
#[ORM\Entity(repositoryClass: DepartmentRepository::class)]
class Department
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['department:read'])]
private ?int $id = null;
#[ORM\Column(length: 100)]
#[Groups(['department:read', 'employee:read'])]
private string $name;
// MaxDepth limits serialization depth
#[ORM\OneToMany(targetEntity: Employee::class, mappedBy: 'department')]
#[Groups(['department:read'])]
#[MaxDepth(1)] // Does not serialize employee relations
private Collection $employees;
public function __construct()
{
$this->employees = new ArrayCollection();
}
}Pattern Repository con criteri dinamici
// Flexible search criteria
namespace App\Repository;
use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
class ProductRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Product::class);
}
// Search with optional filters
public function findByCriteria(array $criteria): array
{
$qb = $this->createQueryBuilder('p')
->leftJoin('p.category', 'c')
->addSelect('c')
->leftJoin('p.tags', 't')
->addSelect('t');
// Conditional filters
if (isset($criteria['category'])) {
$qb->andWhere('c.slug = :category')
->setParameter('category', $criteria['category']);
}
if (isset($criteria['minPrice'])) {
$qb->andWhere('p.price >= :minPrice')
->setParameter('minPrice', $criteria['minPrice']);
}
if (isset($criteria['maxPrice'])) {
$qb->andWhere('p.price <= :maxPrice')
->setParameter('maxPrice', $criteria['maxPrice']);
}
if (isset($criteria['tags']) && is_array($criteria['tags'])) {
$qb->andWhere('t.name IN (:tags)')
->setParameter('tags', $criteria['tags']);
}
if (isset($criteria['inStock']) && $criteria['inStock']) {
$qb->andWhere('p.stock > 0');
}
return $qb->orderBy('p.createdAt', 'DESC')
->getQuery()
->getResult();
}
}Conclusione
Padroneggiare le relazioni Doctrine ORM si basa su alcuni principi fondamentali:
✅ Lato proprietario vs inverso: modificare sempre il lato proprietario per garantire la persistenza
✅ Sincronizzazione bidirezionale: i metodi add/remove devono sincronizzare entrambi i lati
✅ Fetch join: utilizzare addSelect() dopo ogni join per evitare il problema N+1
✅ EXTRA_LAZY: attivarlo sulle collezioni grandi per ottimizzare count() e contains()
✅ Cascade con cautela: persist è spesso utile; remove e orphanRemoval dipendono dal contesto di business
✅ Inizializzazione delle collezioni: sempre nel costruttore con ArrayCollection
Questi pattern formano la base di un'applicazione Symfony performante. Il passo successivo consiste nel padroneggiare gli indici e le query native per i casi di prestazioni estreme.
Inizia a praticare!
Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.
Tag
Condividi
Articoli correlati

Domande di colloquio Symfony: Top 25 nel 2026
Le 25 domande di colloquio Symfony più frequenti. Architettura, Doctrine ORM, servizi, sicurezza, form e test con risposte dettagliate ed esempi di codice.

Symfony 7: API Platform e Best Practices
Guida completa ad API Platform 4 con Symfony 7: State Processors, State Providers, gruppi di serializzazione, filtri, sicurezza e test automatizzati per API REST professionali.

Symfony Live Components e UX 3.0: Applicazioni Reattive Senza JavaScript nel 2026
Questo tutorial mostra come creare interfacce reattive con Symfony Live Components e UX 3.0 senza scrivere JavaScript. Esempi pratici con componenti di ricerca, carrello e form dinamici.