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 runtime | What happens in PHP | What to do instead |
|---|---|---|---|
| 1 | Cache a value in a static property or a global | It is gone when the response is sent; the next request computes it again | A shared cache outside the worker: APCu on one server, Redis on several |
| 2 | Keep sessions in memory | The default handler writes session files to the server’s local disk | Sessions in Redis or the database from the second server on |
| 3 | Write uploads to the local disk | The next request may land on a server that does not have the file | Object storage, with the file’s key kept in the database |
| 4 | Count in memory — a rate limit, a counter | Each worker counts on its own, and each count dies with its request | An atomic counter in Redis (INCR) or the database |
| 5 | Keep a connection pool inside the application | No pool survives; each request opens its own connection | A pooler in front of the database (PgBouncer, ProxySQL) |
| 6 | Do slow work after replying — email, a PDF, a webhook | The worker stays busy until it finishes and cannot take the next request | A queue, consumed by a separate long-running worker process |
| 7 | Run a timer or a background thread | No process is alive between requests to run it | Cron calling the framework’s scheduler once a minute |
| 8 | Hold a WebSocket open | A worker held by one connection is a worker lost to every other user | A separate real-time server: Laravel Reverb, Mercure, a Node or Go service |
| 9 | Pay the start-up cost once | The framework boots on every request | OPcache, preloading, and cached config, routes and container |
| 10 | Watch a memory leak grow over hours | A leak dies with its request — except in queue workers, which live for hours | Restart queue workers after N jobs or past a memory limit |
| 11 | Deploy by copying files over the running code | OPcache keeps serving the bytecode of the old files | Switch to a new release directory at once, then reload PHP-FPM |
| 12 | Take as many requests at once as the traffic brings | A pool serves exactly pm.max_children at once — 5 in the stock config | Size 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.
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".
| What you see | What it means | Where to look |
|---|---|---|
server reached pm.max_children setting in the FPM log | Every worker was busy; requests queued | Slow requests first, pool size second |
| 504 Gateway Timeout from nginx | A request waited or ran longer than the web server allows | The slowest dependency of that endpoint |
| 502 Bad Gateway | FPM refused the connection or a worker died mid-request | The FPM log: a full queue, a crash, a killed worker |
Allowed memory size of 134217728 bytes exhausted | One request hit memory_limit (128 MB by default) | A query loading a whole table; stream or chunk it |
Maximum execution time of 30 seconds exceeded | PHP itself computed for 30 seconds — waiting does not count | A 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.
| Version | Released | What you will meet in code | Security fixes until |
|---|---|---|---|
| 7.0 | Dec 2015 | Scalar parameter and return types, ??, an engine up to twice as fast | Ended Jan 2019 |
| 7.4 | Nov 2019 | Typed properties, arrow functions fn, preloading | Ended Nov 2022 |
| 8.0 | Nov 2020 | Union types, match, named arguments, attributes, constructor promotion, ?->, saner comparisons, JIT | Ended Nov 2023 |
| 8.1 | Nov 2021 | Enums, readonly properties, fibers, never, first-class callables | Ended Dec 2025 |
| 8.2 | Dec 2022 | Readonly classes, DNF types, dynamic properties deprecated | 31 Dec 2026 |
| 8.3 | Nov 2023 | Typed class constants, #[\Override], json_validate() | 31 Dec 2027 |
| 8.4 | Nov 2024 | Property hooks, asymmetric visibility, lazy objects, array_find() | 31 Dec 2028 |
| 8.5 | Nov 2025 | The pipe operator |>, a URI extension, clone() with new values, #[\NoDiscard], array_first() | 31 Dec 2029 |
| 8.6 | Due 19 Nov 2026 | Partial function application, readonly defaults, session cookies HttpOnly and SameSite=Lax by default | 31 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.
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.
| Expression | PHP 7.4 | PHP 8 | Why |
|---|---|---|---|
| 0 == "foo" | true | false | "foo" is not numeric, so 8 compares "0" with "foo" as strings |
| in_array("abc", [0]) | true | false | The same rule; in_array is loose unless its third argument is true |
| "1" == "01" | true | true | Both look numeric, so both are compared as numbers |
| "10" == "1e1" | true | true | "1e1" is scientific notation for 10 |
| null == false | true | true | Both convert to false |
On the JITJust-In-Time compilation — compiling while runningCompiling code while the program is already running: the runtime interprets it at first, watches which parts are hot, and turns those into machine code on the fly. That is why the first seconds after start are slower than the minutes that follow.All terms 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.
| Standard | What it fixes | Where you meet it |
|---|---|---|
| PSR-4 | How a class name maps to a file path | The autoload section of every composer.json |
| PSR-3 | One logger interface | Monolog, used by Laravel and Symfony |
| PSR-7, PSR-17 | Request and response objects, and factories for them | HTTP libraries, Slim, Mezzio |
| PSR-15 | Middleware and request handlers | Middleware written once for many frameworks |
| PSR-11 | Getting a service from a container | Every dependency-injection container |
| PSR-6, PSR-16 | A detailed and a simple cache interface | Cache libraries that work in any framework |
| PSR-14, PSR-18 | Event dispatching; an HTTP client | SDKs that let you choose the HTTP client |
| PSR-20 | "Now" as a service you can replace | Tests that freeze time |
| PER Coding Style | Code layout; it replaced PSR-12 | PHP-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.
| Laravel | Symfony | |
|---|---|---|
| Stance | Decides for you: one idiomatic way to do most things, present out of the box | Gives you components to assemble and configure; less is implied |
| Data access | Eloquent, an active-record ORM: the model is the row | Doctrine, a data-mapper ORM: entities know nothing about the database |
| Releases | A major every year; bug fixes for 18 months, security fixes for 2 years | A minor every six months; an LTS (x.4) every two years, 3 years of bug fixes and 4 of security |
| Best fit | Product teams shipping features fast; small and mid-size teams | Long-lived systems, complex domains, teams that want explicit boundaries |
| Main risk | Convenience invites business logic into models and controllers until nothing is testable | Ceremony 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 ORMObject-Relational Mapping — objects instead of SQLA library that hands you language objects where the database has table rows, so you write little or no SQL. The convenience costs control: the query it generates can be far heavier than the one you would have written yourself, and you only find out under load.Full article →All terms 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 for | What goes wrong with outside input | Instead |
|---|---|---|
| unserialize( | The input chooses which classes are created, and their magic methods run — object injection | json_decode(); or allowed_classes => false |
| "SELECT … $ | SQL injection | Bound parameters |
| include $ | Runs a file the attacker named | A fixed map of allowed names |
| exec( · shell_exec( · system( | Command injection | An argument array (Symfony Process), or escapeshellarg() |
| md5( · sha1( on passwords | Cracked offline in hours | password_hash() and password_verify() |
| == on tokens and signatures | Loose comparison and a timing leak | hash_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.
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 for | What happens in worker mode | Fix |
|---|---|---|
| Request data on a singleton or a static property | The next user’s request reads it | Pass it as an argument, or reset it after every request |
| A long-lived service that received the request, user or config in its constructor | It keeps the first request’s copy for the life of the worker | Resolve it per request, or inject a resolver instead of the value |
| An array used as a cache | It grows until the worker is killed for memory | A bounded cache, or APCu or Redis |
Per-request global settings — setlocale(), date_default_timezone_set(), ini_set() | They stay set for the next request | Set them on every request, or not at all |
| A database connection opened at boot | The server closes it after an idle timeout | Reconnect 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.
| Case | What happened | What was missing |
|---|---|---|
| HipHop, HHVM and PHP 7, 2010–2019 | Facebook built its own PHP engines; PHP 7 caught up, and HHVM dropped PHP | Speed in the reference engine |
| CVE-2019-11043, Oct 2019 | A newline in a URL broke nginx’s path split and let attackers rewrite FPM’s settings | An owner for the seam between web server and FPM |
| The php-src backdoor, Mar 2021 | Two commits in the names of core developers added a remote-execution backdoor | Strong identity on commits — review caught it |
| CVE-2024-4577, Jun 2024 | A soft hyphen became a command-line flag for PHP-CGI on Windows | A supported version, and no CGI mode on the internet |
| Laravel APP_KEY, 2018–2025 | 260,000 keys on public GitHub; 600 applications open to remote code execution | Secret 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 CVECommon Vulnerabilities and Exposures — a public ID for a known vulnerabilityA public identifier for a known security flaw, such as CVE-2021-44228 for Log4Shell, so that vendors, scanners and advisories all mean the same bug. The number says a flaw exists; how dangerous it is for you depends on whether you run the affected version and expose the affected part.All terms-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).
- N+1one query per row, by accidentThe classic ORM trap: one query fetches a list, then the loop over it quietly fires one more query per item — a hundred rows become a hundred and one round trips to the database. The fix is to ask for the related data up front instead of row by row.Full article →All terms 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 — WebSocketa connection kept open both waysA connection that stays open in both directions: unlike HTTP, the server can send a message on its own without waiting to be asked. Chats and live feeds are built on it, and so is most of what looks like the page updating by itself.All terms, 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
The failures this page learns from
four of the “Five cases, and what each one was missing”- DocumentationPHP Sec Bug #78599 — env_path_info underflow in fpm_main.c can lead to RCE (opens in a new tab)PHP Bug Tracker · Sep 2019
- Articleneex/phuip-fpizdam — Exploit for CVE-2019-11043 (opens in a new tab)GitHub · Oct 22, 2019
- PostmortemNikita Popov — Changes to Git commit workflow (opens in a new tab)php.internals · Mar 29, 2021
- PostmortemNikita Popov — Update on git.php.net incident (opens in a new tab)php.internals · Apr 6, 2021
- ArticleSecurity Alert: CVE-2024-4577 — PHP CGI Argument Injection Vulnerability (opens in a new tab)DEVCORE · Jun 6, 2024
- ArticleUpdate: CVE-2024-4577 quickly weaponized to distribute “TellYouThePass” Ransomware (opens in a new tab)Imperva · Jun 2024
- ResearchExploiting Public APP_KEY Leaks to Achieve RCE in Hundreds of Laravel Applications (opens in a new tab)GitGuardian · Jul 2025
Practices and where the terms come from
what a worker forgets, the type system, and the version table- DocumentationPHP Manual — Type declarations (opens in a new tab)php.net
- DocumentationPHP Manual — set_time_limit (opens in a new tab)php.net
- DocumentationPHP — Supported Versions (opens in a new tab)php.net
- DocumentationPHP — Unsupported Branches (opens in a new tab)php.net
- StandardPHP Standards Recommendations (opens in a new tab)PHP-FIG
- DocumentationLaravel — Release Notes (opens in a new tab)laravel.com
- DocumentationSymfony releases (opens in a new tab)symfony.com
HipHop, HHVM and PHP 7
the first of the five casesResearched 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.