Performance Optimization
By the end of this lesson you'll know how to measure a PHP app, find the real bottleneck, and fix it — killing N+1 queries, turning on OPcache, caching expensive work, and keeping memory flat with generators — so your pages stay fast under real traffic.
Part of the free Php course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
1️⃣ Measure First — Never Guess
The golden rule of performance is measure before you optimise . Your instinct about what's slow is wrong more often than it's right, and "optimised" code that you never measured is just code that's now harder to read. The quickest measuring tool is microtime(true) , which returns the current time as a float — take one reading before a block and one after, and the difference is how long it took. Pair it with memory_get_peak_usage(true) to see the most RAM the script ever used.
For real apps you'll graduate from manual timers to a proper profiler — a tool that records how long every function and query took in a request. Xdebug 's profiler ships with PHP and writes a cachegrind file you open in a viewer; Blackfire.io is a hosted profiler with beautiful call graphs. Both answer the only question that matters: where is the time actually going?
2️⃣ Don't Repeat Work Inside Loops
A loop that runs a million times amplifies any waste inside it a million-fold. The classic mistake is calling something like count($items) in the loop condition , so it's recomputed on every single pass. Anything whose answer can't change inside the loop is invariant — compute it once above the loop and reuse the variable. Compare these two:
The output is identical — but the "after" version does the expensive part once instead of N times. With five items it's invisible; with a database call or a heavy calculation in the loop body, hoisting it out is the difference between snappy and unusable.
3️⃣ The N+1 Query Problem (the big one)
Most PHP slowness lives in the database , and the worst offender is the N+1 query problem : one query to fetch a list, then one more query per row in that list. Fetch 100 users, loop over them asking for each one's orders, and you've fired 101 separate queries — each a round-trip to the database. Here's the trap:
The fix is eager loading : gather the IDs, fetch every related row in one WHERE user_id IN (...) query, then group the results in PHP with an array. 101 queries become 2 — and they stay 2 whether you have 100 users or 100,000.
Two more database essentials: an index is a lookup structure that lets the database jump straight to matching rows instead of scanning the whole table — always index the columns you filter or join on (like a foreign key). And a value used in a query should be parameterised or cast (here intval ) so it's safe. ORMs like Laravel's Eloquent expose eager loading as User::with('orders') — same fix, less typing.
4️⃣ Cache Expensive Work
If a result is slow to compute and rarely changes, cache it: do the work once, store the answer, and serve the stored copy next time. APCu is the simplest cache — an in-memory store local to one server. Redis works identically but is a shared service across many servers. The pattern never changes: check the cache → on a miss, compute and store with a TTL → return . A TTL (time-to-live) is how many seconds the cached value stays valid before it's recomputed.
5️⃣ Keep Memory Flat with Generators & Lazy Loading
Speed isn't the only resource — memory matters too. Building a giant array to loop over it holds every element in RAM at once. A generator (a function that uses yield ) produces values lazily , one at a time, so peak memory stays flat no matter how many items you process. This is the same idea as lazy loading : don't fetch or build something until you actually need it.
Use generators whenever you read a large file line-by-line, page through a big query result, or transform a long stream — anything where you don't need every item in memory simultaneously.
6️⃣ Turn On OPcache (and Know When JIT Helps)
Every request, PHP normally re-reads and re-compiles your source files into bytecode before running them. OPcache stores that compiled bytecode in shared memory so the parse-and-compile step is skipped — commonly a 30-70% speed-up for free . It ships with PHP; you just enable it. In production set opcache.validate_timestamps=0 so PHP never checks file timestamps per request, and clear the cache on each deploy so new code is picked up.
JIT (Just-In-Time compilation), added in PHP 8, goes one step further by compiling hot code paths to native machine code. It's a big win for CPU-bound work — maths, image processing, simulations — but typical web apps spend most of their time waiting on the database, so JIT often helps them only a little. Turn it on with opcache.jit=tracing , then measure ; if the database is your bottleneck, fix that first.
7️⃣ Autoloader, Output Buffering & gzip
Three quick production wins. First, optimise the Composer autoloader : composer dump-autoload --classmap-authoritative pre-builds one complete class-to-file map, so PHP stops hitting the filesystem to locate classes on every request. Second, output buffering with ob_start() collects your output in memory and sends it in one efficient chunk instead of many small writes. Third, enable gzip compression at the web server (or with ob_start("ob_gzhandler") ) so HTML, CSS, and JS travel to the browser much smaller — often a 70%+ reduction in bytes over the wire.
8️⃣ Your Turn — Time a Block
Now you try. The script below is almost complete — fill in each ___ using the 👉 hint, then run it and compare against the Output panel. You only need two microtime(true) readings.
One more — hoist the invariant count() out of the loop so it's computed once, not on every pass.
Common Errors (and the fix)
- Premature optimisation — you rewrote code that wasn't even slow. You spent a day micro-tuning a loop while a single N+1 query was the real cost. Fix: always profile first; optimise the proven bottleneck, leave the rest readable.
- Optimising with no profiler ("I'm sure it's the array_map"). Guessing wastes time and usually misses the mark. Fix: measure with microtime() , then move up to Xdebug 's profiler or Blackfire to see exactly which calls and queries dominate.
- Page is slow and the database log is full of identical queries — the N+1 problem. A query is running inside a loop. Fix: eager-load with one WHERE id IN (...) query and group in PHP, and add an index on the filtered column.
- Production is mysteriously slow but works fine locally — OPcache is off. Without it, PHP recompiles every file on every request. Fix: set opcache.enable=1 and opcache.validate_timestamps=0 in php.ini , then clear the cache on each deploy.
- "Allowed memory size of N bytes exhausted" — you loaded a huge result set or file into one array. Fix: stream it with a generator ( yield ) so only one item is in memory at a time.
Pro Tips
- 💡 Fix the biggest bottleneck first. A 10% gain on the slowest 80% beats a 50% gain on the fastest 1%. Profile, fix the top item, re-profile.
- 💡 The database is almost always the answer. Reach for query count, indexes, and caching before you ever micro-tune PHP code.
- 💡 Cache with a sensible TTL. Too long and users see stale data; too short and you barely cache. Match the TTL to how often the data really changes.
- 💡 OPcache in prod, validate_timestamps off, clear on deploy. This one-time setup is the cheapest speed-up you'll ever ship.
📋 Quick Reference — PHP Performance
Tool / Technique
How
What It Does
microtime(true)
$t = microtime(true);
Time a block of code
Xdebug / Blackfire
profile a request
Find the real bottleneck
Eager loading
WHERE id IN (...)
Fix N+1 (1+N → 2 queries)
Index
CREATE INDEX ...
Make filtered lookups instant
APCu / Redis
apcu_store($k, $v, $ttl)
Cache expensive results
Generator
yield $item;
Stream data, keep memory flat
OPcache
opcache.enable=1
Cache compiled bytecode (30-70% faster)
JIT
opcache.jit=tracing
Native code for CPU-heavy work
Autoloader
dump-autoload --classmap-authoritative
Skip filesystem class lookups
Frequently Asked Questions
Mini-Challenge: Measure-Then-Cache
No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This is exactly the measure → cache → re-measure loop you'll use on every real bottleneck.
🎉 Lesson Complete!
- ✅ Measure first with microtime() , then profile with Xdebug or Blackfire — never guess
- ✅ Move invariant work out of loops so it runs once, not N times
- ✅ Kill the N+1 query problem with eager loading ( WHERE id IN (...) ) and add indexes
- ✅ Cache expensive results with APCu or Redis using check → miss → store with a TTL
- ✅ Keep memory flat by streaming with generators ( yield ) and lazy loading
- ✅ Enable OPcache in production (and JIT for CPU-heavy work); optimise the autoloader and turn on gzip
- ✅ Next lesson: PHPUnit Testing — write unit and integration tests so your fast code stays correct
Practice quiz
What is the golden rule before optimising PHP performance?
- Rewrite every loop first
- Add caching everywhere
- Measure (profile) before you change anything
- Switch to a faster framework
Answer: Measure (profile) before you change anything. Your instinct about what's slow is often wrong; measure with microtime() and profile with Xdebug or Blackfire before optimising.
Which function gives the current time as a float to time a block of code?
- microtime(true)
- time()
- date()
- sleep()
Answer: microtime(true). microtime(true) returns the current time as a float; subtract two readings to measure how long a block took.
What is the N+1 query problem?
- Running one query that returns N+1 rows
- Adding one index too many
- Caching N+1 results
- One query for a list, then one more query per row in that list
Answer: One query for a list, then one more query per row in that list. N+1 is one query to fetch a list plus one query per row — 100 users becomes 101 round-trips, which crawls.
How do you fix the N+1 query problem?
- Add more web servers
- Eager-load with one WHERE id IN (...) query, then group in PHP
- Run each query in a separate thread
- Increase the database timeout
Answer: Eager-load with one WHERE id IN (...) query, then group in PHP. Eager loading gathers the ids and fetches all related rows in a single IN(...) query, turning 101 queries into 2.
In a loop, where should an invariant like count($items) be computed?
- Once, above the loop, into a variable
- In the loop condition every pass
- After the loop
- Inside the loop body twice
Answer: Once, above the loop, into a variable. Anything whose answer can't change inside the loop should be hoisted above it and reused, so it runs once instead of N times.
What does OPcache store to speed up PHP, commonly 30-70%?
- Database query results
- Rendered HTML pages
- Compiled bytecode in shared memory
- User session data
Answer: Compiled bytecode in shared memory. OPcache keeps compiled bytecode in shared memory so PHP skips re-parsing and re-compiling files on every request.
What should opcache.validate_timestamps be set to in production?
- 1, so PHP checks files every request
- 0, so PHP never stats files per request (clear the cache on deploy instead)
- It must be left unset
- -1 to disable OPcache
Answer: 0, so PHP never stats files per request (clear the cache on deploy instead). Set validate_timestamps=0 in production so PHP never checks file timestamps per request; clear the cache on each deploy.
What is the check-miss-store caching pattern?
- Always recompute, then store
- Store first, then check
- Cache only on the second request
- Check the cache; on a miss, compute and store with a TTL; return
Answer: Check the cache; on a miss, compute and store with a TTL; return. Try the cache first; only on a miss do the slow work and store it with a sensible TTL, then return — the same pattern for APCu or Redis.
How does a generator (yield) keep memory flat over a huge dataset?
- It compresses the array
- It produces values lazily, one at a time, instead of building the whole array
- It moves the data to Redis
- It runs in a separate process
Answer: It produces values lazily, one at a time, instead of building the whole array. A generator yields values lazily one at a time, so peak memory stays flat no matter how many items you process.
When does PHP 8's JIT give the biggest speed-up?
- On database-bound web requests
- On every request equally
- On CPU-bound work like maths, image processing, or simulations
- Only when OPcache is disabled
Answer: On CPU-bound work like maths, image processing, or simulations. JIT compiles hot paths to native machine code, a big win for CPU-bound work; typical web apps waiting on the database benefit only a little.
Continue this course
- Previous: File Storage
- Next: PHPUnit Testing