1. OPcache
PHP is interpreted — without OPcache, every single request re-parses and re-compiles your PHP source files into bytecode from scratch, every time. OPcache caches that compiled bytecode in shared memory, skipping parsing entirely on every request after the first:
opcache.enable = 1
opcache.memory_consumption = 256 ; MB of shared memory for cached bytecode
opcache.max_accelerated_files = 20000 ; enough to cover every file in a large app
opcache.validate_timestamps = 0 ; production: never re-check if a file changed
opcache.revalidate_freq = 0
validate_timestamps = 0 is the setting that matters most, and the
one most likely to confuse someone new to it: with it off, OPcache never
re-checks whether a source file has changed — it just keeps serving the cached
bytecode forever. That's exactly right for production (files don't change without
a deliberate deploy), but wrong for local development, where you expect every
saved change to take effect immediately.
opcache.validate_timestamps = 1 ; check on every request (or few requests) if the file changed
opcache.revalidate_freq = 0 ; check every single request -- fine for dev, too slow for prod
validate_timestamps is off
With production's setting, a deployed code change won't take effect until OPcache's cache is cleared — usually via opcache_reset() or a PHP-FPM restart as part of the deploy script itself (covered properly in Week 28). Deploying new code without this step is a genuine, common production bug: the server serves the old, cached bytecode indefinitely.
2. PHP-FPM Tuning
PHP-FPM (FastCGI Process Manager) is what actually runs PHP behind Nginx in production (previewed in Week 1, covered fully in Week 28) — a pool of worker processes, each handling one request at a time:
pm = dynamic
pm.max_children = 50 ; hard ceiling on concurrent PHP processes
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 15
pm.max_children is a memory budget decision as much as a performance
one: each worker process consumes real memory, so the right value is roughly
(available RAM) / (memory per PHP process), not an arbitrarily large
number. Setting it too high risks the server running out of memory under load;
too low means requests queue up waiting for a free worker even when CPU and
memory both have headroom.
3. Redis Caching Patterns
Beyond Week 24's WordPress-specific object cache, the same Redis patterns apply directly in Laravel:
<?php
use Illuminate\Support\Facades\Cache;
function getPopularProjects(): \Illuminate\Support\Collection {
return Cache::remember('popular_projects', now()->addMinutes(15), function () {
return Task::withCount('tags')->orderByDesc('tags_count')->limit(10)->get();
});
}
Cache::remember() is the cache-aside pattern spelled
out explicitly: check the cache first; on a miss, run the expensive operation, store
the result, and return it; on a hit, skip straight to returning the cached value.
This is the same idea as Week 24's wp_cache_get/wp_cache_set
pair, wrapped into one call.
<?php
function getExchangeRate(string $currency): float {
return Cache::remember("exchange_rate:{$currency}", now()->addHour(), function () use ($currency) {
return Http::get("https://api.example.com/rates/{$currency}")->json('rate');
});
}
Caching an external API call is often the highest-leverage cache in a real application — it avoids both the latency and the reliability risk of a third-party service on every single request, not just the database load a query cache saves.
4. Cache Invalidation
"There are only two hard things in computer science: cache invalidation and naming things" is a cliché precisely because it's true. A cache that returns stale data is often worse than no cache at all — the two main strategies:
<?php
// Accept staleness for up to 15 minutes, in exchange for simplicity
Cache::remember('popular_projects', now()->addMinutes(15), fn () => /* ... */);
<?php
class TaskController extends Controller
{
public function update(UpdateTaskRequest $request, Task $task)
{
$task->update($request->validated());
Cache::forget('popular_projects'); // invalidate immediately -- next read recomputes
return redirect()->route('tasks.index');
}
}
TTL expiration is simpler but accepts a window of staleness; explicit invalidation
is always fresh but requires remembering to call Cache::forget() at
every single write path that could affect the cached value — miss one, and that
cache silently goes stale with no expiration to eventually correct it. Real systems
often combine both: explicit invalidation as the primary mechanism, with a TTL as a
safety net for any invalidation path that gets missed.
5. Query Optimization & Indexing
Week 6 introduced keys for relationships; an index is a separate,
deliberate structure that makes lookups on a specific column fast — without one, a
WHERE clause on a large table forces a full table scan:
EXPLAIN SELECT * FROM tasks WHERE user_id = 42 AND is_done = false;
EXPLAIN's output shows whether MySQL used an index (type: ref
or better) or scanned every row (type: ALL) — ALL on a
large table is the signal an index is missing.
<?php
Schema::table('tasks', function (Blueprint $table) {
$table->index(['user_id', 'is_done']); // a composite index, matching this exact query pattern
});
A composite index on (user_id, is_done) — column order matters — speeds
up exactly this query pattern (filtering by both columns together, or by
user_id alone, since it's the leftmost column) without the overhead
of maintaining two separate single-column indexes. Every index also has a cost:
it speeds up reads but slows down writes (each INSERT/UPDATE
must also update every index), so indexing every column "just in case" is its own
mistake — index the columns your actual slow queries filter or join on, confirmed
with EXPLAIN, not by guessing.
6. Hands-on Exercise
Profile and speed up the task tracker
Find real slow points in the Laravel app and fix them with caching and indexing.
Requirements:
- Seed the task tracker with 10,000+ tasks across 100+ users (extending Week 12's factories/seeders).
- Run
EXPLAINagainst the tasks-index query and confirm whether it's using an index; add a migration with an appropriate composite index if not. - Wrap an expensive aggregate query (e.g. "most active users this month") in
Cache::remember()with a 10-minute TTL. - Add explicit
Cache::forget()calls at every write path that could affect that cached value, and write a quick manual test proving the cache actually reflects a change immediately after invalidation. - Document, before and after, the query time for the slow query you fixed (using Laravel's query log or Debugbar).
Always measure before optimizing, and measure again after — a fix that "feels faster" without an actual before/after number isn't verified, and it's common to add an index that doesn't help (or a cache with a subtle bug) without ever noticing if you skip this step.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why is opcache.validate_timestamps = 0 correct for production but wrong for local development?
Why is opcache.validate_timestamps = 0 correct for production but wrong for local development?
With it off, OPcache never re-checks whether a source file changed — it serves the cached compiled bytecode indefinitely, which is the fastest possible option and correct in production, where files only change via a deliberate, controlled deploy. In local development, you expect every saved file change to take effect on the very next request; with timestamp validation off, your edits would silently appear to do nothing until the cache was manually cleared.
Q2
What's the trade-off between TTL-based cache expiration and explicit invalidation on write?
What's the trade-off between TTL-based cache expiration and explicit invalidation on write?
TTL expiration is simple to implement (set a duration, forget about it) but accepts a window where the cache can return stale data. Explicit invalidation (Cache::forget() at every relevant write path) is always accurate, but requires correctly identifying and updating every place that could affect the cached value — miss one, and that value goes stale with nothing to eventually correct it, unlike a TTL which self-heals after it expires.
Q3
Why not just add an index to every column, to be safe?
Why not just add an index to every column, to be safe?
Every index speeds up reads on that column but slows down writes, since each INSERT/UPDATE/DELETE must also update every index on the affected table — and each index also consumes storage. Indexing indiscriminately trades write performance and disk space for read speed on columns that may never actually be queried that way; the right approach is confirming an actual slow query with EXPLAIN first, then indexing specifically what that query needs.
Q4
In a composite index on (user_id, is_done), does column order matter?
In a composite index on (user_id, is_done), does column order matter?
Yes — a composite index is only fully useful for queries filtering on its leftmost column(s) first. An index on (user_id, is_done) speeds up queries filtering by user_id alone, or by user_id AND is_done together, but does little for a query filtering by is_done alone without user_id — that would need is_done as the leftmost column, or its own separate index.