Doctrine ORM: Relaties beheersen in Symfony

Volledige gids voor Doctrine ORM-relaties in Symfony. OneToMany, ManyToMany, laadstrategieën en performance-optimalisatie met praktische voorbeelden.

Doctrine ORM-relaties in Symfony - Volledige gids

Relaties tussen entiteiten vormen de ruggengraat van elke Symfony-applicatie die Doctrine ORM gebruikt. Een gedegen begrip van de verschillende relatietypen, laadstrategieën en performance-valkuilen maakt het mogelijk robuuste en performante applicaties te bouwen. Deze gids behandelt de essentiële patronen om Doctrine-relaties effectief te beheren.

Kernprincipe

Doctrine gebruikt het concept van eigenaarszijde en inverse zijde. Het begrijpen van dit onderscheid voorkomt veel bugs gerelateerd aan relatie-persistentie.

Doctrine-relatietypen

Doctrine biedt vier relatietypen tussen entiteiten: OneToOne, OneToMany, ManyToOne en ManyToMany. Elk type beantwoordt aan een specifieke bedrijfsbehoefte en heeft zijn eigen performance-eigenschappen.

ManyToOne-relatie: het meest voorkomende geval

De ManyToOne-relatie vertegenwoordigt de meest voorkomende databasekoppeling. Meerdere entiteiten aan de ene kant zijn gerelateerd aan één entiteit aan de andere kant. Deze relatie is altijd de eigenaarszijde van de associatie.

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;
    }
}

De Comment-entiteit bevat de foreign key article_id. Doctrine zorgt automatisch voor de persistentie: het wijzigen van $comment->setArticle($article) creëert de koppeling in de database.

OneToMany-relatie: de inverse zijde

De OneToMany-relatie vertegenwoordigt de inverse zijde van een ManyToOne. Het maakt navigatie van de «één»-entiteit naar de «veel»-entiteiten mogelijk. Deze relatie is nooit de eigenaarszijde en bevat geen foreign key.

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;
    }
}

De methoden addComment() en removeComment() waarborgen de bidirectionele consistentie. Zonder de regel $comment->setArticle($this) zou de relatie niet correct worden gepersisteerd.

Veelvoorkomende valkuil

Vergeten beide zijden van een bidirectionele relatie te synchroniseren is de meest voorkomende fout. Wijzig altijd de eigenaarszijde om persistentie te garanderen.

ManyToMany-relatie: meervoudige associaties

De ManyToMany-relatie verbindt meerdere entiteiten aan beide zijden. Doctrine creëert automatisch een join-tabel. Eén zijde moet als eigenaar worden aangewezen via het inversedBy-attribuut.

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;
    }
}

Voor een ManyToMany-relatie met aanvullende gegevens (associatiedatum, volgorde, enz.) is het beter deze om te zetten naar twee ManyToOne-relaties die naar een tussenliggende entiteit wijzen.

Klaar om je Symfony gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Laadstrategieën en performance

Het laden van relaties is het kritische performance-punt bij Doctrine. Er bestaan drie strategieën: LAZY (standaard), EAGER en EXTRA_LAZY.

Lazy Loading: laden op aanvraag

Lazy loading haalt relaties pas op bij de eerste toegang. Deze standaardstrategie voorkomt onnodige queries, maar kan het N+1-probleem veroorzaken.

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;
    }
}

Voor 100 artikelen genereert deze code 101 SQL-queries: één voor de artikelen en daarna één per artikel om de reacties te laden.

Eager loading met joins

Eager loading lost het N+1-probleem op door relaties in dezelfde query op te halen via joins.

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();
    }
}

Het gebruik van addSelect() na elke leftJoin() is essentieel. Zonder addSelect() voert Doctrine de join uit, maar laadt het de gerelateerde entiteiten niet.

Extra Lazy: grote collecties optimaliseren

De EXTRA_LAZY-strategie optimaliseert bewerkingen op grote collecties zonder alle elementen te laden.

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 voorkomt het laden van duizenden entiteiten voor een eenvoudige controle of telling.

Wanneer EXTRA_LAZY gebruiken

Pas EXTRA_LAZY toe op potentieel grote collecties (meer dan 100 elementen) waarbij operaties zoals count(), contains() of slice() vaak voorkomen.

Cascade en lifecycle-beheer

Cascade-opties automatiseren de propagatie van bewerkingen naar gerelateerde entiteiten. Drie hoofdopties: persist, remove en orphanRemoval.

Cascade Persist: automatische persistentie

Cascade persist slaat nieuwe gerelateerde entiteiten automatisch op tijdens 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 en OrphanRemoval

Cascade remove verwijdert gerelateerde entiteiten. OrphanRemoval gaat verder door entiteiten te verwijderen die los van de collectie zijn geraakt.

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;
    }
}

Het belangrijkste verschil: cascade: ['remove'] verwijdert gerelateerde entiteiten alleen wanneer de ouder-entiteit wordt verwijderd. orphanRemoval: true verwijdert ook entiteiten die uit de collectie zijn gehaald.

Geavanceerde DQL-queries voor relaties

DQL (Doctrine Query Language) biedt maximale flexibiliteit voor queries met complexe relaties.

Filteren op relaties

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();
    }
}

Subqueries en aggregaties

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();
    }
}

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Best practices en geavanceerde patronen

Correcte initialisatie van collecties

Collecties moeten altijd in de constructor worden geïnitialiseerd om typefouten te voorkomen.

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();
    }
}

Circulaire referenties vermijden

Bidirectionele relaties kunnen oneindige loops veroorzaken tijdens serialisatie.

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();
    }
}

Repository-patroon met dynamische criteria

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();
    }
}

Conclusie

Het beheersen van Doctrine ORM-relaties berust op enkele fundamentele principes:

Eigenaarszijde vs inverse zijde: wijzig altijd de eigenaarszijde om persistentie te garanderen

Bidirectionele synchronisatie: add/remove-methoden moeten beide zijden synchroniseren

Fetch joins: gebruik addSelect() na elke join om het N+1-probleem te voorkomen

EXTRA_LAZY: activeer dit op grote collecties om count() en contains() te optimaliseren

Cascade met zorg: persist is vaak nuttig; remove en orphanRemoval hangen af van de business-context

Initialisatie van collecties: altijd in de constructor met ArrayCollection

Deze patronen vormen de basis van een performante Symfony-applicatie. De volgende stap betreft het beheersen van indexen en native queries voor gevallen met extreme performance-eisen.

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Tags

#doctrine
#symfony
#orm
#php
#database

Delen

Gerelateerde artikelen