lumnd / plato-workerman
Workerman server adapter for PlatoPHP: the event loop, protocol and worker processes behind plato\server\driver
Requires
- php: ^8.0
- ext-json: *
- lumnd/platophp: ~0.1
- workerman/workerman: ^4.1 || ^5.0
Requires (Dev)
- lumnd/plato-coding-standard: ^0.1
- pestphp/pest: ^3.7 || ^4.7
- phpstan/phpstan: ^2.2
- squizlabs/php_codesniffer: ^3.11
Suggests
- ext-event: libevent based event loop. Without it Workerman falls back to select(), which is fine below a thousand connections per worker and a bottleneck above it
- ext-pcntl: Required by Workerman for the master process, worker forking and signal handling
- ext-posix: Required by Workerman to drop privileges and to check whether a master process is alive
This package is not auto-updated.
Last update: 2026-08-07 05:12:23 UTC
README
The Workerman driver behind plato\server\driver: the event loop, the protocol and the worker
processes PlatoPHP deliberately does not ship.
lumnd/platophp owns what happens between an inbound message and a controller — one whole message,
one ct / ac dispatch, clean request state, an identity that was established once and belongs to
the connection. It owns none of the socket: an event loop, a process manager and a protocol codec
are not a framework's business, and a protocol parser is the part of the stack where a mistake is a
remote memory exhaustion rather than a wrong page. This package is the other half of that seam, and
nothing above plato\server\driver knows it is here.
Requirements
| PHP | 8.0+ |
| Workerman | ^4.1 || ^5.0 — both majors, one code path |
| Extensions | pcntl and posix, which Workerman needs for the master process |
| Suggested | ext-event, for a loop that scales past a thousand connections per worker |
Install
composer require lumnd/plato-workerman
The shipped config/server.php already names workerman as its driver, so a default installation
starts:
php vendor/bin/plato server:start
after the one line that registers the commands, below.
Configure
Everything the framework does not own in config/server.php is passed to this adapter untouched.
Copy the file into your application's config/ and change what you need:
return [ 'default' => 'default', 'servers' => [ 'default' => [ // 'workerman' is registered by this package; the class name works just as well and // needs no bootstrap file at all 'driver' => 'workerman', 'listen' => 'websocket://127.0.0.1:8282', 'name' => 'platophp-server', 'processes' => 4, 'heartbeat' => ['interval' => 30, 'timeout' => 120], // Read by plato\server\dispatcher, not by this adapter 'dispatch' => ['max_payload' => 65536], ], ], ];
| Setting | Default | What it is |
|---|---|---|
listen |
websocket://127.0.0.1:8282 |
Where to listen, and in which protocol. See below |
name |
platophp-server |
Process name in ps, and the stem of the pid, status and log file names |
processes |
4 |
Worker processes. One serves one message at a time |
user / group |
'' |
Drop privileges after binding |
pid_file |
data_path()/server/<name>.pid |
Where the master keeps its pid |
status_file |
data_path()/server/<name>.status |
Where server:status collects its report |
log_file |
log_path()/<name>-workerman.log |
Workerman's own operational log |
stdout_file |
log_path()/<name>-stdout.log |
Where a daemonized process sends its output |
daemonize |
false |
Detach from the terminal; --daemon sets it for one run |
graceful |
true |
Let stop, restart and reload wait for the workers to finish |
reuse_port |
false |
SO_REUSEPORT, so a restart does not drop the listening socket |
protocol |
'' |
Workerman protocol class framing a raw tcp:// listener |
max_package_size |
0 |
Largest packet assembled off the socket; 0 keeps Workerman's 10 MB |
stop_timeout |
0 |
Seconds a worker gets after a graceful stop; 0 keeps Workerman's 2 |
ssl |
[] |
local_cert, local_pk, verify_peer, allow_self_signed. Paths, never key material |
context |
[] |
Extra stream context for the listening socket |
heartbeat |
[] |
interval and timeout, in seconds. Either at 0 turns the sweep off |
event_loop |
'' |
Workerman event loop class; a coroutine loop is refused |
on_worker_start |
null |
fn(int $index, int $count), in each worker once it knows which it is |
on_worker_stop |
null |
fn(int $index, int $count), on the way out |
TLS is ssl.local_cert plus a websocket:// listener — that combination is wss. Terminating TLS
at a reverse proxy and binding 127.0.0.1 is the usual arrangement, and the one to prefer.
Protocols
dispatcher::handle() takes one whole application message. Which protocol delivers it is this
adapter's business and not the framework's, so several do:
| Listen value | |
|---|---|
websocket://host:port |
What most clients speak, and what the shipped configuration defaults to |
text://host:port |
One message per line |
frame://host:port |
Four bytes of total length, big endian, then the payload |
tcp://host:port + protocol |
Any Workerman protocol class, including one you wrote |
and some do not:
| Refused | Why |
|---|---|
tcp://, ssl://, unix:// without protocol |
A byte stream has no message boundaries, and a dispatcher handed half a message cannot tell |
udp:// |
A datagram carries no connection, so there is nothing to authenticate once and nothing to answer on |
http:// |
A different shape of request; serve HTTP through php-fpm or the framework's own entry point |
ws://, wss:// |
Workerman speaks those as a client; a listener is websocket:// |
Each is refused at start with a message saying which of these it is, rather than with a connection that half works.
Run it
Register the commands once, in plato.config.php or under console.commands in
config/config.php:
'commands' => [plato\workerman\console::class],
php vendor/bin/plato server:start # foreground, until a signal php vendor/bin/plato server:start --daemon # detached php vendor/bin/plato server:start --server=chat --processes=8 php vendor/bin/plato server:reload # new code, same listening socket php vendor/bin/plato server:stop # --force to skip the graceful wait php vendor/bin/plato server:status php vendor/bin/plato server:connections
server:start is a foreground process on purpose, and belongs under something that keeps it
running:
[Service] ExecStart=/usr/bin/php /srv/app/vendor/bin/plato server:start Restart=always KillSignal=SIGTERM TimeoutStopSec=40
An application that would rather not use the console calls the facade itself, which is all the command does:
plato\server\server::start();
Write the application
An action reached over a socket is an ordinary action. It reads its input through req, asks who
the caller is through plato::$auth, and returns a plato\http\reply:
namespace control; use plato\http\resp; use plato\plato; use plato\server\dispatcher; class ctl_chat { public function say() { // The identity was established at open and belongs to the connection $user = plato::$auth; // Reach this client again from anywhere in the process dispatcher::current()->send(['code' => 0, 'msg' => 'delivered']); return resp::json(['code' => 0, 'seq' => dispatcher::seq()]); } }
Authentication happens once, at open. On a websocket the only thing a client has to
authenticate with is the handshake — the frames that follow carry no headers — so this adapter puts
it on the connection under driver::HANDSHAKE:
use plato\server\connection; use plato\server\dispatcher; use plato\workerman\driver; dispatcher::on('open', function (connection $conn) { $handshake = (array) $conn->get(driver::HANDSHAKE, []); $user = my_auth((string) ($handshake['query']['token'] ?? '')); if ( $user === null ) { // The driver closes the connection return false; } // Every later message of this client is dispatched as this identity $conn->set(connection::AUTH, $user); return true; });
handshake is ['path' => string, 'query' => array, 'headers' => array], with lower case header
names, on both Workerman majors. A protocol that has no handshake — a framed tcp:// listener —
sets no attribute at all.
Processes
Every worker calls plato\worker::enter() before it serves anything, so an application shards work
the same way it does under plato\pool:
'on_worker_start' => function (int $index, int $count) { // Exactly one worker of this listener runs the sweep if ( plato\worker::owns() ) { Workerman\Timer::add(60, 'my_sweep'); } },
Two things the framework says out loud, and this adapter keeps to:
- One message at a time per process. Request state lives in static properties, so a coroutine
scheduler running two dispatches inside one pid corrupts it. Workerman 5's
Fiber,SwooleandSwowloops are refused rather than half supported. send()reaches this process only. Workers do not share memory, and neitherdriver::connections()norserver::send()pretends otherwise. Fanning out to every worker needs a backend both can see — Redis pub/sub, or another external bus.
Tests
composer test # Unit + Feature composer analyse # phpstan level 5, no baseline composer style # phpcs, zero errors
The feature suite starts real listeners in child processes and talks to them over real sockets with a hand written websocket handshake and hand written frames — a client that shared its framing code with the server would prove nothing about the wire.
License
MIT. Security reports go to the address in SECURITY.md.