Skip to content
Architecture Programming

Stateless Laravel Core

By Romi Fadli, S.Kom. 13 min read Software Design
On this page
Three identical empty application nodes stacked as copies, connected to a database, a cache, object storage, a pool of queue workers, and a telemetry collector that hold all the state

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

OriginThe Laravel framework, with my own conventions for running it as a stateless core
I use it forThe core of a platform maintained by a team: many domains, many users, many integrations
I do not use it forWork fully under my own control, or teams that are all fluent in a compiled language
LanguagesPHP, with Laravel
Where Stateless Laravel Core sits among the architectures I use.

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.

ini
[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=true

H. 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.

ConcernWhere it lives
Slow or external workqueued jobs
Filesobject storage, through a storage service and short-lived links
Sessions, tokens, counters, cachesRedis
Records and lookup datathe relational database
Logs, traces, metrics, auditan OpenTelemetry collector
Schema changesa deployment step, never the container start
Where each concern lives in a stateless Laravel core.

How I check a Laravel service against it

AreaQuestion I askSource
StateCould this node be deleted right now without losing anything?the node holds nothing
FilesDoes any code write to local disk or hold storage credentials?files never touch the node
Blocking workDoes any request wait for email, signing, rendering, or a call to another system?anything that can block goes to a job
QueueDo jobs carry identifiers, and secrets only in encrypted form?anything that can block goes to a job
TokensDoes every login end in the core's own token?one kind of token
RuntimeAre runtime settings explicit, and is the image built once and promoted?a fast runtime
Start-upDoes the container start without running migrations?one container, supervised
HealthDoes readiness check every dependency and answer 503 honestly?health that tells the truth
TelemetryCan a failure in a job or another service be found without logging into a server?logs go to a collector
ConfigurationDoes any application code read the environment directly?configuration that survives caching
The questions I check a Laravel core against.

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

About the author

Photo of Romi Fadli

Romi Fadli, S.Kom.

Solution Architect · Backend Engineer · Embedded Systems

In my day job I work as a solution architect. I design the system, lead the team that builds it — quality assurance, backend developers, a UI developer, DevOps and DevSecOps — and write the most critical modules myself, such as a certificate authority, a long-term PDF signer, and the core API of a personnel system.

I work across the whole stack that a running system actually needs. On the hardware side that means PCB design, microcontrollers, and embedded firmware — including RISC-V boards running FreeRTOS and Embassy, speaking MQTT over mutually authenticated TLS on a microcontroller with 64 KB of SRAM.

Most of my day-to-day work is software: Rust backends and API modules built on Clean Architecture, an OAuth2/OpenID Connect authorization server written from scratch, PAdES document signing, and certificate authorities — root CAs, timestamping authorities, and OCSP responders. I also build the frontends that sit on top of them.

Running all of that means infrastructure: Docker Compose stacks, networking, Mikrotik and RADIUS, observability with OpenTelemetry, and a steady habit of writing command-line tools and utilities for Linux when the existing ones do not fit.

Share

Enjoyed this?

Get an email when a new article is published.

Referenced by

The core API at the centre, fed by storage, address, face and preview services, with documents sent across a boundary to an external signing platform

The Laravel core of a civil-service personnel system: employee records and career histories, self-service proposals with review, salary-step, promotion and retirement decisions, rules taken from regulations, a versioned organisation chart, and access scoped down to a single unit.

11 min read Read more →
A central core holding people, organisations, documents, and certificates, connected to eight surrounding services for storage, addresses, certificates, timestamps, the root, signing, thumbnails, and face challenges

The Laravel core of a document-signing platform: it owns users, identities, organisations, client systems, documents and their signed versions, stamps, and certificates, and orchestrates the services that sign, timestamp, render, and store — without ever holding a file.

9 min read Read more →

Related Articles

The core API at the centre, fed by storage, address, face and preview services, with documents sent across a boundary to an external signing platform

The Laravel core of a civil-service personnel system: employee records and career histories, self-service proposals with review, salary-step, promotion and retirement decisions, rules taken from regulations, a versioned organisation chart, and access scoped down to a single unit.

11 min read Read more →