Le back-office annonce quatre commandes à préparer. Vous demandez deux commandes par page, mais la première n’en montre qu’une ; la suivante répète parfois cette même référence. Un opérateur peut croire que le filtre masque des dossiers ou que le stock manque. Le risque est plus discret lorsque la commande apparaît bien, mais que certaines de ses lignes n’ont jamais été chargées.
En pratique, la pagination doit limiter les objets métier avant de charger leurs détails. Une limite SQL appliquée à une jointure peut compter des lignes de commande, alors que l’écran promet un nombre de commandes. La déduplication des objets ne répare pas cette différence. Il faut vérifier ensemble les identifiants des parents, les enfants attendus, le filtre, les droits et l’ordre de lecture.
Le cas concret ci-dessous a été exécuté avec Doctrine ORM 3.6.6, DBAL 4.4.3, PHP 8.5.12 et SQLite 3.46.1 en mémoire. Il compare une requête limitée directement, le paginateur, une collection filtrée et une insertion entre deux pages. Les données sont fictives ; aucune base client, aucun stock et aucune préparation réelle ne sont modifiés. Les résultats permettent de reproduire le défaut avant de discuter de performance.
Notre démarche de développement web sur mesure relie ce contrat de lecture au parcours des équipes. Un audit technique d’application web peut retrouver la requête réellement exécutée et les informations perdues entre SQL, hydratation et affichage. Les sources officielles ont été vérifiées le 24 octobre 2026 ; ce mécanisme ancien ne devient pas une nouvelle anomalie de Doctrine à cette date.
Dans quel cas examiner la pagination des collections
Une ligne d’écran représente un dossier avec plusieurs enfants
Le diagnostic concerne les listes de commandes et leurs lignes, les interventions et leurs pièces, les factures et leurs échéances, ou les comptes et leurs contacts. Un parent peut produire plusieurs lignes SQL dès qu’une association multiple est jointe. L’écart devient visible lorsque le nombre d’enfants varie fortement : les grandes commandes occupent une page que les petites traversent sans incident apparent.
Commencez par décrire l’unité annoncée à l’utilisateur. « Deux commandes » et « deux lignes à préparer » sont deux contrats valables, mais différents. Si l’écran sert réellement une file de lignes, il peut être cohérent de paginer ces lignes et d’afficher explicitement leur commande. Le défaut apparaît lorsque le compteur, les boutons de navigation et la sélection ne parlent pas de la même unité.
Écarter les causes qui ne viennent pas de cette jointure
Une liste sans association multiple peut être incomplète pour d’autres raisons : filtre implicite, périmètre de société, suppression logique ou réponse réseau ancienne. Le présent exercice ne diagnostique pas ces causes à votre place. Il donne un témoin précis : la page SQL contient la taille demandée, mais plusieurs de ses lignes correspondent au même parent. Retrouvez ce témoin avant de remplacer la pagination.
Le tri d’un tableau côté navigateur pose encore une autre question. Une sélection mémorisée par position peut changer de cible alors que toutes les commandes ont été chargées correctement. Ici, nous examinons d’abord ce qui arrive au tableau. Une réparation du repository ne garantit pas que le composant conservera ensuite la bonne identité lors d’une action groupée.
Comprendre ce que la jointure fait au nombre de résultats
Lire la sélection DQL et pas seulement le mot JOIN
La documentation DQL de Doctrine 3.6 distingue jointure ordinaire et fetch join. Lorsque l’entité associée figure dans la sélection, ses objets sont également hydratés. Une requête qui sélectionne la commande et ses lignes doit donc être examinée autrement qu’une recherche qui utilise une jointure uniquement pour choisir les commandes.
Sur le schéma fictif, la commande 40 possède trois lignes, la 30 en possède deux, la 20 aucune et la 10 une seule. Une jointure gauche produit sept lignes pour ces quatre commandes : trois, deux, une ligne avec enfant nul et une dernière. La limite de deux s’applique à cet ensemble SQL. Elle n’exprime pas automatiquement « deux identifiants de commande distincts ».
Ne pas prendre la fusion des objets pour une preuve de complétude
Dans notre exécution directe, les deux premières lignes SQL décrivent toutes deux la commande 40. Le résultat hydraté contient un seul parent et les enfants 401 et 402. L’enfant 403 n’a pas été lu. Ce résultat est particulièrement trompeur si l’écran construit le nombre de pièces ou le montant depuis la collection ainsi chargée : le parent semble présent, mais sa représentation est partielle.
Un premier signal faible est donc une page courte surtout sur les dossiers complexes. Un second est un détail différent selon que l’utilisateur arrive depuis la liste ou ouvre directement la commande. Ces symptômes ne prouvent pas seuls la cause, mais ils justifient de comparer les identifiants enfants réellement reçus. Un simple contrôle du nombre de parents manquerait la seconde anomalie.
Cas exécuté : quatre commandes et une page de deux
Fixer une réponse attendue indépendante de la requête
Le jeu de données appartient à la société A. Les commandes 40 et 30 partagent la même valeur de date ; leur identifiant décroissant les départage. La 20 est plus ancienne, puis vient la 10. Une commande 50 appartient à B et doit rester absente. Le contrat attendu est donc une première page 40, 30, puis une seconde 20, 10, avec tous les enfants de chaque parent sélectionné.
La commande sans ligne est volontaire. Le scénario autorise un brouillon vide et impose de le retrouver. Une jointure interne changerait ce périmètre en supprimant la 20. Si votre métier exclut ces brouillons, cette exclusion doit être décidée et testée explicitement ; elle ne doit pas résulter d’un changement technique ajouté pour faire disparaître une ligne vide.
| Lecture | Parents | Détails |
|---|---|---|
| LIMIT direct, page 1 | 40 | 401 et 402 ; 403 manque |
| LIMIT direct, page 2 | 40, 30 | 40 revient sur une autre page |
| Paginator, page 1 | 40, 30 | Trois lignes puis deux |
| Paginator, page 2 | 20, 10 | Aucune ligne puis une |
| Paginator, après la fin | Aucun | Résultat vide attendu |
Rejouer l’expérience avec un EntityManager propre
Le programme PHP complet de pagination Doctrine contient les entités, le schéma en mémoire et dix-sept contrôles. Il se lance en ligne de commande depuis un projet disposant des dépendances indiquées et de PDO SQLite ; il utilise les objets paresseux natifs de PHP 8.4 ou supérieur. Son extension texte permet de le lire sans exécution par le serveur web.
Chaque lecture ORM comparée repart après un nettoyage de l’EntityManager. Cette précaution évite qu’une collection déjà hydratée dans un essai serve de réponse au suivant. Pour votre recette, partez également d’un contexte propre, puis ajoutez un essai de parcours complet avec le cycle de vie réel. Une expérience isolée ne démontre pas le comportement d’un service qui conserve ses objets longtemps.
Choisir le paginateur adapté à la version installée
Paginer les parents avant de récupérer les associations
Le paginateur documenté pour Doctrine 3.6 prévoit le comptage distinct, la sélection limitée des identifiants parents et leur chargement. Le constructeur accepte fetchJoinCollection: true pour une collection jointe. Dans notre témoin, il produit quatre parents au total et conserve les cinq enfants attendus sur la première page de deux commandes.
use Doctrine\ORM\Tools\Pagination\Paginator;
$query = $entityManager->createQuery(
'SELECT o, l FROM App\Entity\Commande o
LEFT JOIN o.lignes l
WHERE o.societe = :societe
ORDER BY o.dateCreation DESC, o.id DESC'
)->setParameter('societe', $societeAutorisee)
->setFirstResult(0)
->setMaxResults(2);
$page = new Paginator($query, fetchJoinCollection: true);
Ce fragment illustre la forme de la requête ; les noms d’entités et de propriétés sont à adapter. Le programme téléchargeable utilise ses propres classes de démonstration. Un identifiant de société reçu du navigateur ne doit pas être considéré comme autorisé par sa seule présence : le périmètre fourni au repository doit provenir du contrôle d’accès de l’application.
Refuser une optimisation dont l’hypothèse est fausse
Passer le drapeau à false n’est pas une correction de la pagination d’une collection. Notre essai reproduit alors la page réduite à la commande 40. De même, supprimer le DISTINCT suppose de prouver que la requête ne multiplie pas les parents. La documentation limite cette optimisation aux situations appropriées, notamment les associations simples ; elle ne fournit pas un raccourci universel.
La documentation Doctrine 3.7 présente aussi les interfaces OffsetPaginator et CursorPaginator, tout en documentant le Paginator historique. Vérifiez la version verrouillée dans votre projet avant de copier un exemple. Le banc fourni exerce uniquement l’API historique avec ORM 3.6.6 ; il ne constitue pas une validation des nouvelles classes ni une recommandation de rester sur cette version.
Filtrer les commandes sans tronquer leur contenu
Une commande contenant X peut aussi contenir Y et Z
Supposons que l’opérateur cherche les commandes contenant le produit X, puis souhaite voir leur contenu complet. Dans la fixture, les commandes 40 et 30 correspondent au filtre. La première contient X, Y et Z ; la seconde X et Z. Une condition appliquée directement à la ligne chargée peut ne conserver que les enfants X. Le bon nombre de parents masque alors une nouvelle perte d’information.
L’expérience compare deux lectures. Le filtre sur l’enfant du fetch join rend 40 avec 401 et 30 avec 301 seulement. Une condition EXISTS pour sélectionner les parents, suivie du chargement de leurs lignes, rend les trois enfants de 40 et les deux de 30. Ce constat vient de l’exécution ORM décrite, pas d’une hypothèse sur ce que le framework devrait faire.
Nommer la portée d’une collection volontairement filtrée
Une interface peut parfaitement vouloir montrer uniquement les lignes X. Dans ce cas, elle doit présenter une projection de recherche, avec un libellé et un total correspondants. Ne faites pas passer cette projection pour le contenu complet de la commande. Le même principe vaut pour les seules échéances impayées ou les seules pièces restant à recevoir : l’extrait est utile lorsque sa frontière est visible.
Le responsable métier tranche donc avant le développeur : le filtre sélectionne-t-il des dossiers complets ou des lignes individuelles ? La réponse détermine les montants, le compteur et les actions autorisées. Si la sélection prépare une opération sur toute la commande, le serveur doit relire les préconditions courantes. Une liste partielle ne doit jamais constituer, à elle seule, l’autorisation d’agir sur des enfants invisibles.
Stabiliser le tri sans promettre un instantané permanent
Départager les dates identiques par une identité unique
La documentation PostgreSQL sur LIMIT et OFFSET rappelle qu’un ordre prévisible est nécessaire pour obtenir un sous-ensemble cohérent. Notre tri utilise la date puis l’identifiant, tous deux décroissants. Trier seulement sur la date laisserait 40 et 30 à égalité ; leur position relative ne serait plus définie par le contrat de lecture.
Le départage doit correspondre à la réalité du modèle. Un numéro de commande peut être unique dans une société et répété dans une autre. Un tri par montant total demande d’abord de définir ce total sans multiplier les lignes par d’autres associations. Pour une première correction, préférez un ordre parent simple et vérifiable. Traitez ensuite les tris calculés avec une fixture propre et le SQL généré.
Observer ce qui se passe entre deux demandes de page
Après la première page 40, 30, notre essai insère une nouvelle commande 60 plus récente. Une seconde lecture par offset de deux renvoie maintenant 30, 20 : la 30 revient. Une lecture par frontière de date et d’identifiant après 30 continue avec 20, 10. Cet essai SQL illustre le décalage ; il n’exécute pas CursorPaginator et ne mesure aucune performance sous charge.
Un curseur ne fige pas pour autant les données. Une modification de la clé de tri, du statut ou des droits peut changer le parcours. Par ailleurs, PostgreSQL en Read Committed peut donner des instantanés différents à deux SELECT successifs. Le simple fait d’ouvrir une transaction ne garantit donc pas une photographie unique. Le choix d’isolation ou d’export figé doit répondre au besoin réel et être testé sur le moteur cible.
Arbitrer entre liste, export et préparation d’une action
Une navigation interactive accepte certaines variations expliquées
Pour une liste vivante, un total qui évolue peut être acceptable si l’utilisateur comprend que des commandes arrivent. Le produit doit préciser le comportement du bouton retour, de la sélection et du rafraîchissement. La promesse raisonnable peut être « état courant au chargement » ; elle ne doit pas devenir implicitement « toutes les commandes du début de ma session ».
Pour un export contractuel, une revue de clôture ou une opération de masse, l’exigence peut être différente. Il faut définir les identifiants retenus, le moment de référence et le traitement des changements ultérieurs. Garder une transaction ouverte pendant toute une navigation humaine n’est pas une réponse automatique. Une sélection matérialisée ou un traitement dédié peut rendre la frontière plus claire.
Conserver les mêmes droits à chaque étape de lecture
Une solution en deux requêtes doit préserver le périmètre autorisé lors de la sélection des identifiants puis du chargement des détails. Un tableau d’identifiants fourni par le client n’est pas une preuve d’accès. La fixture comporte volontairement la société B pour détecter une fuite évidente ; elle ne remplace pas les tests de votre politique de rôles, délégations et changements de société.
Paradoxalement, une requête supplémentaire peut être la correction la plus sûre si elle exprime nettement les parents retenus et les enfants chargés. Le nombre de requêtes ne suffit pas à juger la qualité. Comparez aussi les lignes transférées, les objets hydratés et la réponse métier. Le bon compromis évite autant la collection tronquée que le chargement de tout le catalogue pour paginer ensuite en PHP.
Erreurs fréquentes qui donnent une fausse impression de réparation
Ajouter DISTINCT à toutes les colonnes ou augmenter la limite
Dans notre SQL témoin, DISTINCT o.id, l.id conserve deux lignes différentes de la commande 40 : les identifiants enfants diffèrent. Le parent reste seul dans la page. Dédupliquer le couple complet ne revient pas à limiter des parents uniques. Conservez cet essai lorsque quelqu’un propose DISTINCT comme correction générale sans examiner la sélection exacte.
Augmenter la limite à vingt ou cent peut masquer le problème avec les petites commandes puis échouer dès qu’un dossier plus volumineux arrive. Le seuil n’a plus de relation fiable avec l’unité annoncée. Ce raccourci augmente aussi la quantité hydratée et rend le résultat dépendant du contenu des commandes précédentes. La recette doit inclure un parent avec plus d’enfants que la taille de page.
Valider seulement le compteur ou la vitesse
Un total de quatre ne prouve pas que les quatre commandes seront parcourues une fois chacune. Une réponse plus rapide peut simplement contenir moins de détails. Comparez les ensembles et leur ordre avec une attente explicite : deux parents sur les premières pages, aucune répétition sur données fixes, aucun enfant attendu manquant et aucun dossier hors société. La dernière page vide constitue aussi un résultat à conserver.
Le coût caché vient du rapprochement manuel : avec trente dossiers à vérifier pendant quatre minutes chacun, le scénario représente deux heures de travail. Ce calcul est une hypothèse de budget, pas un résultat client. Ajoutez le temps de reconstitution d’une préparation incomplète si ce risque existe. Une optimisation de quelques millisecondes ne compense pas automatiquement cette reprise opérationnelle.
Plan d’action : recevoir la correction sur des commandes connues
Réunir les preuves avant de modifier le repository
Le contrat de recette attribue les responsabilités : le développeur conserve la DQL, le SQL généré, les paramètres d’entrée non sensibles, la version ORM et les identifiants en sortie. Le responsable des opérations fournit une petite liste attendue et le détail de chaque commande. Le responsable produit fixe l’unité de pagination, la portée du filtre et le comportement lorsque les données changent. Ces décisions précèdent le choix du paginateur.
- Reproduire une page courte avec un parent possédant plus d’enfants que sa taille.
- Comparer les parents et leurs enfants, pas uniquement le nombre d’objets.
- Inclure un parent vide autorisé, deux dates identiques et une autre société.
- Rejouer le filtre sur un enfant en précisant si les autres doivent rester visibles.
- Tester une insertion entre deux pages et documenter la navigation attendue.
Le seuil de réception proposé pour la fixture figée est zéro parent perdu ou répété et zéro enfant manquant dans une collection annoncée complète. Les dix-sept contrôles locaux portent sur ce témoin réduit. Ils ne certifient ni votre base de production ni les requêtes agrégées, les clés composites ou plusieurs collections jointes simultanément. Ces configurations demandent leurs propres cas de recette.
Mesurer puis déployer sur le parcours corrigé
Une fois les résultats exacts, mesurez durée SQL, volume retourné, mémoire et temps de réponse sur des commandes représentatives. Gardez la même cohorte pour comparer avant et après. Une page de deux commandes fictives ne permet pas de promettre un gain de performance sur cent mille dossiers. Les plans, index, statistiques et tailles de collection du moteur cible restent déterminants.
Le déploiement définit un seuil de repli : une divergence d’identifiants ou un enfant manquant dans la fixture bloque l’ouverture du parcours. La journalisation conserve le dossier concerné et la version livrée, avec un moyen de revenir au chemin précédent. Si une ancienne liste a déjà servi à préparer une action, le rollback du code ne reconstitue pas cette action. L’équipe opérationnelle doit pouvoir retrouver les identifiants concernés et vérifier les effets réels. La clôture exige donc la lecture correcte du parcours livré et le traitement explicite des dossiers historiques identifiés.
Lectures complémentaires pour recevoir le parcours complet
Replacer la requête dans le coût global de l’application
Le diagnostic Doctrine à grande échelle examine les requêtes implicites, l’hydratation, les lots et les transactions. Il aide à décider si la correction doit rester dans le repository ou s’appuyer sur une projection dédiée. Le témoin présent apporte une preuve plus étroite sur le nombre de parents et la complétude de leurs collections.
La conception des listes et filtres de back-office traite le travail de l’opérateur : comprendre la portée, retrouver un dossier et préparer une action. Une requête exacte reste insuffisante si les libellés du tableau ou les compteurs suggèrent une autre unité. La recette doit vérifier ces mots avec les données rendues.
Protéger l’identité de la sélection et les effets suivants
Le cas sélection d’inventaire après un tri montre comment un tableau peut déplacer le choix d’un utilisateur sans perdre une seule ligne. Il complète la preuve de chargement : recevoir les bons objets et conserver ensuite leur identité sont deux étapes distinctes du même parcours.
Les tests d’une application métier critique permettent enfin de placer les contrôles aux frontières utiles. Une fixture de repository protège la lecture ; les autorisations, les changements de statut et les reprises d’action nécessitent des tests au niveau qui porte réellement ces décisions.
Conclusion : compter les commandes et vérifier ce qu’elles contiennent
Une page Doctrine courte peut venir d’une limite appliquée aux lignes jointes. Notre exécution montre un parent unique et une collection tronquée, puis deux commandes complètes avec le paginateur adapté. Le compteur général, la présence du parent et la vitesse de la réponse doivent être complétés par la vérification des enfants attendus.
Le filtre et le tri restent des contrats séparés. Chercher une commande contenant X ne signifie pas nécessairement masquer ses autres lignes ; départager les dates ne fige pas les données entre deux pages. L’exemple rend ces différences observables sur une base en mémoire, avec une autre société et un brouillon vide pour préserver le périmètre décidé.
Un audit technique de votre application web peut relier les symptômes au SQL, à l’hydratation et au composant utilisé par les équipes. La réception s’appuie alors sur des identifiants connus, des collections attendues et des conditions de navigation explicites, avant de comparer les coûts sur une volumétrie représentative.
Dawap vous accompagne en développement web pour cadrer et corriger ces parcours de lecture, de sélection et d’action. Nous construisons avec vos équipes les preuves nécessaires pour qu’une commande affichée corresponde au dossier attendu, avec ses détails utiles et ses contrôles métier jusqu’à l’étape suivante.