Performance & Caching
Making PHP fast — application caching in Redis or Memcached, cache invalidation, queues and asynchronous jobs, profiling with Xdebug and Blackfire, scaling the FPM pool, offloading static assets to a CDN, and HTTP caching headers.
13 questions
JuniorTheoryCommonHow does an application cache in Redis or Memcached differ from OPcache?
How does an application cache in Redis or Memcached differ from OPcache?
OPcache stores the compiled opcodes of your PHP files, so a script is not re-parsed and re-compiled on every request; it never holds your data. Redis or Memcached is an application cache — it stores results your code computed, such as a query result, and is shared across every worker.
Common mistakes
- ✗Believing OPcache caches your data or your rendered output rather than compiled opcodes
- ✗Expecting OPcache to remove a slow database query from the request time
- ✗Caching in APCu or a static property without noticing it is per-worker, not shared
Follow-up questions
- →Why does OPcache not shorten the time a slow database query adds to the request?
- →Where do you put an application cache when several FPM servers must share it?
JuniorTheoryCommonWhat does moving static assets to a CDN take off your PHP application servers?
What does moving static assets to a CDN take off your PHP application servers?
Images, CSS and JS still consume connections and bandwidth on your origin. A CDN serves them from an edge node near the user, so latency drops and the origin handles only dynamic requests. You version the file names so a new deploy is never served from a stale edge cache.
Common mistakes
- ✗Deploying new assets under the same file name and serving a stale copy from the edge
- ✗Expecting a CDN to speed up dynamic, per-user responses that cannot be cached at the edge
- ✗Leaving assets on the origin and blaming FPM for the bandwidth they are spending
Follow-up questions
- →How does hashing a build artefact's file name make a long
Cache-Controlmax-age safe? - →Which responses must never be stored on an edge node, and why?
JuniorTheoryCommonWhat do Cache-Control and ETag change about the requests a browser sends you?
What do Cache-Control and ETag change about the requests a browser sends you?
Cache-Control: max-age lets the client reuse the response without asking, so no request reaches PHP until it expires. An ETag is a content fingerprint the client resends as If-None-Match; if it still matches you answer 304 Not Modified with no body, saving the render and the transfer.
Common mistakes
- ✗Computing an
ETagby rendering the whole page first, which saves the transfer but not the work - ✗Sending a long
max-ageon a per-user page and leaking it through a shared proxy cache - ✗Believing a
304response still carries the body
Follow-up questions
- →How does
Cache-Control: privatechange what a shared proxy is allowed to store? - →When is a
Last-Modifieddate a better validator than anETag?
JuniorTheoryCommonWhy push slow work such as sending email onto a queue instead of doing it in the request?
Why push slow work such as sending email onto a queue instead of doing it in the request?
Doing it inline holds an FPM worker for the whole call, so the user waits and that slot serves nobody else; a third-party failure fails the request too. Queueing a job returns the response immediately, and a separate long-running worker does the work, retrying on failure.
Common mistakes
- ✗Assuming a queued job runs itself, forgetting a supervised worker process must consume the queue
- ✗Making a slow third-party call inline and letting its timeout become the request's timeout
- ✗Queueing a job whose result the response needs, then blocking on the queue to get it
Follow-up questions
- →What supervises the worker, and what happens to a job when the worker restarts mid-deploy?
- →How should a failed job be retried, and when does it belong on a dead-letter queue?
JuniorTheoryCommonHow do horizontal and vertical scaling differ for a PHP-FPM application?
How do horizontal and vertical scaling differ for a PHP-FPM application?
Vertical scaling buys a bigger box — more cores and RAM let you raise pm.max_children. Horizontal scaling adds servers behind a load balancer. PHP's shared-nothing model makes that natural, provided sessions, uploads and caches live in shared storage such as Redis, not on local disk.
Common mistakes
- ✗Sizing
pm.max_childrenfrom CPU count alone and swapping once each worker's RAM is counted - ✗Adding servers while sessions or uploads still live on one node's local disk
- ✗Scaling the web tier when the bottleneck is the single database all the servers share
Follow-up questions
- →How do you size
pm.max_childrenfrom average worker memory and the RAM you can spare? - →What breaks first when you add web servers but keep one database behind them?
JuniorTheoryCommonWhat is the difference between Xdebug's debug mode and its profiler mode?
What is the difference between Xdebug's debug mode and its profiler mode?
xdebug.mode=debug gives step debugging — breakpoints, stepping and variable inspection driven from the IDE. xdebug.mode=profile records where the time goes and writes a cachegrind file you read in KCachegrind. Both add heavy overhead, so neither belongs on a production box.
Common mistakes
- ✗Leaving Xdebug enabled in production, where it costs a large share of every request
- ✗Expecting the profiler to give breakpoints, or the debugger to produce a call graph
- ✗Trying to read a cachegrind file by hand instead of opening it in a viewer
Follow-up questions
- →How does
xdebug.start_with_request=triggerlimit profiling to the requests you choose? - →Why is a sampling profiler such as Blackfire safer to point at production traffic?
MiddleTheoryCommonHow do Laravel queues and Symfony Messenger differ in how a job is dispatched and consumed?
How do Laravel queues and Symfony Messenger differ in how a job is dispatched and consumed?
Laravel dispatches a serialized Job object to a connection (Redis, database, SQS) and queue:work consumes it; retries, backoff and a failed_jobs table come built in. Messenger dispatches a plain message onto a bus, middleware routes it to a transport, and a separate handler consumes it — so the same message can also be handled synchronously.
Common mistakes
- ✗Forgetting the consumer is a long-lived process that must be supervised and restarted on deploy
- ✗Serializing an Eloquent model's whole state into the job instead of just its id
- ✗Assuming a failed job retries forever, with no retry limit and no dead-letter transport
Follow-up questions
- →Why does a long-running worker need restarting after a deploy, and what does
queue:restartdo? - →How do you size the number of worker processes independently of the FPM pool?
MiddleDesignOccasionalA product listing endpoint runs the same expensive aggregate query for every visitor, and the result changes only when an admin edits a product. You decide to cache the result in Redis. Design the caching: what the key is built from, how long an entry lives, and — the hard part — how a product edit removes or replaces the stale entry. Explain how you avoid serving a stale listing for an hour, and what happens when the cache is empty and a burst of requests arrives at once.
A product listing endpoint runs the same expensive aggregate query for every visitor, and the result changes only when an admin edits a product. You decide to cache the result in Redis. Design the caching: what the key is built from, how long an entry lives, and — the hard part — how a product edit removes or replaces the stale entry. Explain how you avoid serving a stale listing for an hour, and what happens when the cache is empty and a burst of requests arrives at once.
Key the entry by everything that changes the result — filters, page, sort, locale — and give it a TTL as a safety net. The real work is invalidation: a product write must delete or rewrite the affected keys, usually via a tag or a version prefix bumped on write, since you cannot enumerate keys cheaply. Guard the empty-cache case with a lock, or a burst stampedes the database.
Common mistakes
- ✗Relying on TTL expiry alone and serving a stale listing until it happens to expire
- ✗Building the key without the filters, so two different queries collide on one entry
- ✗Leaving a cold key unguarded, so every concurrent miss rebuilds it against the database
Follow-up questions
- →How does a version prefix bumped on write invalidate many keys without enumerating them?
- →What is a cache stampede, and how does a short lock or a stale-while-revalidate window stop it?
MiddlePerformanceOccasionalAn endpoint takes 3 seconds. How do you use a profiler to find where the time actually goes?
An endpoint takes 3 seconds. How do you use a profiler to find where the time actually goes?
Profile the actual request — Xdebug's profiler locally, or Blackfire against real traffic — and read the call graph by inclusive wall time, not by call count. It almost always shows one of three things: a query repeated hundreds of times, one slow query, or a blocking third-party call. Fix what the profile shows; caching a 2 ms function changes nothing.
Common mistakes
- ✗Optimising by guesswork and measuring afterwards, instead of profiling first
- ✗Ranking the call graph by call count rather than by the time actually spent
- ✗Missing an N+1 — one cheap query multiplied by 300 rows, not one slow query
Follow-up questions
- →How does a repeated 2 ms query show up in a call graph, and what makes it easy to miss?
- →When is the right fix a database index rather than an application cache?
SeniorDesignOccasionalMarketing needs to send a campaign to 500 000 users. A naive foreach over the users with a mail call inside it times out, and when it dies halfway nobody knows who was already emailed. Design a queue-based system for this: how the work is broken into jobs, how you avoid emailing the same person twice after a retry or a redeploy, how you stay inside the provider's rate limit, and how the campaign stays observable and resumable while it runs.
Marketing needs to send a campaign to 500 000 users. A naive foreach over the users with a mail call inside it times out, and when it dies halfway nobody knows who was already emailed. Design a queue-based system for this: how the work is broken into jobs, how you avoid emailing the same person twice after a retry or a redeploy, how you stay inside the provider's rate limit, and how the campaign stays observable and resumable while it runs.
Split the send into small jobs — one per recipient or per chunk — dispatched as a batch, never one giant job. Persist per-recipient state so a retry is idempotent: a worker claims a row and skips one already marked sent. Throttle workers to the provider's rate limit, cap retries with backoff, and expose batch progress and failures so the run can be paused and resumed.
Common mistakes
- ✗Assuming at-least-once delivery means exactly once, so a retry silently re-sends the email
- ✗Making the unit of work the whole campaign, so any failure loses all the progress
- ✗Ignoring the provider's rate limit and getting the entire batch throttled or blocked
Follow-up questions
- →What makes a job idempotent, and where exactly must the per-recipient marker be written?
- →How do you drain in-flight jobs safely during a deploy that restarts every worker?
SeniorDebuggingOccasionalResponse times degrade only under load, while a single request stays fast. Diagnose the cause.
Response times degrade only under load, while a single request stays fast. Diagnose the cause.
The pool is saturated, not slow: 20 of 20 children are active and 312 requests sit in the listen backlog, so most of the 8 s is spent waiting for a worker. The slow log names the culprit — a blocking curl_exec() to the warehouse service holds a worker for seconds. Raising pm.max_children only moves the queue; add a timeout and take that call out of the request.
Common mistakes
- ✗Reading a long listen queue as a network problem rather than as pool exhaustion
- ✗Raising
pm.max_childrenpast the RAM budget and turning a queue into swapping - ✗Ignoring the slow log because one request in isolation looks perfectly fast
Follow-up questions
- →Why does a blocking third-party call hurt far more under load than in a single request?
- →What does a
curltimeout guarantee about the worst-case time a worker is held?
SeniorPerformanceOccasionalHow do you load-test a Laravel API to choose pm.max_children for the FPM pool?
How do you load-test a Laravel API to choose pm.max_children for the FPM pool?
Measure, do not guess. Take an average worker's resident memory under real traffic and divide the RAM you can spare by it — that is the ceiling. Then load-test with k6 at rising concurrency against a production-like box, watching p95 latency, the FPM listen queue and memory. The right value is the largest one where latency stays flat and the box never swaps.
Common mistakes
- ✗Sizing the pool from CPU count while ignoring each worker's resident memory
- ✗Load-testing one cached URL, so the run never touches the database real traffic hits
- ✗Reading only requests-per-second and missing that p95 latency has already collapsed
Follow-up questions
- →What does the FPM listen queue tell you that an average latency figure hides?
- →Why does throughput fall once the pool is larger than the RAM budget allows?
SeniorDesignOccasionalA public read-heavy API serves the same catalogue data to millions of anonymous callers, and the database is now the bottleneck. Design a caching layer for it: which tiers you use and what each one holds, how a write propagates through them, what you serve when the cache node is unreachable, and how you keep a cold or evicted key from stampeding the database. Say which data must never be cached in a shared tier, and why.
A public read-heavy API serves the same catalogue data to millions of anonymous callers, and the database is now the bottleneck. Design a caching layer for it: which tiers you use and what each one holds, how a write propagates through them, what you serve when the cache node is unreachable, and how you keep a cold or evicted key from stampeding the database. Say which data must never be cached in a shared tier, and why.
Layer it: HTTP validators at the edge for the public GETs, a shared Redis tier for computed responses, the database behind both. Writes invalidate explicitly — delete or version the affected keys rather than waiting for a TTL. Redis must stay a cache, not a dependency: on failure fall through to the database behind a circuit breaker. Guard cold keys with a lock or stale-while-revalidate, and never cache per-user or authorised data in a shared tier.
Common mistakes
- ✗Caching an authorised or per-user response in a tier shared by every caller
- ✗Treating Redis as a dependency, so a cache outage turns straight into an API outage
- ✗Skipping invalidation and letting the TTL decide how stale the catalogue may get
Follow-up questions
- →How does a circuit breaker in front of Redis keep a cache outage from becoming an outage?
- →What does stale-while-revalidate serve while a key is being rebuilt?