swoole-expert

Deep reference for writing production Swoole PHP code. Covers the long-running process mental model (object lifetimes, what breaks vs php-fpm, per-request state via Coroutine::getContext), runtime hoo

SKILL.md

GitHub

Swoole Expert

Reference for swoole/swoole-src (NOT openswoole). Target: Swoole 5.x/6.x, PHP 8.2+. Assumes baseline coroutine/async knowledge. This file covers only what's house-specific, load-bearing, or shifts year-to-year.

Non-negotiable defaults:

  • Prefer idiomatic Co\run / go() / Co::defer patterns
  • Always enable SWOOLE_HOOK_ALL so native PHP I/O becomes coroutine-aware
  • Never share a connection across coroutines -- use a pool
  • Workers are resident processes, not short-lived like php-fpm

1. The long-running process mental model

Swoole workers are resident PHP processes. Unlike php-fpm, memory is NOT torn down between requests. Everything allocated at class, file, or worker scope survives across every request.

Four object lifetimes

Lifetime Where created Destroyed by
Program-global Before $server->start() Full process shutdown (reload will NOT refresh)
Process-global In onWorkerStart max_request reached, worker crash, or reload
Session onConnect / first onReceive onClose
Request Inside the request handler End of request

If you allocated it in a hot path, assume it's a leak unless you can point to where it's freed.

What does NOT work (vs php-fpm)

  • Superglobals: $_GET, $_POST, $_COOKIE, $_FILES, $_SERVER, $_REQUEST, $_SESSION are NOT populated. Use $request->get, $request->post, $request->cookie, $request->files, $request->server, $request->header, $request->rawContent().
  • session_start() -- implement sessions with Redis/DB keyed by a cookie from $request->cookie.
  • echo / print_r / var_dump -- go to stdout/log_file, NOT the client. Use $response->write() / $response->end().
  • exit() / die() -- kills the worker. Swoole 4.1+ converts to Swoole\ExitException. Never use for control flow.
  • header() / setcookie() -- silent no-ops. Use $response->header(), $response->cookie(), $response->status(). Must be called BEFORE $response->end().
  • Static properties / singletons / global -- leak request state across coroutines. Use Coroutine::getContext().
  • pcntl_* -- forbidden in coroutines. Use Swoole\Process and Process::signal().
  • Xdebug, phptrace, aop, molten, xhprof, phalcon -- incompatible with coroutines. Disable them.

Per-request state with Coroutine::getContext()

use Swoole\Coroutine;

// Context is a Swoole\Coroutine\Context (extends ArrayObject),
// auto-destroyed when coroutine exits -- no cleanup needed.
$ctx = Coroutine::getContext();
$ctx['user_id']   = 42;
$ctx['requestId'] = bin2hex(random_bytes(8));

function innerFunction(): void
{
    $ctx = Coroutine::getContext();
    $userId = $ctx['user_id'];  // works from anywhere in the same coroutine
}

Do NOT stash $this in the context -- it holds a strong reference and usually leaks the controller.


2. Coroutines

Creating and running

Swoole\Coroutine::create(callable $fn, mixed ...$args): int|false
go(callable $fn, mixed ...$args): int|false  // short alias

Swoole\Coroutine\run(callable $fn): bool    // top-level entry point
Co\run(callable $fn): bool                   // alias

All coroutine-creating APIs must run inside a coroutine container — either Co\run(), a server event callback, or a Process/Process\Pool worker with enable_coroutine = true. Nesting run() inside another run() is forbidden.

Introspection

Swoole\Coroutine::getCid(): int                   // -1 if outside coroutine
Swoole\Coroutine::getPcid(int $cid = 0): int|false
Swoole\Coroutine::exists(int $cid): bool
Swoole\Coroutine::list(): Swoole\Coroutine\Iterator
Swoole\Coroutine::stats(): array                  // coroutine_num, coroutine_peak_num, ...
Swoole\Coroutine::getBackTrace(int $cid = 0, int $options = DEBUG_BACKTRACE_PROVIDE_OBJECT, int $limit = 0): array
Swoole\Coroutine::printBackTrace(int $cid = 0): void
Swoole\Coroutine::getElapsed(int $cid = 0): int   // milliseconds alive

Key non-obvious behaviors

Parent/child priority gotcha

When you call go(), the child starts immediately and runs until its first yield. Only then does go() return to the parent:

echo "a\n";
go(function () {
    echo "b\n";
    Co::sleep(0.1);   // yields here
    echo "d\n";
});
echo "c\n";
// Output: a, b, c, d

Coroutines have no parent/child lifecycle -- a parent exiting does not cancel or wait for children. Use Barrier or WaitGroup to wait.

Scheduling yield points

A coroutine runs until: hooked I/O, Co::sleep(), Channel::push()/pop(), Coroutine::yield()/suspend(), WaitGroup::wait()/Barrier::wait().

A CPU-bound loop with no I/O monopolizes the worker. Sprinkle Coroutine::sleep(0) to voluntarily yield, or dispatch CPU work to a task worker.

Coroutine config

Swoole\Coroutine::set(array $options): void  // call BEFORE run() / Server->start()
Option Default Note
max_coroutine 100000 Global limit
stack_size / c_stack_size 2 MB C stack per coroutine
hook_flags 0 One-click hooks, e.g. SWOOLE_HOOK_ALL
enable_preemptive_scheduler false Force preemption at 10 ms
socket_connect_timeout Default connect timeout
socket_timeout Read/write timeout
dns_cache_expire / dns_server DNS defaults
enable_deadlock_check true

Cancellation

Swoole\Coroutine::cancel(int $cid, bool $throwException = false): bool  // $throwException added 6.1
Swoole\Coroutine::isCanceled(): bool
Swoole\Coroutine::setTimeLimit(float $seconds): void  // 6.2+

Caveat: cancel() cannot cancel file I/O coroutines; may segfault. Since 6.2 it can cancel in-flight io_uring ops.


3. Runtime hooks and flag reference

Hooks patch PHP's blocking stdlib to yield on I/O. Set once at bootstrap:

Co::set(['hook_flags' => SWOOLE_HOOK_ALL]);
// Or: $server->set(['hook_flags' => SWOOLE_HOOK_ALL]);

Flag reference (ext-swoole 6.x)

Flag Covers
SWOOLE_HOOK_TCP TCP streams, fsockopen, mysqlnd-based PDO_MYSQL/mysqli, predis, php-amqplib
SWOOLE_HOOK_UNIX Unix-domain stream sockets
SWOOLE_HOOK_UDP / SWOOLE_HOOK_UDG UDP / Unix datagram
SWOOLE_HOOK_SSL / SWOOLE_HOOK_TLS TLS streams
SWOOLE_HOOK_SLEEP sleep, usleep (>= 1ms), time_nanosleep, time_sleep_until
SWOOLE_HOOK_FILE fopen, fread, fwrite, file_get_contents, file_put_contents, unlink, mkdir, rmdir. Uses AIO or io_uring in 6.0+
SWOOLE_HOOK_STREAM_FUNCTION stream_select()
SWOOLE_HOOK_BLOCKING_FUNCTION gethostbyname, shell_*
SWOOLE_HOOK_PROC proc_open, proc_close, proc_get_status, proc_terminate
SWOOLE_HOOK_NATIVE_CURL Real libcurl coroutinized -- use this, not legacy SWOOLE_HOOK_CURL. Requires --enable-swoole-curl. Guzzle/Symfony HttpClient work transparently
SWOOLE_HOOK_CURL Legacy partial reimplementation. Avoid
SWOOLE_HOOK_SOCKETS ext-sockets. Auto-dropped in 6.1.6+ if ext-sockets not loaded
SWOOLE_HOOK_STDIO STDIN/STDOUT/STDERR
SWOOLE_HOOK_PDO_PGSQL pdo_pgsql (5.1+; 6.1.7 added timeout)
SWOOLE_HOOK_PDO_ODBC pdo_odbc (5.1+)
SWOOLE_HOOK_PDO_ORACLE pdo_oci (5.1+). Constant is _ORACLE, not _OCI
SWOOLE_HOOK_PDO_SQLITE pdo_sqlite (5.1+) -- requires sqlite serialized/multi-thread
SWOOLE_HOOK_PDO_FIREBIRD pdo_firebird (new 6.2)
SWOOLE_HOOK_MONGODB MongoDB (new 6.2 via Swoole\RemoteObject\Server)
SWOOLE_HOOK_NET_FUNCTION coroutine gethostbyname (6.2+)
SWOOLE_HOOK_ALL All of the above

Not hookable (block -- don't use in a coroutine)

  • mysql extension (libmysqlclient)
  • mongo / mongodb (mongo-c-client) -- use the 6.2+ MongoDB hook instead
  • php-amqp (C AMQP ext -- but php-amqplib over streams works)
  • Any extension bypassing PHP's streams layer

pdo_mysql and mysqli hookable only in mysqlnd mode. Check php -m | grep mysqlnd.


4. Concurrency primitives

Swoole\Coroutine\Channel

final class Swoole\Coroutine\Channel {
    public int $capacity;
    public int $errCode;  // SWOOLE_CHANNEL_OK | _TIMEOUT | _CLOSED | _CANCELED

    public function __construct(int $capacity = 1);
    public function push(mixed $data, float $timeout = -1): bool;
    public function pop(float $timeout = -1): mixed;
    public function close(): bool;
    public function length(): int;
    public function isEmpty(): bool;
    public function isFull(): bool;
    public function stats(): array;
}

Gotchas:

  • Pushing false/null/0 is ambiguous -- pop() also returns false on close/timeout. Always check $chan->errCode.
  • close() wakes ALL waiting producers/consumers; they return false.
  • Create channels in onWorkerStart, not before start().

Channel as semaphore (capacity N, pre-filled):

$sem = new Channel(5);
for ($i = 0; $i < 5; $i++) $sem->push(true);

go(function () use ($sem) {
    $sem->pop();
    try { doLimitedWork(); } finally { $sem->push(true); }
});

Swoole\Coroutine\WaitGroup

final class Swoole\Coroutine\WaitGroup {
    public function __construct(int $delta = 0);
    public function add(int $delta = 1): void;
    public function done(): void;
    public function wait(float $timeout = -1): bool;  // false on timeout
    public function count(): int;
}

Always call done() in finally -- if a worker throws and skips done(), wait() hangs.

Swoole\Coroutine\Barrier (preferred over WaitGroup)

use Swoole\Coroutine\Barrier;

$barrier = Barrier::make();
for ($i = 0; $i < 4; $i++) {
    go(function () use ($barrier) {  // capturing $barrier bumps refcount
        Co::sleep(0.5);
    });
}
Barrier::wait($barrier);  // by reference; nulled after wait

wait() takes the barrier by reference. If you forget use ($barrier) the child doesn't hold a ref and wait() returns immediately.

defer -- Go-style cleanup

go(function () {
    $db = new PDO(/* ... */);
    defer(fn() => $db = null);  // runs on coroutine exit, LIFO order, even on exception
});

Timers

Swoole\Timer::tick(int $msec, callable $cb, mixed ...$params): int
Swoole\Timer::after(int $msec, callable $cb, mixed ...$params): int
Swoole\Timer::clear(int $timer_id): bool
Swoole\Timer::clearAll(): bool
Swoole\Timer::info(int $timer_id): ?array
Swoole\Timer::list(): Swoole\Timer\Iterator
Swoole\Timer::stats(): array

Callback: function(int $timerId, mixed ...$params): void. In a coroutine container, timer callbacks run inside a new coroutine automatically.

Batch primitives

Swoole\Coroutine\batch(array $tasks, float $timeout = -1): array    // concurrent callables
Swoole\Coroutine\parallel(int $n, callable $fn): void               // N copies of $fn
Swoole\Coroutine\map(array $list, callable $fn, float $timeout = -1): array
Swoole\Coroutine::join(array $cids, float $timeout = -1): bool      // wait by cid

5. HTTP / WebSocket / TCP servers

Canonical HTTP server skeleton

<?php
declare(strict_types=1);

use Swoole\Http\{Server, Request, Response};

$server = new Server('0.0.0.0', 9501, SWOOLE_BASE);

$server->set([
    'worker_num'            => swoole_cpu_num() * 2,
    'task_worker_num'       => 4,
    'task_enable_coroutine' => true,
    'max_request'           => 10_000,
    'max_wait_time'         => 60,
    'reload_async'          => true,
    'hook_flags'            => SWOOLE_HOOK_ALL,
    'http_compression'      => true,
    'log_file'              => '/var/log/swoole.log',
    'log_level'             => SWOOLE_LOG_INFO,
    'package_max_length'    => 8 * 1024 * 1024,
]);

$server->on('request', function (Request $request, Response $response): void {
    $method = $request->server['request_method'] ?? 'GET';
    $uri    = $request->server['request_uri']    ?? '/';
    $body   = $request->rawContent();
    $response->status(200);
    $response->header('Content-Type', 'application/json');
    $response->end(json_encode(['method' => $method, 'uri' => $uri]));
});

$server->on('workerStart', function (Server $server, int $workerId): void {
    // Reset per-worker state, open DB/Redis pools here
});

$server->start();

Server constructor

new Swoole\Server(
    string $host = '0.0.0.0',
    int $port = 0,
    int $mode = SWOOLE_BASE,           // SWOOLE_BASE (default), SWOOLE_PROCESS, SWOOLE_THREAD (6.0+ ZTS)
    int $sock_type = SWOOLE_SOCK_TCP
)

Http\Server and WebSocket\Server extend Swoole\Server with the same constructor. SSL: OR sock type with SWOOLE_SSL.

All server events

onStart(Server $server)                    // master start; NOT in SWOOLE_BASE mode
onShutdown(Server $server)
onManagerStart(Server $server)             // NOT in SWOOLE_BASE mode
onManagerStop(Server $server)
onBeforeReload(Server $server)             // 4.5+
onAfterReload(Server $server)              // 4.5+
onWorkerStart(Server $server, int $workerId)
onWorkerStop(Server $server, int $workerId)
onWorkerExit(Server $server, int $workerId)   // fires when reload_async and worker is draining
onWorkerError(Server $server, int $workerId, int $workerPid, int $exitCode, int $signal)
onBeforeShutdown(Server $server)           // 4.8+
onPipeMessage(Server $server, int $srcWorkerId, mixed $message)

// TCP
onConnect(Server $server, int $fd, int $reactorId)
onReceive(Server $server, int $fd, int $reactorId, string $data)
onPacket(Server $server, string $data, array $clientInfo)   // UDP
onClose(Server $server, int $fd, int $reactorId)

// Task workers
onTask(Server $server, Swoole\Server\Task $task)
onFinish(Server $server, int $taskId, mixed $data)

// HTTP
onRequest(Swoole\Http\Request $request, Swoole\Http\Response $response)

// WebSocket
onHandShake(Http\Request $request, Http\Response $response): bool
onOpen(WebSocket\Server $server, Http\Request $request)
onMessage(WebSocket\Server $server, WebSocket\Frame $frame)

Core server methods

public function set(array $settings): bool;
public function on(string $event, callable $callback): bool;
public function start(): bool;
public function stop(int $workerId = -1): bool;
public function shutdown(): bool;
public function reload(bool $onlyReloadTaskworker = false): bool;

public function send(int|string $fd, string $data, int $serverSocket = -1): bool;
public function close(int $fd, bool $reset = false): bool;
public function exists(int $fd): bool;
public function pause(int $fd): bool;
public function resume(int $fd): bool;

public function task(mixed $data, int $workerIdx = -1, ?callable $finishCb = null): int|false;
public function taskwait(mixed $data, float $timeout = 0.5, int $workerIdx = -1): mixed;
public function taskCo(array $tasks, float $timeout = 0.5): array|false;
public function finish(mixed $data): bool;

public function sendMessage(mixed $message, int $dstWorkerId): bool;
public function addProcess(Swoole\Process $process): int|false;

public function getClientInfo(int $fd, int $reactorId = -1): false|array;
public function getWorkerId(): int|false;
public function stats(): array;

Swoole\Http\Request properties

public int    $fd;
public int    $streamId;
public array  $header;     // lowercase keys
public array  $server;     // request_method, request_uri, query_string, request_time, remote_addr, ...
public ?array $cookie;
public array  $get;
public array  $post;
public array  $files;

public function rawContent(): string|false;   // alias getContent()
public function getMethod(): string|false;

Swoole\Http\Response methods

public function status(int $httpCode, string $reason = ''): bool;
public function header(string $key, string|array $value, bool $format = true): bool;
public function cookie(string $name, string $value = '', int $expires = 0,
    string $path = '/', string $domain = '', bool $secure = false, bool $httponly = false,
    string $samesite = '', string $priority = ''): bool;
public function trailer(string $key, string $value): bool;
public function write(string $content): bool;     // chunked; disables compression
public function end(?string $content = null): bool;
public function sendfile(string $filename, int $offset = 0, int $length = 0): bool;
public function redirect(string $location, int $httpCode = 302): bool;
public function detach(): bool;

// Upgrade to WebSocket from an HTTP server
public function upgrade(): bool;
public function push(Frame|string $data, int $opcode = WEBSOCKET_OPCODE_TEXT, int $flags = WEBSOCKET_FLAG_FIN): bool;
public function recv(float $timeout = 0): Frame|string|false;
public function close(): bool;

Server modes

Default mode changed from SWOOLE_PROCESS to SWOOLE_BASE in 5.0. SWOOLE_BASE has no manager process -- workers accept directly. SWOOLE_THREAD (6.0+) requires ZTS PHP.

Dispatch modes

Mode Constant Use for
1 SWOOLE_DISPATCH_ROUND Stateless, async only; onConnect/onClose suppressed
2 SWOOLE_DISPATCH_FDMOD Stateful TCP (fd % worker_num)
3 SWOOLE_DISPATCH_IDLE_WORKER Preemptive to idle worker -- recommended for HTTP
4 SWOOLE_DISPATCH_IPMOD Sticky by client IP
5 SWOOLE_DISPATCH_UIDMOD Sticky by $server->bind($fd, $uid)
7 SWOOLE_DISPATCH_STREAM Workers accept themselves
9 SWOOLE_DISPATCH_CO_REQ_LB Coroutine request LB (best for stateless HTTP)

Response gotchas

  • end() can only be called once.
  • status()/header()/cookie() must be called before the first write() or end().
  • write() switches to chunked transfer and disables compression.

Task workers

// Fire-and-forget
$server->task(['job' => 'resize', 'file' => $file]);

// Concurrent batch (requires task_enable_coroutine=true)
$results = $server->taskCo([['job' => 'a'], ['job' => 'b']], 5.0);

Caveat: task()/taskwait() only callable from event workers. Prefer taskCo() when task_enable_coroutine=true.

WebSocket server

WS-specific methods:

public function push(int $fd, Frame|string $data, int $opcode = WEBSOCKET_OPCODE_TEXT, int $flags = WEBSOCKET_FLAG_FIN): bool;
public function isEstablished(int $fd): bool;    // NOT exists() -- use this for WS
public function disconnect(int $fd, int $code = WEBSOCKET_CLOSE_NORMAL, string $reason = ''): bool;
public function ping(int $fd, string $data = ''): bool;

Use $server->isEstablished($fd) (NOT exists()) to check WS connection. onHandShake gives full control of the handshake; onOpen fires after the built-in one.

Broadcast pattern:

foreach ($server->connections as $fd) {
    if ($server->isEstablished($fd)) $server->push($fd, $payload);
}

TCP packet framing

open_length_check + package_length_type delivers complete packets to onReceive. EOF variant: 'open_eof_split' => true, 'package_eof' => "\r\n".

Graceful reload

  • $server->reload() -> workers finish in-flight, exit, get replaced.
  • reload_async=true: old workers keep running current coroutines. onWorkerExit fires when done.
  • Smooth reload only picks up files require'd inside onWorkerStart. Files required before start() stay cached forever.
  • If opcache.validate_timestamps=0, add opcache_reset() at the top of onWorkerStart.

6. Process and Process\Pool

Swoole\Process

final class Swoole\Process {
    public int $pipe;
    public int $pid;
    public int $id;

    public function __construct(
        callable $callback,
        bool $redirectStdinStdout = false,
        int $pipeType = SOCK_DGRAM,    // 0=none, 1=SOCK_STREAM, 2=SOCK_DGRAM
        bool $enableCoroutine = false
    );

    public function start(): bool|int;
    public function write(string $data): int|false;
    public function read(int $size = 8192): string|false;

    // Sysv message queue IPC
    public function useQueue(int $key = 0, int $mode = 2, int $capacity = -1): bool;
    public function push(string $data): bool;
    public function pop(int $size = 65536): string|false;

    public function exportSocket(): Swoole\Coroutine\Socket|false;
    public function name(string $name): bool;
    public function exit(int $exitCode = 0): void;

    public static function wait(bool $blocking = true): array|false;
    public static function signal(int $signalNo, ?callable $callback = null): bool;
    public static function kill(int $pid, int $signalNo = SIGTERM): bool;
    public static function daemon(bool $nochdir = true, bool $noclose = true, array $pipes = []): bool;
}

$enableCoroutine = true makes the callback run inside a coroutine scheduler. Signal handling: use Swoole\Process::signal(), not pcntl_signal().

Swoole\Process::signal(SIGTERM, fn() => $server->shutdown());
Swoole\Process::signal(SIGINT,  fn() => $server->shutdown());

Swoole\Process\Pool

final class Swoole\Process\Pool {
    public function __construct(
        int $workerNum,
        int $ipcType = SWOOLE_IPC_NONE,   // 0=none, 1=UNIXSOCK, 2=MSGQUEUE, 3=SOCKET
        int $msgqueueKey = 0,
        bool $enableCoroutine = false
    );
    public function set(array $settings): void;
    public function on(string $event, callable $cb): bool;
    public function getProcess(int $workerId = -1): Process|false;
    public function listen(string $host, int $port = 0, int $backlog = 2048): bool;
    public function write(string $data): bool;
    public function sendMessage(string $data, int $dstWorkerId): bool;
    public function start();
    public function stop(): void;
    public function shutdown(): bool;
}

Minimal supervised worker pool -- bring your own protocol, no reactor. Good for "run my callable across N processes with shared TCP socket". Events: WorkerStart, WorkerStop, Message, Start, Shutdown.

Pool vs Server: Pool has no onConnect/onReceive/HTTP/WS. Server is the full reactor + protocol helpers.


7. Shared memory -- Table, Atomic, Lock

Swoole\Table

Mmap'd shared hash table -- the only way to share state across workers. Per-row spinlocks + CAS.

final class Swoole\Table implements Iterator, Countable {
    public const TYPE_INT    = 1;
    public const TYPE_FLOAT  = 2;
    public const TYPE_STRING = 3;

    public function __construct(int $tableSize, float $conflictProportion = 0.2);
    public function column(string $name, int $type, int $size = 0): bool;
    public function create(): bool;
    public function destroy(): bool;

    public function set(string $key, array $value): bool;
    public function get(string $key, ?string $field = null): mixed;
    public function exists(string $key): bool;
    public function del(string $key): bool;
    public function count(): int;
    public function incr(string $key, string $column, int|float $incrby = 1): int|float;
    public function decr(string $key, string $column, int|float $incrby = 1): int|float;
    public function getSize(): int;
    public function getMemorySize(): int;
    public function stats(): array|false;
}
$table = new Swoole\Table(8192);
$table->column('name',  Swoole\Table::TYPE_STRING, 64);
$table->column('age',   Swoole\Table::TYPE_INT);
$table->column('score', Swoole\Table::TYPE_FLOAT);
$table->create();  // MUST create before $server->start()

$server->table = $table;  // attach so workers can reach it

Gotchas:

  • TYPE_STRING columns have fixed byte length; overflow silently truncated.
  • Size ~30% over peak for collision chains.
  • In 5.0+, Table no longer implements ArrayAccess -- use set()/get().

Atomic / Lock

Swoole\Atomic -- shared-memory counters (32-bit unsigned; Atomic\Long for 64-bit signed). wait()/wakeup() are futex-style and block the entire process -- use Channel in event workers instead.

Swoole\Lock -- process-level, NOT coroutine-safe. If a coroutine holds the lock and yields, deadlock. Use Channel(1) for coroutine-safe mutex. In 6.1+: API unified to lock($op = LOCK_EX, $timeout = -1), unlock(). lockwait()/trylock() removed.


8. Coroutine clients

Swoole\Coroutine\Http\Client

new Swoole\Coroutine\Http\Client(string $host, int $port, bool $ssl = false)
// $host = IP, domain (async DNS), or unix://tmp/foo.sock. Do NOT pass http:// prefix.

Properties: $errCode, $errMsg, $statusCode (negative = network issue), $body, $headers, $cookies, $set_cookie_headers.

Negative statusCode constants: -1 CONNECT_FAILED, -2 REQUEST_TIMEOUT, -3 SERVER_RESET, -4 SEND_FAILED.

set(array $options): void
setMethod(string $method): void
setHeaders(array $headers): void
setCookies(array $cookies): void
setData(string|array $data): void
addFile(string $path, string $name, ?string $mime = null, ?string $filename = null, int $offset = 0, int $length = 0): void
addData(string $data, string $name, ?string $mime = null, ?string $filename = null): void
get(string $path): bool
post(string $path, mixed $data): bool
download(string $path, string $filename, int $offset = 0): bool
upgrade(string $path): bool        // websocket handshake
push(mixed $data, int $opcode = WEBSOCKET_OPCODE_TEXT, int $flags = WEBSOCKET_FLAG_FIN): bool
recv(float $timeout = 0): Frame|false
close(): bool

Functional shortcuts (Swoole\Coroutine\Http namespace, >= 4.6.4):

use function Swoole\Coroutine\Http\{get, post, request};
$resp = get('https://httpbin.org/get?hello=world');

Swoole\Coroutine\Socket

Low-level coroutine-native socket for custom protocols.

new Swoole\Coroutine\Socket(int $domain, int $type, int $protocol);

bind(string $address, int $port = 0): bool
listen(int $backlog = 0): bool
accept(float $timeout = 0): Co\Socket|false
connect(string $host, int $port = 0, float $timeout = 0): bool
send(string $data, float $timeout = 0): int|false
sendAll(string $data, float $timeout = 0): int|false
recv(int $length = 65536, float $timeout = 0): string|false
recvAll(int $length, float $timeout = 0): string|false
recvPacket(float $timeout = 0): string|false   // uses framing from setProtocol()
recvLine(int $length = 65536, float $timeout = 0): string|false
setProtocol(array $settings): bool              // same framing options as Server->set()
checkLiveness(): bool
close(): bool

Removed coroutine clients (6.0+)

Removed entirely: Swoole\Coroutine\MySQL, Coroutine\Redis, Coroutine\PostgreSQL. Use hooked PDO/ext-redis with SWOOLE_HOOK_ALL. Never generate code using these for 6.x.


9. Hooked PDO and curl

Hooked PDO

With SWOOLE_HOOK_ALL, native PDO becomes coroutine-aware transparently.

One-connection-per-coroutine rule -- a single PDO handle is not safe across concurrent coroutines. The socket is bound to one coroutine; interleaved use corrupts the wire protocol. Use PDOPool.

Persistent connections are NOT compatible -- always PDO::ATTR_PERSISTENT => false.

SQLite caveat: pdo_sqlite hook requires SQLite in serialized/multi-threaded mode. Since 6.1.5, PDO::sqliteCreateAggregate/Collation/Function removed in coroutine mode.

Hooked curl

Use SWOOLE_HOOK_NATIVE_CURL (included in SWOOLE_HOOK_ALL), not legacy SWOOLE_HOOK_CURL. Guzzle and Symfony HttpClient work transparently:

Co\run(function () {
    $guzzle = new \GuzzleHttp\Client();
    $resp   = $guzzle->get('https://httpbin.org/get');  // coroutine-aware, no changes needed
});

10. Connection pooling

swoole/library ships with ext-swoole (auto-loaded via swoole.enable_library=On).

Canonical pattern -- "put it back or leak"

A pool connection that isn't returned is lost forever. Always try/finally:

use Swoole\Database\{PDOConfig, PDOPool};

$pool = new PDOPool(
    (new PDOConfig())
        ->withDriver('mysql')
        ->withHost('127.0.0.1')
        ->withPort(3306)
        ->withDbname('app')
        ->withCharset('utf8mb4')
        ->withUsername('app')
        ->withPassword('secret')
        ->withOptions([PDO::ATTR_EMULATE_PREPARES => false]),
    size: 32,
);

go(function () use ($pool) {
    $pdo = $pool->get();
    try {
        $stmt = $pdo->prepare('SELECT id, email FROM users WHERE id = :id');
        $stmt->execute(['id' => 1]);
    } finally {
        $pool->put($pdo);  // MUST return, even on exception
    }
});

// Or cleaner with defer:
go(function () use ($pool) {
    $conn = $pool->get();
    Co::defer(fn() => $pool->put($conn));
    // ... use $conn ...
});

Pool notes:

  • get(-1) blocks forever; get($timeout) returns false on timeout.
  • put(null) signals broken connection -- pool decrements count, rebuilds next get().
  • PDOProxy auto-reconnects on lost-connection exceptions outside transactions.
  • Transactions must begin and commit on the same connection. Don't return mid-transaction.
  • RedisPool / MysqliPool follow the same shape.
  • SQLite restriction: PDOPool rejects :memory: and empty DB names.

Channel-as-pool pattern

final class GrpcPool
{
    private Channel $channel;

    public function __construct(
        private readonly \Closure $factory,
        private readonly int $size = 32,
    ) {
        $this->channel = new Channel($size);
        for ($i = 0; $i < $size; $i++) {
            $this->channel->push(($this->factory)());
        }
    }

    public function get(float $timeout = -1): object
    {
        $conn = $this->channel->pop($timeout);
        if ($conn === false) throw new \RuntimeException('pool exhausted or closed');
        return $conn;
    }

    public function put(object $conn): void { $this->channel->push($conn); }
}

11. Pitfalls catalog

Never block inside a coroutine

These stall the whole worker (not just one coroutine):

  • mysql/mysqli/pdo_mysql without mysqlnd + hooks
  • sleep() without SWOOLE_HOOK_SLEEP
  • curl_* without SWOOLE_HOOK_NATIVE_CURL
  • file_*/fread/fwrite without SWOOLE_HOOK_FILE
  • Any extension bypassing PHP streams

Fix: SWOOLE_HOOK_ALL at bootstrap. Exclude flags only when a library fights the scheduler (e.g. SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP for PHPMailer raw SMTP).

Sharing a connection across coroutines

Swoole error: "redis client has already been bound to another coroutine". PDO/Redis/socket handles are bound to the first coroutine. Always pool, one checkout per coroutine.

Multiple processes must not share connections either. Create per-worker pools in onWorkerStart, not program-global.

pcntl_* is forbidden

pcntl_fork/pcntl_signal/pcntl_waitpid conflict with Swoole's signalfd. Use Swoole\Process, Process::signal(), Coroutine\System::waitSignal().

go() in onStart / onManagerStart

onStart runs in the master. Coroutine APIs there can conflict with dispatch_func and package_length_func. Put bootstrap in onWorkerStart.

Channel deadlocks

Channel::pop() with default timeout = -1 hangs forever if all producers exited and channel is empty. Always pass a timeout, or pair with $channel->close().

Cooperative != concurrent-safe

Read-modify-write across an I/O boundary is a data race:

// WRONG
$count = $table->get('k', 'count');
$count++;  // yield possible if I/O runs
$table->set('k', ['count' => $count]);

// RIGHT
$table->incr('k', 'count');

Exception handling across coroutines

Exceptions cannot cross coroutine boundaries. go(fn() => throw new X) inside an outer try/catch does nothing. Every top-level go() closure needs its own try/catch.

Stashing $this in context

Coroutine::getContext()['controller'] = $this keeps the controller alive -- usually a leak. Stash only data.

Framework compatibility

Framework Status
Hyperf Swoole-native, production-ready
Webman Fast, Swoole driver available
Imi Swoole-native
Laravel Octane Works, but use app->scoped() not singleton() for request-adjacent services
Slim / Mezzio Work via PSR-15 bridges, not Swoole-aware
Phalcon Incompatible -- C extension, globals-heavy

12. Production tuning

Worker sizing

Setting Guidance
worker_num Default swoole_cpu_num(). Async/coroutine: 1-4x cores
task_worker_num (tasks/sec / tasks/worker/sec). Default 0
max_request Respawn after N requests. Mitigates leaks. No effect in SWOOLE_BASE. 10k-50k reasonable
max_wait_time Drain timeout on reload/shutdown. 30-60s for HTTP
reload_async Set true for graceful coroutine reload
max_coroutine Per-worker cap, default 100000

Buffers

Setting Default Note
package_max_length 2 MB Max packet / HTTP POST body
buffer_output_size 2 MB Max send() payload. Memory = worker_num x size
socket_buffer_size 2 MB Per-connection send buffer

SSL / HTTP/2 / compression

  • ssl_cert_file / ssl_key_file: PEM. Set SWOOLE_SSL socket flag.
  • open_http2_protocol: requires --enable-http2.
  • http_compression: gzip/brotli/zstd (6.0+ added zstd).

Daemon / logs

  • daemonize: false under systemd/Docker/k8s. Only true from interactive shell.
  • log_file: use absolute paths (CWD changes after daemonize).
  • SIGRTMIN reopens the log after logrotate.
  • log_rotation: SWOOLE_LOG_ROTATION_DAILY|HOURLY|MONTHLY.

Kernel tuning (Linux)

ulimit -n 262140
net.core.somaxconn = 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_max_syn_backlog = 81920
net.core.wmem_max = 16777216
net.core.rmem_max = 16777216
net.unix.max_dgram_qlen = 100

Do not set net.ipv4.tcp_tw_recycle = 1 -- removed in Linux 4.12, unsafe with NAT.


13. Testing

PHPUnit entry point

public function testAsync(): void
{
    $result = null;
    \Swoole\Coroutine\run(function () use (&$result) {
        $client = new \Swoole\Coroutine\Http\Client('httpbin.org', 443, true);
        $client->get('/ip');
        $result = $client->body;
    });
    self::assertNotNull($result);
}

Resetting state

Workers are resident -- static state persists. In setUp(): reset singletons, run each test in its own Co\run (fresh context), Timer::clearAll(). In tearDown() assert Coroutine::stats()['coroutine_num'] === 0 -- non-zero means a leak.

IDE stubs

composer require --dev swoole/ide-helper

PHPStan: add vendor/swoole/ide-helper/src/swoole/constants.php to bootstrapFiles.


14. Swoole 6.x version notes

6.0 (2024-12)

  • Removed: Coroutine\MySQL, Coroutine\Redis, Coroutine\PostgreSQL. Use hooked PDO.
  • Requires PHP 8.1+.
  • Multi-threading: SWOOLE_THREAD, Swoole\Thread, Thread\Map/ArrayList/Queue/Lock/Barrier/Atomic. Requires ZTS + --enable-swoole-thread.
  • io_uring for file AIO: --enable-iouring.
  • Swoole\Async\Client (non-blocking TCP/UDP/Unix).
  • zstd HTTP compression.
  • Non-blocking reentrant coroutine mutex.

6.1 (2025-10)

  • llhttp replaces http_parser.
  • Lock API unified: lock($op = LOCK_EX, $timeout = -1), unlock(). lockwait()/trylock() removed.
  • Coroutine cancellation gains $throwException -> throws CanceledException.
  • WebSocket: disconnect(), ping(), fragmented messages.
  • Runtime hooks can only be set in main thread, before child threads.
  • 6.1.5: PDO::sqliteCreateAggregate/Collation/Function removed from coroutine mode.
  • 6.1.6: auto-strips SWOOLE_HOOK_SOCKETS if ext-sockets not loaded.
  • 6.1.7: hooked pdo_pgsql gained timeout.

6.2 (2026-04)

  • Requires PHP 8.2+. Supports up to PHP 8.5.
  • Coroutine FTP (--enable-swoole-ftp) and SSH (--with-swoole-ssh2) clients.
  • SWOOLE_HOOK_PDO_FIREBIRD, SWOOLE_HOOK_MONGODB, SWOOLE_HOOK_NET_FUNCTION.
  • Coroutine::setTimeLimit() -- per-coroutine timeout.
  • HTTP over io_uring sockets: --enable-uring-socket.
  • --enable-openssl removed (always on). liburing >= 2.8 required.
  • Coroutine::cancel() cancels in-flight io_uring ops.

Build flags cheatsheet

./configure --enable-swoole \
  --enable-sockets \
  --enable-swoole-thread \       # requires ZTS
  --enable-swoole-curl \          # SWOOLE_HOOK_NATIVE_CURL
  --enable-swoole-pgsql \
  --enable-swoole-sqlite \
  --with-swoole-firebird \        # 6.2+
  --with-swoole-ssh2 \            # 6.2+
  --enable-swoole-ftp \           # 6.2+
  --enable-iouring \              # Linux io_uring
  --enable-uring-socket \         # 6.2+
  --enable-brotli \
  --enable-zstd \                 # 6.0+
  --enable-cares                  # c-ares DNS

swoole/library version alignment

swoole/library is bundled into ext-swoole and auto-loaded. Composer releases are tagged for 6.1 and 6.2 (latest v6.2.3 as of 2026-09), so composer require swoole/library resolves to 6.2.x. Treat the extension as source of truth for library classes in production.


Quick reference -- canonical program skeleton

<?php
declare(strict_types=1);

use Swoole\Coroutine;
use Swoole\Coroutine\{Barrier, Channel};
use function Swoole\Coroutine\run;

Coroutine::set([
    'hook_flags'            => SWOOLE_HOOK_ALL,
    'max_coroutine'         => 10_000,
    'socket_timeout'        => 5,
    'enable_deadlock_check' => true,
]);

run(function (): void {
    $jobs    = new Channel(32);
    $results = new Channel(32);
    $barrier = Barrier::make();

    for ($w = 0; $w < 4; $w++) {
        go(function () use ($jobs, $results, $barrier): void {
            while (($job = $jobs->pop()) !== false) {
                $results->push(['job' => $job, 'pid' => getmypid()]);
            }
        });
    }

    go(function () use ($jobs, $barrier): void {
        foreach (range(1, 20) as $i) $jobs->push($i);
        $jobs->close();
    });

    go(function () use ($results, $barrier): void {
        defer(fn() => $results->close());
        $collected = [];
        for ($i = 0; $i < 20; $i++) {
            $msg = $results->pop(5.0);
            if ($msg === false) break;
            $collected[] = $msg;
        }
        var_dump($collected);
    });

    Barrier::wait($barrier);
});