EthicsComputerCourses
Join training +91 90532 10052

Performance: OPcache, Caching & Profiling

PHP · Lesson 61 of 63 Advanced 8 min read Updated

A slow site loses customers: people leave a checkout page that takes five seconds, and Google ranks slow pages lower. The good news is that most PHP apps are slow for a handful of predictable reasons, and the biggest wins come from configuration and database habits, not from clever code tricks.

In this lesson you will learn the professional order of work: measure first, switch on and tune OPcache, remove wasted database queries, cache expensive results, speed up Composer's autoloader, and use a profiler to find the real bottleneck when the obvious fixes are not enough.

Measure before you change anything

Guessing where time goes is almost always wrong. PHP gives you simple measuring tools: hrtime(true) returns a high-resolution time in nanoseconds, and memory_get_peak_usage() the most memory the script has used so far:

PHP
<?php
declare(strict_types=1);

$start = hrtime(true);

$total = 0;
foreach (range(1, 1_000_000) as $n) {
    $total += $n % 7;
}

$ms = (hrtime(true) - $start) / 1_000_000;
printf("Total %d in %.1f ms, peak memory %.1f MB\n", $total, $ms, memory_get_peak_usage() / 1048576);
Output
Total 2999998 in 404.3 ms, peak memory 18.4 MB

Your time will differ, but the memory figure shows something useful at once: range() built an array of a million numbers. A plain for loop (or a generator) would use almost no memory. Measure on realistic data, change one thing, and measure again.

For whole pages, your browser's developer tools (Network tab) show the server response time, often called TTFB (time to first byte). If TTFB is low but the page still feels slow, the problem is images, CSS or JavaScript, not PHP.

OPcache: the biggest free win

Every time a PHP file runs, PHP first compiles it into opcodes, the low-level instructions the engine executes. Without a cache, this happens again on every request for every file, including every Composer package. OPcache stores compiled opcodes in shared memory, so each file is compiled once and reused by all requests. On a framework app with hundreds of files, this alone often makes pages several times faster.

OPcache ships with PHP and is enabled for web requests by default in PHP 8.4, but hosting setups vary, so check. Good production settings in php.ini:

Text
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
SettingPHP 8.4 defaultWhy change it
opcache.memory_consumption128 (MB)Large apps run out of room and stop caching new files
opcache.interned_strings_buffer8 (MB)Frameworks use many repeated strings (class and method names)
opcache.max_accelerated_files10000A Laravel or Symfony app with vendor/ can exceed it
opcache.validate_timestamps1With 1, PHP checks files for changes every revalidate_freq (2) seconds

Setting validate_timestamps=0 means PHP never checks whether files changed, which saves work on every request. The price: after you deploy new code, you must reset the cache by reloading PHP-FPM (sudo systemctl reload php8.4-fpm, using your version) or the site keeps running the old code. Leave it at 1 on development machines and on shared hosting where you cannot reload PHP.

To see whether OPcache is working, check its status. Put this in a protected admin page or run it in the context where your app runs:

PHP
<?php
declare(strict_types=1);

$status = opcache_get_status(false);
if ($status === false) {
    exit("OPcache is not enabled\n");
}
$stats = $status['opcache_statistics'];
$memory = $status['memory_usage'];

printf("Enabled:        %s\n", $status['opcache_enabled'] ? 'yes' : 'no');
printf("Cached scripts: %d\n", $stats['num_cached_scripts']);
printf("Hit rate:       %.1f%%\n", $stats['opcache_hit_rate']);
printf("Memory used:    %.1f MB of %.1f MB\n",
    $memory['used_memory'] / 1048576,
    ($memory['used_memory'] + $memory['free_memory'] + $memory['wasted_memory']) / 1048576);

Run from the terminal with php -d opcache.enable_cli=1 opcache-status.php, it shows only itself, because each CLI run has its own cache:

Output
Enabled:        yes
Cached scripts: 1
Hit rate:       0.0%
Memory used:    16.7 MB of 128.0 MB

On a busy web server the same page should show hundreds of cached scripts and a hit rate close to 100%. Never leave such a status page public.

The N+1 query problem

Database calls are the most common cause of slow pages. The classic mistake is the N+1 problem: one query to load a list, then one more query per row to load related data. This test counts queries for a page that lists 200 orders with customer names:

PHP
// N+1: one query for the orders, then one per order for its customer
$orders = $db->query('SELECT id, customer_id, total FROM orders ORDER BY id')->fetchAll();
foreach ($orders as &$order) {
    $stmt = $db->prepare('SELECT name, city FROM customers WHERE id = :id');
    $stmt->execute(['id' => $order['customer_id']]);
    $order['customer'] = $stmt->fetch();
}
unset($order);
printf("N+1:  %d queries\n", $db->queries);

// One JOIN
$db->queries = 0;
$rows = $db->query(
    'SELECT o.id, o.total, c.name, c.city
     FROM orders o JOIN customers c ON c.id = o.customer_id
     ORDER BY o.id'
)->fetchAll();
printf("JOIN: %d query, %d rows, first: %s from %s, ₹%d\n", $db->queries, count($rows), $rows[0]['name'], $rows[0]['city'], $rows[0]['total']);
Output
N+1:  201 queries
JOIN: 1 query, 200 rows, first: Customer 2 from Kochi, ₹507

Each query costs a network round trip to MySQL, so 201 queries can easily take 100 times longer than one. Fix N+1 with a JOIN, or with one WHERE id IN (...) query for all needed ids. ORMs have the same trap: in Laravel, use eager loading (Order::with('customer')->get()). Also add indexes on columns you filter and join on, and use MySQL's EXPLAIN to check that a query uses them.

Caching expensive results

Some results are expensive to compute but change rarely: a "top sellers" box, a dashboard total, an exchange-rate API answer. Caching stores the result for a while so later requests reuse it. The usual pattern, cache-aside, is "look in the cache; if missing or expired, compute and store":

PHP
<?php
declare(strict_types=1);

final class FileCache
{
    public function __construct(private string $dir)
    {
        if (!is_dir($dir)) {
            mkdir($dir, 0775, true);
        }
    }

    /** Returns the cached value, or runs $compute and stores its result for $ttl seconds. */
    public function remember(string $key, int $ttl, callable $compute): mixed
    {
        $file = $this->dir . '/' . hash('sha256', $key) . '.cache';

        if (is_file($file)) {
            [$expires, $value] = unserialize((string) file_get_contents($file));
            if ($expires > time()) {
                return $value;
            }
        }

        $value = $compute();
        file_put_contents($file, serialize([time() + $ttl, $value]), LOCK_EX);
        return $value;
    }
}

function topSellers(): array
{
    usleep(300_000);                       // pretend this is a heavy report query
    return ['Masala Chai 250 g', 'Kaju Katli 500 g', 'Basmati Rice 5 kg'];
}

$cache = new FileCache(__DIR__ . '/../storage/cache');
$computed = 0;
$report = function () use (&$computed): array {
    $computed++;
    return topSellers();
};

for ($request = 1; $request <= 3; $request++) {
    $before = $computed;
    $items = $cache->remember('home:top-sellers', 600, $report);
    printf("Request %d: %s (%s)\n", $request, $items[0], $computed > $before ? 'ran the slow query' : 'from cache');
}
Output
Request 1: Masala Chai 250 g (ran the slow query)
Request 2: Masala Chai 250 g (from cache)
Request 3: Masala Chai 250 g (from cache)

A file cache works anywhere, including shared hosting. Faster options keep data in memory: APCu (shared memory on one server), and Redis or Memcached (separate cache servers shared by many app servers). Libraries such as Symfony Cache implement the PSR-6 and PSR-16 cache interfaces, and Laravel's Cache::remember() follows exactly the pattern above. Cache only data that can be a little out of date, choose sensible expiry times, and never cache one user's private data under a shared key.

Composer autoloader and profiling

On every request, Composer's autoloader has to find class files. In production, let it build a complete class map once:

Terminal
composer install --no-dev --optimize-autoloader
composer dump-autoload --classmap-authoritative

--optimize-autoloader (or -o) generates a map from every class to its file. --classmap-authoritative goes further and never searches the disk for classes missing from the map, which is fastest but means you must regenerate it after adding classes.

When you still do not know where the time goes, use a profiler, a tool that records how long every function call takes. Xdebug includes one: set xdebug.mode=profile and xdebug.start_with_request=trigger, then add ?XDEBUG_TRIGGER=1 to a URL on your development machine. Xdebug writes a cachegrind file that tools like QCacheGrind, KCachegrind or PhpStorm display as a sorted list of the slowest functions. SPX is a free, lighter alternative, and Blackfire and Tideways are commercial services for profiling production. Profilers slow PHP down a lot, so never leave them enabled on a live server.

Common mistakes

  • Optimising without measuring. Replacing "double" quotes with 'single' saves nothing measurable; fixing one N+1 loop can save seconds.
  • OPcache off or too small on the live server, so files are compiled again and again.
  • validate_timestamps=0 without a reload on deploy, then wondering why changes do not appear.
  • Queries inside loops, and missing indexes on columns used in WHERE and JOIN.
  • Caching forever. Without an expiry or a clear-on-change rule, users see stale prices.

Key points

  • Measure with hrtime(), memory_get_peak_usage(), browser timings and profilers before and after each change.
  • OPcache stores compiled code in memory; size it for your app and reset it on deploy if timestamps are not checked.
  • JIT is off by default in PHP 8.4 and mainly helps CPU-heavy code.
  • Fix N+1 queries with JOINs or IN (...) queries, and index the columns you filter on.
  • Cache expensive, rarely changing results with an expiry: files, APCu, Redis or a PSR-16 library.
  • In production, install with --no-dev --optimize-autoloader.

Exercise

A product page runs these queries for 30 products in a category: one query for the products, then for each product one query for its average rating. Rewrite it so the page makes exactly two queries, using WHERE product_id IN (...) with placeholders built from the product ids, and GROUP BY product_id. Then add the result to an array keyed by product id so the template can print each rating.

Show solution
PHP
$products = $db->query('SELECT id, name, price FROM products WHERE category_id = 4')->fetchAll();
$ids = array_column($products, 'id');

$ratings = [];
if ($ids !== []) {
    $placeholders = implode(',', array_fill(0, count($ids), '?'));
    $stmt = $db->prepare(
        "SELECT product_id, ROUND(AVG(stars), 1) AS avg_stars
         FROM reviews WHERE product_id IN ($placeholders)
         GROUP BY product_id"
    );
    $stmt->execute($ids);
    $ratings = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);   // [product_id => avg_stars]
}

foreach ($products as $product) {
    printf("%s: ₹%s, rating %s\n", $product['name'], $product['price'], $ratings[$product['id']] ?? 'none');
}
Output
Bamboo Toothbrush: ₹99, rating 4.5
Neem Comb: ₹149, rating none

The placeholders are built only from ? characters, and the ids are passed separately to execute(), so the query stays safe. PDO::FETCH_KEY_PAIR turns two-column rows into a ready-made lookup array.

Quick Quiz

1. What does OPcache store?

2. You set opcache.validate_timestamps=0 and deploy new code. What must you do?

3. A page loads 100 orders and then runs one query per order to get the customer. What is this called?

4. When does JIT give the biggest benefit?

Learn PHP with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call