Une page peut être lente sans une seule requête lente. Vingt produits, et pour chacun une requête pour sa catégorie, son prix et son image : soixante et une requêtes là où deux suffiraient. Chacune prend deux millisecondes et la page prend une seconde et demie.

Comptez-les d'abord

// at the end of the page
echo count($pdo->query('SHOW SESSION STATUS LIKE "Questions"')->fetchAll());

Ou lisez le compte dans la barre de débogage du framework, ou dans le journal lent avec long_query_time à 0 le temps d'une requête. Le nombre compte plus que n'importe quel chronométrage isolé : en dessous de 20 c'est normal, au-dessus de 100 c'est le bug.

À quoi cela ressemble dans le code

// N+1: one query, then one more per row
$products = query('SELECT * FROM products LIMIT 20');
foreach ($products as $p) {
    $p->category = query('SELECT * FROM categories WHERE id = ?', $p->category_id);
}

Le remède est une jointure, ou une requête pour tous les identifiants

$ids = array_column($products, 'category_id');
$cats = query('SELECT * FROM categories WHERE id IN (' . placeholders($ids) . ')', $ids);
// then match them up in PHP

Deux requêtes au lieu de vingt et une, et cela n'empire pas quand la page affiche cent produits - or c'est justement cette propriété qui compte.

Dans un ORM

C'est à cela que sert le chargement anticipé. Le défaut paresseux est commode et produit exactement cette forme, et le remède tient d'ordinaire en un mot.

$products = Product::with('category', 'images')->limit(20)->get();
Mettre une telle page en cache le cache jusqu'à ce que le cache soit froid, et le premier visiteur après chaque déploiement reçoit les 400 requêtes au complet. Corrigez le compte, puis mettez en cache.
Surveillez le nombre de requêtes à mesure que la page grandit. Une page qui fait 3 requêtes pour 1 ligne et 300 pour 100 lignes a cette forme, quoi que disent les chronos aujourd'hui.