DevelopmentAll levels

PHP

PHP is a server-side language that handles each web request in a worker which starts empty and forgets everything once the response is sent. That one fact, the shared-nothing model, explains why state lives in Redis, why slow work goes to a queue and why a stock server handles five requests at a time. The language itself has changed more than its reputation: PHP 8 has types, enums, a strict mode and a new major feature every November. The page opens with what a worker forgets and twelve consequences, then covers the worker pool and its arithmetic, the language as it stands in 8.5, frameworks and standards, the database and the calls worth searching for, long-running servers, and five documented cases.

Current version
8.5 · 8.6 due 19 Nov 2026
Model
One request per worker, then forgotten
Support
2 years active + 2 security

What a PHP worker forgets — and twelve consequences

A Java, Node.js or Go service starts once and then serves requests for weeks from the same process. Whatever it puts in memory stays there: a cache, a connection pool, a counter, a timer. A PHP worker does the opposite. It builds the application from nothing for each request and throws all of it away when the response is sent. Almost everything that surprises a developer arriving from another language follows from that one difference. Here are twelve consequences, each with the habit it breaks and what PHP does instead. Later sections cite the rows by number.

#The habit from a long-running runtimeWhat happens in PHPWhat to do instead
1Cache a value in a static property or a globalIt is gone when the response is sent; the next request computes it againA shared cache outside the worker: APCu on one server, Redis on several
2Keep sessions in memoryThe default handler writes session files to the server’s local diskSessions in Redis or the database from the second server on
3Write uploads to the local diskThe next request may land on a server that does not have the fileObject storage, with the file’s key kept in the database
4Count in memory — a rate limit, a counterEach worker counts on its own, and each count dies with its requestAn atomic counter in Redis (INCR) or the database
5Keep a connection pool inside the applicationNo pool survives; each request opens its own connectionA pooler in front of the database (PgBouncer, ProxySQL)
6Do slow work after replying — email, a PDF, a webhookThe worker stays busy until it finishes and cannot take the next requestA queue, consumed by a separate long-running worker process
7Run a timer or a background threadNo process is alive between requests to run itCron calling the framework’s scheduler once a minute
8Hold a WebSocket openA worker held by one connection is a worker lost to every other userA separate real-time server: Laravel Reverb, Mercure, a Node or Go service
9Pay the start-up cost onceThe framework boots on every requestOPcache, preloading, and cached config, routes and container
10Watch a memory leak grow over hoursA leak dies with its request — except in queue workers, which live for hoursRestart queue workers after N jobs or past a memory limit
11Deploy by copying files over the running codeOPcache keeps serving the bytecode of the old filesSwitch to a new release directory at once, then reload PHP-FPM
12Take as many requests at once as the traffic bringsA pool serves exactly pm.max_children at once — 5 in the stock configSize the pool from memory, and keep every request short

One request, one clean process — and a pool with a ceiling

PHP almost never talks to the network by itself. A web server — nginx, Apache or Caddy — accepts the connection and hands the request over a protocol called FastCGI to PHP-FPM, the FastCGI Process Manager. FPM keeps a pool of worker processes. A free worker takes the request, loads the framework, runs your code, writes the response, and then discards every object, variable and connection it created. The next request gets a worker in the same empty state. This is the shared-nothing model: requests share nothing in memory, by design.

A PHP worker boots the application, handles exactly one request, writes the response, and then throws away everything it created — every object, every variable, every connection. The next request starts from an empty process, so nothing carries over in memory and anything that must survive lives in a database, a cache or a session store. This is why PHP scales by adding workers, why a leak inside one request is harmless, and why an in-process cache does nothing for you here.

The upside is that a whole category of bug cannot exist. A leaked object, a corrupted global, a connection left in a broken state — all of it is thrown away within milliseconds, so a request can only poison itself. A fatal error takes down one request, not a server. Scaling is equally blunt: workers share nothing, so more capacity means more workers, and more workers on more servers means nothing to coordinate except the database.

The pool is the ceiling

Row 12 is the one that causes outages. A worker handles one request at a time, so the pool size is the number of requests PHP can serve at once — and the stock FPM configuration sets pm.max_children = 5. A fresh install handles five concurrent requests; the sixth waits in a queue. The right number comes from memory, not from CPU: measure how much one worker uses under real traffic, divide the memory you can give PHP by it, and leave room for everything else on the box. With 4 GB for PHP and 64 MB per worker, that is about sixty workers.

The pool then has a throughput ceiling, and it is simple arithmetic: workers divided by the time each request takes. Sixty workers at 100 ms a request serve 600 requests a second. Now let a payment provider slow down to 2 seconds. Every request that calls it holds its worker for 2 seconds, the same sixty workers serve 30 requests a second, and everything above that queues. The diagram draws it with a pool of eight. The CPU graph shows the server nearly idle the whole time, because waiting costs no CPU — which is why this outage is so often misread as "not enough servers".

A PHP-FPM worker serves one request at a time, so a pool’s throughput is the number of workers divided by the time each request takes. With eight workers and a dependency that answers in 100 ms the pool serves 80 requests a second; slow that dependency to 2 seconds and the same pool serves 4, every worker is held waiting, and new requests queue until the web server gives up with a 504. The CPU stays almost idle throughout, because waiting costs no CPU — which is why this outage is often misread as a lack of servers. Sixty workers give 600 and 30 requests a second.
What you seeWhat it meansWhere to look
server reached pm.max_children setting in the FPM logEvery worker was busy; requests queuedSlow requests first, pool size second
504 Gateway Timeout from nginxA request waited or ran longer than the web server allowsThe slowest dependency of that endpoint
502 Bad GatewayFPM refused the connection or a worker died mid-requestThe FPM log: a full queue, a crash, a killed worker
Allowed memory size of 134217728 bytes exhaustedOne request hit memory_limit (128 MB by default)A query loading a whole table; stream or chunk it
Maximum execution time of 30 seconds exceededPHP itself computed for 30 seconds — waiting does not countA loop, not a slow API

The language people remember, and the one that exists

Most opinions about PHP were formed on PHP 5: no types, comparisons that made no sense, and a speed problem large enough that Facebook wrote its own compiler. PHP 7.0, in December 2015, rewrote the engine and was up to twice as fast as 5.6, and every release since has added to the type system. A release now ships every November, gets two years of bug fixes and two more of security fixes, and then gets nothing. The table lists what each one added that you will actually meet in code, and how long it is patched.

VersionReleasedWhat you will meet in codeSecurity fixes until
7.0Dec 2015Scalar parameter and return types, ??, an engine up to twice as fastEnded Jan 2019
7.4Nov 2019Typed properties, arrow functions fn, preloadingEnded Nov 2022
8.0Nov 2020Union types, match, named arguments, attributes, constructor promotion, ?->, saner comparisons, JITEnded Nov 2023
8.1Nov 2021Enums, readonly properties, fibers, never, first-class callablesEnded Dec 2025
8.2Dec 2022Readonly classes, DNF types, dynamic properties deprecated31 Dec 2026
8.3Nov 2023Typed class constants, #[\Override], json_validate()31 Dec 2027
8.4Nov 2024Property hooks, asymmetric visibility, lazy objects, array_find()31 Dec 2028
8.5Nov 2025The pipe operator |>, a URI extension, clone() with new values, #[\NoDiscard], array_first()31 Dec 2029
8.6Due 19 Nov 2026Partial function application, readonly defaults, session cookies HttpOnly and SameSite=Lax by default31 Dec 2030, by the policy

Strict types: the calling file decides

Type declarations in PHP have two modes. By default they coerce: pass the string "5" to an int parameter and PHP quietly converts it to 5. With declare(strict_types=1) at the top of a file they check: the same call throws a TypeError. The part almost everyone gets wrong is which file decides. In the manual’s words, strict typing "applies to function calls made from within the file with strict typing enabled, not to the functions declared within that file". A function written in a strict file still coerces when it is called from a file without the line.

In PHP, declare(strict_types=1) applies to the calls made from the file that contains it, not to the functions defined there. So a function written in a strict file still accepts the string "5" for an int parameter when it is called from a file without the line — the value is quietly converted — and throws a TypeError only when the calling file is strict. A library cannot make its callers strict. Return values work the other way round: they are checked in the mode of the file that defines the function.

The practical rule follows: put the line in every file, including tests and templates, and let a code-style tool enforce it. A library cannot make its own functions strict for the people who call it. Its only defence is static analysis on the caller’s side. PHPStan and Psalm read the declarations and find the null that reaches a method call before a user does. Both work in levels — PHPStan has eleven, 0 to 10 since its 2.0 release in November 2024 — so an existing code base can climb one level at a time rather than in one impossible sweep.

The comparisons PHP 8 fixed, and the ones it kept

The reputation for "weird comparisons" was earned by the loose operator ==, which converts both sides before it compares them. PHP 8 changed one rule: a number compared with a string that does not look numeric is now compared as a string. That removed the worst cases. The rest are still there, and they are the reason ===, which compares type and value without converting, is the default in modern code.

ExpressionPHP 7.4PHP 8Why
0 == "foo"truefalse"foo" is not numeric, so 8 compares "0" with "foo" as strings
in_array("abc", [0])truefalseThe same rule; in_array is loose unless its third argument is true
"1" == "01"truetrueBoth look numeric, so both are compared as numbers
"10" == "1e1"truetrue"1e1" is scientific notation for 10
null == falsetruetrueBoth convert to false

On the compiler added in 8.0, be honest. It speeds up long numeric computation measurably and typical web requests barely at all, because a web request spends its time waiting on the database and the network rather than doing arithmetic. If a PHP page is slow, the answer is in the query log and in row 6, not in the JIT.

Laravel, Symfony, and the standards under both

Almost no one starts a PHP application from an empty directory. Composer, the dependency manager, installs packages from Packagist and writes the exact versions it chose into composer.lock; commit that file, and run composer install, never composer update, on a server. Composer also generates the autoloader, so you will not write a single require for your own classes. The autoloader works because of a standard: PHP-FIG, a group of framework and library authors, publishes PSRs — PHP Standard Recommendations — that let code from different projects plug together.

StandardWhat it fixesWhere you meet it
PSR-4How a class name maps to a file pathThe autoload section of every composer.json
PSR-3One logger interfaceMonolog, used by Laravel and Symfony
PSR-7, PSR-17Request and response objects, and factories for themHTTP libraries, Slim, Mezzio
PSR-15Middleware and request handlersMiddleware written once for many frameworks
PSR-11Getting a service from a containerEvery dependency-injection container
PSR-6, PSR-16A detailed and a simple cache interfaceCache libraries that work in any framework
PSR-14, PSR-18Event dispatching; an HTTP clientSDKs that let you choose the HTTP client
PSR-20"Now" as a service you can replaceTests that freeze time
PER Coding StyleCode layout; it replaced PSR-12PHP-CS-Fixer, PHP_CodeSniffer, Laravel Pint

On top of that layer, two frameworks hold the field, and choosing between them is choosing how much the framework decides for you.

LaravelSymfony
StanceDecides for you: one idiomatic way to do most things, present out of the boxGives you components to assemble and configure; less is implied
Data accessEloquent, an active-record ORM: the model is the rowDoctrine, a data-mapper ORM: entities know nothing about the database
ReleasesA major every year; bug fixes for 18 months, security fixes for 2 yearsA minor every six months; an LTS (x.4) every two years, 3 years of bug fixes and 4 of security
Best fitProduct teams shipping features fast; small and mid-size teamsLong-lived systems, complex domains, teams that want explicit boundaries
Main riskConvenience invites business logic into models and controllers until nothing is testableCeremony and configuration slow a small team down for no return

The honest summary: for a typical product team, Laravel gets you to a working feature sooner, and the risk is architectural drift. For a system a bank will still run in twelve years, Symfony’s explicitness is worth the ceremony. Both are maintained and fast enough. The difference between a good and a bad code base in either is discipline about where business logic lives, not the framework.

The database, and six calls worth a search before every release

PHP’s reputation for SQL injection came from a real era of queries built by gluing strings together, and the fix has been standard for well over a decade: prepared statements. A prepared statement sends the shape of the query and the values separately, so a value can never be read as SQL. Every and query builder in the ecosystem uses them underneath. The only way back to the vulnerability is to put a variable into SQL by hand.

Two more habits separate a code base that survives growth from one that does not. Keep transactions short: a transaction held open across an HTTP call to a payment provider locks its rows for as long as that provider is slow, and it also holds a worker (row 12). And keep migrations in version control next to the code that needs them, so a deployment and its schema change move together.

Injection is not the only door. Most serious PHP vulnerabilities come from a short list of calls that are safe with your own data and dangerous with anyone else’s. Each is easy to find with a text search, which makes the list a cheap review step before every release:

Search forWhat goes wrong with outside inputInstead
unserialize(The input chooses which classes are created, and their magic methods run — object injectionjson_decode(); or allowed_classes => false
"SELECT … $SQL injectionBound parameters
include $Runs a file the attacker namedA fixed map of allowed names
exec( · shell_exec( · system(Command injectionAn argument array (Symfony Process), or escapeshellarg()
md5( · sha1( on passwordsCracked offline in hourspassword_hash() and password_verify()
== on tokens and signaturesLoose comparison and a timing leakhash_equals()

Long-running PHP: what worker mode gives back and takes away

There is now a second way to run PHP. Application servers boot the framework once and then serve many requests from the same process. FrankenPHP, built on the Caddy web server and officially supported by the PHP Foundation since May 2025, calls it worker mode. RoadRunner does it from a Go process; Swoole and OpenSwoole do it as a PHP extension with coroutines; Laravel Octane wraps all three, and Symfony runs on them through its Runtime component.

What that gives back is rows 1, 5 and 9. The bootstrap is paid once, so a request that spent most of its time loading the framework becomes several times faster. An in-process cache and a persistent connection start to mean something again. What it takes away is the safety of the shared-nothing model. Memory is no longer wiped between requests, so anything a request leaves behind on a long-lived object is visible to the next one — and the next one belongs to a different user.

Under PHP-FPM every request starts with empty memory, so a service that caches the current user is filled again for each request. In worker mode — FrankenPHP, RoadRunner, Swoole, Laravel Octane — one process serves many requests and long-lived objects survive between them. A service that caches the user with ??= fills it on the first request and keeps it, because on the next request the property is already set: the second user sees the first user’s data. No other bug is needed for the leak, which is why code written for FPM has to be audited before it runs in worker mode.

Code written for FPM was never tested for that, because under FPM it could not happen. Before switching, audit for these five things:

What to look forWhat happens in worker modeFix
Request data on a singleton or a static propertyThe next user’s request reads itPass it as an argument, or reset it after every request
A long-lived service that received the request, user or config in its constructorIt keeps the first request’s copy for the life of the workerResolve it per request, or inject a resolver instead of the value
An array used as a cacheIt grows until the worker is killed for memoryA bounded cache, or APCu or Redis
Per-request global settings — setlocale(), date_default_timezone_set(), ini_set()They stay set for the next requestSet them on every request, or not at all
A database connection opened at bootThe server closes it after an idle timeoutReconnect on failure; most frameworks do it for you in this mode

Five cases, and what each one was missing

Each case below is documented in PHP’s own announcements, bug tracker and mailing list, or in the published research that found it. Between them they cover the engine, the seam with the web server, the source repository, the support window and the framework layer.

CaseWhat happenedWhat was missing
HipHop, HHVM and PHP 7, 2010–2019Facebook built its own PHP engines; PHP 7 caught up, and HHVM dropped PHPSpeed in the reference engine
CVE-2019-11043, Oct 2019A newline in a URL broke nginx’s path split and let attackers rewrite FPM’s settingsAn owner for the seam between web server and FPM
The php-src backdoor, Mar 2021Two commits in the names of core developers added a remote-execution backdoorStrong identity on commits — review caught it
CVE-2024-4577, Jun 2024A soft hyphen became a command-line flag for PHP-CGI on WindowsA supported version, and no CGI mode on the internet
Laravel APP_KEY, 2018–2025260,000 keys on public GitHub; 600 applications open to remote code executionSecret scanning, rotation — and no unserialize() behind one key

The first case is why the language section opens with a speed claim. In February 2010 Facebook announced HipHop, which translated PHP into C++ because the standard engine could not keep up with its traffic, and it then replaced it with HHVM, a virtual machine with a JIT. For a few years the fastest way to run PHP was not PHP’s own. The answer came from inside the project: PHP 7.0, released on 3 December 2015, rebuilt the engine’s core data structures and was, in the release announcement’s words, "up to twice as fast as PHP 5.6". In September 2018 the HHVM team announced it would stop supporting PHP, and HHVM 4.0, in February 2019, runs only Hack, Facebook’s own language. Teams that had moved to HHVM for speed moved back. A fork that outperforms the original is a bet on one company’s roadmap.

The third case is about the language’s own source. On 28 March 2021 two commits appeared in php-src on PHP’s self-hosted git server, signed with the names of Rasmus Lerdorf, who created PHP, and Nikita Popov, one of its most active developers. They were labelled as typo fixes. They added code that executed any PHP sent in a User-Agentt header — note the second t — starting with the word "zerodium". Developers reading the commit stream spotted the first one within hours and reverted it; the second put the same code back and was reverted too. The next day the project made its GitHub repository the canonical one, with two-factor authentication required for write access. The later finding was that the git server had probably not been broken into at all: the user database behind it had most likely leaked, so the attacker pushed with real credentials. Review worked. Identity did not.

The fifth case joins row 1 of the grep table to a secret. Laravel encrypts and other values with the application’s APP_KEY, and its decrypt() function unserialises what it decrypts by default. Whoever holds the key can therefore encrypt an object of their choosing, hand it to the application, and have it unserialised — remote code execution. For old Laravel versions this was -2018-15133. In July 2025 GitGuardian and Synacktiv published what that means at scale: they extracted more than 260,000 APP_KEY values from public GitHub repositories committed between 2018 and May 2025, and identified more than 600 live applications that could be taken over with them. The key had been committed with a .env file, or pasted into a config, and never rotated. The controls were all ordinary — a secret scanner on every push, a rotation when a key leaks — and the design lesson is the old one: never let one secret be the only thing between the internet and unserialize().

Where it degrades

PHP’s model is forgiving, and that is both its strength and the way it fails. A leak, a bad global or a slow loop costs one request and is forgotten, so a code base can carry a great deal of disorder before anything visibly breaks. When it does break, it is almost always one of the twelve rows ignored, or one of the six calls trusted with someone else’s data:

  • Slow work inside the request — mail, PDFs, third-party calls — instead of on a queue (row 6).
  • A pool left at the stock five workers, or sized for CPU instead of memory (row 12).
  • No wall-clock timeout on outgoing calls, so one slow dependency holds every worker.
  • Sessions or uploads on local disk, which works on one server and breaks silently on two (rows 2 and 3).
  • queries behind a convenient relation, invisible until the table has real data in it.
  • Files without strict_types, so type declarations coerce instead of checking and give false confidence.
  • Business logic inside controllers and models, until a rule can only be tested through an HTTP request.
  • unserialize() on anything that crossed a network, or a framework key committed to a repository.
  • Worker mode switched on for speed without auditing what long-lived objects now keep between users.
  • A version past the last column of the table, where the next advisory has no patch.

The honest limit is the model itself. A request that must stay open — a chat, a live dashboard, a stream — fights the one-request-one-worker design the whole way, and the right answer is usually a second, small service in another runtime beside the PHP application, not a PHP application bent into that shape. The same goes for heavy computation and data pipelines: the runtime can do them, but the libraries, the tooling and the people are elsewhere. Worker mode narrows the gap without closing it, and it buys its speed with exactly the safety that made PHP easy to reason about.

The practical check takes five minutes. Run php -v on the production server and find the row in the version table. Run composer audit, which lists installed packages with known advisories. Open the FPM status page (pm.status_path) under real traffic and compare "active processes" with pm.max_children: if they are equal at peak, row 12 is already your bottleneck.

When to use it

Use it when

  • Web applications and APIs where the work per request is short and the traffic scales horizontally.
  • Teams that need a working product quickly — framework, ORM, queue and admin tooling arrive as one package.
  • Content-heavy and e-commerce systems, where hosting, hiring and the ecosystem are all deep.
  • Existing PHP systems — a PHP 8 code base with static analysis is a better bet than a rewrite.

Avoid it when

  • Long-lived connections — , streaming, chat — where one worker per request fights you the whole way.
  • CPU-heavy processing and data pipelines; the runtime can do it, but the ecosystem is elsewhere.
  • Worker mode on code written for FPM, before an audit of what its singletons keep between users.
  • Running a version past its security date — or PHP-CGI exposed to the internet at all.

Sources and method

What this page stands on

  • 16 sources
  • 4 documented failures
  • Checked October 13, 2026

Researched with Claude. Finding and cross-checking the primary sources was done with Claude (Anthropic). Selection, writing and fact-checking: the Anatomy of IT Product editors.

Found this useful?

Share it with someone who is working on the same problem.