Symfony13 min

Repository personnalises en Symfony : QueryBuilder et DQL avances

Par Pierre-Arthur Demengel
SymfonyDoctrineQueryBuilderDQLRepository

Les methodes find(), findBy() et findAll() couvrent les cas simples, mais la realite des projets Symfony impose rapidement des requetes plus sophistiquees : jointures, aggregations, filtres dynamiques, pagination. C'est la que les repositories personnalises, le QueryBuilder et le DQL deviennent indispensables. Ce guide couvre les techniques avancees que j'utilise quotidiennement sur mes projets en production.

Rappel : les methodes de base

Avant d'aller plus loin, assurons-nous que les bases sont solides. Si vous debutez avec findBy() et findAll(), consultez d'abord la reference complete findBy/findAll.

// Methodes heritees de ServiceEntityRepository
$repo->find(42);                          // Par ID
$repo->findOneBy(['slug' => 'mon-post']); // Un resultat
$repo->findBy(['status' => 'published'], ['createdAt' => 'DESC'], 10); // Plusieurs
$repo->findAll();                         // Tous
$repo->count(['status' => 'draft']);       // Comptage

Ces methodes suffisent pour les CRUD basiques. Des que vous avez besoin de jointures, de conditions complexes ou de calculs agreges, passez au QueryBuilder.

QueryBuilder : l'API fluide

Le QueryBuilder construit des requetes DQL de maniere programmatique. Chaque appel de methode ajoute une clause a la requete :

// src/Repository/PostRepository.php
namespace App\Repository;

use App\Entity\Post;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class PostRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Post::class);
    }

    /**
     * Articles publies, tries par date, avec auteur pre-charge.
     */
    public function findPublishedWithAuthor(int $limit = 20): array
    {
        return $this->createQueryBuilder('p')
            ->addSelect('a') // evite le lazy loading sur l'auteur
            ->innerJoin('p.author', 'a')
            ->where('p.status = :status')
            ->setParameter('status', 'published')
            ->orderBy('p.publishedAt', 'DESC')
            ->setMaxResults($limit)
            ->getQuery()
            ->getResult();
    }
}

Le addSelect('a') apres le innerJoin est crucial. Sans lui, Doctrine ferait une requete supplementaire pour chaque auteur (probleme N+1). Avec, l'auteur est charge dans la meme requete SQL.

Filtres dynamiques

Le QueryBuilder prend tout son sens quand les filtres sont optionnels :

public function search(
    ?string $keyword = null,
    ?string $category = null,
    ?\DateTimeInterface $from = null,
    ?\DateTimeInterface $to = null,
    int $page = 1,
    int $limit = 20,
): array {
    $qb = $this->createQueryBuilder('p')
        ->addSelect('a', 't')
        ->innerJoin('p.author', 'a')
        ->leftJoin('p.tags', 't')
        ->where('p.status = :status')
        ->setParameter('status', 'published')
        ->orderBy('p.publishedAt', 'DESC');

    if ($keyword !== null) {
        $qb->andWhere('p.title LIKE :keyword OR p.content LIKE :keyword')
           ->setParameter('keyword', '%' . $keyword . '%');
    }

    if ($category !== null) {
        $qb->andWhere('p.category = :category')
           ->setParameter('category', $category);
    }

    if ($from !== null) {
        $qb->andWhere('p.publishedAt >= :from')
           ->setParameter('from', $from);
    }

    if ($to !== null) {
        $qb->andWhere('p.publishedAt <= :to')
           ->setParameter('to', $to);
    }

    return $qb
        ->setFirstResult(($page - 1) * $limit)
        ->setMaxResults($limit)
        ->getQuery()
        ->getResult();
}

Chaque condition est ajoutee uniquement si le parametre est fourni. Essayez de faire ca avec findBy() - c'est impossible.

DQL : le SQL des entites

Le DQL (Doctrine Query Language) ressemble a SQL mais travaille sur les entites, pas sur les tables. Il est utile pour les requetes statiques ou la lisibilite prime :

public function findMostCommented(int $limit = 10): array
{
    $dql = '
        SELECT p, COUNT(c.id) AS commentCount
        FROM App\Entity\Post p
        LEFT JOIN p.comments c
        WHERE p.status = :status
        GROUP BY p.id
        HAVING COUNT(c.id) > 0
        ORDER BY commentCount DESC
    ';

    return $this->getEntityManager()
        ->createQuery($dql)
        ->setParameter('status', 'published')
        ->setMaxResults($limit)
        ->getResult();
}

DQL vs QueryBuilder : pour une requete fixe (pas de filtres dynamiques), le DQL est souvent plus lisible. Pour une requete dynamique, le QueryBuilder est indispensable.

Jointures avancees : LEFT JOIN, JOIN FETCH

Comprendre la difference entre join, leftJoin et les variantes avec addSelect est fondamental :

// INNER JOIN : exclut les posts sans commentaires
$qb->innerJoin('p.comments', 'c');

// LEFT JOIN : inclut les posts sans commentaires (c peut etre null)
$qb->leftJoin('p.comments', 'c');

// LEFT JOIN + addSelect : charge les commentaires en memoire (evite N+1)
$qb->leftJoin('p.comments', 'c')
   ->addSelect('c');

// JOIN avec condition supplementaire
$qb->leftJoin('p.comments', 'c', 'WITH', 'c.publishedAt > :date')
   ->setParameter('date', new \DateTimeImmutable('-30 days'));

Aggregations : COUNT, SUM, AVG, GROUP BY

// Nombre d'articles par categorie
public function countByCategory(): array
{
    return $this->createQueryBuilder('p')
        ->select('p.category, COUNT(p.id) AS total')
        ->where('p.status = :status')
        ->setParameter('status', 'published')
        ->groupBy('p.category')
        ->orderBy('total', 'DESC')
        ->getQuery()
        ->getResult();
}

// Moyenne des notes avec HAVING
public function findHighRatedAuthors(float $minAvg = 4.0): array
{
    return $this->createQueryBuilder('p')
        ->select('IDENTITY(p.author) AS authorId, AVG(p.rating) AS avgRating')
        ->groupBy('p.author')
        ->having('AVG(p.rating) >= :minAvg')
        ->setParameter('minAvg', $minAvg)
        ->getQuery()
        ->getResult();
}

Pagination avec Doctrine Paginator

Pour une pagination correcte avec des jointures, utilisez le Paginator de Doctrine. Il gere les cas ou setMaxResults seul donne des resultats incorrects (notamment avec les collections OneToMany) :

use Doctrine\ORM\Tools\Pagination\Paginator;

public function findPaginated(int $page, int $limit = 20): Paginator
{
    $query = $this->createQueryBuilder('p')
        ->addSelect('a')
        ->innerJoin('p.author', 'a')
        ->leftJoin('p.tags', 't')
        ->addSelect('t')
        ->where('p.status = :status')
        ->setParameter('status', 'published')
        ->orderBy('p.publishedAt', 'DESC')
        ->setFirstResult(($page - 1) * $limit)
        ->setMaxResults($limit)
        ->getQuery();

    $paginator = new Paginator($query);
    $paginator->setUseOutputWalkers(false); // performance

    return $paginator;
}

// Dans le controleur :
$paginator = $postRepo->findPaginated($page);
$totalPosts = count($paginator);     // COUNT total (une seule requete)
$posts = iterator_to_array($paginator); // Resultats de la page

Criteria : filtrer les collections en memoire

Quand vous avez deja une collection chargee en memoire et que vous voulez la filtrer sans nouvelle requete SQL, utilisez Criteria :

use Doctrine\Common\Collections\Criteria;

// Dans l'entite Post
public function getRecentComments(int $days = 7): Collection
{
    $criteria = Criteria::create()
        ->where(Criteria::expr()->gte(
            'publishedAt',
            new \DateTimeImmutable("-{$days} days")
        ))
        ->orderBy(['publishedAt' => 'DESC'])
        ->setMaxResults(5);

    return $this->comments->matching($criteria);
}

Attention : si la collection n'est pas encore chargee, Doctrine generera une requete SQL optimisee automatiquement. C'est le meilleur des deux mondes.

Performances : eviter les pieges

Le probleme N+1

Le piege le plus courant. Si vous affichez 20 posts avec leur auteur sans addSelect, Doctrine fait 1 requete pour les posts + 20 requetes pour les auteurs = 21 requetes au lieu d'une seule.

// MAL : 21 requetes
$posts = $repo->findBy(['status' => 'published']);
// Chaque $post->getAuthor()->getName() declenche une requete

// BIEN : 1 requete
$posts = $repo->createQueryBuilder('p')
    ->addSelect('a')
    ->innerJoin('p.author', 'a')
    ->where('p.status = :status')
    ->setParameter('status', 'published')
    ->getQuery()
    ->getResult();

DTO pour les lectures

Si vous n'avez pas besoin d'entites completes (pas de persistence), utilisez des DTO pour reduire la consommation memoire :

// Projection vers un DTO - pas d'hydratation d'entite
$results = $this->createQueryBuilder('p')
    ->select('NEW App\DTO\PostSummary(p.id, p.title, p.slug, a.fullName)')
    ->innerJoin('p.author', 'a')
    ->where('p.status = :status')
    ->setParameter('status', 'published')
    ->getQuery()
    ->getResult();

Cache de resultats

$query = $qb->getQuery();
$query->enableResultCache(3600, 'posts_homepage'); // 1h de cache
$results = $query->getResult();

Pour un guide complet sur le cache Symfony, consultez l'article dedie au cache PSR-6/PSR-16.

SQL natif avec ResultSetMapping

Pour les requetes que DQL ne supporte pas (CTE, window functions, JSON operators), utilisez du SQL natif :

use Doctrine\ORM\Query\ResultSetMapping;

public function findWithRank(): array
{
    $rsm = new ResultSetMapping();
    $rsm->addEntityResult(Post::class, 'p');
    $rsm->addFieldResult('p', 'id', 'id');
    $rsm->addFieldResult('p', 'title', 'title');
    $rsm->addScalarResult('rank', 'rank');

    $sql = '
        SELECT p.id, p.title,
               RANK() OVER (ORDER BY p.views DESC) as rank
        FROM post p
        WHERE p.status = :status
    ';

    return $this->getEntityManager()
        ->createNativeQuery($sql, $rsm)
        ->setParameter('status', 'published')
        ->getResult();
}

Bonnes pratiques

  • Toujours utiliser des parametres bindes (setParameter) - jamais de concatenation de valeurs dans les requetes.
  • Nommez vos methodes de repository de maniere explicite : findPublishedByCategory() plutot que getList().
  • Utilisez addSelect systematiquement sur les jointures pour eviter le N+1.
  • Profitez du Profiler Symfony pour compter le nombre de requetes sur chaque page.
  • Pour les migrations, consultez le guide des migrations Doctrine.

Besoin d'optimiser les performances de votre application Symfony ou de refactorer des requetes complexes ? Contactez-moi pour un audit technique, ou consultez mes tarifs et mes services.

Questions fréquentes

13 projets livrésGrand-Est & BelgiqueLighthouse >90Disponible immédiatement

Un projet en tête ?

Discutons de votre site web. Réponse garantie sous 24h.

Ou appelez directement :06 95 41 30 25

WhatsApp
Appeler