A page can be slow without a single slow query. Twenty products, and for each one a query for its category, its price and its image: sixty-one queries where two would do. Each takes two milliseconds and the page takes a second and a half.

Count them first

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

Or read it from the framework's debug bar, or from the slow log with long_query_time set to 0 for one request. The number matters more than any individual timing: under 20 is normal, over 100 is the bug.

What it looks like in 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);
}

The fix is a join, or one query for all the ids

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

Two queries instead of twenty-one, and it does not get worse when the page shows a hundred products - which is the property that actually matters.

In an ORM

This is what eager loading is for. The lazy default is convenient and produces exactly this shape, and the fix is usually one word.

$products = Product::with('category', 'images')->limit(20)->get();
Caching an N+1 page hides it until the cache is cold, and the first visitor after every deploy gets the full 400 queries. Fix the count, then cache.
Watch the query count as the page grows. A page that runs 3 queries for 1 row and 300 for 100 rows has this shape, whatever the timings say today.