Doctrine ORM: Symfony'de İlişkilerde Ustalaşmak
Symfony'de Doctrine ORM ilişkileri için kapsamlı rehber. OneToMany, ManyToMany, yükleme stratejileri ve pratik örneklerle performans optimizasyonu.

Varlıklar arası ilişkiler, Doctrine ORM kullanan her Symfony uygulamasının omurgasını oluşturur. Farklı ilişki türleri, yükleme stratejileri ve performans tuzakları konusunda sağlam bir kavrayış, sağlam ve performanslı uygulamalar geliştirmeyi mümkün kılar. Bu rehber, Doctrine ilişkilerini etkili bir şekilde yönetmek için temel desenleri ele alır.
Doctrine, sahibi taraf ve ters taraf kavramını kullanır. Bu ayrımın anlaşılması, ilişki kalıcılığıyla ilgili pek çok hatanın önüne geçer.
Doctrine İlişki Türleri
Doctrine, varlıklar arasında dört ilişki türü sunar: OneToOne, OneToMany, ManyToOne ve ManyToMany. Her tür belirli bir iş ihtiyacına yanıt verir ve kendine özgü performans özellikleri taşır.
ManyToOne İlişkisi: en yaygın durum
ManyToOne ilişkisi, en sık karşılaşılan veritabanı bağlantısını temsil eder. Bir taraftaki birden fazla varlık, diğer taraftaki tek bir varlıkla ilişkilenir. Bu ilişki her zaman birleşmenin sahibi tarafıdır.
// 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;
}
}Comment varlığı article_id yabancı anahtarını barındırır. Doctrine kalıcılığı otomatik olarak yönetir: $comment->setArticle($article) çağrısı veritabanındaki bağlantıyı oluşturur.
OneToMany İlişkisi: ters taraf
OneToMany ilişkisi, ManyToOne ilişkisinin ters tarafını temsil eder. «Bir» varlıktan «çok» varlıklara doğru gezinmeyi sağlar. Bu ilişki asla sahibi taraf değildir ve yabancı anahtar içermez.
// 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;
}
}addComment() ve removeComment() metotları çift yönlü tutarlılığı garanti eder. $comment->setArticle($this) satırı olmadan ilişki düzgün biçimde kalıcı kılınamaz.
Çift yönlü bir ilişkinin her iki tarafını da senkronize etmeyi unutmak en yaygın hatadır. Kalıcılığı garantilemek için her zaman sahibi taraf değiştirilmelidir.
ManyToMany İlişkisi: çoklu birleşmeler
ManyToMany ilişkisi her iki tarafta birden fazla varlığı birbirine bağlar. Doctrine birleşim tablosunu otomatik olarak oluşturur. inversedBy özniteliği aracılığıyla bir tarafın sahibi olarak belirlenmesi gerekir.
// 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;
}
}Ek veriler içeren ManyToMany ilişkilerinde (birleşme tarihi, sıra vb.), bunu bir ara varlığa işaret eden iki ayrı ManyToOne ilişkisine dönüştürmek daha uygun olur.
Symfony mülakatlarında başarılı olmaya hazır mısın?
İnteraktif simülatörler, flashcards ve teknik testlerle pratik yap.
Yükleme stratejileri ve performans
İlişkilerin yüklenmesi, Doctrine'de kritik performans noktasıdır. Üç strateji vardır: LAZY (varsayılan), EAGER ve EXTRA_LAZY.
Lazy Loading: talep üzerine yükleme
Lazy loading, ilişkileri yalnızca ilk erişimde getirir. Bu varsayılan strateji gereksiz sorguların önüne geçer ancak N+1 sorununu doğurabilir.
// 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;
}
}100 makale için bu kod 101 SQL sorgusu üretir: makaleler için bir tane, ardından yorumları yüklemek üzere her makale için bir tane.
Eager loading ve join'ler
Eager loading, ilişkileri aynı sorguda join'lerle çekerek N+1 sorununu çözer.
// 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();
}
}Her leftJoin() sonrasında addSelect() kullanmak hayati önemdedir. addSelect() olmadan Doctrine join'i çalıştırır ancak ilgili varlıkları yüklemez.
Extra Lazy: büyük koleksiyonların optimize edilmesi
EXTRA_LAZY stratejisi, tüm öğeleri yüklemeden büyük koleksiyonlar üzerindeki işlemleri optimize eder.
// 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, basit bir kontrol veya sayım için binlerce varlığın yüklenmesini önler.
EXTRA_LAZY, count(), contains() veya slice() işlemlerinin sıkça yapıldığı ve potansiyel olarak büyük (100'den fazla öğe) koleksiyonlarda kullanılmalıdır.
Cascade ve yaşam döngüsü yönetimi
Cascade seçenekleri, işlemlerin ilgili varlıklara yayılmasını otomatikleştirir. Üç ana seçenek vardır: persist, remove ve orphanRemoval.
Cascade Persist: otomatik kalıcılaştırma
Cascade persist, flush sırasında yeni ilgili varlıkları otomatik olarak kaydeder.
// 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 ve OrphanRemoval
Cascade remove, ilgili varlıkları siler. OrphanRemoval ise koleksiyondan ayrılmış varlıkları da silerek bir adım daha ileri gider.
// 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;
}
}Kritik fark: cascade: ['remove'] yalnızca üst varlık silindiğinde ilgili varlıkları siler. orphanRemoval: true ise koleksiyondan kaldırılan varlıkları da siler.
İlişkiler için ileri DQL sorguları
DQL (Doctrine Query Language), karmaşık ilişkiler içeren sorgular için maksimum esneklik sunar.
İlişkiler üzerinde filtreleme
// 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();
}
}Alt sorgular ve toplulaştırmalar
// 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();
}
}Pratik yapmaya başla!
Mülakat simülatörleri ve teknik testlerle bilgini test et.
En iyi uygulamalar ve ileri desenler
Koleksiyonların doğru şekilde başlatılması
Koleksiyonlar, tip hatalarından kaçınmak için daima yapıcıda başlatılmalıdır.
// 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();
}
}Dairesel referanslardan kaçınmak
Çift yönlü ilişkiler, serileştirme sırasında sonsuz döngülere yol açabilir.
// 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();
}
}Dinamik kriterlerle Repository deseni
// 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();
}
}Sonuç
Doctrine ORM ilişkilerinde ustalaşmak birkaç temel ilkeye dayanır:
✅ Sahibi taraf vs ters taraf: kalıcılığı garantilemek için her zaman sahibi taraf değiştirilmelidir
✅ Çift yönlü senkronizasyon: add/remove metotları her iki tarafı da senkronize etmelidir
✅ Fetch join: N+1 sorunundan kaçınmak için her join'den sonra addSelect() kullanılmalıdır
✅ EXTRA_LAZY: count() ve contains() işlemlerini optimize etmek için büyük koleksiyonlarda etkinleştirilmelidir
✅ Cascade dikkatle: persist çoğu zaman yararlıdır; remove ve orphanRemoval iş bağlamına göre değişir
✅ Koleksiyon başlatma: daima yapıcıda ArrayCollection ile yapılmalıdır
Bu desenler performanslı bir Symfony uygulamasının temelini oluşturur. Bir sonraki adım, aşırı performans gerektiren durumlar için indekslerin ve native sorguların ustalıkla kullanımıdır.
Pratik yapmaya başla!
Mülakat simülatörleri ve teknik testlerle bilgini test et.
Etiketler
Paylaş
İlgili makaleler

Symfony Mülakat Soruları: 2026 İlk 25
En sık sorulan 25 Symfony mülakat sorusu. Mimari, Doctrine ORM, servisler, güvenlik, formlar ve testler ayrıntılı yanıtlar ve kod örnekleriyle.

Symfony 8: 2026'daki Yeni Ozellikler, PHP 8.4 Lazy Objects ve Mulakat Sorulari
Symfony 8'in yeni ozelliklerini, PHP 8.4 lazy objects entegrasyonunu, cok adimli formlari ve 2026 mulakat sorularini kod ornekleriyle kesfet.

Symfony Live Components ve UX 3.0: 2026'da JavaScript Olmadan Reaktif Uygulamalar
Symfony Live Components ve UX 3.0 ile JavaScript yazmadan reaktif arayuzler olusturma rehberi. PHP ve Twig ile dinamik bilesenler icin kapsamli tutorial ve pratik ornekler.