Chapitre 6.3 — Repositories, DQL & QueryBuilder
Partie 6 : Doctrine ORM — Chapitre 3/7 Version de référence : Doctrine ORM 3 / Symfony 7.4.
⏱️ TL;DR
- Un repository centralise les requêtes d’une entité. On y ajoute ses propres méthodes (
findConfirmedForProvider(...)).- DQL (Doctrine Query Language) ressemble au SQL mais interroge des entités et propriétés, pas des tables et colonnes :
SELECT b FROM App\Entity\Booking b WHERE b.status = :status.- Le QueryBuilder construit du DQL de façon fluide et programmatique — idéal pour des filtres conditionnels.
- Toujours passer les valeurs par
setParameter()(jamais de concaténation) → pas d’injection SQL.- Récupération :
getResult()(liste),getOneOrNullResult(),getSingleScalarResult()(un nombre).- Pagination via
setFirstResult()/setMaxResults()+ lePaginatorde Doctrine.- Pour un dev Next.js : DQL/QueryBuilder ≈ l’API de requête de Prisma/TypeORM, en un peu plus « SQL-like » ; les
:paramsremplacent l’interpolation, comme les requêtes préparées.
🎯 Objectifs
- Ajouter des méthodes de requête à un repository.
- Écrire des requêtes en DQL et avec le QueryBuilder.
- Sécuriser les requêtes avec des paramètres.
- Paginer et récupérer les bons types de résultats.
1. Le repository, point d’entrée des requêtes
Chaque entité a un repository (généré). On lui ajoute ses requêtes métier. Il étend ServiceEntityRepository et est injectable comme n’importe quel service.
// src/Repository/BookingRepository.php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Booking;
use App\Enum\BookingStatus;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<Booking> */
final class BookingRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Booking::class);
}
// ... vos méthodes de requête ici
}On l’injecte dans un service/contrôleur :
public function __construct(private readonly BookingRepository $bookings) {}⚠️ Piège : ne mettez pas de logique métier (règles, effets de bord) dans un repository — il ne fait que lire (et parfois écrire) la base. La logique vit dans les services (Partie 5). Un repository qui envoie des e-mails est un mauvais signe.
2. DQL : requêter des objets, pas des tables
Le DQL ressemble au SQL, mais manipule des classes d’entités et leurs propriétés (pas des noms de tables/colonnes). Doctrine le traduit en SQL selon votre mapping.
public function findConfirmedSince(\DateTimeImmutable $since): array
{
$dql = <<<'DQL'
SELECT b
FROM App\Entity\Booking b
WHERE b.status = :status
AND b.start >= :since
ORDER BY b.start ASC
DQL;
return $this->getEntityManager()
->createQuery($dql)
->setParameter('status', BookingStatus::Confirmed)
->setParameter('since', $since)
->getResult(); // Booking[]
}Notez : App\Entity\Booking b (une classe), b.status (une propriété), :status (un paramètre). On ne parle jamais de la table bookings ni de la colonne status — Doctrine fait le pont.
3. QueryBuilder : construire des requêtes dynamiques
Quand la requête dépend de conditions (filtres optionnels), le QueryBuilder est plus pratique que du DQL en chaîne :
public function search(?BookingStatus $status, ?int $providerId, int $page = 1, int $perPage = 20): array
{
$qb = $this->createQueryBuilder('b') // alias 'b' pour Booking
->orderBy('b.start', 'DESC')
->setFirstResult(($page - 1) * $perPage) // offset (pagination)
->setMaxResults($perPage); // limit
if ($status !== null) {
$qb->andWhere('b.status = :status')
->setParameter('status', $status);
}
if ($providerId !== null) {
$qb->join('b.slot', 's') // jointure vers Slot
->join('s.service', 'svc') // puis vers Service
->andWhere('svc.provider = :pid')
->setParameter('pid', $providerId);
}
return $qb->getQuery()->getResult();
}Le QueryBuilder produit du DQL en interne. On ajoute conditionnellement des andWhere, join, orderBy… — impossible à faire proprement avec une chaîne DQL figée.
💡 Pour un dev Next.js : le QueryBuilder ≈ construire une requête Prisma avec un objet
wheremonté conditionnellement (if (status) where.status = status). Même esprit : on assemble la requête selon les filtres présents. Les:params+setParametersont l’équivalent des requêtes paramétrées (anti-injection).
4. Sécurité : toujours des paramètres
Ne concaténez jamais de valeur dans une requête. Utilisez des paramètres nommés (:x) et setParameter() — Doctrine les envoie comme requête préparée, éliminant l’injection SQL.
// ❌ JAMAIS : injection SQL
->where("b.customerEmail = '" . $email . "'")
// ✅ TOUJOURS : paramètre
->where('b.customerEmail = :email')->setParameter('email', $email)⚠️ Piège : même pour un
int« qui vient de vous », prenez l’habitude du paramètre. C’est une règle sans exception — la concaténation dans une requête est la porte ouverte n°1 aux failles. Doctrine rend le bon usage simple ; adoptez-le systématiquement.
5. Récupérer le bon type de résultat
Selon ce que vous attendez :
| Méthode | Renvoie | Cas |
|---|---|---|
getResult() | Entity[] | une liste |
getOneOrNullResult() | Entity|null | 0 ou 1 |
getSingleResult() | Entity (lève si 0 ou >1) | exactement 1 |
getSingleScalarResult() | scalaire | un COUNT, une somme |
toIterable() | générateur | gros volumes (mémoire constante) |
// compter
$count = $this->createQueryBuilder('b')
->select('COUNT(b.id)')
->where('b.status = :s')->setParameter('s', BookingStatus::Pending)
->getQuery()
->getSingleScalarResult(); // int
// un seul
$booking = $this->createQueryBuilder('b')
->where('b.customerEmail = :e')->setParameter('e', $email)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult(); // Booking|null6. Pagination
Pour paginer proprement (surtout avec des jointures), Doctrine fournit un Paginator qui gère le comptage total correct :
use Doctrine\ORM\Tools\Pagination\Paginator;
public function paginateConfirmed(int $page, int $perPage): Paginator
{
$query = $this->createQueryBuilder('b')
->where('b.status = :s')->setParameter('s', BookingStatus::Confirmed)
->orderBy('b.start', 'DESC')
->setFirstResult(($page - 1) * $perPage)
->setMaxResults($perPage)
->getQuery();
return new Paginator($query); // count() = total, itérable = la page
}count($paginator) donne le total (pour afficher « page 2/17 »), et l’itération donne les éléments de la page. En API, on préfère souvent laisser API Platform gérer la pagination automatiquement (Partie 11) — mais connaître le mécanisme reste utile.
7. Projeter vers des DTO (lecture optimisée)
Quand on n’a besoin que de quelques champs (pour une liste, un export), charger des entités complètes est du gaspillage. On peut projeter directement dans un DTO avec l’opérateur NEW du DQL :
// un DTO de lecture
final readonly class BookingListItem
{
public function __construct(public int $id, public string $email, public BookingStatus $status) {}
}
// requête projetée
public function listItems(): array
{
return $this->getEntityManager()->createQuery(<<<'DQL'
SELECT NEW App\Dto\BookingListItem(b.id, b.customerEmail, b.status)
FROM App\Entity\Booking b
ORDER BY b.start DESC
DQL)->getResult(); // BookingListItem[] (objets légers, pas d'entités gérées)
}C’est plus rapide (moins de données, pas de suivi Unit of Work) et découple la lecture de l’entité. Une piste vers le pattern CQRS (Partie 15).
✏️ Exercices
Exercice 1. Ajoutez à BookingRepository une méthode countPendingForUser(User $user): int avec le QueryBuilder.
✅ Solution
public function countPendingForUser(User $user): int
{
return (int) $this->createQueryBuilder('b')
->select('COUNT(b.id)')
->where('b.user = :user')->setParameter('user', $user)
->andWhere('b.status = :status')->setParameter('status', BookingStatus::Pending)
->getQuery()
->getSingleScalarResult();
}getSingleScalarResult() renvoie le COUNT. On caste en int (Doctrine peut renvoyer une string selon le driver).
Exercice 2. Corrigez cette requête vulnérable :
$qb->where("b.customerEmail = '$email'")->getQuery()->getResult();✅ Solution
$qb->where('b.customerEmail = :email')
->setParameter('email', $email)
->getQuery()->getResult();La version d’origine concatène $email → injection SQL. Toujours un paramètre nommé + setParameter() (requête préparée). Règle sans exception.
Exercice 3. Vous affichez une liste de 50 réservations avec seulement l’id, l’email et le statut. Pourquoi ne pas charger les entités Booking complètes, et quelle technique utiliser ?
✅ Solution
Charger 50 entités complètes récupère tous les champs et les met sous suivi de l’Unit of Work (mémoire, coût), pour n’afficher que 3 colonnes. Mieux : projeter dans un DTO avec l’opérateur NEW du DQL (SELECT NEW App\Dto\BookingListItem(b.id, b.customerEmail, b.status) ...), qui renvoie des objets légers non gérés. Plus rapide, moins de mémoire, et lecture découplée de l’entité.
🧠 Quiz de révision
1. Qu’interroge le DQL — des tables ou des entités ?
Des entités et leurs propriétés (App\Entity\Booking b, b.status), pas des tables/colonnes. Doctrine traduit en SQL via le mapping.
2. Quand préférer le QueryBuilder au DQL en chaîne ?
Quand la requête est dynamique (filtres conditionnels, jointures optionnelles) : on ajoute andWhere/join selon le contexte.
3. Comment éviter l’injection SQL dans une requête Doctrine ?
Toujours passer les valeurs par paramètres nommés (:x) + setParameter() (requête préparée), jamais par concaténation.
4. Quelle méthode pour récupérer un COUNT ?
COUNT ?getSingleScalarResult() (renvoie un scalaire ; à caster en int).
5. Comment récupérer seulement quelques champs sous forme d’objets légers ?
Avec l’opérateur NEW du DQL qui projette dans un DTO (SELECT NEW App\Dto\X(b.a, b.b) ...) — objets non gérés, plus rapides.
Prochain chapitre : 6.4 — Migrations & fixtures — versionner le schéma et peupler des données de test.