The first time you run php artisan octane:start and watch the same Laravel app handle ten times the throughput on the same hardware, it feels like cheating. The benchmarks pop, the dashboards calm down, and the temptation is to ship it that afternoon. Don't.

Octane gives you real performance, but it does it by changing the rules of how a Laravel request actually runs. PHP-FPM boots the framework, handles one request, and throws everything away. Octane boots the framework once and reuses the same worker process across thousands of requests. That single change is the source of every performance win and every weird production bug you're about to meet.

What Octane Actually Does

Under PHP-FPM, the lifecycle is "boot, serve, die." Every request reloads the autoloader, re-resolves the container, re-reads config, re-instantiates service providers. That's the slowest 30–60ms of a typical Laravel request, and it happens every single time.

Octane runs Laravel inside a long-lived application server. There are four supported runtimes in 2025: Swoole and Open Swoole (PHP extensions), RoadRunner (a Go binary that talks to PHP workers), and FrankenPHP (a Caddy-based server that ships with PHP embedded — currently the most ergonomic option).

Install is two lines:

Bash
composer require laravel/octane
php artisan octane:install

The installer asks which server. Pick FrankenPHP unless you have a reason not to — it's the simplest to deploy, supports HTTP/2 and HTTP/3 out of the box, and doesn't need a separate extension. Then start it:

Bash
php artisan octane:start --server=frankenphp --workers=4 --max-requests=500

Four workers, each handling up to 500 requests before being recycled. That last flag is your safety net for memory leaks, and you'll learn to love it.

State Is The Whole Game

Here's the rule that breaks people's mental models: anything you put on a singleton, a static, or a container binding stays there until the worker dies.

A common pattern under FPM looks like this:

PHP
$this->app->singleton(Tenant::class, function () {
    return Tenant::resolveFromRequest(request());
});

Under FPM that's fine — the container is fresh on every request. Under Octane, the first request that hits this worker resolves a tenant, caches it on the container, and every subsequent request on that worker gets the same tenant. Including requests from other tenants. That's not a slowdown, that's a data leak.

The fix is either to use scoped bindings (scoped() instead of singleton(), which Laravel resets between Octane requests) or to resolve request-scoped state from the request itself, never from a long-lived container binding.

PHP
// Reset every request — safe under Octane.
$this->app->scoped(Tenant::class, fn ($app) => Tenant::resolveFromRequest($app['request']));

The same logic applies to facades that hold state, models with global scopes that read from the container, and any package that "remembers" something. Audit your service providers with one question: does this object hold data that belongs to one request?

Diagram comparing two Laravel request lifecycles. Top lane: traditional PHP-FPM — boot framework, handle request, shutdown, repeat — every request pays the boot cost. Bottom lane: Octane — boot once, then a long-lived worker handles many requests with shared memory, with red warning markers showing the new responsibilities (memory leaks, request-scoped state in singletons, container bindings that persist, third-party packages with internal caches). On the right, the operational controls Octane gives you in return: max-requests recycling, octane.warm pre-warming, ticks for periodic work, and the in-memory cache.
Octane swaps boot-per-request for boot-once, which is where the speed and the new bugs both come from.

Memory Leaks Are Now Your Problem

Under FPM you could write a memory-hungry endpoint, leak a few megabytes, and never notice — the process died at the end of the request. Under Octane, that few megabytes survives. Multiply by a thousand requests and your worker is at 2 GB.

The classes of leak I see most often:

  1. Static caches that grow without bounds. Someone added static $cache = []; $cache[$key] = $value; years ago. Under FPM it was a per-request memo. Under Octane it's a process-lifetime memory leak.
  2. Logger or telemetry buffers that never flush. Some packages collect events into a buffer keyed off the request, and forget to clear it.
  3. Eloquent global scopes that hold collections. A static model collection is the easiest hot leak to write by accident.
  4. Container bindings made via closures that capture large objects. The closure holds a reference, the binding holds the closure, the worker holds the binding.

The two operational defenses are --max-requests (recycle the worker after N requests) and a memory-watching healthcheck that restarts the worker if RSS exceeds a threshold. Set both. Treat memory leaks as a real category of bug, not a "we'll get to it" item.

Things That Used To Be Free Now Cost You

A handful of habits that were harmless under FPM become hot paths under Octane.

config() lookups are still cheap, but reading a deeply nested config value 10,000 times per second is a measurable cost when nothing else has any overhead. Cache the value once. The Octane in-memory cache (Cache::store('octane')) is per-worker and lives in process memory — perfect for things like feature flags, parsed config, or anything that's stable for the worker's lifetime. Worth knowing: this cache driver is Swoole-only (it's backed by Swoole tables), so on FrankenPHP or RoadRunner you'll lean on a regular array store or just a static property instead.

OctaneServiceProvider::boot() runs once per worker, not once per request. That's where pre-warming lives. The octane.warm config array lets you list services to resolve on worker boot:

PHP
// config/octane.php
'warm' => [
    \App\Services\PricingTable::class,
    \App\Services\GeoIpDatabase::class,
],

Anything that's expensive to build and stable across requests should warm here. Anything that's per-request must not.

There's also a tick mechanism for periodic in-process work — useful for refreshing in-memory caches without hitting Redis on every request. Same caveat as the Octane cache: ticks are a Swoole-only feature.

PHP
Octane::tick('refresh-flags', fn () => FeatureFlags::refreshFromDb())
    ->seconds(30)
    ->immediate();

That fires every 30 seconds inside the worker. You don't need a separate cron entry for it.

A three-lane audit of where state lives in an Octane app. Left lane "Per-request — scoped()" lists things that must reset every request: tenant resolved from the request, current user, request ID, locale, timezone. Middle lane "Per-worker — singleton" shows long-lived bindings that persist between requests but are recycled when the worker exits: HTTP client pools, S3 clients, OpenSearch indices, in-memory feature flags. Right lane "Forever — Octane in-memory cache (Swoole only)" lists workload that survives even worker restarts via the Octane cache plus tick refreshes. A red bar at the bottom marks the failure mode for each lane: leaked tenant context, stale singleton state, and unbounded memory growth.
Where state lives now — the audit you do once before turning Octane on, and again after every memory leak.

Hot Reloading And The Local Loop

Local dev has one annoying difference from FPM: code changes don't show up until the worker restarts. Run Octane with --watch and it'll restart workers when files in app/, config/, routes/, and resources/ change. It's not as instant as FPM, but it's close enough that you stop noticing.

Bash
php artisan octane:start --server=frankenphp --watch

In CI, run your tests against PHP-FPM the same way you always have — Octane is a runtime concern, not a behavior change for the framework. If your tests pass under FPM, they'll pass under Octane unless you're leaning on per-request container resets, which is the bug you wanted to find anyway.

When Octane Earns Its Keep

Octane is a real performance improvement, but it's also a real operational responsibility. Decide based on what your bottleneck actually is.

You want Octane when PHP boot time is the dominant cost — high-throughput APIs, latency-sensitive endpoints, anything where you're paying 30ms of framework boot for 5ms of actual work. You want it when you have the engineering capacity to audit service providers, watch memory, and write request-aware code.

You don't want Octane when your bottleneck is the database or an external API — speeding up the framework boot doesn't help when you're waiting 800ms on a query. You don't want it on a small team that's still figuring out where their state lives. PHP-FPM 8.3 with OPcache and a sensible cache layer is fast enough for most CRUD apps and far less work to operate.

If you do ship Octane, the rules that hold up are simple: scope your bindings carefully, recycle workers, monitor memory, and treat every singleton as a thing that survives until you kill it. The performance is real. The new responsibilities are also real. Knowing which side of that trade you're on before you flip the switch is the whole job.

A three-panel walkthrough from local dev to a healthy Octane deploy. Left "Local code" panel — the developer edits AppServiceProvider to use $app->scoped(Tenant::class, fn (...) => Tenant::resolveFromRequest($request)) instead of singleton. Middle "Production behavior" panel — load test at 800 RPS shows steady memory per worker, no tenant bleed across requests, p95 dropping from 220ms (FPM) to 90ms (Octane). Right "Monitoring feedback" panel — Pulse memory gauge per worker plateauing flat, max-requests recycle event logged every 1000 requests, and an alert silenced because the leak was caught in code review, not at 2 AM.
What a healthy Octane rollout looks like — code change, production response, and the dashboard staying boring.