Tutorial¶
This tutorial builds a small real-time application from an empty directory, one working piece at a time: a reply that comes back immediately, one that comes back later through a queue, one that fires on its own schedule, and a browser page that watches all three happen live. Every step leaves you with something you can actually run and test before moving to the next one.
The finished shape of this tutorial — the same pieces, with a more
developed dashboard in place of this guide’s plain log page — ships
ready to run as kinetis/pingpong. See
Starting from kinetis/pingpong instead
if you’d rather begin from that and modify it.
What you’ll build¶
A tiny “ping/pong” API:
POST /pong/directreplies in the same request.POST /pong/queuedreplies a few seconds later, from a separate worker process.A scheduled command replies on its own, every few seconds, with no request involved at all.
A browser page watches every one of those happen in real time, over a public WebSocket channel, plus a private one it has to authorize against first.
Each piece is stored in a database, so MySQL, a migration, and the
query builder come first, and the same table again as mapped entities
right after — everything from there builds on having somewhere to write
a row.
Requirements¶
PHP 8.4 or later
Docker, with Compose
Note
PHP version support policy. Kinetis targets PHP’s own currently
actively supported minor versions — those still receiving both bug and
security fixes upstream, not the older security-only tail. Today that is
PHP 8.4 and 8.5, tracked by every package’s composer.json floor and by
the CI matrix (see Appendix: Continuous Integration). As new minors enter active
support and older ones age out, Kinetis’s floor and CI move with them:
this is a policy of following PHP’s own release lifecycle, not a
commitment to 8.4/8.5 specifically.
Setting up the project¶
mkdir ping-pong && cd ping-pong
composer init --name=you/ping-pong --type=project --no-interaction
composer require kinetis/framework
Add a PSR-4 mapping for your own code to composer.json:
{
"require": {
"kinetis/framework": "^1.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
composer dump-autoload
That mapping is not optional. Kinetis finds your controllers, commands
and tools by reading composer.json’s own autoload.psr-4 section — it
never scans the filesystem blindly — so without one there is nothing for
it to look at. The namespace you choose does not matter, only that one is
declared.
Warning
Skipping it produces no error. Discovery finds zero routes and logs a
warning naming the gap (Kinetis\Cache\NamespaceScanner found no PSR-4 root [...] — did you forget an "autoload": {"psr-4": ...} entry in composer.json?), but every request still returns a plain 404, because
nothing was registered to match. If a route below 404s, check this
first, and look for that warning in your error log.
Last, a .env at the project root, read at startup before anything else
decides how to behave:
APP_ENV=development
# Serve /openapi.json and /openapi here. Unset, they are served nowhere.
OPENAPI_ENVIRONMENTS=development
APP_ENV=development is what keeps routes, commands and listeners
discovered from source on every boot. Caching & AOT Compilation covers what every
other environment does instead, and Configuration covers .env loading
in full.
A minimal controller¶
<?php
declare(strict_types=1);
namespace App\Http;
use Kinetis\Http\Attributes\Get;
final readonly class PingController
{
#[Get('/')]
public function index(): array
{
return ['message' => 'pong'];
}
}
Nothing registers it — any class anywhere under one of your own PSR-4 roots is discovered automatically, with no required directory or namespace convention. The entry point is the Composer autoloader plus one call:
<?php
declare(strict_types=1);
use Kinetis\Runtime\HttpStartup;
require dirname(__DIR__) . '/vendor/autoload.php';
HttpStartup::run(__DIR__);
Kinetis\Runtime\HttpStartup is the startup program itself, owned by the
framework: it loads .env, builds Config, discovers routes, global and
grouped middleware, event listeners and every installed package’s own
registrations, runs the package-then-application bootstrap chain, boots
the container, and hands the Kernel to whichever runtime adapter this
process is running under. Nothing later in this tutorial changes this
file — kinetis/skeleton and kinetis/pingpong ship exactly it.
Appendix: System Layout documents the order it runs in.
Running it¶
Three files get docker compose running PHP-FPM behind nginx:
FROM php:8.4-fpm-alpine
# unzip is what Composer extracts downloaded packages with. pcntl is
# what lets the queue-worker service below stop gracefully; the official
# images do not load it, and building it needs the toolchain for the
# length of this step only.
RUN apk add --no-cache unzip \
&& apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
&& docker-php-ext-install pcntl \
&& apk del .build-deps
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
# Kinetis reads and bounds the request body itself; PHP must not parse
# it first. Required, not tuning.
RUN printf 'enable_post_data_reading=0\n' > /usr/local/etc/php/conf.d/zz-kinetis.ini
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["php-fpm", "-F"]
That enable_post_data_reading=0 line is required, not tuning:
Runtime Adapters covers what it does and why the SAPI adapters
refuse to run without it.
#!/bin/sh
set -e
composer install --no-interaction --no-progress
exec "$@"
chmod +x docker/entrypoint.sh
server {
listen 8080;
root /app/public;
index index.php;
# nginx reads the body before PHP does, so this must be at least
# Kinetis's MAX_BODY_SIZE; raise both together.
client_max_body_size 2m;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /app/public/index.php;
include fastcgi_params;
}
}
services:
app:
build:
context: .
dockerfile: docker/Dockerfile
volumes:
- .:/app
- vendor:/app/vendor
env_file: .env
nginx:
image: nginx:alpine
volumes:
- .:/app
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "8080:8080"
depends_on:
app:
condition: service_started
volumes:
vendor:
The vendor volume keeps installed dependencies out of your own project
directory, so the container’s composer install never writes into it
directly. app runs PHP-FPM; nginx proxies HTTP requests to it over
FastCGI. This split matters beyond just “how do I serve HTTP”: PHP-FPM
reboots the whole public/index.php script on every single request, and
under APP_ENV=development that reboot re-runs discovery from source, so
an edit to a controller takes effect on your very next request, no
restart needed. A persistent-worker runtime like FrankenPHP or RoadRunner
can’t offer that (once a class is loaded in a worker process, PHP has no
way to redeclare it with new content), which is why local development
here runs on PHP-FPM rather than a worker mode — see
Runtime Adapters for when to reach for one instead.
docker compose up --build
curl http://localhost:8080/
# {"message":"pong"}
That route already documents itself. Open
http://localhost:8080/openapi for a Swagger UI, and
http://localhost:8080/openapi.json for the OpenAPI 3.1 document behind
it — both generated from the attributes you just wrote, with nothing to
annotate or keep in step. Both answer here because .env named this
environment in OPENAPI_ENVIRONMENTS; unset, neither is served anywhere.
Every route you add below appears there as you go.
Routing & Validation covers what the generator reads.
Storing pings: MySQL, migrations, and the query builder¶
composer require kinetis/migrations kinetis/query-builder
The connection settings go into .env, read by both the app and the
tools you’re about to add:
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_NAME=pingpong
DB_USER=pingpong
DB_PASSWORD=pingpong
A migration for the table every scenario writes to:
<?php
declare(strict_types=1);
use Kinetis\Persistence\Contract\MysqlLink;
use Kinetis\Persistence\Contract\PostgresLink;
use Kinetis\Migrations\Migration;
return new class implements Migration
{
public function up(MysqlLink|PostgresLink $db): void
{
$db->execute(<<<'SQL'
CREATE TABLE ping_messages (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
scenario VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
ponged_at DATETIME NULL
)
SQL);
}
public function down(MysqlLink|PostgresLink $db): void
{
$db->execute('DROP TABLE ping_messages');
}
};
A small repository wraps reading and writing that table:
<?php
declare(strict_types=1);
namespace App\Repositories;
use Kinetis\Persistence\Contract\MysqlLink;
use Kinetis\QueryBuilder\Query;
final readonly class PingRepository
{
public function __construct(
private MysqlLink $db,
) {}
public function create(string $scenario): int
{
$id = new Query($this->db)->table('ping_messages')->insertGetId([
'scenario' => $scenario,
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
]);
return (int) $id;
}
public function markPonged(int $id): void
{
new Query($this->db)->table('ping_messages')->where('id', '=', $id)->update([
'status' => 'ponged',
'ponged_at' => date('Y-m-d H:i:s'),
]);
}
}
PingRepository needs a real MysqlLink client registered before the
container locks its bindings — and kinetis/database-bridge, which
kinetis/migrations requires, does that itself: its package bootstrap (see CLI) reads the DB_* keys you
just put in .env and binds the connection under MysqlLink, with no
wiring of your own. public/index.php does not change: running every
installed package’s bootstrap before the container boots is already part
of what HttpStartup::run() does.
The image needs one more extension: under PHP-FPM, DB_DRIVER=auto
resolves to the PDO MySQL driver, which the official PHP images don’t
build in.
apk add line¶# unzip is what Composer extracts downloaded packages with; pdo_mysql is
# the driver DB_DRIVER=auto picks under PHP-FPM.
RUN apk add --no-cache unzip \
&& docker-php-ext-install pdo_mysql
Now update the controller to actually create and reply to a ping:
<?php
declare(strict_types=1);
namespace App\Http;
use App\Repositories\PingRepository;
use Kinetis\Http\Attributes\Post;
final readonly class PingController
{
public function __construct(
private PingRepository $pings,
) {}
#[Post('/pong/direct')]
public function direct(): array
{
$id = $this->pings->create('direct');
$this->pings->markPonged($id);
return ['id' => $id, 'status' => 'ponged'];
}
}
A database needs a place to run, and a migration needs to run against it before the app starts serving requests:
services:
app:
build:
context: .
dockerfile: docker/Dockerfile
volumes:
- .:/app
- vendor:/app/vendor
env_file: .env
depends_on:
migrate:
condition: service_completed_successfully
entrypoint: []
command: ["php-fpm", "-F"]
nginx:
image: nginx:alpine
volumes:
- .:/app
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "8080:8080"
depends_on:
app:
condition: service_started
mysql:
image: mysql:8.4
environment:
MYSQL_DATABASE: pingpong
MYSQL_USER: pingpong
MYSQL_PASSWORD: pingpong
MYSQL_ROOT_PASSWORD: root
healthcheck:
# -h 127.0.0.1 (TCP), not -h localhost (socket): the image's init
# phase runs a temporary skip-networking server whose socket answers
# a ping before the real server listens on TCP.
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-proot"]
interval: 5s
timeout: 5s
retries: 20
migrate:
build:
context: .
dockerfile: docker/Dockerfile
volumes:
- .:/app
- vendor:/app/vendor
env_file: .env
depends_on:
mysql:
condition: service_healthy
command: ["php", "vendor/bin/kinetis", "migrate"]
healthcheck:
disable: true
volumes:
vendor:
migrate is the one service that installs dependencies and runs to
completion — app now waits for it, with its own entrypoint cleared so
it doesn’t also try to install into the same shared vendor volume at
the same time.
docker compose up --build
curl -X POST http://localhost:8080/pong/direct
# {"id":1,"status":"ponged"}
The same pings as entities: kinetis/orm¶
What you just built works, and nothing below takes it away: a repository
that writes its own two statements is a complete, supported way to store
a ping. This section builds the second flavor of the same thing — the
rows become mapped objects and the statements become a unit of work —
because it is the shape kinetis/pingpong ships with, and everything
after this point builds on it.
composer require kinetis/orm
kinetis/orm is a data mapper over the query builder you just used, and
kinetis/database-bridge — already installed, since kinetis/migrations
requires it — is what wires it into the application. You will not add a
line to bootstrap.php for it.
Since the ORM builds on the query builder, the application stops naming it:
composer remove kinetis/query-builder
The package stays installed — kinetis/orm requires it — but as a
transitive dependency rather than a direct one.
First the table has to hold what an entity writes. The ORM writes a
timestamp as a UTC instant to the microsecond, and a plain DATETIME
cannot retain a fractional second: it discards the fraction on the way
in, and the stored row silently stops matching the object that wrote it.
The create migration you already ran stays exactly as it is — a second
migration widens the two columns:
<?php
declare(strict_types=1);
use Kinetis\Persistence\Contract\MysqlLink;
use Kinetis\Persistence\Contract\PostgresLink;
use Kinetis\Migrations\Migration;
return new class implements Migration
{
public function up(MysqlLink|PostgresLink $db): void
{
$db->execute(<<<'SQL'
ALTER TABLE ping_messages
MODIFY created_at DATETIME(6) NOT NULL,
MODIFY ponged_at DATETIME(6) NULL
SQL);
}
public function down(MysqlLink|PostgresLink $db): void
{
$db->execute(<<<'SQL'
ALTER TABLE ping_messages
MODIFY created_at DATETIME NOT NULL,
MODIFY ponged_at DATETIME NULL
SQL);
}
};
A new file rather than an edit to the first one: kinetis_migrations
already records the create as applied, so changing it would change
nothing in a database that ran it. The migrate service applies this on
the next docker compose up.
Now the row as a class:
<?php
declare(strict_types=1);
namespace App\Entities;
use DateTimeImmutable;
use DateTimeZone;
use Kinetis\Orm\Attributes\Entity;
use Kinetis\Orm\Attributes\Id;
use LogicException;
#[Entity(table: 'ping_messages')]
final class Ping
{
#[Id(generated: true)]
private ?int $id = null;
private string $status = 'pending';
private DateTimeImmutable $createdAt;
private ?DateTimeImmutable $pongedAt = null;
public function __construct(private string $scenario)
{
$this->createdAt = self::now();
}
public function id(): int
{
return $this->id ?? throw new LogicException('A ping has no id until its insert has been flushed.');
}
public function pong(): void
{
$this->status = 'ponged';
$this->pongedAt = self::now();
}
private static function now(): DateTimeImmutable
{
return new DateTimeImmutable('now', new DateTimeZone('UTC'));
}
}
Every non-static property maps a column, named in snake case, so
pongedAt is ponged_at and all five columns of ping_messages are
covered without naming one of them. #[Id(generated: true)] says MySQL
assigns the key: the property is ?int, holds null until that row’s
INSERT commits, and id() refuses rather than handing a null onward for
the next layer to guess about. Each DateTimeImmutable property maps one
of the timestamp columns you just widened, and is UTC in and out.
Nothing registers this class. #[Entity] under your own PSR-4 root is
what discovery looks for, exactly as #[Post] and #[Command] are —
kinetis/database-bridge declares the entity scan, and APP_ENV=development
runs it at every boot. (Caching & AOT Compilation covers what production compiles
ahead of time instead.)
Note what the constructor does not do: loading a row never runs it. The
ORM allocates the object and writes each mapped property directly, so
$createdAt is the creation time of a new ping and never overwrites a
loaded one.
The repository keeps its two methods and the signatures the controller already calls, and swaps the statements for the unit of work:
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Entities\Ping;
use Kinetis\Orm\EntityManager;
final readonly class PingRepository
{
public function __construct(
private EntityManager $entities,
) {}
public function create(string $scenario): int
{
$ping = new Ping($scenario);
$this->entities->persist($ping);
$this->entities->flush();
return $ping->id();
}
public function markPonged(int $id): void
{
$ping = $this->entities->repository(Ping::class)->findOrFail($id);
$ping->pong();
$this->entities->flush();
}
}
persist() schedules an insert and flush() writes everything pending
in one transaction. Nothing flushes on its own — not at the end of a
request, not when the manager closes — so every write in this
application is a flush() you can point at.
create() flushes before it returns because the id is MySQL’s. Until
that INSERT commits there is no key to hand back, which is why id()
throws before a flush and returns the generated key after one.
markPonged() loads the ping, changes the object, and flushes. The ORM
compares each property against the snapshot the row was loaded with, so
the UPDATE carries status and ponged_at and nothing else.
findOrFail() throws when the row is not there: every caller in this
application passes an id a flush already committed, so a missing row is
a broken assumption, not a race worth retrying.
A direct ping therefore flushes twice — once to create, once to pong —
and the second one costs no SELECT: within one request the manager holds
one object per row, so findOrFail() returns the very ping the insert
just created.
That manager is per unit of work, and kinetis/database-bridge binds it
on every one: an HTTP request, a queued job, an MCP message, a command.
It opens the first time something resolves it, and when that scope is
disposed it is closed — everything detached, anything still unflushed
abandoned. Nothing survives into the next request of a persistent
worker, and nothing reaches the database that you did not flush. A
manager also belongs to the Fiber that opened it and refuses every
other, so concurrent work takes a unit of work of its own rather than
sharing one.
bootstrap.php, public/index.php and the controller are all unchanged:
DB_CONNECTION in .env is still the entire configuration, and
PingRepository now constructor-injects Kinetis\Orm\EntityManager
where it injected Kinetis\Persistence\Contract\MysqlLink before.
docker compose up --build
curl -X POST http://localhost:8080/pong/direct
# {"id":1,"status":"ponged"}
Deferring the reply: Redis and the queue¶
composer require kinetis/queue kinetis/queue-redis
REDIS_HOST=redis
REDIS_PORT=6379
QUEUE_CONNECTION=redis
A job that pongs a ping, run later by a worker instead of inline:
<?php
declare(strict_types=1);
namespace App\Queue;
use App\Repositories\PingRepository;
use Kinetis\Queue\Job;
final readonly class PongJob implements Job
{
public function __construct(
public int $id,
) {}
public function handle(PingRepository $pings): void
{
$pings->markPonged($this->id);
}
}
Nothing to register: QUEUE_CONNECTION=redis in .env is the whole
wiring — kinetis/queue’s package bootstrap binds QueueInterface to a
Redis-backed queue from it, the same way kinetis/database-bridge
already bound the EntityManager your repository injects.
A job is its own unit of work, so it resolves an EntityManager of its
own: the worker holds nothing from the request that queued the ping, and
markPonged() loads the row that request committed.
Add a second method that pushes a job instead of ponging inline:
<?php
declare(strict_types=1);
namespace App\Http;
use App\Queue\PongJob;
use App\Repositories\PingRepository;
use Kinetis\Http\Attributes\Post;
use Kinetis\Queue\QueueInterface;
final readonly class PingController
{
public function __construct(
private PingRepository $pings,
private QueueInterface $queue,
) {}
#[Post('/pong/direct')]
public function direct(): array
{
$id = $this->pings->create('direct');
$this->pings->markPonged($id);
return ['id' => $id, 'status' => 'ponged'];
}
#[Post('/pong/queued')]
public function queued(): array
{
$id = $this->pings->create('queued');
$this->queue->push(new PongJob($id), delaySeconds: 5);
return ['id' => $id, 'status' => 'pending'];
}
}
Add Redis and a worker process to run the job:
redis:
image: redis:7-alpine
queue-worker:
build:
context: .
dockerfile: docker/Dockerfile
volumes:
- .:/app
- vendor:/app/vendor
env_file: .env
depends_on:
redis:
condition: service_started
migrate:
condition: service_completed_successfully
entrypoint: []
command: ["php", "vendor/bin/kinetis", "queue:work"]
stop_signal: SIGTERM
healthcheck:
disable: true
Graceful shutdown — the worker finishing the job in flight instead of
being cut off mid-job — needs both halves: ext-pcntl loaded, and
SIGTERM or SIGINT delivered. The image above installs the
extension. stop_signal is the other half, because the
php:8.4-fpm-alpine base declares STOPSIGNAL SIGQUIT, which this
worker does not handle: without the line, docker compose stop kills it
mid-job and leaves the ping for the backend to redeliver. See
Queue’s “Deploys and restarts” for the production form of both.
docker compose up --build
curl -X POST http://localhost:8080/pong/queued
# {"id":2,"status":"pending"}
The row stays pending for five seconds, then queue-worker picks up
the job and marks it ponged — check with another request against
whatever endpoint reads it back, or query the database directly.
Replying on a schedule: a console command¶
No new package — Kinetis\Console ships in core. Any class anywhere
under your own PSR-4 root is discovered automatically — App\Console is
just the convention this tutorial keeps using, not a requirement:
<?php
declare(strict_types=1);
namespace App\Console;
use App\Repositories\PingRepository;
use Kinetis\Console\Attributes\Command;
final readonly class PongCronCommand
{
public function __construct(
private PingRepository $pings,
) {}
#[Command('pings:pong-cron', description: 'Creates and pongs a cron-driven ping')]
public function run(): int
{
$id = $this->pings->create('cron');
$this->pings->markPonged($id);
return 0;
}
}
Kinetis doesn’t schedule anything itself — a plain interval loop in its own container runs the command every five seconds:
cron:
build:
context: .
dockerfile: docker/Dockerfile
volumes:
- .:/app
- vendor:/app/vendor
env_file: .env
depends_on:
migrate:
condition: service_completed_successfully
entrypoint: []
command: ["sh", "-c", "while true; do php vendor/bin/kinetis pings:pong-cron; sleep 5; done"]
healthcheck:
disable: true
docker compose up --build
Watch ping_messages grow a new cron-scenario row, already ponged,
every five seconds — with no request involved at all.
Making it real-time: events and kinetis/broadcasting¶
Three scenarios work independently now. The last piece is watching all three happen live, in a browser, instead of checking the database by hand.
composer require kinetis/broadcasting
BROADCAST_DRIVER=pusher
BROADCAST_APP_ID=app-id
BROADCAST_KEY=app-key
BROADCAST_SECRET=app-secret
# Used by the PHP backend to publish, over the docker-compose network.
BROADCAST_HOST=soketi
BROADCAST_PORT=6001
BROADCAST_TLS=false
# Used by the browser to subscribe, from outside the docker-compose
# network — app-level config, not part of kinetis/broadcasting's own
# BROADCAST_* contract, since only the server ever talks to BROADCAST_HOST.
BROADCAST_BROWSER_HOST=localhost
BROADCAST_BROWSER_PORT=6001
soketi:
image: quay.io/soketi/soketi:1.4-16-debian
environment:
SOKETI_DEFAULT_APP_ID: ${BROADCAST_APP_ID:-app-id}
SOKETI_DEFAULT_APP_KEY: ${BROADCAST_KEY:-app-key}
SOKETI_DEFAULT_APP_SECRET: ${BROADCAST_SECRET:-app-secret}
ports:
- "6001:6001"
A plain object to carry “something happened” through the pipeline:
<?php
declare(strict_types=1);
namespace App\Events;
final readonly class ActionEvent
{
public function __construct(
public string $stage,
public ?int $id = null,
public ?string $scenario = null,
) {}
}
Installing kinetis/broadcasting is the entire wiring — its own package
bootstrap reads BROADCAST_DRIVER and binds BroadcasterInterface
before your own bootstrap.php ever runs, the same “nothing to
register” shape kinetis/database-bridge and kinetis/queue already
have.
There’s no publisher class to write and no bootstrap.php needed for
this. A listener that republishes every ActionEvent it sees just
constructor-injects Kinetis\Broadcasting\Broadcaster:
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Events\ActionEvent;
use Kinetis\Broadcasting\Broadcaster;
use Kinetis\Events\Listener;
final readonly class ActionEventListener
{
public const string CHANNEL = 'ping-pong';
public function __construct(
private Broadcaster $broadcaster,
) {}
#[Listener]
public function onActionEvent(ActionEvent $event): void
{
$this->broadcaster->broadcast(self::CHANNEL, 'action', [
'stage' => $event->stage,
'id' => $event->id,
'scenario' => $event->scenario,
]);
}
}
ActionEventListener needs nothing registered for it either — any class
anywhere under your own PSR-4 root carrying a #[Listener] method is
found automatically.
Now dispatch an ActionEvent at each real stage a ping passes through.
PingRepository::create() gets one for the write:
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Entities\Ping;
use App\Events\ActionEvent;
use Kinetis\Events\EventDispatcher;
use Kinetis\Orm\EntityManager;
final readonly class PingRepository
{
public function __construct(
private EntityManager $entities,
private EventDispatcher $events,
) {}
public function create(string $scenario): int
{
$ping = new Ping($scenario);
$this->entities->persist($ping);
$this->entities->flush();
$id = $ping->id();
$this->events->dispatch(new ActionEvent('db', $id));
return $id;
}
public function markPonged(int $id): void
{
$ping = $this->entities->repository(Ping::class)->findOrFail($id);
$ping->pong();
$this->entities->flush();
}
}
The db stage says a ping reached the database, so it is dispatched
after the flush, never before: the id it carries is the one the INSERT
generated, and a flush that fails — or whose COMMIT ends in an unknown
outcome — throws out of create() having announced nothing.
The controller’s two methods each get one for being called, and — since
the browser will now hear about a finished pong over the socket instead
of in the HTTP response — return void rather than a body:
<?php
declare(strict_types=1);
namespace App\Http;
use App\Events\ActionEvent;
use App\Queue\PongJob;
use App\Repositories\PingRepository;
use Kinetis\Events\EventDispatcher;
use Kinetis\Http\Attributes\Post;
use Kinetis\Queue\QueueInterface;
final readonly class PingController
{
public function __construct(
private PingRepository $pings,
private QueueInterface $queue,
private EventDispatcher $events,
) {}
#[Post('/pong/direct')]
public function direct(): void
{
$id = $this->pings->create('direct');
$this->events->dispatch(new ActionEvent('app', $id, 'direct'));
$this->pings->markPonged($id);
$this->events->dispatch(new ActionEvent('socket', $id, 'direct'));
}
#[Post('/pong/queued')]
public function queued(): void
{
$id = $this->pings->create('queued');
$this->events->dispatch(new ActionEvent('app', $id, 'queued'));
$this->queue->push(new PongJob($id), delaySeconds: 5);
}
}
direct() announces app between creating the ping and ponging it,
which is the order the dashboard draws. Each repository call flushes its
own work, so a direct ping is two transactions rather than one: folding
them into a single flush would have db and app announce a ping no
transaction had stored yet.
PongJob and PongCronCommand each get their own stage, plus the same
socket announcement once the pong is actually written:
<?php
declare(strict_types=1);
namespace App\Queue;
use App\Events\ActionEvent;
use App\Repositories\PingRepository;
use Kinetis\Events\EventDispatcher;
use Kinetis\Queue\Job;
final readonly class PongJob implements Job
{
public function __construct(
public int $id,
) {}
public function handle(PingRepository $pings, EventDispatcher $events): void
{
$events->dispatch(new ActionEvent('queue', $this->id));
$pings->markPonged($this->id);
$events->dispatch(new ActionEvent('socket', $this->id, 'queued'));
}
}
<?php
declare(strict_types=1);
namespace App\Console;
use App\Events\ActionEvent;
use App\Repositories\PingRepository;
use Kinetis\Console\Attributes\Command;
use Kinetis\Events\EventDispatcher;
final readonly class PongCronCommand
{
public function __construct(
private PingRepository $pings,
private EventDispatcher $events,
) {}
#[Command('pings:pong-cron', description: 'Creates and pongs a cron-driven ping')]
public function run(): int
{
$id = $this->pings->create('cron');
$this->pings->markPonged($id);
$this->events->dispatch(new ActionEvent('cron', $id, 'cron'));
$this->events->dispatch(new ActionEvent('socket', $id, 'cron'));
return 0;
}
}
Last, a page the browser can actually open. Add a route for it — / is
free again, since direct()/queued() moved to /pong/... earlier.
index() goes first in the class, ahead of the two /pong/... methods —
this is the route someone opens first, in a browser, not an API
consumer’s typical entry point:
<?php
declare(strict_types=1);
namespace App\Http;
use App\Events\ActionEvent;
use App\Queue\PongJob;
use App\Repositories\PingRepository;
use Kinetis\Config\Config;
use Kinetis\Events\EventDispatcher;
use Kinetis\Http\Attributes\Get;
use Kinetis\Http\Attributes\Post;
use Kinetis\Http\Responses\HtmlResponse;
use Kinetis\Queue\QueueInterface;
use Psr\Http\Message\ResponseInterface;
final readonly class PingController
{
public function __construct(
private PingRepository $pings,
private QueueInterface $queue,
private EventDispatcher $events,
private Config $config,
) {}
#[Get('/')]
public function index(): ResponseInterface
{
$key = $this->config->string('BROADCAST_KEY', 'app-key');
$host = $this->config->string('BROADCAST_BROWSER_HOST', 'localhost');
$port = $this->config->int('BROADCAST_BROWSER_PORT', 6001);
return HtmlResponse::create(<<<HTML
<!doctype html>
<script src="https://js.pusher.com/8.4.0/pusher.min.js"></script>
<button onclick="fetch('/pong/direct', {method: 'POST'})">Direct</button>
<button onclick="fetch('/pong/queued', {method: 'POST'})">Queued</button>
<ul id="log"></ul>
<script>
var pusher = new Pusher("{$key}", {
wsHost: "{$host}",
wsPort: {$port},
forceTLS: false,
enabledTransports: ['ws'],
cluster: 'kinetis'
});
pusher.subscribe('ping-pong').bind('action', function (data) {
var li = document.createElement('li');
li.textContent = '#' + data.id + ' ' + data.stage + ' (' + data.scenario + ')';
document.getElementById('log').prepend(li);
});
</script>
HTML);
}
#[Post('/pong/direct')]
public function direct(): void
{
$id = $this->pings->create('direct');
$this->events->dispatch(new ActionEvent('app', $id, 'direct'));
$this->pings->markPonged($id);
$this->events->dispatch(new ActionEvent('socket', $id, 'direct'));
}
#[Post('/pong/queued')]
public function queued(): void
{
$id = $this->pings->create('queued');
$this->events->dispatch(new ActionEvent('app', $id, 'queued'));
$this->queue->push(new PongJob($id), delaySeconds: 5);
}
}
docker compose up --build
Open http://localhost:8080/ and click either button. Each click logs
app immediately, then db, then — for a queued ping — queue and
socket a few seconds later once the worker picks it up. Leave the page
open and watch a cron/socket pair appear on its own every five
seconds, with nobody clicking anything.
Reporting statistics as a typed value¶
Every scenario writes a row to ping_messages. A repository method that
reports how many landed in each scenario is a good place to return
something more structured than a bare array:
<?php
declare(strict_types=1);
namespace App\Dto;
final readonly class ScenarioCounts
{
/**
* @param array<string, int> $counts
*/
public function __construct(
public int $total,
public array $counts,
) {}
}
use App\Dto\ScenarioCounts;
private const array SCENARIOS = ['direct', 'queued', 'cron'];
public function countByScenario(): ScenarioCounts
{
$pings = $this->entities->repository(Ping::class);
$total = $pings->query()->count();
$counts = [];
foreach (self::SCENARIOS as $scenario) {
$counts[$scenario] = $pings->query()->where('scenario', '=', $scenario)->count();
}
return new ScenarioCounts($total, $counts);
}
An entity query names properties, not columns: where() takes the
property and the ORM resolves it — pongedAt would reach the SQL as
ponged_at — and converts the value through that property’s type before
any statement is built. count() asks the database for the number
rather than loading pings to count them.
The total and each scenario’s count are four independent queries, and
outside a unit of work they would be a natural fit for concurrently().
Here they are not: an EntityManager belongs to the Fiber that opened it
and refuses every other, so these four run one after another on the
request’s own manager. Overlapping them would take four managers and four
identity maps to answer one tally, which four counts do not earn.
concurrently() is still how independent work overlaps in Kinetis — each
task simply needs a unit of work of its own where it touches one. What
that buys also depends on the driver underneath: a persistent worker
takes the native MySQL driver, where a query suspends only its own Fiber;
PHP-FPM, which this tutorial runs on, takes the blocking PDO driver,
where nothing overlaps. See Concurrency and Database.
countByScenario() builds ScenarioCounts with a plain new, not
Hydrator::hydrate(). Hydrator casts and validates data crossing an
HTTP request body or an MCP tool call — data you don’t control yet. Here,
$total and $counts are values this same method just computed from its
own query results, already trusted; there’s nothing to validate, so a
constructor call is all it needs.
Add a route that returns it:
use App\Dto\ScenarioCounts;
#[Get('/pong/tally')]
public function tally(): ScenarioCounts
{
return $this->pings->countByScenario();
}
A returned DTO encodes to JSON exactly like an equivalent array would —
nothing else changes for tally() to reach a client as normal JSON.
curl http://localhost:8080/pong/tally
# {"total":3,"counts":{"direct":1,"queued":1,"cron":1}}
Exposing it to an AI agent: an MCP tool¶
The MCP server is its own package:
composer require kinetis/mcp
That one install registers the kinetis mcp:serve command and the
/mcp HTTP endpoint — nothing else to wire. The same statistics can
then answer a question for an AI agent, through an #[McpTool] method
instead of a route attribute. A slightly richer
response — a percentage alongside each count — is a good excuse to nest
one more DTO inside another:
<?php
declare(strict_types=1);
namespace App\Dto;
final readonly class ScenarioStat
{
public function __construct(
public int $count,
public float $percentage,
) {}
}
<?php
declare(strict_types=1);
namespace App\Dto;
final readonly class PingScenarioBreakdown
{
/**
* @param array<string, ScenarioStat> $byScenario
*/
public function __construct(
public int $total,
public array $byScenario,
) {}
}
<?php
declare(strict_types=1);
namespace App\Mcp;
use App\Dto\PingScenarioBreakdown;
use App\Dto\ScenarioStat;
use App\Repositories\PingRepository;
use Kinetis\Mcp\Attributes\McpTool;
final readonly class PingStatsToolController
{
public function __construct(
private PingRepository $pings,
) {}
#[McpTool(
name: 'ping_scenario_breakdown',
description: 'Reports how many ping messages came from each scenario (direct, queued, cron) and what percentage of the total each represents',
)]
public function pingScenarioBreakdown(): PingScenarioBreakdown
{
$counts = $this->pings->countByScenario();
$byScenario = [];
foreach ($counts->counts as $scenario => $count) {
$byScenario[$scenario] = new ScenarioStat(
$count,
$counts->total > 0 ? round($count / $counts->total * 100, 1) : 0.0,
);
}
return new PingScenarioBreakdown($counts->total, $byScenario);
}
}
Like PingController, nothing registers this class — any class under one
of your own PSR-4 roots carrying an #[McpTool] method is discovered
automatically.
The tool still needs a transport to reach a client over. This
application already serves HTTP, and kinetis/mcp contributes POST /mcp as an ordinary discovered route, so the Streamable HTTP transport
is already there — no extra process, no extra container, and nothing to
register.
What the endpoint needs is a caller it can identify. /mcp runs the
mcp middleware group, whose last member answers 401 for any request
reaching it with no Kinetis\Http\CurrentUserInterface on the request
scope. DemoVisitor is bound application-wide in bootstrap.php, so it
satisfies that check on every route, /mcp included — a fixed visitor
is an identity, not authentication. Give the endpoint a credential of
its own, joining the group by attribute the way everything else here is
discovered:
<?php
declare(strict_types=1);
namespace App\Http;
use App\Broadcasting\DemoVisitor;
use Kinetis\Config\Config;
use Kinetis\Container\RequestScope;
use Kinetis\Http\Attributes\AsMiddlewareGroup;
use Kinetis\Http\CurrentUserInterface;
use Kinetis\Http\Responses\ErrorResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
#[AsMiddlewareGroup('mcp')]
final readonly class McpAuthMiddleware implements MiddlewareInterface
{
public function __construct(
private Config $config,
private RequestScope $scope,
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$token = $this->config->string('MCP_TOKEN', '');
if ($token === '' || !hash_equals('Bearer ' . $token, $request->getHeaderLine('Authorization'))) {
return ErrorResponse::create(401, 'Unauthenticated.');
}
$this->scope->instance(CurrentUserInterface::class, new DemoVisitor());
return $handler->handle($request);
}
}
MCP_TOKEN=local-mcp-token
The group runs it at the attribute’s default priority 50: after the
package’s own Origin validation at 100, and before the identity
guard at 0, which the CurrentUserInterface it registers satisfies.
A shared secret is the smallest thing that authenticates a
caller; an application with real users puts kinetis/auth’s or
kinetis/auth-jwt’s middleware in the group instead, and the identity
it resolves reaches the tool the same way — see Model Context Protocol (MCP)’s “Securing
the HTTP transport”.
A request also mirrors its protocol version and method into headers, and the server rejects one whose header and body disagree:
curl -X POST http://localhost:8080/mcp \
-H "Authorization: Bearer local-mcp-token" \
-H "Content-Type: application/json" \
-H "MCP-Protocol-Version: 2025-06-18" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
That confirms ping_scenario_breakdown is reachable over /mcp. To ask a
real question through it from Claude Code’s CLI, register the endpoint as
an MCP server, carrying the same token:
claude mcp add --transport http --scope user ping-pong http://localhost:8080/mcp \
--header "Authorization: Bearer local-mcp-token"
Claude Code reads its list of MCP servers once, at session start, so restart your session after adding (or removing) one before it takes effect. Once it’s connected, ask something the application itself has to answer — not something Claude already knows:
using ping-pong, what percentage of pings are cron-triggered?
Claude Code calls ping_scenario_breakdown over /mcp, reads back
whatever PingRepository actually has in the database at that moment,
and answers from that.
Recap¶
Four independent scenarios — an immediate reply, a delayed one, a
scheduled one, and a live view of all three — built up one working piece
at a time: a controller, a repository backed by a real database — two
query-builder statements first, then a mapped Ping entity written
through a unit of work — a queued job, a scheduled command, and an event
published to a browser over a public and a private WebSocket channel.
Nothing here is scenario-specific plumbing either — the same
bootstrap.php convention, the same request-scoped unit of work, the
same queue and event dispatcher, apply to any Kinetis application. The
same repository also fed a typed DTO to an HTTP route and, unchanged, to
an MCP tool an AI agent can call directly over the same server.
Starting from kinetis/pingpong instead¶
kinetis/pingpong is this same application, already built, with a more
developed dashboard in place of the plain log page above. To start a new
project from it instead of building one up by hand:
composer create-project --no-install kinetis/pingpong my-app
cd my-app
cp .env.example .env
docker compose up --build
--no-install leaves dependency resolution to the containers, which is
where docker compose up runs it.
Everything from this tutorial — bootstrap.php, the migrations, the
Ping entity, the repository, the job, the scheduled command, the
events, the broadcaster and its private-channel authorizer, the
statistics DTOs, and the MCP tool — is already there, under the same file
layout this tutorial used, ready to read through and modify directly.
Its repository is the ORM one, the flavor this tutorial ends on.
Unlike this tutorial’s own PHP-FPM setup above, kinetis/pingpong’s
docker-compose.yml runs app under a genuine FrankenPHP persistent
worker — Kinetis’s primary optimization target. One side effect worth
knowing: a FrankenPHP worker loads public/index.php (including all
discovery) exactly once at boot, so a code change needs an app
container restart to take effect — there’s no PHP-FPM-style hot reload
on every request; RoadRunner’s own worker processes have the identical
limitation. See Runtime Adapters for more on when to reach for a
persistent-worker runtime.
See also¶
Core Concepts — why a persistent worker changes the rules, and what the request lifecycle you just used actually does.
Appendix: Container Lifecycle —
AppScopeandRequestScope, and why Kinetis bansstaticproperties.Routing & Validation — the full attribute vocabulary, validation constraints, and the OpenAPI generator.
Configuration —
.envloading, typedConfigaccess, andbootstrap.phpin full.Database — connecting to MySQL, Postgres, and Redis directly.
Concurrency —
concurrently()in full, including what happens when one of several concurrent tasks fails.Migrations — the migration runner used above, in full.
Query Builder — the query builder used above, in full.
ORM — entities, the request’s unit of work, relationships and transaction sessions, in full.
Queue — the job queue used above, including multiple workers, named queues, and retry limits.
Events — the event dispatcher used above, including stopping propagation and deferring a listener onto a queue.
Broadcasting — the broadcaster,
ShouldBroadcast, and private/presence channel authorization used above, in full.Model Context Protocol (MCP) — tools and resources, transports, and progress notifications in full.
CLI — how
#[Command]classes are discovered, andkinetis buildfor pre-compiling everything ahead of time in production.Caching & AOT Compilation — pre-compiling routes, commands, and validation ahead of time for production.