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

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 (FestiJsonProtocol by default: JSON frames terminated by ^).
  • A request lifecycle: onInitonStart → per-message onRequestonClose. Subclass PersistService for 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), or event (libevent), libev, or EventEvent
  • PHP extensions: pcntl, pdo, intl, sockets, json, simplexml
  • Composer
  • For integration tests: screen and ps on $PATH (the test harness forks daemons via screen -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 (RpcException codes 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 SIGTERM for 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.md is a symlink to the same file.