PHP Runtime & Internals
How PHP runs — the shared-nothing request model, daemons, php-fpm with nginx, OPcache and its production tuning, generators and iterators, PHP 8 language features, worker-mode runtimes (FrankenPHP, RoadRunner, Octane), and engine internals (JIT, refcounting and the cycle collector, copy-on-write zvals).
17 questions
MiddleTheoryVery commonHow does php-fpm work and interact with nginx?
How does php-fpm work and interact with nginx?
php-fpm is a FastCGI process manager: it keeps a pool of persistent PHP worker processes. nginx serves static files itself and forwards PHP requests over the FastCGI protocol (a Unix socket or TCP) via fastcgi_pass. A free worker handles the request and returns the response; nginx never executes PHP. Pool sizing (pm.max_children) bounds concurrency.
Common mistakes
- ✗Thinking nginx runs PHP itself rather than delegating to php-fpm over FastCGI
- ✗Believing php-fpm forks a fresh process per request instead of reusing pool workers
- ✗Confusing the FastCGI protocol with proxying plain HTTP to a PHP backend
Follow-up questions
- →How does
pm.max_childreninteract with available memory to set a safe pool size? - →What is the difference between the
static,dynamic, andondemandprocess managers?
SeniorTheoryVery commonWalk a request from nginx through php-fpm to a Laravel response and back.
Walk a request from nginx through php-fpm to a Laravel response and back.
nginx serves static files and forwards the rest over FastCGI to the php-fpm master, which hands it to a free child; a busy pool waits in the listen backlog. The child runs index.php, OPcache supplies the opcodes, the framework bootstraps and routes, then tears it all down.
Common mistakes
- ✗Treating OPcache as an application or response cache instead of a cache of compiled opcodes
- ✗Believing the framework bootstrap is paid once at pool start rather than on every request
- ✗Forgetting the listen backlog, where requests wait once every child worker is busy
Follow-up questions
- →What exactly does a php-fpm worker retain between two requests under the shared-nothing model?
- →Why does OPcache remove parsing and compilation but not the framework bootstrap cost?
JuniorTheoryCommonWhat are generators and how do they relate to iterators?
What are generators and how do they relate to iterators?
A generator is a function that uses yield to produce values lazily, one at a time, without building the whole collection in memory. Calling it returns a Generator object, which implements the Iterator interface — so you can foreach over it. Each yield pauses the function and resumes on the next iteration.
Common mistakes
- ✗Thinking a generator builds the full collection up front rather than producing values lazily
- ✗Not knowing a
GeneratorimplementsIterator, so it works directly inforeach - ✗Believing you can rewind or re-iterate a generator like an array (it is forward-only)
Follow-up questions
- →What does
yield $key => $valuechange compared with a bareyield $value? - →When would you implement the
Iteratorinterface by hand instead of using a generator?
JuniorTheoryCommonWhat does OPcache store between requests, and what does it save PHP from redoing?
What does OPcache store between requests, and what does it save PHP from redoing?
Without it, PHP parses and compiles every source file into opcodes on every request. OPcache keeps those compiled opcodes in shared memory, so the next request skips compilation and executes at once. It caches code, not data: no query results.
Common mistakes
- ✗Thinking OPcache caches data or query results rather than compiled opcodes
- ✗Believing it stores rendered HTML and serves pages without running PHP
- ✗Assuming PHP would not otherwise re-compile every source file on every request
Follow-up questions
- →How does the JIT compiler go further than what OPcache itself caches?
- →What happens to the cached opcodes when you deploy new source files?
MiddleTheoryCommonHow does PHP 8 differ from PHP 7, and what does strict_types do?
How does PHP 8 differ from PHP 7, and what does strict_types do?
PHP 8 adds the JIT compiler, union types (int|string), named arguments, attributes, the match expression, constructor property promotion, and the nullsafe operator ?->. declare(strict_types=1) switches a file from coercive to strict scalar typing — passing a string where an int is declared throws a TypeError instead of silently casting.
Common mistakes
- ✗Thinking
strict_types=1is global — it applies only to the file that declares it - ✗Believing strict typing forbids coercion everywhere, including between
intandfloat - ✗Confusing union types (PHP 8.0) with the nullable
?typeshorthand from PHP 7.1
Follow-up questions
- →Why does
strict_types=1affect the calling file's behaviour, not the called function's? - →How does the
matchexpression differ fromswitchin comparison and fall-through?
JuniorTheoryOccasionalHow do you write a long-lived daemon (background process) in PHP?
How do you write a long-lived daemon (background process) in PHP?
Run a CLI script with an infinite loop that does work then sleeps, kept alive by a supervisor (systemd or supervisord) rather than truly daemonizing by hand. Inside the loop call gc_collect_cycles() and watch memory, because a long-lived PHP process accumulates state. Handle signals (pcntl_signal) for graceful shutdown, and let the supervisor restart it on exit.
Common mistakes
- ✗Ignoring memory growth in a long-lived process, leading to a slow leak and OOM
- ✗Daemonizing by hand instead of letting systemd/supervisord manage the lifecycle
- ✗Forgetting signal handling, so the process cannot shut down or reload gracefully
Follow-up questions
- →Why does a long-running PHP worker leak memory more than a per-request php-fpm process?
- →How does
pcntl_signallet a worker finish the current job before shutting down?
MiddlePerformanceOccasionalWhat happens in memory when you pass a large array to a function by value?
What happens in memory when you pass a large array to a function by value?
Nothing is copied at the call. PHP is by-value semantically, but the engine shares the array and bumps a refcount — copy-on-write. Duplication happens only when one side writes while refcount > 1. Appending one element in the callee copies the whole array.
Common mistakes
- ✗Thinking by-value semantics means a physical copy is made at the call
- ✗Reaching for
&on read-only parameters believing it saves a copy - ✗Forgetting that one write in the callee separates and duplicates the whole array
Follow-up questions
- →When is passing with
&justified, and what does it cost the caller? - →How does separation behave for a nested array that is itself shared by refcount?
MiddleTheoryOccasionalWhat does the persistent Laravel runtime Octane keep in memory between requests, and what breaks?
What does the persistent Laravel runtime Octane keep in memory between requests, and what breaks?
Octane boots the Laravel application once and keeps that instance and its container alive inside a Swoole, FrankenPHP or RoadRunner worker. So anything assuming a fresh process breaks: your singletons, statics, mutated config, a user leaking onward.
Common mistakes
- ✗Caching request-scoped data in a singleton that Octane keeps alive
- ✗Assuming Octane resets your own static properties and runtime-mutated config
- ✗Expecting the application container to be rebuilt from scratch on every request
Follow-up questions
- →Which Octane setting flushes bindings, and why does that not cover your own statics?
- →How would you detect a request-scoped value leaking into a later request?
MiddlePerformanceOccasionalWhich OPcache settings matter in production, and what does opcache.validate_timestamps=0 cost?
Which OPcache settings matter in production, and what does opcache.validate_timestamps=0 cost?
opcache.memory_consumption must hold every compiled script or the cache thrashes, and opcache.max_accelerated_files must exceed the real file count. opcache.validate_timestamps=0 stops the per-request file check, but a deploy then needs a reset.
Common mistakes
- ✗Leaving
opcache.max_accelerated_filesat a default far below the real file count - ✗Setting
validate_timestamps=0without resetting OPcache or reloading FPM on deploy - ✗Expecting JIT to speed up typical I/O-bound web requests
Follow-up questions
- →How do you read
opcache_get_status()to tell a full cache from healthy churn? - →What is the safest way to invalidate the cache during a zero-downtime deploy?
MiddleTheoryOccasionalHow does PHP reclaim memory — what does refcounting miss, and what does the cycle collector do?
How does PHP reclaim memory — what does refcounting miss, and what does the cycle collector do?
Every value carries a refcount; at zero it is freed at once, which handles almost every object. Refcounting can never free a reference cycle — each object still holds a count from the other. PHP buffers possible cycle roots and runs a separate cycle collector.
Common mistakes
- ✗Believing refcounting alone reclaims everything, including reference cycles
- ✗Assuming the cycle collector runs on a timer rather than when the root buffer fills
- ✗Ignoring cycles in a long-running worker because an FPM request frees everything anyway
Follow-up questions
- →Why does a cycle matter far more in a long-running worker than in an FPM request?
- →How would you break a parent-child cycle so that refcounting alone can free it?
MiddleTheoryOccasionalHow do the worker-mode runtimes RoadRunner and FrankenPHP change PHP's request model?
How do the worker-mode runtimes RoadRunner and FrankenPHP change PHP's request model?
The application is bootstrapped once and the worker then serves many requests in a loop, so framework boot, config, the container and database connections are reused. The price: the process outlives the request, so statics and singletons leak into the next one.
Common mistakes
- ✗Assuming request-scoped state is still torn down automatically between requests
- ✗Leaving a request-scoped value in a static property or a singleton
- ✗Thinking the win comes from a faster HTTP layer rather than a reused bootstrap
Follow-up questions
- →Which library or framework patterns leak state once the process outlives a request?
- →How do you keep a database connection healthy across thousands of requests in one worker?
MiddleTheoryOccasionalHow does yield from differ from looping over an inner generator and re-yielding it?
How does yield from differ from looping over an inner generator and re-yielding it?
yield from delegates: it yields the inner iterable's values preserving its keys, propagates the inner generator's return value outward (readable via getReturn()), and forwards send() into it. A manual foreach re-yield does none of those three.
Common mistakes
- ✗Believing a manual
foreachre-yield preserves the inner keys andreturnvalue - ✗Passing a delegating generator to
iterator_to_array()without disabling key preservation - ✗Not knowing
yield fromforwardssend()into the inner generator
Follow-up questions
- →Why can
iterator_to_array()lose values after delegating to several generators? - →How does
getReturn()behave if the generator has not finished running yet?
SeniorDebuggingOccasionalA long-running CLI import script dies on memory_limit. Diagnose the cause.
A long-running CLI import script dies on memory_limit. Diagnose the cause.
This is retention, not a leak. fetchAll() materialises all 5M rows before the loop starts, and $processed[] keeps a live reference to every entity, so refcounts never reach zero and nothing can be freed. Stream instead: fetch() or a generator, and stop accumulating.
Common mistakes
- ✗Calling
gc_collect_cycles()— it cannot free anything the script still holds a reference to - ✗Diagnosing a leak when the script is simply retaining the result set and every entity
- ✗Raising
memory_limitinstead of streaming, which only postpones the same fatal error
Follow-up questions
- →Why does
fetchAll()peak the memory before the first loop iteration even runs? - →How does an unbuffered query in the database extension
PDOchange what PHP holds in memory?
SeniorDesignOccasionalYour team wants to launch a new HTTP service on a persistent worker runtime — FrankenPHP, RoadRunner or Laravel Octane — instead of classic php-fpm, on the grounds that it is much faster. The constraints: the service pulls in several third-party Composer packages that nobody has audited, no one on the team has run a persistent runtime in production, and on php-fpm today p95 latency is 90 ms, of which framework bootstrap is roughly 15 ms. Make the call and justify it. Say what the worker runtime genuinely buys at these numbers, what class of failure it introduces that FPM does not have, what you would have to verify before adopting it, and what would have to change for your answer to flip.
Your team wants to launch a new HTTP service on a persistent worker runtime — FrankenPHP, RoadRunner or Laravel Octane — instead of classic php-fpm, on the grounds that it is much faster. The constraints: the service pulls in several third-party Composer packages that nobody has audited, no one on the team has run a persistent runtime in production, and on php-fpm today p95 latency is 90 ms, of which framework bootstrap is roughly 15 ms. Make the call and justify it. Say what the worker runtime genuinely buys at these numbers, what class of failure it introduces that FPM does not have, what you would have to verify before adopting it, and what would have to change for your answer to flip.
Stay on FPM. Shared-nothing teardown is a safety property, not just overhead: a fatal or a leak in one request cannot touch the next. Worker mode boots once, but turns statics, singletons and globals into cross-request incidents. Bootstrap is 15 ms of 90 — a ~17% ceiling.
Common mistakes
- ✗Reading FPM's per-request teardown as pure overhead rather than as an isolation guarantee
- ✗Assuming application code needs no change once statics and singletons survive the request
- ✗Treating a 15 ms bootstrap inside a 90 ms p95 as though it were the dominant cost
Follow-up questions
- →Which library-level state would you audit first before moving a service to worker mode?
- →What would the latency budget have to look like for the trade-off to flip toward worker mode?
SeniorTheoryOccasionalHow does Opcache differ from JIT, and how does PHP's garbage collector work?
How does Opcache differ from JIT, and how does PHP's garbage collector work?
Opcache caches compiled opcodes in shared memory so PHP skips re-parsing and re-compiling source on every request. JIT (PHP 8) goes further, compiling hot opcodes to native machine code at runtime — it helps CPU-bound code more than typical I/O-bound web requests. The GC is reference counting on each ZVAL plus a cycle collector that periodically finds and frees unreachable reference cycles.
Common mistakes
- ✗Conflating Opcache (opcode cache) with JIT (native machine-code compilation)
- ✗Expecting JIT to speed up typical I/O-bound web code as much as CPU-bound work
- ✗Thinking refcounting alone reclaims everything, missing the cycle collector's role
Follow-up questions
- →What is a ZVAL, and how do its type tag and refcount support copy-on-write?
- →Why can reference counting alone never reclaim a self-referential object graph?
SeniorDesignOccasionalYou are starting a greenfield service on the current PHP 8.5 stable line, but the team's habits come from PHP 7.4: no declare(strict_types=1), hand-written constructors, class constants used as enum-like magic strings, and mutable value objects. You own the coding guideline. Decide which modern language features become mandatory, which stay optional, and which you deliberately hold back for now, justifying each bucket by the bugs it removes against the review burden it adds. Cover at least readonly, enums, match, constructor property promotion, property hooks, asymmetric visibility, and the newest 8.5 syntax. Finally, state what the service should rely on for concurrency, and whether any part of the design may assume that support will change.
You are starting a greenfield service on the current PHP 8.5 stable line, but the team's habits come from PHP 7.4: no declare(strict_types=1), hand-written constructors, class constants used as enum-like magic strings, and mutable value objects. You own the coding guideline. Decide which modern language features become mandatory, which stay optional, and which you deliberately hold back for now, justifying each bucket by the bugs it removes against the review burden it adds. Cover at least readonly, enums, match, constructor property promotion, property hooks, asymmetric visibility, and the newest 8.5 syntax. Finally, state what the service should rely on for concurrency, and whether any part of the design may assume that support will change.
Mandate what removes bug classes: strict_types, promotion, enums, readonly, match. Take PHP 8.4 property hooks and asymmetric visibility where they cut boilerplate. Defer the newest 8.5 syntax until reviewers read it fluently. Concurrency: True Async is an unvoted draft.
Common mistakes
- ✗Planning around async/await reaching PHP core — the True Async RFC is a draft never put to a vote
- ✗Attributing property hooks and asymmetric visibility to 8.5 when both shipped in PHP 8.4
- ✗Mandating the newest syntax before reviewers and static analysis can read it fluently
Follow-up questions
- →Which of these rules would you enforce mechanically in CI rather than in a review checklist?
- →What would have to be true before you allowed the pipe operator
|>into production code?