phasync / swerve-laravel
Run Laravel applications on swerve: a fresh application per request, WebSockets and Server-Sent Events, no rewrite.
Requires
- php: ^8.2
- laravel/framework: ^12.0 || ^13.0
- laravel/octane: ^2.0
- phasync/phasync: ^2.0
- phasync/swerve: ^0.1
- psr/http-message: ^1.0 || ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.58
- pestphp/pest: ^3.0 || ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-19 22:01:21 UTC
README
Your Laravel application, many requests at once. swerve
is a PHP application server: long-running workers that serve HTTP/1.1 themselves, stream
request and response bodies, and hold WebSockets and Server-Sent Events. This package lets it
run a Laravel application unchanged. It is concurrent by design: a worker serves many requests
at once, each in a pooled application of its own, so a request that waits (an API call, a
coroutine sleeping, and with ext-phasync sleep() and queries) does not hold the others.
The framework running correctly on swerve does not mean your application does: static
properties and other globals in your own code are not reset between requests — the pool resets
Laravel's own state, not yours (what it doesn't cover). See Making your
application safe for concurrent
requests.
phasync/doctor lists the candidates in your code:
composer require --dev phasync/doctor, then vendor/bin/phasync-doctor.
It requires Laravel Octane (laravel/octane ^2.0, installed
with the package) for the listeners that reset an application between requests; Octane's own
servers and octane:install are not used. ext-phasync is optional.
composer require phasync/swerve-laravel
vendor/bin/swerve --http=0.0.0.0:8080 --public=public
That's the whole setup: the laravel adapter serves the application. public/index.php stays as
it is, so the same application still runs under PHP-FPM. The handler's settings (below) take a
swerve.php, run with --adapter=swerve:
<?php // swerve.php, next to composer.json return new Swerve\RequestHandler((new Swerve\Laravel\Handler(__DIR__, basePath: '/app'))->handle(...));
WebSockets
A route returns Swerve\Laravel\WebSocketResponse::from(); its callback gets swerve's
Swerve\WebSocket. The handshake is an ordinary Laravel request (middleware, session,
$request->user()); the callback runs after it, for as long as the connection is open, and holds
no application: the worker goes on serving requests meanwhile (tested with 250 open sockets in one
worker).
use Illuminate\Http\Request; use Swerve\Laravel\WebSocketResponse; use Swerve\WebSocket; Route::get('/echo', fn (Request $request) => WebSocketResponse::from($request, function (WebSocket $ws) { foreach ($ws as $message) { // ends when the client leaves $ws->isBinary() ? $ws->sendBinary($message) : $ws->send("echo: $message"); } }));
An ordinary GET to that route is answered 426 Upgrade Required. from() also takes
subprotocols, an origins allow-list and maxMessage, as swerve's WebSocket::from() does.
Server push. A socket that only forwards a topic, and any route, command or job that publishes to it. Messages reach the subscribers in every worker; the callback ends when its client leaves, or with a close frame (1001) when the worker drains.
use Swerve\Swerve; Route::get('/news', fn (Request $request) => WebSocketResponse::from($request, function (WebSocket $ws) { foreach (Swerve::subscribe('news') as $message) { $ws->send($message); } })); Route::post('/news', function (Request $request) { Swerve::publish('news', json_encode($request->validate(['text' => 'required|string']))); return response()->noContent(); });
Every subscriber sees a topic's messages in the same order. Messages published by one request arrive in the order published; two requests in different workers may publish in the other order, so give messages that carry state a version from your database.
Take the user first. The callback runs outside any request, and its application is gone: in
it app(), the facades and Eloquent throw. Read what the socket needs before
WebSocketResponse::from(), and run Laravel code from the callback with Handler::run():
use Swerve\Laravel\Handler; Route::get('/chat', function (Request $request) { $user = $request->user(); // now, while this request runs return WebSocketResponse::from($request, function (WebSocket $ws) use ($user) { foreach ($ws as $text) { $message = Handler::run(fn () => $user->messages()->create(['text' => $text])); Swerve::publish('chat', $message->toJson()); } }); })->middleware('auth');
Handler::run() runs its closure in a fresh application, bootstrapped as php artisan does,
and drops it afterwards. It returns what the closure returns and throws what it throws. The
application has no request, session or user.
An exception thrown by the callback closes the socket with 1011 and is written to swerve's log, not to Laravel's exception handler.
How it runs
Each request runs in an application of its own, taken from a pool of booted applications and reset
afterwards by Laravel Octane's listeners (the listeners of
config/octane.php, so yours apply too). A request never shares an application with another
that runs at the same time.
- Per worker: an application is booted on demand (
bootstrap/app.phpis required and the HTTP kernel bootstraps: config, providers,boot()), and the services Octane warms (octane.warm) are resolved. A worker boots as many as its overlapping requests need, and no spare. It keeps as many as were in use at once during the last 60 seconds (the third argument ofHandler,$window) and drops the rest when the pool is next used. The fourth,maxApplications(128), caps them: it is back-pressure, requests above it wait for an application to come free. The applications are aphasync\Util\Pool. - Per-application state: each application has an
AppState::$statearray of its own, which the request that takes it runs with: aphasync::onScopeChange()hook binds it by the request'sScopewhenever one of its coroutines runs. When slots were added toAppState::$defaultsby the time the first application has booted, Laravel's pointers to the application are left to that state, as the application's own, and the reset above applies on top. Otherwise the process-wide pointers are proxied (docs/concurrency.md; what a request can leave behind for the next, and what to do instead: docs/shared-state.md). Adding a slot after that point fails every later request with aLogicException. A WebSocket callback keeps a copy of the state its request left it. - Per request: swerve's request becomes a Laravel request: headers, cookies, query, form
fields, JSON, and uploads, which stay swerve's temporary files (no copy; deleted after the
request unless moved). Octane's
RequestReceivedlisteners reset the application (session, auth, cookies, locale, ...), the HTTP kernel handles the request and the response goes to swerve. After thatterminate()runs, with thedefer()callbacks, and the container goes back to what it held after boot: whatever the request resolved or registered is dropped. An application whose request failed or was cancelled is dropped instead of reused.app()is an instance of the classbootstrap/app.phpreturned (Application, or your subclass of it); afinalsubclass is refused at start. - No request at boot: a provider that reads the request while it boots finds an empty one
for
/, as under Octane. Do that work in a middleware or a route. - Concurrency: Laravel keeps "the" application in process-wide pointers (
app(), the facades, Eloquent's connection resolver and event dispatcher, ...). They are proxies that forward to the application of the request that runs, so requests that wait overlap in one worker, each in its own application. A request waits in a coroutine (phasync::sleep(), phasync's HTTP client, sockets) with or without ext-phasync; with itsleep(),usleep(), PDO/mysqli queries, curl andflock()wait that way as well, and without it they hold the worker, as under any other server. Laravel's view engine buffers output (ob_start()inPhpEngine, and in@section,@componentand@push), and PHP's output buffers belong to the process: a wait inside a view while it renders, such as a lazily loaded relation with ext-phasync, mixes the output of requests that overlap there (#15). Load what a view needs before it renders (eager loading) until that is fixed. - Output outside the response:
echo,printanddump()in a route are not part of its response: return a response (response(), a view) instead. A stream callback's echoed output is part of its response (see Streaming). - Sessions: Laravel's own drivers (database, file, cookie, Redis), unchanged.
- Streaming:
response()->stream(),response()->eventStream()and downloads go out as the callback echoes;HEADrequests don't run the callback. A client that leaves cancels the callback where it next waits: itsfinallyblocks run, but the application is dropped instead of reset, andterminate()callbacks do not run for that request. A stream callback's output is collected in a buffer that belongs to the process, so stream callbacks thatechorun one at a time in a worker, the others waiting their turn (a stream that never ends holds the turn; other requests are not held up). A callback thatyields its chunks (response()->stream(function () { yield ...; })) needs no buffer and overlaps. - WebSockets: see WebSockets. The connection is swerve's; only the handshake is a Laravel request.
Before you deploy
- Memory is one application per request in flight (about 0.6 MiB each beyond the first).
Requests overlap while they wait in a coroutine; without ext-phasync
sleep(),usleep(), database queries and curl hold the worker, so size--workerslike PHP-FPM'spm.max_childrenfor an application that waits on those. ext-phasync makes them yield. - Behind a proxy that serves the application in a folder (
https://example.com/app/forwarded to swerve as/), usenew Handler(__DIR__, basePath: '/app')in aswerve.php(see the setup above).url(),route(),back()and signed URLs then include the folder, as under PHP-FPM.X-Forwarded-Prefixdoes nothing for an application that doesn't trust it. - Run swerve with opcache (
php -d opcache.enable_cli=1 vendor/bin/swerve ..., or in the CLI'sphp.ini): without it every application recompiles Laravel's files, which is slow and makes the worker's memory grow. exit,die()anddd()end the worker, and the requests it serves with it.- A stop ends the requests in flight. On a shutdown or reload swerve stops a worker at once:
a request still waiting then ends, answered
503by swerve when nothing was sent yet (Laravel neither reports nor renders it); clients retry.defer()work that waits is cut short too. - A validation closure that waits holds the worker with ext-phasync (a query in a closure
rule: Laravel calls it through
Closure::__invoke(), ext-phasync-src#106). A rule class is not affected. - Open WebSockets count as connections: without ext-phasync a worker holds about 960; see swerve's sizing.
- Multipart
PUTandPATCHbodies are not parsed; url-encoded and JSON ones are. Send forms with files asPOSTwith_method=PUT. - Code and config changes need a reload:
--watchduring development,SIGHUP(a rolling reload) in production.php artisan optimizeworks as usual. - SQLite: use WAL mode and a busy timeout (
'journal_mode' => 'wal','busy_timeout' => 5000inconfig/database.php). With Laravel's defaults, concurrent session writes time out under load. - An application is reused, as under Octane: state your own code keeps in a singleton or a
static between requests is shared by the requests that run in that application one after
the other. Octane's
config/octane.phplistenersandflushare the place to reset it.
Compatibility
| Laravel | PHP | ext-phasync |
|---|---|---|
| 12 | 8.2 – 8.5 | optional; tested with and without |
| 13 | 8.3 – 8.5 | optional; tested with and without |
The suite runs on each row without and with ext-phasync. The classification of Laravel's static
properties (tests/statics/allowlist.php) is that of Laravel 13; its test is skipped on 12.
License
MIT. See the Ennerd philosophy for why this stack is built to be owned.