Deployment & Operations
Running PHP in production — environment configuration through .env, logging and error monitoring, feature flags for risky rollouts, zero-downtime deploy strategies, and scheduled tasks across a fleet of app servers.
10 questions
JuniorTheoryVery commonWhat does the environment-loading library vlucas/phpdotenv do with .env, and why is .env never committed?
What does the environment-loading library vlucas/phpdotenv do with .env, and why is .env never committed?
It reads .env once at bootstrap and puts each line into the process environment, so getenv() and $_ENV return it and no secret is hard-coded in the source. .env holds the values that differ per machine — the database password, API keys — so it stays out of git; you commit .env.example and set the real values on each server.
Common mistakes
- ✗Committing
.envwith real credentials and rotating nothing after it lands in the git history - ✗Calling the
env()helper outside a config file, so it returns null once the config is cached - ✗Treating a
.envfile on disk as the production secret store instead of real environment variables
Follow-up questions
- →What breaks when the framework caches its config and a controller still calls the
env()helper? - →Where do production secrets belong when a
.envfile sitting on disk is not good enough?
SeniorDesignVery commonFour PHP-FPM instances sit behind a load balancer and serve traffic continuously, and the next release also changes the database schema. Design a zero-downtime deploy: how a new release becomes live on one instance without any request seeing a half-updated tree, what must happen after the code is in place before that instance is fair game for traffic again, how the load balancer decides the instance is ready, and how you sequence the schema change so that the old and the new code can both run against the database at the same time. Say how you roll back once the migration has already been applied to live data.
Four PHP-FPM instances sit behind a load balancer and serve traffic continuously, and the next release also changes the database schema. Design a zero-downtime deploy: how a new release becomes live on one instance without any request seeing a half-updated tree, what must happen after the code is in place before that instance is fair game for traffic again, how the load balancer decides the instance is ready, and how you sequence the schema change so that the old and the new code can both run against the database at the same time. Say how you roll back once the migration has already been applied to live data.
Build each release into its own directory and flip a symlink atomically, then reset OPcache and reload FPM — otherwise a warm OPcache keeps serving the old code. Drain the instance, deploy, and let the balancer re-pool it only once a health check passes; roll the fleet in batches. Schema changes go expand-then-contract, and the new path stays behind a flag, so a rollback is a switch, not a migration.
Common mistakes
- ✗Flipping the symlink and leaving a warm OPcache serving the previous release's compiled code
- ✗Applying a destructive migration in the same release as the code that stops using the column
- ✗Returning an instance to the pool before a health check has confirmed that it can actually serve
Follow-up questions
- →What does the expand phase contain, what does the contract phase contain, and how far apart must they ship?
- →Why can a rollback not simply reverse a migration that has already run against live data?
JuniorTheoryCommonWhat does an atomic PHP deploy do, and why is the symlink flip not the last step?
What does an atomic PHP deploy do, and why is the symlink flip not the last step?
You build the release into a new directory — composer install, assets, config — while the previous one keeps serving. One ln -sfn then flips current onto it atomically, so no request lands on a half-copied tree. The flip is not the end: OPcache still holds the old compiled files, so you reset it and reload FPM.
Common mistakes
- ✗Deploying in place, so a request lands on a tree that is only half written
- ✗Flipping the symlink and leaving a warm OPcache serving the previous release
- ✗Forgetting the long-running queue workers still hold the old code in memory until restarted
Follow-up questions
- →Why does a warm OPcache keep serving the old code after
currentalready points at the new release? - →What has to happen to the queue workers before a deploy can be called finished?
JuniorTheoryCommonHow does a PHP script run from cron differ from the same script served through PHP-FPM?
How does a PHP script run from cron differ from the same script served through PHP-FPM?
Cron runs the CLI SAPI, which reads its own php.ini — typically a different memory_limit — and where max_execution_time is 0, so nothing cuts a long run short. There is no request at all: no $_GET, no $_SERVER['HTTP_*'], no session. It runs as cron's user under cron's minimal PATH, and its output is lost unless you redirect it.
Common mistakes
- ✗Assuming the FPM
php.inilimits apply to a cron run — the CLI SAPI reads its own file - ✗Relying on a binary or a variable that exists in your shell but not in cron's minimal environment
- ✗Losing a failed run's output because nothing redirects the script's stdout and stderr anywhere
Follow-up questions
- →Why does
max_execution_timenot stop a runaway cron script the way it stops a web request? - →What makes a scheduled job safe to run twice, and why does that property matter at all?
JuniorTheoryCommonWhere do a PHP warning, an uncaught exception and an application log line each go in production?
Where do a PHP warning, an uncaught exception and an application log line each go in production?
The engine writes its warnings and fatals to the error_log set in php.ini — under FPM that is the pool's log — and display_errors is Off, so an uncaught exception reaches the user as a bare 500 with nothing in the body. Application lines are a separate stream: a PSR-3 logger such as Monolog writes them to stderr or a file, and a collector ships both off the box.
Common mistakes
- ✗Leaving
display_errorson in production and leaking a stack trace straight to the user - ✗Logging only to a file on each server, so nothing is searchable across the fleet
- ✗Assuming a PHP warning reaches the application logger — the engine's
error_logis a separate stream
Follow-up questions
- →Why must
display_errorsbe off whilelog_errorsstays on for a production box? - →What does a log collector give you that running
tail -fon each server cannot?
MiddleTheoryCommonWhat does the exception-monitoring service Sentry add over a log file, and what must you configure?
What does the exception-monitoring service Sentry add over a log file, and what must you configure?
It captures the exception with its stack trace plus request, user and release context, groups repeats by fingerprint so one bug is one issue rather than 40 000 lines, and alerts you — a log file does none of that. Tag release and environment from config, or you cannot say which deploy broke, and scrub secrets and PII before an event leaves the box.
Common mistakes
- ✗Shipping without a
releasetag, so nobody can say which deploy introduced the error - ✗Sending request bodies and headers unscrubbed, leaking passwords and tokens to a third party
- ✗Treating the tracker as a replacement for logs rather than as the layer that groups and alerts
Follow-up questions
- →How does grouping by fingerprint turn 40 000 raw events into one issue somebody can act on?
- →Which fields must be scrubbed before an event leaves your server, and where do you configure that?
MiddleDesignOccasionalYou are about to ship a rewritten checkout to a site that takes payments around the clock. The team wants the new code sitting on production days before any customer sees it, and wants to switch it off within seconds if conversion drops — without waiting for a deploy. Design a feature-flag system for this rollout: where a flag's value is stored and how the running application reads it, how you ramp from 1% to 50% of users while keeping any single user on one consistent side of the flag, how the flag is turned off under pressure, and what becomes of the flag once the rewrite has fully landed. Say what a feature flag must never be used for.
You are about to ship a rewritten checkout to a site that takes payments around the clock. The team wants the new code sitting on production days before any customer sees it, and wants to switch it off within seconds if conversion drops — without waiting for a deploy. Design a feature-flag system for this rollout: where a flag's value is stored and how the running application reads it, how you ramp from 1% to 50% of users while keeping any single user on one consistent side of the flag, how the flag is turned off under pressure, and what becomes of the flag once the rewrite has fully landed. Say what a feature flag must never be used for.
Keep the flag's value outside the deployed code — in a config store the running app reads at request time — so flipping it needs no deploy. Ship the new code dark, then ramp by bucketing users on a hash of their id, so one user always lands on the same side. Keep a kill switch that falls back to the old path. Flags are temporary, and a flag is never an authorization check.
Common mistakes
- ✗Bucketing with a random draw per request, so one user flips between the old and the new checkout
- ✗Storing the flag in deployed config, so turning it off needs the very deploy you were avoiding
- ✗Leaving dead flags in the code, so every branch has to be reasoned about and tested forever
Follow-up questions
- →Why must the bucket come from a hash of the user id rather than from a random draw per request?
- →What is the difference between a feature flag and an authorization check, and why must they not be mixed?
SeniorDesignOccasionalA Laravel app runs on six identical app servers built from one image, and the nightly billing job lives in the app's scheduler, so every server's cron fires it. Customers are being charged six times. Design scheduled tasks for a fleet: how exactly one server ends up running a given job, what happens when the server holding the right to run it dies halfway through, how the job stays correct if it is started twice anyway, and how you find out the next morning that a job silently did not run at all. Say why simply designating one box as the cron server is not the whole answer.
A Laravel app runs on six identical app servers built from one image, and the nightly billing job lives in the app's scheduler, so every server's cron fires it. Customers are being charged six times. Design scheduled tasks for a fleet: how exactly one server ends up running a given job, what happens when the server holding the right to run it dies halfway through, how the job stays correct if it is started twice anyway, and how you find out the next morning that a job silently did not run at all. Say why simply designating one box as the cron server is not the whole answer.
Elect one runner per job with a shared lock — Laravel's onOneServer() takes it in the shared cache and the losers exit — with a TTL so a dead holder does not block the job forever. That is still at-least-once, so the job must be idempotent: key the charge and skip a duplicate. Emit a heartbeat on success and alert on a missed run; a single cron box is just an unmonitored point of failure.
Common mistakes
- ✗Running the scheduler's cron on every server without electing a runner, so the job fires once per box
- ✗Relying on the lock for exactly-once and skipping the idempotency key the job still needs
- ✗Having no alert for a job that did not run — silence looks exactly like success
Follow-up questions
- →Why does a lock give you at-most-one-runner but never exactly-once execution?
- →What must the lock's TTL be relative to the job's worst-case runtime, and what breaks if it is shorter?
SeniorDebuggingOccasionalA bulk save works on staging but silently drops rows on production. Diagnose the cause.
A bulk save works on staging but silently drops rows on production. Diagnose the cause.
PHP stops parsing the body once it has built max_input_vars variables — 1000 by default. Each items[i][id] and items[i][qty] is one variable, so 900 rows are 1800 and everything past the first 500 rows is dropped before the controller runs; only a warning reaches the FPM error log. Staging had the limit raised by hand. Post JSON, which the limit does not touch, and ship php.ini with the release.
Common mistakes
- ✗Assuming a truncated
$_POSTmust throw — PHP drops the excess input silently - ✗Counting form rows rather than input variables, so 900 rows look safely below a limit of 1000
- ✗Leaving
php.inias hand-made server state, so staging and production drift apart unnoticed
Follow-up questions
- →Why does a JSON body read from
php://inputescapemax_input_varsentirely? - →What happens to
$_POSTwhen the body exceedspost_max_size, and how is that different from this?
SeniorDebuggingOccasionalAbout 2% of requests return 500, always from one host, and it never reproduces locally. Diagnose the cause.
About 2% of requests return 500, always from one host, and it never reproduces locally. Diagnose the cause.
The symlink on app-03 does point at the new release, but its FPM master was never restarted and opcache.validate_timestamps is 0, so its workers still execute the previous release's compiled Invoice — a class with no totalWithTax(). The post-flip OPcache reset silently failed on that host. Reset OPcache or reload FPM there, and make the deploy fail when a host does not confirm it.
Common mistakes
- ✗Reading a fault confined to one host as a hardware problem rather than as a stale runtime on that host
- ✗Trusting that a symlink pointing at the new release means the new code is what actually executes
- ✗Letting a deploy report success when its OPcache reset step failed on one machine of the fleet
Follow-up questions
- →With
opcache.validate_timestampsat 0, what is the only thing that makes PHP re-read a changed file? - →Where in the deploy does the OPcache reset belong, and how do you prove it ran on every host?