Blog
Pourquoi les applications Symfony deviennent lentes après plusieurs années
Les applications Symfony ralentissent souvent après des années de petites décisions : requêtes, listeners, sérialisation, intégrations synchrones, entités volumineuses et caches mal invalidés.
Où passe le temps dans une application Symfony qui a grandi
Une liste d’articles affiche d’abord des titres, puis des auteurs, des étiquettes et des traductions. L’enregistrement d’une page déclenche désormais une trace d’audit et une mise à jour de l’index de recherche. Chaque ajout est utile, mais leur ensemble modifie le coût de l’opération initiale.
Cette accumulation est une explication possible, pas un diagnostic. Un mauvais plan d’exécution, un pic de trafic, une limite de ressources, un changement de déploiement ou une régression du framework peuvent aussi ralentir l’application. Le point de départ est donc la requête complète : ce qu’elle lit, attend, construit et transmet.
Les exemples sont hypothétiques. Ce ne sont ni des benchmarks GiSoft ni des récits d’incidents clients. Les extraits PHP demandent au moins PHP 8.2 en raison des classes readonly ; les bibliothèques et outils de test peuvent avoir des prérequis plus élevés. Sauf mention explicite, les entités du projet, mappings, imports, configuration des services et fonctions de préparation des tests sont omis.
Mesurer l’opération vécue par l’utilisateur
Au temps de réponse s’ajoutent des mesures du travail SQL, de l’hydratation, de la normalisation ou du rendu, des appels externes, du cache et de la journalisation. Le routage, la sécurité, les sessions et l’initialisation du conteneur doivent rester dans le champ de l’analyse : le coût du framework n’est pas négligeable par principe. Côté infrastructure, il faut distinguer le traitement applicatif de l’attente d’un processus PHP disponible et du transfert vers le client.
Symfony Profiler aide à examiner une requête dans un environnement de développement protégé. Les traces APM et la supervision de la base éclairent la production sans exposer publiquement le profileur de débogage. Les collecteurs détaillés ont leur propre coût. Pour comparer des versions, l’instrumentation doit être équivalente et les réglages proches de la production.
Ce profil inventé illustre les informations à rechercher :
GET /api/{locale}/posts 20 articles renvoyés
Temps de réponse 480 ms
Base de données 63 requêtes / 120 ms
Hydratation 90 ms
Sérialisation 180 ms
Appels externes 0
Pic mémoire 82 Mo
Corps de réponse 1,8 MoCes durées ne s’additionnent que si leurs périmètres sont distincts. Une mesure de sérialisation peut inclure des requêtes de chargement différé ; des appels HTTP parallèles peuvent se chevaucher. Selon le collecteur, le temps SQL inclut une part différente de la récupération des lignes. L’exemple invite à regarder le chargement et la construction de la réponse, mais la trace doit établir le véritable point de blocage. Gagner cinq millisecondes au démarrage n’explique pas le reste.
Des données représentatives reproduisent aussi la distribution des valeurs, la taille des relations et les droits d’accès. Des jeux illustratifs de 50, 5 000 et 100 000 enregistrements peuvent révéler des plans différents. Si la base grandit alors que la page reste à 50 éléments, quatre requêtes à chaque échelle ne disent rien du nombre de lignes parcourues, du tri, de l’hydratation ou du transfert.
À l’inverse, 52, 502 et 5 002 requêtes pour 50, 500 et 5 000 éléments renvoyés illustrent un éventuel schéma N + 2. C’est une autre expérience : le résultat grandit lui aussi. Cache froid et cache rempli, charge concurrente réaliste et état comparable de l’EntityManager sont à examiner séparément.
Borner la lecture avant de choisir le chargement
Cette liste d’administration est volontairement sans limite :
$posts = $postRepository->findBy(
[],
['createdAt' => 'DESC', 'id' => 'DESC'],
);
$rows = [];
foreach ($posts as $post) {
$rows[] = [
'title' => $post->translationFor($locale)->getTitle(),
'author' => $post->getAuthor()->getDisplayName(),
'tags' => $post->getTags()->map(
static fn (Tag $tag): string => $tag->getName(),
)->toArray(),
];
}L’extrait suppose un auteur pour chaque article et une traduction renvoyée par translationFor() dans la langue demandée ou selon le repli convenu. Les valeurs absentes exigent une politique explicite. Tag et les méthodes d’entités montrées appartiennent à l’application illustrative.
Lire le nom de l’auteur, résoudre une traduction et parcourir les étiquettes peut charger des relations. Pour des associations non initialisées, un modèle simplifié compte une requête d’articles, puis jusqu’à une requête par article pour chacun de ces trois accès. Ce n’est pas un total garanti de 1 + 3N : auteurs partagés dans l’Identity Map, relations déjà chargées, mappings, caches et méthode de traduction changent le résultat. Lire une relation dans une boucle n’est pas une erreur en soi lorsque les données ont été préparées correctement.
Un DTO de liste rend la sortie attendue explicite :
final readonly class PostListItem
{
/**
* @param list<string> $tags
*/
public function __construct(
public int $id,
public string $title,
public string $slug,
public string $authorName,
public array $tags,
public \DateTimeImmutable $publishedAt,
) {
}
}Le titre et le nom de l’auteur sont ici obligatoires ; un article publié possède une date de publication immuable. L’implémentation doit respecter ces hypothèses. Elle peut projeter des valeurs scalaires ou transformer des entités chargées de manière contrôlée. Un mapper qui déclenche le chargement différé de chaque relation recrée le problème initial.
Le contrat de lecture reste ciblé :
interface PostReadRepositoryInterface
{
/**
* @return list<PostListItem>
*/
public function findPublishedPage(
string $locale,
int $page,
int $limit,
): array;
}La signature ne fait pas respecter à elle seule la pagination, les autorisations ou la langue de repli. L’implémentation doit valider la page, appliquer les restrictions d’accès et borner le résultat. PHPStan peut vérifier list<PostListItem> et ses consommateurs, pas le coût SQL.
Les DTO sont utiles lorsqu’une lecture n’a besoin que de quelques valeurs. Ils ne sont ni obligatoires ni systématiquement plus rapides. Les entités gérées restent adaptées aux opérations qui modifient l’état métier. L’article GiSoft « N+1 se joue dans la conception de l’accès aux données » détaille les stratégies de chargement ; ici, nous examinons leur contribution au coût global.
Charger les relations nécessaires à cette lecture
Cet extrait de dépôt charge l’auteur avec l’article :
/**
* @return list<Post>
*/
public function findPublishedWithAuthor(
int $limit,
): array {
if ($limit < 1 || $limit > 100) {
throw new \InvalidArgumentException(
'La limite doit être comprise entre 1 et 100.',
);
}
return $this->createQueryBuilder('post')
->addSelect('author')
->innerJoin('post.author', 'author')
->andWhere('post.published = :published')
->setParameter('published', true)
->orderBy('post.publishedAt', 'DESC')
->addOrderBy('post.id', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}addSelect('author') inclut l’entité jointe dans l’hydratation objet : la jointure devient un fetch join. La jointure interne exclut les articles sans auteur ; un auteur facultatif demande une jointure et un traitement des valeurs absentes adaptés. On suppose ici une relation vers un seul auteur et aucune autre jointure multipliant les lignes. L’identifiant départage les dates égales pour rendre l’ordre déterministe à données inchangées.
Une relation to-one ne multiplie généralement pas les lignes de l’entité principale. Plusieurs collections indépendantes peuvent le faire : quatre traductions, huit étiquettes, vingt commentaires et trois pièces jointes peuvent produire 4 × 8 × 20 × 3 = 1 920 lignes SQL pour un article si toutes les combinaisons sont jointes. Filtres et conditions de jointure changent ce chiffre. Ce ne sont pas 1 920 articles distincts, mais transférer les lignes et reconstituer les objets uniques a toujours un coût. DISTINCT ne supprime pas les combinaisons dont les valeurs enfants diffèrent.
Avec un fetch join de collection, une limite brute peut porter sur les lignes SQL plutôt que sur les entités principales complètes. Il faut un paginateur Doctrine adapté ou une lecture en deux phases réfléchie. Les réglages de pagination ne se transposent pas sans vérification entre requêtes différentes.
Sélectionner les identifiants de page, puis leurs détails
Les méthodes suivantes sont propres au projet, pas fournies par Doctrine :
$postIds = $this->posts->findPublishedIds(
locale: $locale,
page: $page,
limit: $limit,
);
$items = $postIds === []
? []
: $this->posts->findListItemsByIds(
ids: $postIds,
locale: $locale,
);La première phase doit sélectionner des identifiants uniques avec des filtres, des autorisations et un tri cohérents. La seconde doit conserver ces restrictions et restituer cet ordre : IN (:ids) ne préserve pas l’ordre des identifiants fournis. Elle doit charger les relations par lots bornés ou par projections, pas lancer une requête par identifiant. Le cas de la page vide évite une lecture de détails inutile.
Deux phases peuvent nécessiter plus de deux instructions SQL, notamment pour un total ou plusieurs collections. Elles peuvent limiter la multiplication des lignes, mais ajoutent des échanges avec la base. Si les modifications concurrentes entre phases comptent, il faut définir la cohérence attendue du cliché de données ou de la transaction.
Pagination, sérialisation et Twig partagent le même budget
Une lecture non bornée peut dépasser les besoins de son écran d’origine :
$repository->findAll();Une table hypothétique passant de 200 à 400 000 lignes montre le risque. Le nombre renvoyé, pas seulement le volume stocké, détermine une partie de l’allocation des objets et du rendu. La pagination fait partie du contrat de lecture côté serveur.
Ce DTO de paramètres utilise un maximum propre au projet de 100 éléments :
final readonly class PageRequest
{
public function __construct(
public int $page = 1,
public int $limit = 25,
) {
if ($page < 1) {
throw new \InvalidArgumentException(
'Le numéro de page doit être positif.',
);
}
if ($limit < 1 || $limit > 100) {
throw new \InvalidArgumentException(
'La limite doit être comprise entre 1 et 100.',
);
}
}
}Les paramètres HTTP doivent être analysés et validés avant sa construction. Les types PHP ne valident pas intégralement les paramètres bruts de l’URL. La limite doit s’appliquer en SQL, avec un ordre sans ambiguïté. Les grands décalages et les requêtes de comptage ont aussi un coût. Une pagination par clé peut aider à parcourir une liste, mais ne permet pas directement de sauter à n’importe quel numéro de page.
Symfony Serializer peut lire des accesseurs et parcourir des relations selon les normaliseurs, groupes et contexte. Cela peut ajouter des requêtes, grossir les réponses ou exposer des champs non souhaités. Ce n’est pas une conséquence universelle de l’encodage d’une entité : un simple json_encode() ne parcourt pas automatiquement toutes ses associations privées. Les groupes sélectionnent des champs ; ils ne planifient pas le chargement et ne remplacent pas les autorisations.
Une représentation explicite peut aider pour une réponse publique :
final readonly class PublicPostDto
{
/**
* @param list<string> $tags
*/
public function __construct(
public string $title,
public string $slug,
public string $excerpt,
public array $tags,
) {
}
}Seuls les champs accessibles à l’appelant doivent être remplis, et leur transformation doit rester bornée. Un DTO construit à partir d’un graphe trop large ne récupère pas le temps déjà dépensé pour le charger.
Twig peut révéler le même problème de lecture :
{% for page in pages %}
<h2>{{ page.translationFor(app.request.locale).title }}</h2>
<span>
{{ page.parent.translationFor(app.request.locale).title }}
</span>
{% for feature in page.features %}
{{ feature.translationFor(app.request.locale).name }}
{% endfor %}
{% endfor %}Cet extrait suppose un parent et les traductions nécessaires. Une vue réelle doit gérer leurs absences selon les règles d’affichage. Les accesseurs peuvent initialiser des relations non chargées, mais chaque lecture de propriété ne produit pas du SQL. Des entités préparées délibérément peuvent suffire ; un modèle de vue PageAdminRow est une autre solution. Le comptage des requêtes de bout en bout doit inclure le rendu ou la normalisation, pas seulement le dépôt.
Repérer le travail synchrone hors du contrôleur
L’EventDispatcher habituel de Symfony appelle les écouteurs de manière synchrone. Un écouteur peut mettre du travail en file, mais le nom d’un événement ne dit rien de son exécution asynchrone ni de sa livraison durable. Audit, traductions, invalidation du cache, indexation et webhooks peuvent ainsi rallonger une requête sans apparaître dans son contrôleur.
Les événements du cycle de vie Doctrine suivent leurs propres règles. prePersist, postUpdate et postLoad ne s’exécutent pas tous à chaque flush. Par exemple, postUpdate intervient pour les mises à jour ORM concernées au sein de flush(), avant la validation de la transaction ; ce n’est pas une notification après commit. postFlush ne prouve pas non plus qu’une transaction englobante a été validée. Il faut vérifier la version d’ORM utilisée avant de modifier ces hooks. Rappeler flush() depuis un écouteur lui-même déclenché par flush n’est pas une technique générale sûre de persistance.
Les hooks liés à la persistance gagnent à rester petits. Une orchestration coûteuse doit être visible dans un service ou un gestionnaire que l’on peut profiler et tester. C’est un choix de responsabilité, pas une raison d’ajouter des couches à chaque opération.
Le suivi des expéditions peut lui aussi masquer beaucoup d’attente :
$rows = [];
foreach ($orders as $order) {
$tracking = $shippingClient->getTracking(
$order->getTrackingNumber(),
);
$rows[] = $this->mapper->map($order, $tracking);
}getTracking() et le mapper sont des contrats illustratifs du projet, pas des méthodes vérifiées d’un SDK de transporteur. En supposant des appels bloquants, 50 commandes à 100 ms par appel représentent environ cinq secondes avant le reste du traitement. Un client différé ou concurrent demande une autre mesure.
Selon le fournisseur et les exigences de fraîcheur, les options comprennent un point d’accès documenté par lot, un état local synchronisé régulièrement, le cache, une lecture à la demande ou une concurrence bornée. Le traitement par lot ne doit pas être présumé disponible. Il faut limiter concurrence et délais, respecter les quotas et prévoir ce que voit l’utilisateur si le suivi est indisponible.
Les accès au conteneur peuvent masquer la dépendance à l’origine du travail :
$service = $container->get('some_service');L’injection par constructeur facilite leur examen :
final class ProductImportService
{
public function __construct(
private readonly ProductRepositoryInterface $products,
private readonly ProductMapperInterface $mapper,
private readonly ImportMetricsInterface $metrics,
) {
}
}Il s’agit d’une déclaration utilisant des interfaces du projet. L’injection n’est pas intrinsèquement plus rapide que la récupération d’un service Symfony ; récupérer un service partagé ne le reconstruit pas nécessairement. Son intérêt ici est de rendre visibles les accès aux données, la transformation et l’instrumentation de l’import.
Sortir le travail de la requête sans le perdre
Une file déplace le moment et le lieu du coût. Elle ne supprime ni charge SQL, ni facturation du fournisseur, ni travail processeur. Indexation, rapports, notifications et conversion de médias peuvent passer en arrière-plan si le produit accepte une fin de traitement différée. Les permissions et les conditions d’une réponse de succès restent à vérifier au bon endroit.
Une outbox transactionnelle est une option lorsque la modification métier et l’intention de poursuivre doivent être conservées ensemble :
Requête HTTP : valider données et permissions
Transaction locale
Enregistrer l’état métier
Enregistrer l’entrée outbox
COMMIT
La réponse peut partir
Les entrées validées peuvent être publiées
-> transport -> processus de traitement
tentatives bornées / gestion des doublonsLes deux enregistrements doivent participer à la même transaction de base appropriée. Après validation, l’envoi de la réponse et la publication n’ont pas d’ordre relatif imposé. Le processus de publication et le consommateur peuvent répéter leur travail ; une outbox ne signifie donc pas une exécution exactement une fois. Il faut suivre le retard de publication, l’attente en file, les erreurs et la capacité de traitement, pas seulement la latence HTTP.
Avec un transport indépendant, un envoi avant validation peut rendre le message visible avant ses données. Une écriture de transport participant à la même transaction se comporte autrement. Différer un envoi gardé uniquement en mémoire jusqu’après le commit règle l’ordre, mais laisse une possibilité d’arrêt avant publication. Le guide GiSoft « La commande est traitée. Pourquoi le message revient-il ? » développe les reprises, la concurrence et le rapprochement des effets externes ; ces mécanismes ne sont pas redétaillés ici.
Journaux et sessions peuvent aussi faire attendre
Les gros contextes de journalisation augmentent stockage et traitement :
$this->logger->info('Produit importé', [
'product' => $product,
'request' => $request->request->all(),
'response' => $providerResponse,
]);Le passage d’une entité déclenche ou non normalisation et chargement différé selon les formateurs, processeurs et comportements de l’objet. Rien d’automatique. Dans tous les cas, enregistrer intégralement les requêtes et réponses fournisseur constitue un mauvais choix par défaut pour le volume comme pour la confidentialité.
Une entrée bornée est plus exploitable :
$this->logger->info('Import du produit terminé', [
'productId' => $product->getId(),
'provider' => $providerName,
'durationMs' => $durationMs,
'status' => 'completed',
]);Durée, libellé du fournisseur et identifiants doivent être collectés délibérément. Les valeurs doivent rester limitées ; secrets, jetons, champs de paiement sensibles et données personnelles superflues sont à exclure. Échantillonnage et niveaux de journalisation doivent conserver les indices utiles en cas d’échec, pas supprimer toute visibilité.
La contention de session est un autre sujet. Si le gestionnaire configuré verrouille une session, les requêtes simultanées qui la partagent peuvent attendre qu’une requête lente la libère. D’autres gestionnaires ou réglages fonctionnent autrement. Éviter les démarrages inutiles de session aide. Lorsque c’est pris en charge, on peut enregistrer puis fermer la session avant un long travail indépendant, après avoir vérifié les écritures ultérieures, l’authentification et la protection CSRF. Passer toute la sécurité en mode sans état n’est pas une correction à appliquer aveuglément.
Le cache demande une politique de fraîcheur, pas une chaîne de secours
Ces exemples sont des modèles de clés, ni des clés littérales ni une conception complète du contrôle d’accès :
settings.public.{tenant}.{locale}
menu.{tenant}.{category}.{locale}.{audience}
page.{tenant}.{pageId}.{locale}.{audience}
post.list.{tenant}.{locale}.{page}.{limit}.{variant}Les paramètres doivent produire des clés valides pour le stockage choisi. audience doit représenter toutes les règles de visibilité pertinentes ; un rôle ne couvre pas forcément les droits individuels. variant désigne une représentation canonique ou une empreinte des filtres, du tri et du contexte de visibilité. Tout autre élément modifiant le résultat doit être pris en compte, sinon l’entrée ne doit pas être partagée. Une application mono-organisation aux données réellement publiques a besoin de moins de dimensions.
Après la transaction métier réussie, les pages, traductions et menus concernés sont invalidés, avec un parcours de récupération si cette invalidation échoue. La durée de vie limite la conservation d’une entrée et doit correspondre à la fraîcheur attendue ; elle ne rend pas inoffensive une invalidation manquée. Si la fraîcheur est critique, il faut gérer la course entre reconstruction et invalidation, par exemple avec des clés versionnées. Une purge large peut déclencher une vague de reconstructions coûteuses ; leur mutualisation ou un mécanisme adapté contre cet emballement peut aider.
Des données explicites sont souvent plus simples à maîtriser que des graphes ORM arbitraires :
final readonly class PublicSettingsDto
{
public function __construct(
public string $companyName,
public string $slogan,
public string $phone,
) {
}
}Mettre une entité PHP en cache n’est pas impossible. Mais sa restauration depuis le cache applicatif ne rétablit pas sa relation d’origine avec un EntityManager. Proxies, relations partielles et compatibilité entre déploiements demandent de l’attention. Le cache de résultats ou de second niveau Doctrine obéit à d’autres règles que le stockage d’entités arbitraires dans le cache applicatif.
Redis, Memcached et le système de fichiers sont des solutions possibles, pas une chaîne automatique de résilience. Une interface commune ne définit pas la bascule en cas de panne. ChainAdapter de Symfony est un cache multiniveau configuré, pas une garantie universelle de reprise transparente après une défaillance du stockage. Tout secours doit prendre en compte durées de vie, invalidation entre nœuds, valeurs périmées, permissions et capacité. Selon les données, une lecture directe bornée, une ancienne valeur expressément autorisée ou une erreur contrôlée peuvent être préférables.
Il faut mesurer latence des accès, reconstruction, taille des données, erreurs de stockage, invalidation et fraîcheur en plus du taux de succès. Un taux hypothétique de 95 % peut coexister avec des lectures très lentes en cas d’absence ou des réponses périmées.
Examiner formulaires, traductions et médias séparément
Les grandes listes EntityType, collections imbriquées, écouteurs de formulaire et validations peuvent charger bien plus de données que ne le laisse penser l’écran. Filtrage serveur, chargement différé des choix dépendants ou autocomplétion peuvent aider ; chaque formulaire ne nécessite pas une nouvelle API. Les identifiants soumis exigent toujours des contrôles d’existence et d’accès.
La résolution répétée des traductions peut aussi cacher du travail :
$page->translationFor($locale);
$feature->translationFor($locale);
$category->translationFor($locale);Ces méthodes appartiennent au projet, pas à une API universelle de traduction Symfony. Elles peuvent parcourir une collection chargée, l’initialiser ou appliquer une langue de repli. Une lecture SQL peut choisir directement la langue. Cet exemple suppose un schéma propre au projet, une traduction unique par (page_id, locale), un indicateur de publication numérique et des paramètres de pagination liés comme entiers :
SELECT
page.id,
translation.title,
translation.slug
FROM pages__page page
INNER JOIN pages__page_translation translation
ON translation.page_id = page.id
AND translation.locale = :locale
WHERE page.published = 1
ORDER BY page.sequence ASC, page.id ASC
LIMIT :limit OFFSET :offset;La jointure interne exclut les pages sans cette langue. Les conserver ou utiliser une langue de repli demande une autre approche, par exemple des jointures gauches adaptées ou une résolution dans le service de lecture. Il faut ajouter les filtres d’organisation et de permissions nécessaires. Les index dépendent du plan et de la charge réels, pas des noms de tables de l’exemple.
Pour les médias, distinguer traitement PHP lent et téléchargement navigateur lent est essentiel. Originaux trop lourds, vérifications répétées de fichiers et conversions non mises en cache nécessitent des corrections différentes. Les fichiers reçus doivent être validés, les dimensions dérivées autorisées bornées et les fichiers produits réutilisés. La génération peut être contrôlée à l’envoi ou asynchrone si cela se justifie ; une miniature peu coûteuse, générée à la demande puis mise en cache, peut aussi convenir. Le cache HTTP et des formats adaptés aux écrans peuvent améliorer la livraison sans reconstruire le backend.
Transformer les constats en budgets et tests de régression
Les priorités dépendent de la fréquence, de la latence, de la pression sur les ressources et de l’impact métier. Un point d’accès hypothétique représentant 20 % du trafic, avec un P95 à 1,8 s, 74 requêtes SQL, un pic mémoire de 140 Mo et une réponse de 4,2 Mo mériterait une analyse. Ce sont des chiffres illustratifs, pas des mesures réunies sur un déploiement GiSoft. Un rapport rarement utilisé peut néanmoins passer en priorité s’il bloque un processus critique.
Ce YAML exprime des budgets de projet possibles. Symfony ne les applique pas automatiquement :
performance_budget:
public_page:
max_application_queries: 8
max_response_bytes: 300000
target_p95_ms: 400
max_records_per_page: 50
admin_listing:
max_application_queries: 12
max_records_per_page: 100
target_p95_ms: 800Les limites doivent venir des exigences et des mesures de référence, avec leur volume de données et leurs conditions de charge. Les plafonds SQL et de taille peuvent être vérifiés en CI ; les objectifs de percentile demandent une fenêtre d’observation et une charge représentatives. Ni quatre requêtes ni 300 000 octets ne constituent une norme universelle.
Les exemples Pest suivants utilisent les fonctions de projet createPublishedPosts(), postQuery() et startApplicationQueryCollection(). La préparation doit écrire les données, maîtriser ou vider l’EntityManager et établir l’état de cache voulu avant le comptage. Le service est résolu avant le démarrage du collecteur ; la collecte est arrêtée ou réinitialisée entre tests isolés. Elle doit couvrir le SQL de la connexion concernée, transformation des données comprise, mais exclure les fixtures et le démarrage du framework.
La première page contient les 50 articles préparés :
it(
'respecte le budget SQL avec 50 articles',
function (): void {
$this->createPublishedPosts(count: 50);
// Données enregistrées ; état ORM et cache maîtrisé.
$query = $this->postQuery();
$collector = $this->startApplicationQueryCollection();
$result = $query->findPublishedPage(
locale: 'en',
page: 1,
limit: 50,
);
expect($result)->toHaveCount(50)
->and($collector->queryCount())->toBeLessThanOrEqual(4);
},
);La base contient ensuite 500 articles, mais la page reste à 50 :
it(
'respecte le budget de page avec 500 articles',
function (): void {
$this->createPublishedPosts(count: 500);
// Données enregistrées ; état ORM et cache maîtrisé.
$query = $this->postQuery();
$collector = $this->startApplicationQueryCollection();
$result = $query->findPublishedPage(
locale: 'en',
page: 1,
limit: 50,
);
expect($result)->toHaveCount(50)
->and($collector->queryCount())->toBeLessThanOrEqual(4);
},
);Les deux tests vérifient le même plafond et la taille du résultat. Ils ne prouvent ni un nombre identique de requêtes, ni un temps constant, ni les performances en concurrence. Les données doivent comporter auteurs, traductions et étiquettes qui exercent les parcours étudiés. Des benchmarks distincts peuvent faire varier taille de page, relations et charge simultanée. Les assertions à la microseconde ne conviennent pas à une CI ordinaire.
Un test de taille de réponse protège une autre limite :
it(
'borne la taille de la réponse des articles',
function (): void {
$client = static::createClient();
// Préparer des données représentatives et les accès requis.
$client->request(
'GET',
'/api/en/posts?page=1&limit=25',
);
self::assertResponseIsSuccessful();
$content = $client->getResponse()->getContent();
expect($content)->toBeString();
expect(strlen($content))->toBeLessThan(300_000);
},
);Il suppose Pest configuré avec une classe de base Symfony WebTestCase, des données publiées représentatives, les accès adéquats et la route montrée. Le corps est une chaîne, pas une réponse en flux. strlen() compte les octets de la réponse de test, pas nécessairement ceux transmis après compression. Pages invalides, limites excessives, résultat vide et tri stable demandent aussi des tests ; cet extrait ne les couvre pas.
Analyse statique, revue et observations en production
Le contrat list<PostListItem> montré plus haut renseigne PHPStan sur les éléments et l’utilisation du résultat. Selon la configuration et les extensions, l’analyse peut détecter types incompatibles, accès dangereux aux valeurs nullables et structures incohérentes. Elle ne mesure pas N+1 et n’interdit pas automatiquement les dépendances aux dépôts ou la sérialisation des entités. Remplacer l’incertitude par mixed enlève une information utile.
Les tests d’architecture peuvent formaliser des restrictions convenues :
arch('les contrôleurs API évitent EntityManager')
->expect('App\Api\Controller')
->not->toUse('Doctrine\ORM\EntityManagerInterface');
arch('le cœur ne dépend pas du SDK externe')
->expect('App\Core')
->not->toUse('Vendor\ExternalSdk');Ces exemples utilisent l’API d’architecture de Pest ; Vendor\ExternalSdk est un nom de substitution. Versions, espaces de noms et classes effectivement examinées doivent être vérifiés. Ces règles détectent certaines dépendances, pas tous les appels SQL indirects, valeurs de retour ou défauts de performance à l’exécution.
La même revue s’applique au code humain et aux changements assistés par IA. Elle porte sur le comportement réel du dépôt, le volume attendu, les relations, la pagination, les appels externes et l’invalidation du cache. Il faut des vérifications ciblées, pas un nombre de requêtes inventé. Une syntaxe propre ou un DTO ne prouve pas un coût prévisible ; inversement, le code généré par IA n’est pas intrinsèquement lent.
En production, suivre les volumes et P50/P95/P99 par modèle de route, les durées SQL et signatures de requêtes, les latences externes, tailles de réponse, mémoire et erreurs aide à localiser les dérives. L’hydratation ne peut être isolée que si l’instrumentation le permet. En arrière-plan, il faut aussi mesurer attente en file et temps de traitement.
Les étiquettes doivent avoir un nombre de valeurs maîtrisé, comme le type d’opération ou les identifiants de version contrôlés. Identifiants d’organisation, URL brutes, identifiants d’opération et valeurs utilisateur peuvent créer des métriques à forte cardinalité. Les détails nécessaires ont plutôt leur place dans des traces échantillonnées à accès contrôlé que dans chaque étiquette de métrique.
Un enregistrement de comparaison pourrait prendre cette forme ; il s’agit d’un schéma conceptuel, pas d’une migration proposée :
performance_snapshot
id, operation, application_version, captured_at
dataset_record_count, record_count, page_size
query_count, database_duration_ms
hydration_duration_ms, external_duration_ms
serialization_duration_ms, total_duration_ms
peak_memory_bytes, response_bytes
cache_state, measurement_scoperecord_count désigne ici les éléments renvoyés, séparément du volume total du jeu de données. Une plateforme APM peut stocker les mêmes informations. Les comparaisons exigent matériel, charge, instrumentation et état de cache équivalents. Des mesures inclusives ne se somment pas, et un échantillon unique n’est pas un percentile.
Une investigation pratique aboutit à un changement borné, un contrôle de régression, des profils avant/après comparables et une observation après mise en ligne. Chargement anticipé généralisé, cache partout, fonctions statiques ou réécriture ne remplacent pas la localisation du coût. L’objectif est un travail prévisible et un processus de développement qui repère les régressions avant qu’elles deviennent habituelles.
Références techniques
- Symfony Profiler — https://symfony.com/doc/7.4/profiler.html
- Pagination Doctrine — https://www.doctrine-project.org/projects/doctrine-orm/en/3.7/tutorials/pagination.html
- Événements du cycle de vie Doctrine — https://www.doctrine-project.org/projects/doctrine-orm/en/3.7/reference/events.html
- Cache Symfony et caches multiniveaux — https://symfony.com/doc/current/cache.html
- Sessions Symfony — https://symfony.com/doc/7.4/session.html
- Tests d’architecture Pest — https://pestphp.com/docs/arch-testing
- Contrats PHPDoc de PHPStan — https://phpstan.org/writing-php-code/phpdoc-types
