hhxsv5/laravel-s
LaravelS is a high-performance adapter that integrates Swoole with Laravel and Lumen, enabling coroutine-based, resident-memory HTTP/WebSocket/TCP/UDP servers, async tasks, connection pooling, and hot reload. It boosts throughput and lowers latency for existing Laravel apps with minimal code changes...
LaravelS is a runtime swap, not a library — you are replacing PHP-FPM with a long-lived Swoole/OpenSwoole HTTP server that boots Laravel once per worker and serves many requests from that booted state. Internalise that before writing any code, because every subsequent decision follows from it.
Prerequisites
swoole (4.x+) or openswoole extension compiled and enabled. Verify with php -m | grep -i swoole before anything else — a missing extension is the single most common "it doesn't start" cause.Minimal install
composer require hhxsv5/laravel-s
php artisan laravels publish # generates config/laravels.php + bin/laravels, bin/fswatch
php bin/laravels start # foreground, dev mode
# http://127.0.0.1:5200
The service provider is auto-discovered on Laravel; for Lumen you register Hhxsv5\LaravelS\Lumen\ServiceProvider in bootstrap/app.php manually. bin/laravels is the real entry point — php artisan laravels {start|stop|restart|reload|info} is a thin wrapper around the same binary. Pick one and stick to it in scripts so your team doesn't fragment.
Where to look first
config/laravels.php — listen_ip/listen_port (default 5200), server.options (raw passthrough to Swoole), processes (worker_num, task_worker_num, max_request), websocket, cleaners, swoole_tables, inotify_reload.bin/laravels — the supervisor: signals, pid file, daemonize flag. Read it once; you'll debug against it eventually.AppServiceProvider::boot() and anything you register as a singleton or static. This is the origin of nearly every production incident with this package.First real use case
Do not start by writing WebSocket handlers or task workers. Start with the boring baseline:
php bin/laravels start.127.0.0.1:5200 with an upstream block and keepalive.That sequence also gives you the number you need to justify the project internally.
The development loop
php bin/laravels start in the foreground so echo, warnings, and Swoole's own output reach your terminal. -d daemonizes — useful in staging, hostile in dev. For change pickup you have three options:
php bin/laravels reload — graceful worker restart, keeps the master alive. Use for code, .env, and app config changes.php bin/laravels restart — required for anything read by the master: listen socket, worker_num, task_worker_num, WebSocket handler class, swoole_tables schema, server.options.inotify_reload in config, or bin/fswatch — file watching, configured before start.Most developers settle on reload bound to a shell alias or a file watcher, and restart reserved for structural changes.
Write stateless-by-construction
Treat every request as running in a recycled container. Derive everything from the request; never cache per-user data on $this, in statics, or in container singletons. Concretely, these all survive into the next request and are the #1 source of "works locally, leaks in production":
config([...]) mutations and App::setLocale()Auth::login() / guard state$GLOBALS, any module-level cacheA useful review heuristic: if a property is assigned in a controller constructor or a service's boot(), ask what happens on request #2.
Cleaners — the supported per-request reset hook
When you genuinely can't avoid global state, implement Hhxsv5\LaravelS\Swoole\CleanerInterface::clean() and register the class in config('laravels.cleaners'):
class RequestResetCleaner implements CleanerInterface
{
public function clean()
{
setlocale(LC_ALL, 'C');
Carbon::setTestNow(null);
// reset any static registry you own
}
}
Good candidates: static caches, locale, auth guard state, custom singleton registries, targeted Facade::clearResolvedInstances(). Avoid cramming a full container flush in here — you'll pay for it on every request.
Shared state across workers — Swoole\Table
Workers don't share PHP memory. For cross-worker state, wrap a swoole_table:
class WsFdMap implements TableInterface
{
public function name() { return 'ws_fd_map'; }
public function columns() { return ['user_id' => [\Swoole\Table::TYPE_INT, 8]]; }
public function create() { /* size, conflict_proportion */ }
}
Register it in swoole_tables, then access as app('swoole')->ws_fd_map. Keep rows small and fixed-width — this is shared memory, not a database. It's the right tool for fd→user maps, counters, rate limiters, and room membership.
Offloading work — Tasks
With task_worker_num > 0, push serializable work to task workers:
Task::deliver(new SendEmailTask($payload)); // async
Implement TaskInterface::handle(), and optionally finish() for a result callback back in the originating worker. Send plain arrays or DTOs — never Eloquent models with live PDO connections, closures, or resources. A serialization failure here surfaces as a cryptic task-worker error, not a clean exception.
Timers
Timer::tick(1000, fn($id) => ...) and Timer::after(). Rules that matter:
SwooleEvent::setWorkerStart), never at file scope.Timer::clear($id) only works in the same worker that created it.Task.Note that every worker gets its own timer, so Timer::tick(1000, …) across 4 workers is 4 executions per second. If you need exactly-once, designate one worker, or use a task worker.
WebSocket
Enable via websocket.enable => true and websocket.handler => App\WebSocket\Handler::class implementing WebSocketHandlerInterface (onOpen, onMessage, onClose). Key patterns:
onOpen using the handshake request (cookies or query string) — that's your only chance to tie an fd to a user.Swoole\Table, not in a static array (per-worker memory).app('swoole')->push($fd, $data) from the worker that owns the fd. From an HTTP worker, route through Task or Swoole's sendMessage — you cannot push across workers directly.onMessage bodies in try/catch. An uncaught exception there can take down the worker and drop every connection it owns.Server lifecycle hooks
SwooleEvent::setStart(), setWorkerStart(), setRequest(), setClose(), etc. are the sanctioned extension points for instrumentation, connection setup, and timer registration. Prefer these over subclassing the server — it keeps you upgradeable, and it's where you'd wire in metrics/tracing.
Deployment shape
upstream with keepalive to the Swoole port.php bin/laravels start -d.php bin/laravels reload, not a hard restart.max_request set to a few thousand to bound memory creep.opcache.validate_timestamps=0 in production, paired with the reload on release.Upgrade note for v3.8.8. The only change is a return type declaration on configure(). Runtime behaviour is unchanged, so for most codebases this is a safe patch bump. The exception: any class of yours that subclasses a LaravelS class and overrides configure() must now declare a compatible return type, or PHP fatals with "Declaration of … must be compatible with …". Grep for function configure( after bumping. Low probability, seconds to check.
Nothing is per-request unless you make it so. Repeating this because it dominates the debugging experience: config() mutations, Auth state, resolved singletons, $GLOBALS, and static properties all leak forward. If a bug only reproduces after the second request to the same worker, this is the cause.
Output and header functions misbehave. dd(), dump(), echo, var_dump, header(), setcookie(), session_start(), and ob_* either do nothing useful or corrupt state. Output goes to the worker's stdout, not the HTTP response. Use response(), Log::, and the LaravelS log file. Swap dd() for Log::debug() + json_encode() on day one, or you will waste an afternoon.
Blocking calls kill throughput. sleep(), synchronous Guzzle, heavy CPU loops, file_get_contents() on a URL — each blocks the worker for every concurrent user assigned to it. Under FPM this cost one request; here it costs a share of a worker. Audit outbound I/O early and use coroutine-enabled clients or push work into task workers.
Database connections go stale. Long-lived workers hold PDO handles across requests and eventually hit MySQL server has gone away or Packets out of order. Mitigations, roughly in order of preference: a sane max_request so connections recycle; DB::reconnect() in a cleaner or middleware; a proper coroutine connection pool if coroutines are on. Pick one deliberately rather than adding reconnect() everywhere.
reload vs restart is a recurring trap. Worker-level changes (code, .env, app config) survive reload. Master-level settings — port, worker_num, task_worker_num, WebSocket handler class, table schema, server.options — require restart. Symptom of getting it wrong: you change worker_num, run reload, and nothing happens.
Coroutines are opt-in and change everything. With enable_coroutine => true you get concurrency inside a single worker, but shared mutable state becomes genuinely dangerous (two coroutines interleaving mid-request) and blocking calls stall a coroutine rather than a worker. Adopt only with tests that fire concurrent requests at the same worker.
max_request tuning is a watch-and-adjust job. Too high and slow memory leaks accumulate until OOM; too low and you pay Laravel's boot cost constantly, erasing the performance argument. Track worker RSS over hours and set it just above your observed creep window. There is no universal correct value.
WebSocket onMessage must not throw. Wrap the body in try/catch and log. An uncaught exception in the callback can take down the worker and silently drop every connection it owns — a failure mode that looks like a network problem to users.
Debugging toolkit, in escalation order:
php bin/laravels info — pid, worker stats, config echo.php bin/laravels reload — first response to "weird" behaviour after a deploy.storage/logs/laravel.log and the configurable Swoole error log.ps/strace on worker pids when something hangs or memory climbs.worker_num to 1 while reproducing — it makes leaks deterministic.Static file serving (enable_static_handler + document_root) is convenient in dev but should be off in production. Let nginx handle assets — it keeps Swoole workers free and gives you proper caching headers.
Docker/pid-file friction. bin/laravels writes a pid file under storage/. Non-root containers without that path writable will fail to start or stop cleanly, often with a confusing error. Either chown the directory or set the path explicitly in config. Related: signal handling in containers needs start -d under a proper init, not CMD php bin/laravels start.
Extension points worth memorising: CleanerInterface, TableInterface, TaskInterface, WebSocketHandlerInterface, the SwooleEvent lifecycle setters, and the server.options passthrough for anything Swoole supports that LaravelS doesn't surface natively. Between those six, nearly every custom requirement is reachable without forking.
For greenfield work, benchmark against Octane. LaravelS's remaining edge is legacy-app compatibility and bundled extras; Octane offers vendor maintenance and a runtime exit ramp. Worth noting in any adoption write-up rather than assuming the answer.
How can I help you explore Laravel packages today?