yangusik / laravel-spawn
Laravel adapter for PHP TrueAsync — async HTTP server with coroutine-per-request isolation
Requires
- php: ^8.6
- ext-pcntl: *
- ext-pdo: *
- laravel/framework: ^12.0 || ^13.0
Requires (Dev)
- barryvdh/laravel-debugbar: ^4.2
- inertiajs/inertia-laravel: ^3.0
- laravel/socialite: ^5.26
- laravel/telescope: ^5.19
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.0
- spatie/laravel-permission: ^7.2
This package is auto-updated.
Last update: 2026-08-19 18:51:19 UTC
README
Laravel adapter for PHP TrueAsync — a PHP fork with a native coroutine scheduler and async I/O. Think Laravel Octane, but instead of Swoole or RoadRunner the runtime is TrueAsync.
One worker. Many requests. Zero threads. Each HTTP request runs in its own coroutine with isolated state — no shared memory, no leaks between requests.
How it works
- Each request = a separate coroutine with its own
Scope - Request-scoped services (
auth,session,cookie) are isolated viacoroutine_context()andrequest_context()(if use True Async Server) - PDO Pool transparently gives each coroutine its own database connection and returns it when the coroutine ends
- No container cloning — isolation is handled at the coroutine level, not by copying the entire app
Requirements
- PHP TrueAsync fork 8.6+
- Laravel 12+
- For FrankenPHP mode:
trueasync/php-true-async:latest-frankenphpDocker image
Installation
composer require yangusik/laravel-spawn
Via git repository:
"repositories": [ { "type": "vcs", "url": "https://github.com/yangusik/laravel-spawn" } ], "require": { "yangusik/laravel-spawn": "dev-master" }
Via local path:
"repositories": [ { "type": "path", "url": "../laravel-true-async" } ], "require": { "yangusik/laravel-spawn": "*" }
Then run composer update.
The service provider is auto-discovered by Laravel.
Replace the Application class in bootstrap/app.php:
- $app = new Illuminate\Foundation\Application( + $app = new Spawn\Laravel\Foundation\AsyncApplication( $_ENV['APP_BASE_PATH'] ?? dirname(__DIR__) );
This is required for per-coroutine isolation of auth, session, and request. Without it the service adapters register correctly but state isolation does not work.
Publish the config:
php artisan vendor:publish --tag=async-config
Servers
True Async Server
Production-ready adapter using True Async Server in async worker mode.
Check config/async.php!
php artisan async:serve --host=0.0.0.0 --port=8080 --workers=1
Dev server
Simple TCP socket server for local development. Analogous to php artisan serve.
php artisan async:dev --host=0.0.0.0 --port=8080
FrankenPHP
Production-ready adapter using FrankenPHP in async worker mode.
Requires the trueasync/php-true-async:latest-frankenphp Docker image.
php artisan async:franken --host=0.0.0.0 --port=8080 --workers=1 --buffer=1
Docker quick start
TrueAsyncServer (better)
services: app: image: trueasync/php-true-async:latest working_dir: /app command: php artisan async:serve # check config/async.php! ports: - "8080:8080" volumes: - .:/app environment: APP_ENV: local DB_CONNECTION: pgsql DB_HOST: db DB_PORT: 5432 DB_DATABASE: laravel DB_USERNAME: laravel DB_PASSWORD: secret
Dev server
services: app: image: trueasync/php-true-async:latest working_dir: /app command: php artisan async:dev --host=0.0.0.0 --port=8080 ports: - "8080:8080" volumes: - .:/app environment: APP_ENV: local DB_CONNECTION: pgsql DB_HOST: db DB_PORT: 5432 DB_DATABASE: laravel DB_USERNAME: laravel DB_PASSWORD: secret
FrankenPHP
services: app: image: trueasync/php-true-async:latest-frankenphp working_dir: /app command: php artisan async:franken --host=0.0.0.0 --port=8080 --workers=1 --buffer=1 ports: - "8080:8080" volumes: - .:/app environment: APP_ENV: local DB_CONNECTION: pgsql DB_HOST: db DB_PORT: 5432 DB_DATABASE: laravel DB_USERNAME: laravel DB_PASSWORD: secret
Per-request services
A singleton lives as long as the worker and is shared by every request the worker is serving at that moment, so a service holding request state has to be declared per-request. The container gives every request its own instance, built on first resolve and kept in that request's coroutine context; its facade resolves per request as well.
Three registrations declare one, and the container treats them alike.
Laravel's own scoped() — the same call Octane packages already use:
$this->app->scoped(TenantContext::class, fn ($app) => new TenantContext($app['request']));
The config list, for a package whose registration you do not control:
// config/async.php 'scoped_services' => [ \SomePackage\Manager::class, ],
scopedSingleton(), when a shared binding has to stay in place: the factory here is
used for the per-request build only, and whatever bootstrap resolved through the original
binding stays reachable through scopedPrototype(). That is how this package scopes url
without losing URL::forceScheme() and the other setters a provider called at boot.
$this->app->scopedSingleton('url', function ($app) { $url = clone $app->scopedPrototype('url'); $url->setRequest($app->make('request')); return $url; });
Per-request does not fit everything. A service the framework or a package captures —
kept in a static, taken in another singleton's constructor — must stay one object, or the
capture pins one request's copy for the life of the worker. The view factory is the case:
templates receive it as $__env, Component::$factory caches it, MailManager keeps it.
It stays shared, and its render state moves into the request instead.
Boot-time configuration does not follow on its own. A provider calling
Auth::extend() or Session::extend() configures the boot-time instance, and a fresh
per-request instance starts without it. Register a seeder to carry it across:
$this->app->scopedSeeder('session', function ($fresh, $prototype) { // copy what the provider registered on $prototype onto $fresh });
Turn on async.diagnostics to have the worker report, at start-up, every per-request
service that bootstrap configured and no seeder carries over — and every shared service
holding an object that belongs to one request.
Configuration
config/async.php:
If you use TrueAsyncServer, pls read docs: Configuration
<?php return [ /* |-------------------------------------------------------------------------- | Scoped Services |-------------------------------------------------------------------------- | | Services listed here will be resolved per-coroutine instead of shared | as singletons. Use this for third-party packages that hold request state. | | Example: | \SomePackage\Manager::class, | */ 'scoped_services' => [], /* |-------------------------------------------------------------------------- | Database Connection Pool |-------------------------------------------------------------------------- | | When the async server is running, each coroutine gets its own | DatabaseManager instance. The underlying PDO connections are managed | by TrueAsync's built-in pool, so physical connections are reused | across coroutines instead of creating a new one per request. | */ 'db_pool' => [ 'enabled' => true, 'min' => 2, 'max' => 10, 'healthcheck_interval' => 30, // seconds, 0 = disabled ], /* |-------------------------------------------------------------------------- | Async Server |-------------------------------------------------------------------------- | | Configuration for the TrueAsync HTTP server. The server can listen | on multiple interfaces and protocols simultaneously. | */ 'server' => [ /* |-------------------------------------------------------------------------- | Listeners |-------------------------------------------------------------------------- | | Define the TCP interfaces the server should bind to. Each listener | can use a specific HTTP protocol version and optional TLS. | | Available protocols: auto, http1, http2, http3 | */ 'listeners' => [ [ 'host' => env('ASYNC_HOST', '0.0.0.0'), 'port' => (int) env('ASYNC_PORT', 8080), 'tls' => (bool) env('ASYNC_TLS', false), 'protocol' => env('ASYNC_PROTOCOL', 'auto'), // auto, http1, http2, http3 ], ], /* |-------------------------------------------------------------------------- | Workers |-------------------------------------------------------------------------- | | Number of worker threads for the multi-threaded server command | (async:workers). 0 means auto-detect based on CPU core count. | */ 'workers' => (int) env('ASYNC_WORKERS', 0), /* |-------------------------------------------------------------------------- | TLS Certificates |-------------------------------------------------------------------------- | | Absolute paths to the TLS certificate and private key. Used when | at least one listener has 'tls' => true. | */ 'tls_cert' => env('ASYNC_TLS_CERT', '/certs/server.crt'), 'tls_key' => env('ASYNC_TLS_KEY', '/certs/server.key'), /* |-------------------------------------------------------------------------- | Socket & HTTP Settings |-------------------------------------------------------------------------- */ 'backlog' => (int) env('ASYNC_BACKLOG', 2048), 'compression' => (bool) env('ASYNC_COMPRESSION', true), 'max_body_size' => (int) env('ASYNC_MAX_BODY_SIZE', 32 * 1024 * 1024), 'read_timeout' => (int) env('ASYNC_READ_TIMEOUT', 60), 'write_timeout' => (int) env('ASYNC_WRITE_TIMEOUT', 60), /* |-------------------------------------------------------------------------- | Static File Handlers |-------------------------------------------------------------------------- | | Map URL prefixes to local directories for direct static file serving | bypassing the Laravel kernel. | | Example: | [ | 'prefix' => '/assets/', | 'root' => public_path('assets'), | 'etag' => true, | 'precompressed' => ['br', 'gzip'], | ] | */ 'static_handlers' => [], /* |-------------------------------------------------------------------------- | Hot Reload |-------------------------------------------------------------------------- | | Configure hot reload for the TrueAsync server during development. | When enabled, the server watches the configured paths and automatically | reloads whenever a file changes. | | Paths are relative to the application base path. | */ 'hot_reload_paths' => [ 'app', 'bootstrap', 'config', 'resources', 'routes', ], ], ];
Benchmarks (Obsolete use TrueAsyncServer)
Check results in HttpArena
Load: 840 req/s /hello + 360 req/s /test = 1 200 req/s total · constant-arrival-rate · 30s · 12 workers each · WSL2 (Linux 6.6 on Windows)
| Metric | PHP-FPM (12w) | Octane Swoole (12w) | TrueAsync-Franken (12w) |
|---|---|---|---|
| Target rate | 1 200 req/s | 1 200 req/s | 1 200 req/s |
| Actual throughput | ~200 req/s | ~752 req/s | ~1 118 req/s |
| Dropped iterations | ~28 000 | ~5 000 | 20 |
| Avg latency | ~4 000ms | ~880ms | 13ms |
| p95 latency | ~5 000ms | 2 320ms | 21ms |
| p95 < 200ms | ✗ | ✗ | ✓ |
| Failed requests | 0% | 0% | 0% |
| DB connections (peak) | — | — | 120 |
Why TrueAsync wins on DB-bound load
| PHP-FPM | Octane Swoole | TrueAsync-Franken | |
|---|---|---|---|
| Request model | Process per request | 1 process = 1 request at a time | 1 worker = N coroutines |
| DB I/O | Blocking (new conn each req) | Blocking (PDO synchronous) | Non-blocking (coroutine yield) |
| Memory model | Stateless | Long-lived process | Long-lived process + coroutine context isolation |
| App bootstrap | Every request | Once per worker | Once per worker |
Swoole keeps the app in memory (avoids bootstrap cost) but PDO is still synchronous — a worker blocked on a DB call cannot accept another request. TrueAsync yields the coroutine on every DB call, so one worker handles hundreds of concurrent DB-bound requests without blocking.
Notes
- Each adapter has its own PostgreSQL instance on a separate port to avoid interference
APP_DEBUG=falsein all setups for fair comparison- OPcache enabled in PHP-FPM
max_connections=500in all PostgreSQL instances- Absolute numbers will be higher on bare metal (benchmarks run on WSL2)
Full benchmark: ta_benchmark
Raw PHP — TrueAsync vs Swoole (no framework, no I/O)
On pure CPU-bound workloads both servers cap at the same throughput (~10k req/s). With optimal Swoole config (ZTS, 16 reactor threads) Swoole is ~1.6x faster on P95 latency due to FrankenPHP's Go↔PHP boundary overhead (futex synchronization). On I/O-bound workloads this overhead is negligible.
Metrics
The running server's counters — requests, responses by status class, live connections per
protocol, HTTP/2 and streaming traffic — are readable from the application under
async:serve:
use Spawn\Laravel\Server\ServerMetrics; Route::get('/metrics', fn () => response( app(ServerMetrics::class)->toPrometheus(), 200, ['Content-Type' => 'text/plain; version=0.0.4'], ));
The package publishes no endpoint of its own, so the URL and who may read it stay yours.
totals(), workers() and latency() return the same numbers as arrays. The counters are
always incremented; async.server.stats decides whether the aggregate can be read, and it
is on by default. See docs/METRICS.md.
Sessions
Database sessions (built-in fix)
The package automatically replaces Laravel's DatabaseSessionHandler with an async-safe version that uses upsert instead of INSERT + catch + UPDATE.
In a standard async server the HTTP response is sent before kernel->terminate() writes the session. If the client immediately sends the next request with the same cookie, two coroutines can race to INSERT the same session ID — causing duplicate-key warnings in the stock handler. The upsert is atomic, so this race is impossible regardless of concurrency.
No configuration needed. Works transparently when SESSION_DRIVER=database.
Redis sessions (recommended for production)
For high-concurrency workloads Redis sessions have lower overhead than database sessions and avoid any persistence race entirely:
SESSION_DRIVER=redis REDIS_HOST=127.0.0.1
Eloquent relations
Eloquent decides whether a relation adds its own where foreign_key = ? through
Relation::$constraints, a static property. Eager loading switches it off while it builds the
relation object and restores it afterwards from a captured value. A static property is one flag
per worker thread, shared by every coroutine of that worker, so under concurrent serving that
window belongs to whoever happens to be inside it: one request's relations come out unfiltered
because another request was eager loading at that moment, and two overlapping windows leave the
flag off for the rest of the worker's life. The queries stay valid and answer with the whole
table.
Nothing to do — the package puts its own copies of two Eloquent files in front of Laravel's,
Relations\Relation and Concerns\HasRelationships. The first keeps that window in the
coroutine that opened it; the second builds relation classes that read it. Every model is
covered, including the ones that come from other packages.
The copies live in overrides/laravel-13/, beside the one this package already keeps for
Telescope, and are frozen against the Laravel release they were taken from — 13.26.1, which is
why composer.json requires ~13.26.1 — so each carries the checksum of the file behind it. A release that touches either file leaves
the application on Laravel's own classes rather than on a copy that has fallen behind; the
worker writes the reason to stderr at start-up, and EloquentOverrides::status() answers it at
any time. SPAWN_ELOQUENT_OVERRIDES=0 switches the copies off.
License
MIT