Festi Async Framework
A small PHP 8 framework for building long-running TCP services — multiplayer game servers, persistent socket APIs, real-time backends. It gives you a clean lifecycle, a JSON-RPC layer, pluggable I/O, and an offline test harness, so the code you write is domain logic instead of socket plumbing.
Package: festi-team/festi-framework-async.
Table of contents
- What it does
- How it fits together
- Requirements
- Install
- Hello world: an echo daemon
- Building a JSON-RPC service
- Timers
- Project structure for a real service
- Testing
- Docker
- Running in production
- Quality gates
- Project layout
- Further reading
What it does
You write a small Service class. The framework gives you:
- A non-blocking event loop (Swoole, libevent, libev or EventEvent — pluggable via
IEventWrapper). - A wire protocol (
FestiJsonProtocolby default: JSON frames terminated by^). - A request lifecycle:
onInit→onStart→ per-messageonRequest→onClose. SubclassPersistServicefor connection-persistent servers. - A JSON-RPC 2.0 extension — write methods annotated with
#[RpcMethod]on plain classes (called plugins); parameters are auto-deserialised from the request, including typed value objects. - Sessions per connection that survive across requests on the same socket.
- Timers scoped to the event loop.
- An offline test harness that drives your service through an in-memory wrapper — no real sockets, no flakiness.
Two single-letter conventions to remember: requests/responses are JSON frames terminated by ^, and the loop is single-threaded — never block inside a handler.
How it fits together
┌─────────────────────────────────────────────────────────────┐
│ Your Service │
│ extends AbstractService | PersistService │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Extensions (composable, run in order) │ │
│ │ ┌─────────────┐ ┌────────────────┐ │ │
│ │ │ RpcExtension│ │ Your extension │ ... │ │
│ │ │ → plugins │ │ │ │ │
│ │ └─────────────┘ └────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Protocol (IProtocol) ────► reads/writes wire frames │
│ Event wrapper (IEventWrapper) ────► Swoole / libevent / │
│ libev / EventEvent / │
│ TestEventWrapper │
└─────────────────────────────────────────────────────────────┘
Three single-responsibility interfaces — IEventWrapper (I/O), IProtocol (framing), IExtension (handler) — make every layer swappable. The same service code runs against a real Swoole socket in production and against TestEventWrapper in your unit tests.
Requirements
- PHP 8.0+ (CI runs PHP 8.4)
- One non-blocking I/O extension:
swoole(default), orevent(libevent),libev, orEventEvent - PHP extensions:
pcntl,pdo,intl,sockets,json,simplexml - Composer
- For integration tests:
screenandpson$PATH(the test harness forks daemons viascreen -d -m)
Install
composer require festi-team/festi-framework-async:dev-develop
This brings in the four festi-framework-* sibling packages it depends on (core, cacher, database, serialization).
Hello world: an echo daemon
A complete server, runnable end-to-end. Save as server.php:
<?php
require __DIR__ . '/vendor/autoload.php';
use core\async\wrapper\WrapperFactory;
use core\async\wrapper\WrapperConfig;
use core\async\wrapper\IConnection;
use core\async\service\PersistService;
use core\async\service\ISessionStorage;
use core\async\service\SessionStorage;
use core\async\service\model\Request;
use core\async\service\model\Response;
use core\async\service\model\Session;
final class EchoService extends PersistService
{
protected function onInit(): void {}
protected function onStart(): void {}
public function onRequest(
Request &$request,
Response &$response,
Session &$session,
IConnection $connection
): bool {
$response['echo'] = $request['msg'] ?? null;
return true; // mark request handled
}
public function createSessionStorage(): ISessionStorage
{
$store = [];
return new SessionStorage(\Cacher::factory($store));
}
}
$wrapper = WrapperFactory::create(new WrapperConfig());
$server = new EchoService('tcp://0.0.0.0:20001', $wrapper);
$server->start();
Run it:
php server.php
Talk to it:
$ telnet 127.0.0.1 20001
{"msg":"hi"}^
{"echo":"hi"}^
The ^ is the protocol's frame terminator. Send it after every JSON document. The server replies with another ^-terminated frame.
Building a JSON-RPC service
The echo service above uses raw onRequest. For anything non-trivial, register RpcExtension and let it dispatch to plain plugin classes:
// src/plugin/MathPlugin.php
namespace app\plugin;
use core\async\service\plugin\AbstractAsyncPlugin;
use core\async\service\rpc\RpcMethod;
class MathPlugin extends AbstractAsyncPlugin
{
#[RpcMethod] // exposed as "add"
public function add(int $a, int $b): int
{
return $a + $b;
}
#[RpcMethod(name: 'math.sub')] // exposed as "math.sub"
public function subtract(int $a, int $b): int
{
return $a - $b;
}
}
Wire it up inside your service's onInit:
public function onInit(): void
{
$rpc = new \core\async\service\rpc\RpcExtension($this->logger);
$rpc->addComponent(\app\plugin\MathPlugin::class);
$this->addExtension($rpc);
}
Call it:
$ telnet 127.0.0.1 20001
{"jsonrpc":"2.0","method":"add","params":[2,3],"id":1}^
{"jsonrpc":"2.0","id":1,"result":5}^
What you get for free:
- Exceptions thrown from a plugin become
{"error":{"code":...,"message":...}}responses (RpcExceptioncodes pass through; anything else returns 500 Internal Error and is logged). - Typed object parameters in plugin signatures are auto-hydrated by
ObjectParameterDecorator. Type-hint a value object and the RPC layer instantiates and populates it from the JSON payload. - Batch calls work: send a JSON array of requests, get an array of responses.
- Plugins can access the active session (
$this->session), connection ($this->connection), and service ($this->service) — they're injected before each call.
Timers
Schedule recurring work on the event loop with addTimer. Use it from onStart (or anywhere you hold a service reference) for heartbeats, queue drains, garbage collection, broadcast ticks, etc:
protected function onStart(): void
{
$tick = 0;
$this->addTimer('heartbeat', function () use (&$tick) {
$tick++;
$this->logger->i("heartbeat #{$tick}");
if ($tick >= 10) {
$this->removeTimer('heartbeat');
}
}, 2000); // interval in milliseconds
}
Timer names are unique per service — re-registering a name throws. Callbacks must not block; the loop is single-threaded.
Project structure for a real service
Production consumers follow this shape:
my-service/
├── bin/
│ ├── server.sh # supervisor-friendly start script
│ ├── main.php # boots WrapperFactory + your Service
│ ├── common.php # loads config, autoload, include_path
│ └── config.php # constants: DB creds, ports, log dir
├── src/
│ ├── MyService.php # extends PersistService, registers extensions
│ ├── plugin/ # RPC plugins (#[RpcMethod] entry points)
│ ├── facade/ # business logic services
│ ├── storage/ # repositories
│ ├── domain/ # entities and domain events
│ └── libs/
│ ├── DefaultPlugin.php # your project's plugin base
│ └── vo/ # value objects auto-hydrated by RPC
├── tests/
│ ├── bootstrap.php
│ ├── phpunit.xml
│ └── MyServiceTestCase.php # extends OfflineFestiTestCase
└── composer.json
A typical bin/main.php:
<?php
define('FS_ROOT', __DIR__ . DIRECTORY_SEPARATOR);
[$host, $port] = [$argv[1] ?? '0.0.0.0', $argv[2] ?? '20001'];
require_once FS_ROOT . 'common.php';
$wrapper = \core\async\wrapper\WrapperFactory::create(
new \core\async\wrapper\WrapperConfig()
);
$server = new \app\MyService(
"tcp://$host:$port",
$wrapper,
new \core\logger\Logger($CONFIG['FS_LOGS'])
);
$server->start();
A typical plugin base that pulls session helpers into one place:
namespace app\libs;
use core\async\service\plugin\AbstractAsyncPlugin;
class DefaultPlugin extends AbstractAsyncPlugin
{
protected function getUserID(): int
{
$id = $this->session->get('id_user');
if (!$id) {
throw new \app\exception\PermissionException();
}
return $id;
}
}
Combine with a DI container (festi-team/festi-framework-di) and your plugins become thin RPC adapters in front of injected facades — testable in isolation.
Testing
Write tests against the real service class with TestEventWrapper instead of a real socket. No process fork, no port, no flake:
// tests/MyServiceTestCase.php
use core\async\service\IService;
use core\async\wrapper\test\TestEventWrapper;
use core\logger\Logger;
class MyServiceTestCase extends \OfflineFestiTestCase
{
protected function createService(): IService
{
return new \app\MyService(
'tcp://0.0.0.0:' . DAEMON_PORT,
new TestEventWrapper(),
new Logger(FS_LOGS)
);
}
protected function rpc(string $method, array $params = []): array
{
return $this->request([
'jsonrpc' => '2.0',
'method' => $method,
'params' => $params,
]);
}
}
// tests/Plugins/MathPluginTest.php
class MathPluginTest extends MyServiceTestCase
{
public function testAddsTwoNumbers(): void
{
$response = $this->rpc('add', [2, 3]);
$this->assertSame(5, $response['result']);
}
}
OfflineFestiTestCase is exposed via the framework's dev autoload. Your composer.json needs to map it:
"autoload-dev": {
"psr-4": {
"": ["tests", "vendor/festi-team/festi-framework-async/tests/util"]
}
}
Run from the tests/ directory (the bootstrap uses relative paths):
cd tests
../vendor/bin/phpunit --configuration ./phpunit.xml
For end-to-end tests that exercise a real socket, extend FestiAsyncTestCase instead — it forks a real daemon via screen and talks to it over TCP.
Docker
A Dockerfile and docker-compose.yml ship in the repo for the framework's own test suite, and the same image is a good starting point for your service.
composer install # on the host, where your SSH keys live
docker compose build # PHP 8.4 + Swoole + everything tests need
docker compose run --rm test
The compose test service mounts . into /app and runs phpunit against the host's vendor/. For an interactive shell with ~/.ssh mounted read-only:
docker compose --profile dev run --rm shell
Running in production
The shipped pattern is dead simple — bin/server.sh runs the daemon foreground for one tick, then again in the background:
#!/bin/bash
php main.php 0.0.0.0 2010
php main.php 0.0.0.0 2010 &>/dev/null &
In real deployments people usually replace this with systemd, supervisor, or a container orchestrator that respawns the process on exit. Whatever supervises it should:
- Restart on non-zero exit.
- Send
SIGTERMfor graceful shutdown —stop()calls$eventAdapter->stopBaseLoop(). - Rotate the log file passed to
core\logger\Logger.
Quality gates
# Code style (Festi standard, derived from PEAR + Squiz)
./vendor/bin/phpcs --standard=./.phan/ruleset.xml --extensions=php ./src/
# Static analysis (target: PHP 8.0)
./vendor/bin/phan
# Tests with coverage
cd tests && XDEBUG_MODE=coverage ../vendor/bin/phpunit --coverage-text
.gitlab-ci.yml runs all three on every push.
Project layout
src/
├── service/ AbstractService, PersistService, models/, extension/, rpc/
│ ├── AbstractService.php # base service: lifecycle + extension dispatch
│ ├── PersistService.php # connection-persistent variant
│ ├── extension/ # IExtension contract + Extension trait
│ ├── rpc/ # RpcExtension, RpcMethod attribute, plugin factory
│ ├── plugin/ # AbstractAsyncPlugin (base for RPC plugins)
│ └── model/ # Request, Response, Session DTOs
├── protocol/ BaseProtocol, FestiJsonProtocol
├── wrapper/ IEventWrapper + adapters
│ ├── swoole/ Production adapter
│ └── test/ TestEventWrapper for offline unit tests
├── logger/ ILogger, default file Logger
└── util/decorator/ Parameter decoders for the RPC extension
tests/
├── Integration/ Spawn a real daemon and connect over TCP
├── Service/ Offline tests via TestEventWrapper
├── EventWrapper/ Wrapper-layer tests
├── Extension/ RpcExtension tests
└── util/ OfflineFestiTestCase, FestiAsyncTestCase, helpers
tools/ Project scaffolder (`bash tools/install.sh`)
.phan/ phpcs ruleset + phan config + stubs for Swoole/libevent
Further reading
./AGENTS.md— architecture notes and recipes for AI coding assistants and human contributors digging into framework internals.CLAUDE.mdis a symlink to the same file.