TrueAsync Server examples
(PHP 8.6+, true_async_server 0.6+)
JSON API with parallel fan-out
use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;
use function Async\spawn;
use function Async\await_all;
$server = new HttpServer(
(new HttpServerConfig())
->addListener('0.0.0.0', 8080)
->setWorkers(\Async\available_parallelism())
);
$server->addHttpHandler(function ($req, $res) {
if ($req->getPath() !== '/dashboard') {
$res->setStatusCode(404)->json(['error' => 'not found']);
return;
}
$userId = (int) $req->getQueryParam('user_id');
// Three independent database queries in parallel
[$user, $posts, $followers] = await_all([
spawn(fn() => fetchUser($userId)),
spawn(fn() => fetchPosts($userId)),
spawn(fn() => fetchFollowers($userId)),
]);
$res->json([
'user' => $user,
'posts' => $posts,
'followers' => $followers,
]);
});
$server->start();Static + dynamic
use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;
use TrueAsync\StaticHandler;
use TrueAsync\StaticOnMissing;
$config = (new HttpServerConfig())
->addListener('0.0.0.0', 8080)
->setCompressionEnabled(true);
$server = new HttpServer($config);
// /assets/* is served from public/, no PHP handler
$server->addStaticHandler(
(new StaticHandler('/assets/', __DIR__ . '/public'))
->setIndexFiles('index.html')
->enablePrecompressed('br', 'gzip')
->setCacheControl('public, max-age=31536000, immutable')
);
// Everything else goes to PHP
$server->addHttpHandler(function ($req, $res) {
$res->setStatusCode(200)->html('<h1>Dynamic route: ' . htmlspecialchars($req->getPath()) . '</h1>');
});
$server->start();Multipart upload with file move
use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;
$server = new HttpServer(
(new HttpServerConfig())
->addListener('0.0.0.0', 8080)
->setMaxBodySize(100 * 1024 * 1024)
);
$server->addHttpHandler(function ($req, $res) {
if ($req->getMethod() !== 'POST') {
$res->setStatusCode(405); return;
}
$avatar = $req->getFile('avatar');
if ($avatar === null || !$avatar->isValid()) {
$res->setStatusCode(400)->json(['error' => 'no valid avatar']);
return;
}
if ($avatar->getSize() > 5 * 1024 * 1024) {
$res->setStatusCode(413)->json(['error' => 'too big']);
return;
}
$target = '/var/storage/avatars/' . bin2hex(random_bytes(8)) . '.bin';
$avatar->moveTo($target);
$res->json([
'saved' => $target,
'name' => $avatar->getClientFilename(),
'mime' => $avatar->getClientMediaType(),
'size' => $avatar->getSize(),
]);
});
$server->start();Streaming a large upload without holding it in RAM
$server = new HttpServer(
(new HttpServerConfig())
->addListener('0.0.0.0', 8080)
->setBodyStreamingEnabled(true)
->setMaxBodySize(10 * 1024 * 1024 * 1024) // 10 GiB
);
$server->addHttpHandler(function ($req, $res) {
$id = bin2hex(random_bytes(8));
$fp = fopen("/var/storage/uploads/$id.bin", 'wb');
if ($fp === false) {
$res->setStatusCode(500)->json(['error' => 'open failed']); return;
}
$total = 0;
try {
while (($chunk = $req->readBody()) !== null) {
fwrite($fp, $chunk);
$total += strlen($chunk);
}
} finally {
fclose($fp);
}
$res->json(['id' => $id, 'size' => $total]);
});
$server->start();SSE (Server-Sent Events)
$server->addHttpHandler(function ($req, $res) {
if ($req->getPath() !== '/events') {
$res->setStatusCode(404); return;
}
for ($i = 0; $i < 60; $i++) {
$res->sseEvent(json_encode(['t' => time(), 'i' => $i]));
if (!$res->sendable()) {
break; // the client is gone, no point waiting
}
\Async\delay(1000);
}
$res->end();
});See the SSE guide for the full walkthrough of every method.
WebSocket (echo server)
use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;
use TrueAsync\WebSocket;
$server = new HttpServer(
(new HttpServerConfig())
->addListener('0.0.0.0', 8080)
);
$server->addWebSocketHandler(function (WebSocket $ws) {
foreach ($ws as $msg) {
if ($msg->binary) {
$ws->sendBinary($msg->data);
} else {
$ws->send('echo: ' . $msg->data);
}
}
});
// Required: the server refuses to start without an HTTP handler, and it answers
// the requests that are not upgrades.
$server->addHttpHandler(fn ($req, $res) => $res->setStatusCode(404)->end());
$server->start();Broadcasting to every client — including those served by other worker threads — is what topics are for. Subscribe on connect, publish() to fan out; no shared array, no setWorkers(1):
use TrueAsync\HttpRequest;
$server = new HttpServer(
(new HttpServerConfig())
->addListener('0.0.0.0', 8080)
->setWorkers(4) // a real chat, across 4 threads
);
$server->addWebSocketHandler(function (WebSocket $ws, HttpRequest $req) {
$room = ltrim($req->getPath(), '/') ?: 'lobby';
$ws->subscribe("chat/$room");
foreach ($ws as $msg) {
$ws->publish("chat/$room", $msg->data); // reaches subscribers on ALL workers
}
});
$server->addHttpHandler(fn ($req, $res) => $res->setStatusCode(404)->end());
$server->start();publish() never suspends: a peer whose socket is backed up drops the message rather than stalling the rest of the topic. See the WebSocket guide for the topic model, filters, and rate limits.
File download with auth
use TrueAsync\SendFileOptions;
use TrueAsync\SendFileDisposition;
$server->addHttpHandler(function ($req, $res) {
$token = $req->getHeader('Authorization');
if (!isValidToken($token)) {
$res->setStatusCode(401); return;
}
$reportId = preg_replace('#[^a-z0-9-]#', '', $req->getQueryParam('id') ?? '');
if ($reportId === '') {
$res->setStatusCode(400); return;
}
$res->sendFile("/var/storage/reports/$reportId.pdf", new SendFileOptions(
contentType: 'application/pdf',
disposition: SendFileDisposition::ATTACHMENT,
downloadName: "report-$reportId.pdf",
cacheControl: 'private, no-store',
));
});Redirect
$server->addHttpHandler(function ($req, $res) {
if ($req->getPath() === '/old-url') {
$res->redirect('/new-url', 301);
return;
}
// ...
});Pre-encoded JSON (skip re-encoding)
$cache = new RedisCache();
$server->addHttpHandler(function ($req, $res) use ($cache) {
$key = 'feed:' . $req->getQueryParam('uid');
$cached = $cache->get($key);
if ($cached !== null) {
// a string → sent as is, without re-packing
$res->json($cached);
return;
}
$payload = buildFeed($req);
$cache->set($key, $payload = json_encode($payload), 60);
$res->json($payload);
});Bailout firewall: fatals do not bring the server down
A deliberately broken handler:
$server->addHttpHandler(function ($req, $res) {
if ($req->getPath() === '/boom') {
throw new \Error('uncaught fatal');
}
$res->setStatusCode(200)->setBody('ok');
});A request to /boom produces 500 Internal Server Error, the handler coroutine drains, and the listener keeps accepting new connections. The same behaviour applies to E_ERROR, OOM, and uncaught exceptions during shutdown. Works on H1, H2, and H3.
Custom HTTP exception
TrueAsync\HttpException extends Async\AsyncCancellation. Throw it anywhere inside the handler to send a specific HTTP status through the normal cancellation chain.
use TrueAsync\HttpException;
class NotFoundException extends HttpException {}
class ForbiddenException extends HttpException {}
$server->addHttpHandler(function ($req, $res) {
$user = User::find($req->getQueryParam('id'))
?? throw new NotFoundException('user not found', 404);
if (!$user->canBeViewedBy(currentUser()))
throw new ForbiddenException('access denied', 403);
$res->json($user->toArray());
});The status is taken from $code (must be 4xx/5xx, otherwise 500) and the body from $message.
See also
examples/in the repository (minimal-server.php,demo-server.php,multi-worker.php,multi-worker-manual.php)- Configuration
- Multi-worker