Stateless Laravel Core
On this page
- At a glance
- The idea in brief
- A. The node holds nothing
- B. The core delegates
- C. Nothing fails in silence
- Why I chose it
- My conventions
- A. Anything that can block goes to a job
- B. Files never touch the node
- C. State lives in Redis and the database
- D. One kind of token, whoever the provider
- E. Master data is cached, and only where it pays
- F. A fast runtime, built the same way every time
- G. One container, supervised
- H. Health that tells the truth
- I. Logs go to a collector, not to a file
- J. Client identity only from trusted proxies
- K. A server-rendered front page, a separate application
- L. Configuration that survives caching and upgrades
- M. Tests that need no infrastructure
- N. Every dependency behind one door
- How I check a Laravel service against it
- Where I do not use it
- When the work is mine alone
- When the whole team knows a compiled language
- References
- Terms
Stateless Laravel Core is how I use Laravel when it is the heart of a platform built by a team: the application that owns the users, the organisations, and the records, while specialised services do the heavy work around it. Laravel itself — created by Taylor Otwell and shaped by its community — already has a settled structure, and I follow it. This article is about the decisions I add on top of it, so that the core can be copied onto any number of nodes, never makes a user wait for slow work, and can always be observed.
It is one of the architectures I use, and I choose it for people as much as for code. When a team will maintain the core, I want it in a language and framework most of them already know.
At a glance
| Origin | The Laravel framework, with my own conventions for running it as a stateless core |
|---|---|
| I use it for | The core of a platform maintained by a team: many domains, many users, many integrations |
| I do not use it for | Work fully under my own control, or teams that are all fluent in a compiled language |
| Languages | PHP, with Laravel |
The idea in brief
A. The node holds nothing
A running copy of the application keeps no files, no sessions, no counters, and no caches of its own. Everything that must survive a request lives in the database, in Redis, or in object storage, so a node can be replaced, duplicated, or killed at any moment without losing anything.
B. The core delegates
Work that is slow, specialised, or owned by another discipline — signing, rendering, storage, certificates — belongs to a dedicated service or to a background job. The core decides and records; it does not do everything itself.
C. Nothing fails in silence
Every request, every job, and every call to another service leaves a trace in one telemetry pipeline, and the core is designed so that a problem shows up there before a user has to report it.
Why I chose it
The team comes first. A core that many people will read and change should be written in a language most of them already work in. PHP is interpreted, widely known, and quick to iterate on, and Laravel gives everyone the same conventions for routing, validation, queues, and the database, so a new teammate can find their way without me.
The framework is settled, so my effort goes into running it well. I do not redesign Laravel's structure. What decides whether a Laravel application stays healthy in production is mostly outside that structure: where state lives, what happens to slow work, how it is built and deployed, and how it can be watched. Those are the decisions I make deliberately.
A stateless core scales by copying. If a node holds nothing, adding capacity means starting another one, and a crash means nothing more than a restart. That property is worth more to me than any single optimisation inside one process.
My conventions
A. Anything that can block goes to a job
Sending email, signing a document, generating a thumbnail, and calling back another system all run as queued jobs. A request that starts long work answers at once with a status that says the work is under way, separate from the status that says it is done. Jobs receive identifiers rather than whole records and load fresh data when they run, secrets are encrypted before they enter the queue, and the data a job depends on is validated before it is queued, so a job never starts from bad input. A failed side task, such as a webhook to a client, never undoes the main work.
B. Files never touch the node
The application does not write files to local disk and does not carry an S3 client. All storage goes through a separate storage service, so the application holds no storage credentials and a change of storage vendor does not reach it. Browsers upload directly to storage through short-lived links, so file bytes never pass through PHP. Uploads land in a temporary area and are promoted only when they become part of a record, while the storage itself expires whatever was never promoted. Before anything is deleted, one place in the code answers whether that object is still referenced.
C. State lives in Redis and the database
Access tokens are stateless JWTs signed with a private key. Refresh tokens live in Redis, rotate on every use, and travel in an httpOnly cookie; revoked tokens and signed-in devices are tracked there too. Rate-limit counters are kept in Redis rather than the database, and certificates or key sets fetched from other services are cached in Redis rather than saved as files, so no node needs anything on its own disk.
D. One kind of token, whoever the provider
Users can sign in through external identity providers, but the core never lets their tokens into the rest of the system. It completes the external login, validates the provider's identity token against that provider's published keys, and then issues its own token in the same format as every other login. Accounts are linked by the provider's subject identifier, never by an unverified e-mail address. The core publishes its own signing keys, so other services can verify its tokens without calling it.
E. Master data is cached, and only where it pays
Reference data that is read constantly and changed rarely is cached in Redis, refreshed when a record is saved or deleted, and read back with all its type conversions intact. If Redis is unavailable, the code falls back to the database instead of failing. Not everything is cached: a large data set I once cached turned out to be read only during rare administrative edits, so I removed the cache again. I measure before I cache.
F. A fast runtime, built the same way every time
The application runs on FrankenPHP in worker mode, which keeps the framework booted between requests, with its built-in web server serving plain HTTP behind the reverse proxy that terminates TLS. The PHP settings that matter, such as upload limits, are set explicitly, because a runtime image without its own settings silently falls back to defaults that are far too small. Images are built in stages, with dependencies cached in their own layers and the application code copied last; an image is built once from a release tag, only after the tests pass, and the same image is promoted from environment to environment.
G. One container, supervised
Each container runs the web server and a pool of queue workers side by side under a process supervisor, so one image is the whole application and scaling it scales both. Workers are recycled after a number of jobs, an amount of time, or a memory ceiling, which keeps long-lived PHP processes from slowly growing. At start-up the container warms the configuration, route, and view caches, but it never runs database migrations: two replicas starting together would race, so migrating is a separate deployment step.
[program:web]
command=frankenphp run --config /etc/caddy/Caddyfile
autorestart=true
[program:queue]
command=php artisan queue:work --tries=3 --max-jobs=100 --max-time=3600 --memory=512
numprocs=8
process_name=%(program_name)s_%(process_num)02d
user=www-data
autorestart=true
stopasgroup=true
killasgroup=trueH. Health that tells the truth
Health is checked on two levels. Inside the image, a check confirms that the supervisor, the web server, and at least one worker are running. Above it, an aggregate readiness endpoint checks the database, Redis, storage, and every service the core depends on, reports each one separately, and answers 503 if any is down. It is exempt from rate limiting, so the platform's own checks never lock it out. Automation that deploys or restarts the core can only be trusted if this answer can be trusted.
I. Logs go to a collector, not to a file
The application does not rely on Laravel's log files on local disk. Logs, traces, and metrics go to an OpenTelemetry collector:
- every request gets a time-ordered UUIDv7 id, and its log entries are collected during the request and sent after the response, so telemetry never delays the user;
- Laravel's own logger is bridged into the same pipeline, with a guard against recursion when the collector itself is unreachable;
- buffers are flushed when a job finishes, fails, or its worker stops, because the logs of a job that was killed are exactly the ones needed most;
- exceptions are recorded centrally, and their messages never reach a client;
- API routes always answer in JSON, never with a redirect, and validation failures are logged by field name only, never by value;
- secrets are redacted in one place, audit events are written once as log records, and metrics use delta temporality;
- the trace of a request continues into every service it calls and into the jobs it queues.
The rule underneath is simple: telemetry fails open and security fails closed. A broken collector must never break a request; a doubtful check must never let one through.
J. Client identity only from trusted proxies
The client's address is taken from forwarded headers only when the request arrives from a proxy in an explicit list of address ranges, never from any proxy at all. The raw forwarded chain is kept as forensic evidence but never used to decide anything, and the same attribute names are used by every service, so one query follows a client across all of them.
K. A server-rendered front page, a separate application
The public home page is rendered on the server, so search engines can index it without running JavaScript. The application people actually work in is a single-page application in its own repository, with its own build and release cycle, which keeps both easier to maintain and lets the interface evolve without touching the core.
L. Configuration that survives caching and upgrades
Application code reads settings through configuration files, never directly from the environment, because once configuration is cached, direct environment reads return nothing. Each external dependency has its own configuration file, with comments that explain why each value is what it is. Laravel's own configuration blocks are left as the framework ships them, even where the project does not use them, so upgrading the framework stays easy. Environment files are mounted when the container starts and never baked into an image.
M. Tests that need no infrastructure
Tests run against an in-memory database, in-memory caches and rate limiters, a synchronous queue, and with telemetry and external checks switched off. They need no database server, no Redis, and no storage, run in seconds instead of waiting on network timeouts, and cannot leak state from one test into the next.
N. Every dependency behind one door
Each external service is reached through one class of its own, with its own configuration and its own timeout, and its failures are translated into a small family of exceptions — not found, invalid request, unavailable — that the rest of the core can act on. When a dependency lacks something the core needs, I ask for it upstream rather than patch around it locally.
| Concern | Where it lives |
|---|---|
| Slow or external work | queued jobs |
| Files | object storage, through a storage service and short-lived links |
| Sessions, tokens, counters, caches | Redis |
| Records and lookup data | the relational database |
| Logs, traces, metrics, audit | an OpenTelemetry collector |
| Schema changes | a deployment step, never the container start |
How I check a Laravel service against it
| Area | Question I ask | Source |
|---|---|---|
| State | Could this node be deleted right now without losing anything? | the node holds nothing |
| Files | Does any code write to local disk or hold storage credentials? | files never touch the node |
| Blocking work | Does any request wait for email, signing, rendering, or a call to another system? | anything that can block goes to a job |
| Queue | Do jobs carry identifiers, and secrets only in encrypted form? | anything that can block goes to a job |
| Tokens | Does every login end in the core's own token? | one kind of token |
| Runtime | Are runtime settings explicit, and is the image built once and promoted? | a fast runtime |
| Start-up | Does the container start without running migrations? | one container, supervised |
| Health | Does readiness check every dependency and answer 503 honestly? | health that tells the truth |
| Telemetry | Can a failure in a job or another service be found without logging into a server? | logs go to a collector |
| Configuration | Does any application code read the environment directly? | configuration that survives caching |
Where I do not use it
When the work is mine alone
Laravel is a choice made for a team. When a piece of work is entirely under my control, with no one else who has to read or change it, I am free to choose a compiled language and a structure that suits the problem rather than the team.
When the whole team knows a compiled language
If everyone who will maintain a service understands Rust, Go, or another compiled language and the context it brings, I build it in that language instead. If even one member of the team does not, the core stays in Laravel, because a codebase only part of the team can change is a codebase the team cannot really own. The services I build in Rust follow one of these shapes:
References
The Laravel documentation, for the framework's own structure, queues, caching, and configuration caching. Adam Wiggins, The Twelve-Factor App (2011): stateless processes, configuration in the environment, logs as event streams, disposable processes, and one build promoted through every environment are close relatives of the conventions above. I mention them as a resemblance, not as the source; the conventions here grew out of running one platform in production.
Terms
- Stateless node
- A running copy of an application that keeps nothing between requests, so it can be replaced or duplicated freely.
- Queued job
- A unit of work recorded in a queue and carried out by a background worker, instead of while the user waits.
- Worker mode
- A way of running PHP in which the application is booted once and then serves many requests, instead of booting again for each one.
- Process supervisor
- A program that starts several processes in one container, restarts them when they stop, and stops them together.
- Fail open, fail closed
- Whether a component lets work continue (open) or stops it (closed) when it cannot do its own job.
Technologies covered
- PHP
- Laravel
- FrankenPHP