PHP forgets, Python remembers. It comes down to the execution model
The same hit counter shows 1 on every refresh in PHP and, with a single process, climbs 1, 2, 3 in Python. The reason is how long the state of the program serving your request lives: in classic PHP until the end of the request, in Python until the process ends. That decides what survives from one request to the next, and it applies to PHP in worker mode too.
The counter that cannot count
Try a small experiment. A PHP endpoint with a hit counter in a static variable, behind nginx and PHP-FPM:
<?php
// public/index.php, served by nginx + PHP-FPM
function hits(): int
{
static $count = 0;
return ++$count;
}
header('Content-Type: text/plain');
echo hits(); // 1. Refresh: 1. Refresh a hundred times: 1.
Refresh ten times. Ten ones. Now the same thing in FastAPI:
# main.py, started with: uvicorn main:app
from fastapi import FastAPI
app = FastAPI()
count = 0
@app.get("/")
def hits():
global count
count += 1
return {"count": count} # 1, 2, 3... grows with every request
With a single uvicorn process, ten refreshes give you 10. The logic is the same; the difference is one level down, in what the process does between one request and the next.
This is a demonstration with sequential, one-at-a-time requests. As a counter under concurrent traffic it does not hold up: FastAPI runs plain def endpoints in a thread pool, and count += 1 is not guaranteed to be atomic, so with several requests at once some increments can be lost.
Classic PHP: every request from scratch
PHP under PHP-FPM runs in a shared-nothing model, meaning every request gets a fresh execution environment and the previous request's script state does not carry over. Variables, objects, static class properties, singletons: all of it goes in the bin the moment the script finishes.
Picture an employee who goes home after every task and comes back the next morning remembering nothing. They get a slip of paper (the request), do the job, hand back the result (the response) and forget. They do not remember the previous customer or that they already counted yesterday, so the counter shows 1.
The PHP-FPM process does not die after the request at all. An FPM worker (a child of the master process) serves hundreds of requests in a row, until pm.max_requests tells it to retire. The default is 0, meaning the limit is off: the worker is not recycled because of request count, though a reload, a restart or the pool shrinking will end it sooner or later. But between requests PHP runs a full request shutdown: it frees the script's memory, closes ordinary connections, wipes the symbol table.
So what does OPcache do?
OPcache keeps the compiled bytecode of your scripts (the intermediate form the engine executes instead of parsing the file's text) in shared memory, so PHP does not compile the same thing on every request. OPcache holds the compiled function together with the static variable's initial value; the current value lives in the request's memory.
Preloading (since PHP 7.4) goes one step further and loads chosen classes into memory once, when FPM starts. That is code too, with one side effect: FPM will not see a change to a preloaded file until the FPM master starts over, whatever the timestamp checks say.
Where does state go, then?
Outside the process. The session (file, Redis, database) for anything tied to a user. The database for anything that has to survive a restart. Redis or Memcached for a cache shared across servers. APCu if a local cache on a single machine is enough: it is shared memory across the FPM workers, it outlives the end of the request but disappears when FPM restarts or reloads. A counter built on apcu_inc('hits') will climb until FPM starts over or APCu clears its cache for lack of space: APCu creates the missing key itself, and the increment is atomic.
Application state does not pile up in this model. Ordinary script memory is released after every request, so a forgotten array or object lives for one request at most. That does not cover leaks in extensions and native libraries. Those stay in the process, and they are exactly why the PHP manual points you to pm.max_requests.
After a code change you usually do not have to restart anything either. By default OPcache checks file timestamps (every 2 seconds), so the new version comes in on its own, though not necessarily on the very next request. With opcache.validate_timestamps off you need to clear OPcache (for example opcache_reset() called inside the FPM process) or reload FPM, and for code covered by preloading you need an FPM reload (on Debian and Ubuntu systemctl reload php8.5-fpm sends USR2, after which the master starts over and runs the preload again). One caveat: copying files one by one onto a running application can briefly mix the old version with the new one. A fresh state per request does not make that an atomic deploy; you need a separate mechanism for switching versions.
Python: the process lives long
Gunicorn and uvicorn work differently. You start a process, that process imports your module once, builds the application object once and from then on does nothing but serve requests. The line count = 0 ran exactly once, and every subsequent request adds to the same variable in the same memory.
In Python the process simply stays: the database connection and every object put down "just for a moment" in a global variable are still in memory.
The catch: there are several workers
Run uvicorn main:app --workers 4 (or gunicorn -w 4 -k asgi main:app) and send a few requests from a terminal: for i in 1 2 3 4 5 6; do curl -s localhost:8000/; echo; done. Each curl call is a separate connection, so it can land on a different worker, and the counts start jumping around, for example 1, 1, 2, 1, 3, 2. Each worker is a separate process with its own copy of the module and its own count. The workers share one listening socket and accept connections as they come, so you land here one time and there the next. And after a restart all four counters go back to zero. A browser may not show you this: if it reuses the same TCP connection to uvicorn (keep-alive; 5 seconds by default in plain uvicorn, while under gunicorn with an ASGI worker its own keepalive applies, 2 seconds by default; the sync worker closes the connection after every response), subsequent requests go to the same worker and the counter simply climbs. Behind a reverse proxy there is also the proxy's own layer of connections.
Without --workers, a WEB_CONCURRENCY variable or a config file of your own, gunicorn starts a single synchronous worker (--workers 1, class sync). That worker speaks WSGI, and FastAPI is an ASGI application, which is why gunicorn needs an ASGI worker: built in as -k asgi since version 24, before that -k uvicorn_worker.UvicornWorker from the uvicorn-worker package. Uvicorn in the same situation runs a single process with an asyncio loop. When there are several processes, each has its own copy of the global variable.
The traps of a long-lived process
The counter hurts nobody. The same mechanism applied to user data looks like this:
# auth.py: "convenient" access to the current user
# (sketch: app, users and User come from the rest of the app;
# users.from_header() parses "Bearer <token>", returns None if invalid)
current_user = None
@app.middleware("http")
async def load_user(request, call_next):
global current_user
header = request.headers.get("authorization")
if header:
current_user = users.from_header(header) # no else branch: nobody clears it
return await call_next(request)
@app.get("/me")
def me():
return {"email": current_user.email}
# Request with a token: fine. The next request WITHOUT a token on the
# same worker gets the e-mail of the last user that worker served.
In a start-from-scratch model this code would blow up on the first anonymous request: None, exception, log entry, all visible right away. Under a long-lived process the exception only fires when current_user is None: on a fresh worker or after a request with an invalid token. Once the worker has served someone with a token, the endpoint returns that person's e-mail. The error log has no trace of it, the access log shows a plain 200.
One worker does not mean one request at a time, either. Async middleware interleaves with other requests on the event loop, and FastAPI runs endpoints declared with a plain def in a thread pool. Two requests with different tokens arrive at once. It can happen that the first one's middleware sets user A, the second one's overwrites it with user B before the first endpoint reads it, and that endpoint returns B's e-mail. Adding else: current_user = None only closes the first case. The race goes away once you take the user out of the global, for example with a dependency that reads the token from the current request:
# sketch, same assumptions as above
from fastapi import Depends, HTTPException, Request
def get_current_user(request: Request) -> User:
header = request.headers.get("authorization")
user = users.from_header(header) if header else None
if user is None:
raise HTTPException(status_code=401, headers={"WWW-Authenticate": "Bearer"})
return user
@app.get("/me")
def me(user: User = Depends(get_current_user)):
return {"email": user.email} # belongs to this request only
If middleware should resolve it once for every endpoint, the place for it is request.state, not a module variable.
Memory is a separate problem. A list you append to "temporarily", a dict used as a cache with no size limit, an object held by reference in a global. CPython frees unused objects while the process runs (reference counting plus a garbage collector for cycles), but as long as a global list or dict holds the objects, they stay reachable and cannot be freed. If such a collection grows without a limit, the process's memory grows with it. That is why gunicorn has --max-requests (default 0, meaning off): after N requests gunicorn restarts the worker. The leak stays; the restart just returns the memory every so often.
Database connections behave similarly. A pool created at process start lives as long as the process, and its connections serve request after request. Meanwhile the database may restart or a firewall may close an idle session, and the request that checks out such a connection gets, with Postgres, something like "server closed the connection unexpectedly". When a firewall silently drops the session, the request may simply hang until a timeout. For the first case there is pool_pre_ping=True in SQLAlchemy: it tests the connection when it is checked out of the pool, though it does not protect against a drop in the middle of a running transaction. Against a silent firewall the ping hangs just the same; there pool_recycle shorter than the firewall's timeout (it replaces an old connection the next time it is checked out of the pool) or TCP keepalive helps. Other ORMs and connection pools have similar mechanisms.
On deploy you put new code on disk, while the process has the old code in memory. Until you send HUP to the gunicorn master (the master starts new workers with the new code and gracefully shuts down the old ones, unless the app runs with --preload, in which case they inherit the old code from the master) or restart the service, production serves the version from before the deploy.
PHP can do this too (and falls into the same holes)
PHP can run long-lived processes too. In PHP you will most often meet three servers or extensions and one layer on top of them. Swoole (and its fork Open Swoole) is a PHP extension with its own HTTP server and event loop. RoadRunner is an application server written in Go that keeps a pool of PHP processes and feeds them requests over pipes. FrankenPHP is PHP embedded in the Caddy server, officially supported by the PHP Foundation since 2025, with a worker mode in which your own script runs the frankenphp_handle_request() loop. Laravel Octane is the layer that puts a Laravel application on any of the three with one command: php artisan octane:start --server=frankenphp.
The mechanics are the same as in Python: the application boots once, then serves requests in a loop. Container, configuration, routing, connections, all of it stays in memory. That is usually where the speed comes from, because the cost of booting the framework on every request goes away, though how much it buys a given application has to be measured.
For the same reason, the same traps show up. A static property holding a counter starts climbing. A singleton you injected a Request object into via the constructor can hold the first user's request for the rest of the worker's life if nothing refreshes that dependency. Octane resets a fair amount of framework state between requests on its own, but the documentation notes it does not always know how to reset state created by your application, and static properties and singletons are exactly that. The documentation has a dedicated section on this and a dedicated safety valve: the worker is recycled every 500 requests (--max-requests=500, the default). RoadRunner has pool.max_jobs (an execution limit) and, separately, pool.supervisor.max_worker_memory (a memory limit), FrankenPHP's example worker script reads MAX_REQUESTS.
// Same bug as in Python, this time in Laravel under Octane
// (sketch: User::fromToken() stands for your own token lookup)
final class CurrentUser
{
public static ?User $user = null;
}
// middleware: sets the user when a token is present, never clears it
if ($token = $request->bearerToken()) {
CurrentUser::$user = User::fromToken($token);
}
// controller
return ['email' => CurrentUser::$user?->email];
// PHP-FPM: a request without a token gets null.
// Octane: it gets the last user served by the same worker.
Under Octane this code throws no exception, so you have to find it by review: look for static properties set in middleware.
From the server side
The nginx config shows a difference too. PHP-FPM speaks FastCGI (a protocol between the web server and the application process, older than most of your dependencies), so nginx hands it the request over a socket with fastcgi_pass. Gunicorn, uvicorn, RoadRunner and Swoole expose plain HTTP, so nginx acts as a reverse proxy with proxy_pass. FrankenPHP is a server in its own right (it is Caddy), so nginx in front of it is an option, not a requirement.
| PHP-FPM | Python (gunicorn / uvicorn) | PHP in worker mode (Octane, FrankenPHP, RoadRunner, Swoole) | |
|---|---|---|---|
| Application server | php-fpm (master + workers) | gunicorn (arbiter + workers, for ASGI with -k asgi) or uvicorn --workers N | php artisan octane:start, frankenphp php-server --worker public/worker.php, rr serve |
| Nginx talks over | fastcgi_pass unix:/run/php/php8.5-fpm.sock | proxy_pass http://127.0.0.1:8000 | proxy_pass http://127.0.0.1:8000 (FrankenPHP can stand without nginx) |
| Reload after deploy | usually unnecessary; systemctl reload php8.5-fpm with validate_timestamps off, also with preloading (service name as on Debian and Ubuntu) | kill -HUP <master> (gunicorn without --preload, uvicorn only with --workers) or a service restart, mandatory | php artisan octane:reload, rr reset, FrankenPHP admin endpoint (with the API enabled) or a restart, mandatory |
| Deploy | swap the files, OPcache picks up the change itself after a few seconds; with symlinked releases the realpath cache adds a delay (reload FPM or use $realpath_root in nginx) | new code on disk changes nothing until a reload | same as Python |
| State in memory between requests | none (apart from APCu and persistent connections) | yes, separate in each process | yes, separate in each worker |
| Safety valve for leaks | pm.max_requests (default 0) | gunicorn --max-requests, uvicorn --limit-max-requests (both off by default) | Octane --max-requests (default 500), RoadRunner max_jobs |
| What sits in the cache | bytecode (OPcache) | modules in process memory | the booted application in worker memory; bytecode only with OPcache enabled for that SAPI (on CLI opcache.enable_cli=1) |
What follows from this
Write the application as if every request could land in a different process, on a different machine, right after a restart. User state in the session or on the current request object, application state in the database, shared cache in Redis. In globals and static properties keep what is common to every request: configuration, a compiled regular expression, an HTTP client. If you keep something that changes there, a local cache for instance, answer three questions yourself: who clears it, when it refreshes and what happens with two requests at once.
Under PHP-FPM you get most of that rule for free. In worker mode, data left in a global can reach the next request handled by the same worker.