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.

Relazioni Doctrine ORM in Symfony - Guida completa

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.

Principio fondamentale

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.

src/Entity/Comment.phpphp
// 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.

src/Entity/Article.phpphp
// 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.

Trappola comune

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.

src/Entity/Article.phpphp
// 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;
    }
}
src/Entity/Tag.phpphp
// 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.

src/Repository/ArticleRepository.phpphp
// 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.

src/Repository/ArticleRepository.phpphp
// 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.

src/Entity/Category.phpphp
// 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.

Quando usare EXTRA_LAZY

È 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.

src/Entity/Order.phpphp
// 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;
    }
}
src/Service/OrderService.phpphp
// 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.

src/Entity/BlogPost.phpphp
// 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

src/Repository/ArticleRepository.phpphp
// 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

src/Repository/UserRepository.phpphp
// 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.

src/Entity/Author.phpphp
// 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.

src/Entity/Department.phpphp
// 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

src/Repository/ProductRepository.phpphp
// 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

#doctrine
#symfony
#orm
#php
#database

Condividi

Articoli correlati