phpdot / server
Swoole HTTP, WebSocket, and TCP server for PSR-15 handlers.
Requires
- php: >=8.5
- ext-swoole: >=6.2
- phpdot/contracts: ^0.1
- phpdot/http: ^0.1
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- phpdot/container: ^0.1
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.0
- swoole/ide-helper: ^6.0
README
Swoole-native application server for PHP 8.5. One process owner, attachable transports, and a
PSR-15 handler in front — HTTP, WebSocket, SSE, and raw TCP served from a single Swoole master.
It builds its PSR-7 messages with phpdot/http, so there is no third-party PSR-7 implementation
under the hood.
Beyond request handling it owns the whole runtime: worker and task-worker pools, the full Swoole lifecycle as typed listener interfaces, a server-wide connection registry (WebSocket push, TCP broadcast), coroutine-safe timers, live statistics, graceful signal handling with drain diagnostics, an orphan-reaping watchdog, and a file watcher for hot reload in development.
Table of Contents
Requirements
| Requirement | Constraint |
|---|---|
| PHP | >= 8.5 |
| ext-swoole | >= 6.2 |
phpdot/contracts |
^0.1 |
phpdot/http |
^0.1 |
psr/http-factory |
^1.0 |
psr/http-message |
^2.0 |
psr/http-server-handler |
^1.0 |
Installation
composer require phpdot/server
Usage
Feature overview
- One master, many protocols — HTTP, WebSocket, and SSE share the primary port; raw TCP attaches on its own port. All driven by one PSR-15 handler aggregate.
- Self-contained PSR-7 — requests and responses are built with
phpdot/http; no separate PSR-7 implementation required. - Attachable transports —
HttpServer(primary) andTcpServerimplement a smallTransportcontract; theServerowns the process and wires them onto the Swoole master. - Typed lifecycle — eleven
On*Interfacehooks (start, shutdown, reload, worker and manager events) fanned out from a single registry. - Connection registry — a server-wide surface for WebSocket push/ping/disconnect and TCP
broadcast, exposed to real-time layers through
ConnectionSenderInterface. - Task workers — offload blocking work to a task pool, with callback or coroutine-await completion.
- Timers — coroutine-safe recurring and one-shot timers.
- Statistics — master/manager/worker PIDs, worker status, and full Swoole stats.
- Operational safety — fail-fast port checks, graceful SIGINT/SIGTERM teardown, drain diagnostics, SSE cancellation on worker exit, and an orphan-reaping watchdog.
- Hot reload — a file watcher that reloads workers (or restarts) on source changes.
Quick Start
use PHPdot\Http\Factory\ResponseFactory; use PHPdot\Server\Config\HttpServerConfig; use PHPdot\Server\Config\ServerConfig; use PHPdot\Server\Http\HttpServer; use PHPdot\Server\Server; $factory = new ResponseFactory(); $server = new Server(new ServerConfig(workerNum: 4)); $server->attach(new HttpServer($factory, new HttpServerConfig(port: 8080))); $server->serve($handler); // any PSR-15 RequestHandlerInterface — blocks on the event loop
Protocols
The handler passed to serve() is the application's aggregate. It is always a PSR-15
RequestHandlerInterface; it opts into the other protocols by additionally implementing the
handler interfaces from phpdot/contracts:
| Protocol | Handler interface | Trigger |
|---|---|---|
| HTTP | Psr\Http\Server\RequestHandlerInterface |
every request |
| WebSocket | PHPdot\Contracts\Server\WebSocketHandlerInterface |
upgrade on the primary port |
| SSE | PHPdot\Contracts\Server\SseHandlerInterface |
Accept: text/event-stream |
| TCP | PHPdot\Contracts\Server\TcpHandlerInterface |
data on an attached TcpServer port |
A single object may implement all four — the same socket upgrades WebSocket connections, streams
SSE, and serves HTTP, while an attached TcpServer routes raw frames to the TCP hooks.
Transports
Server owns the process and the Swoole master; transports attach to it. HttpServer is always
the primary transport (it owns the main port). TcpServer adds a raw-TCP port alongside it:
use PHPdot\Server\Config\TcpServerConfig; use PHPdot\Server\Tcp\TcpServer; $server->attach(new HttpServer($factory, $httpConfig)); $server->attach(new TcpServer(new TcpServerConfig(port: 9501))); $server->serve($handler); // handler also implements TcpHandlerInterface
TCP framing modes (TcpFraming): Eof (delimiter, default "\n"), Length (length-prefixed),
None (stream — primary-only). A non-primary TCP port requires framing; Swoole never fires
receive on an unframed added port.
Connection operations
ConnectionRegistry is the server-wide connection surface, and implements the
ConnectionSenderInterface seam a real-time layer depends on to reach clients without naming a
concrete server:
$registry->send($fd, $data); // raw write to one connection $registry->pushWs($fd, $frame); // WebSocket push, established sockets only $registry->pingWs($fd); // WebSocket PING control frame (heartbeats) $registry->broadcastWs($frame); // push to every open WebSocket $registry->broadcast($data, $tcpPort); // raw-TCP broadcast, never touches HTTP/WS sockets $registry->disconnect($fd, 4401, 'bye'); // WebSocket close handshake; no-op on dead fds $registry->close($fd); // close a connection $registry->exists($fd); // liveness check $registry->info($fd); // Swoole client info $registry->list($startFd, $count); // iterate connected fds
Lifecycle events
Subscribe any object to the lifecycle registry; each PHPdot\Server\Contract\On*Interface it
implements is fanned out from a single composite per Swoole event:
final class Bootstrap implements OnWorkerStartInterface, OnBeforeShutdownInterface { public function onWorkerStart(\Swoole\Server $server, int $workerId): void { /* warm caches */ } public function onBeforeShutdown(\Swoole\Server $server): void { /* flush */ } } $server->events()->subscribe(new Bootstrap());
| Hook interface | Fires on |
|---|---|
OnStartInterface |
master start |
OnManagerStartInterface / OnManagerStopInterface |
manager process start / stop |
OnWorkerStartInterface / OnWorkerStopInterface |
worker start / stop |
OnWorkerExitInterface |
worker exit (drain) |
OnWorkerErrorInterface |
worker fatal error |
OnBeforeReloadInterface / OnAfterReloadInterface |
around a reload |
OnBeforeShutdownInterface / OnShutdownInterface |
around shutdown |
Task workers
Configure a task pool with ServerConfig(taskWorkerNum: N), then offload blocking work off the
request workers:
$server->onTask(fn (mixed $data) => heavyWork($data)); // runs in a task worker $server->onFinish(fn (mixed $result) => log($result)); // back on the originating worker // dispatch (from within a request): $dispatcher->task($payload, onFinish: fn ($r) => handle($r)); // fire-and-callback $results = $dispatcher->taskCo([$a, $b, $c], timeout: 0.5); // dispatch + coroutine-await all
Timers
Coroutine-safe timers over Swoole's timer wheel:
$id = $timer->tick(1000, fn (int $id) => heartbeat()); // recurring, every 1000ms $timer->after(5000, fn () => runOnce()); // one-shot, after 5000ms $timer->clear($id); // cancel
Server control
$server->reload(); // graceful worker reload (new code, no dropped connections) $server->reload(onlyReloadTaskWorker: true); $server->stop($workerId); // stop one worker (it respawns) $server->shutdown(); // stop the whole server $server->ensurePortsAvailable(); // fail-fast port check before serve()
Statistics
$stats->all(); // full Swoole server stats (connections, requests, queued tasks, …) $stats->masterPid(); // master / manager / worker identity $stats->managerPid(); $stats->workerId(); $stats->workerPid($id); $stats->workerStatus($id);
User processes and hot reload
ProcessManager (via $server->processes()) adds long-running user processes and drives the
file watcher for development hot reload:
$server->processes()->add(fn (\Swoole\Process $p) => customLoop()); use PHPdot\Server\Watch\Watcher; $server->processes()->watch(new Watcher( paths: [__DIR__ . '/src'], // change here -> reload workers (new code) restart: [__DIR__ . '/config'], // change here -> full server restart extensions: ['php'], ));
A change under paths reloads the workers; a change under restart restarts the whole server
(WatchAction::Reload / Restart / Ignore is the resolved per-change decision).
Configuration
Three config DTOs, one per concern. In a dot application they hydrate from
config/server/{master,http,tcp}.php via #[Config]; standalone consumers construct them
directly.
ServerConfig (#[Config('server.master')]) — process and pool tuning:
| Field | Default | Purpose |
|---|---|---|
workerNum |
null (CPU count) |
request workers |
taskWorkerNum |
0 |
task workers |
maxRequest |
100000 |
requests before a worker recycles |
mode |
SWOOLE_PROCESS |
process model (PROCESS / BASE) |
maxWaitTime |
3 |
drain seconds on reload/shutdown |
orphanWatchdog |
true |
reap the tree if the master dies ungracefully |
hookFlags |
SWOOLE_HOOK_ALL |
coroutine runtime hooks |
daemonize, pidFile, logFile, logLevel |
— | daemon / logging |
tcpNodelay, tcpKeepalive, backlog, bufferOutputSize, socketBufferSize, packageMaxLength |
— | socket tuning |
rawSettings |
[] |
any Swoole setting without a typed field |
HttpServerConfig (#[Config('server.http')]) — host, port, serverSoftware, http2,
httpCompression (+ min length), and the httpParsePost / httpParseCookie / httpParseFiles
parsing toggles.
TcpServerConfig (#[Config('server.tcp')]) — host, port, sockType, framing, and the
framing parameters (packageEof, packageLengthType, lengthOffset, bodyOffset,
packageMaxLength).
Operational behaviour
- Fail-fast ports —
serve()refuses to start when a listen port is held, naming the exact host:port. - Graceful signals — Ctrl+C (SIGINT) and SIGTERM tear the whole tree down cleanly, in PROCESS and BASE modes.
- Orphan watchdog (default on) — if the master dies without teardown, a watchdog process reaps the manager and workers instead of leaving an orphaned tree. macOS has no parent-death signal, so this is what prevents orphaned trees from poisoning the next boot.
- Drain diagnostics — a worker still pinned one second into shutdown logs exactly which coroutines and timers hold it, instead of a bare ERRNO 9101 force-kill.
- SSE cancellation — in-flight event streams are cancelled on worker exit so recycles never hang on a streaming loop.
Architecture
graph TD
APP["Your handler<br/><br/>PSR-15 / WebSocket / SSE / TCP"]
SERVER["Server<br/><br/>owns the Swoole master:<br/>mode, hooks, worker lifecycle"]
subgraph Transports
direction TB
HTTP["HttpServer<br/><br/>primary: HTTP + WS upgrades + SSE"]
TCP["TcpServer<br/><br/>added port: length / EOF framing"]
end
subgraph Runtime["per worker"]
direction TB
EVENTS["LifecycleEventRegistry<br/><br/>start / worker / reload hooks,<br/>graceful SIGINT shutdown"]
TASK["TaskDispatcher<br/><br/>task workers"]
TIMER["Timer<br/><br/>interval callbacks"]
CONN["ConnectionRegistry<br/><br/>fd tracking + stats"]
WATCH["OrphanWatchdog + FileWatcher<br/><br/>reap on master death, hot reload"]
end
APP --> SERVER
SERVER --> Transports
SERVER --> Runtime
Loading
Testing
The package is standalone-testable (requires ext-swoole):
composer install composer test # PHPUnit — integration tests boot real servers composer analyse # PHPStan, level max + strict rules composer cs-check # PHP-CS-Fixer composer check # All three
Integration tests boot real servers and assert raw bytes on the wire — HTTP parity, WebSocket
frames, SSE streams, TCP framing, signal handling, and orphan reaping. They build all PSR-7 inputs
and responses with phpdot/http; no external PSR-7 library is used.
License
MIT.
This repository is a read-only mirror, generated by CI from phpdot/monorepo. Pull requests and issues belong in the monorepo.