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/skeleton. See
Starting from kinetis/skeleton 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 WebSocket.
Each piece is stored in a database, so MySQL, a migration, and the
query builder come first — everything after that builds on having
somewhere to write a row.
Requirements¶
PHP 8.4 or later
Docker, with Compose
Setting up the project¶
mkdir ping-pong && cd ping-pong
composer init --name=you/ping-pong --type=project --no-interaction
composer require kinetis/kinetis
Add a PSR-4 mapping for your own code to composer.json:
{
"require": {
"kinetis/kinetis": "^1.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
composer dump-autoload
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. Wire up the entry point every runtime adapter converges on:
<?php
declare(strict_types=1);
use Kinetis\Container\AppScope;
use Kinetis\Http\Kernel;
use Kinetis\Http\Routing\RouteDiscovery;
use Kinetis\Runtime\ProjectRoot;
use Kinetis\Runtime\RuntimeDetector;
require dirname(__DIR__) . '/vendor/autoload.php';
$projectRoot = ProjectRoot::detect(__DIR__);
$app = new AppScope();
$app->boot();
$router = RouteDiscovery::discover($projectRoot);
$adapter = RuntimeDetector::detect();
$kernel = new Kernel($app, $router, isPersistent: $adapter->isPersistent());
$adapter->run($kernel->handle(...));
Tip
This tutorial keeps public/index.php in its plain, always-live-discovery
form throughout — routes and commands are (re-)discovered on every
request, which is the simplest thing to reason about while a project is
this small. Caching & AOT Compilation covers pre-compiling all of this for
production once you actually need it.
Running it¶
Three files get docker compose running PHP-FPM behind nginx, without
needing PHP or Composer installed on the host:
FROM php:8.4-fpm-alpine
RUN apk add --no-cache unzip curl-dev $PHPIZE_DEPS \
&& docker-php-ext-install curl \
&& apk del $PHPIZE_DEPS
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
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"]
#!/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;
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
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 — including route/command
discovery — on every single request, so an edit to a controller takes
effect on your very next request, no restart needed. A persistent-worker
runtime like FrankenPHP 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 FrankenPHP’s
worker mode — see Runtime Adapters for when to reach for FrankenPHP
instead.
docker compose up --build
curl http://localhost:8080/
# {"message":"pong"}
Storing pings: MySQL, migrations, and the query builder¶
composer require kinetis/migrations kinetis/query-builder
Add a .env file at the project root — 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 Amp\Mysql\MysqlLink;
use Amp\Postgres\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 Amp\Mysql\MysqlConnectionPool;
use Kinetis\QueryBuilder\Query;
final readonly class PingRepository
{
public function __construct(
private MysqlConnectionPool $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 MysqlConnectionPool — something has to
build one and register it before the container locks its bindings. An
optional bootstrap.php at the project root is the place for that:
<?php
declare(strict_types=1);
use Amp\Mysql\MysqlConnectionPool;
use Kinetis\Config\Config;
use Kinetis\Container\AppScope;
use Kinetis\Persistence\SqlConnectionFactory;
return static function (AppScope $app, Config $config): void {
$app->instance(MysqlConnectionPool::class, SqlConnectionFactory::fromConfig($config));
};
public/index.php needs to load .env, build a Config, and run
bootstrap.php before it boots the container. Replace everything up to
(and including) $app->boot(); with:
use Kinetis\Config\Config;
use Kinetis\Config\EnvFile;
require dirname(__DIR__) . '/vendor/autoload.php';
$projectRoot = ProjectRoot::detect(__DIR__);
EnvFile::safeLoad($projectRoot);
$app = new AppScope();
$config = Config::fromEnvironment();
$app->instance(Config::class, $config);
RoutesFile::loadBootstrap($projectRoot)($app, $config);
$app->boot();
The rest of the file — building the Router, detecting the runtime
adapter, constructing the Kernel — stays exactly as it was.
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:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot"]
interval: 5s
timeout: 5s
retries: 10
migrate:
build:
context: .
dockerfile: docker/Dockerfile
volumes:
- .:/app
- vendor:/app/vendor
env_file: .env
depends_on:
mysql:
condition: service_healthy
command: ["php", "vendor/bin/migrate", "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"}
Deferring the reply: Redis and the queue¶
composer require kinetis/queue
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);
}
}
Register the queue in bootstrap.php:
<?php
declare(strict_types=1);
use Amp\Mysql\MysqlConnectionPool;
use Kinetis\Config\Config;
use Kinetis\Container\AppScope;
use Kinetis\Persistence\SqlConnectionFactory;
use Kinetis\Queue\QueueInterface;
use Kinetis\Queue\RedisQueue;
use Kinetis\SimpleCache\RedisSimpleCache;
use function Amp\Redis\createRedisClient;
return static function (AppScope $app, Config $config): void {
$app->instance(MysqlConnectionPool::class, SqlConnectionFactory::fromConfig($config));
$redisConfig = RedisSimpleCache::buildRedisConfig($config);
if ($redisConfig !== null) {
$app->instance(QueueInterface::class, new RedisQueue(createRedisClient($redisConfig)));
}
};
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/queue", "work"]
healthcheck:
disable: true
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 Soketi¶
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 pusher/pusher-php-server
SOKETI_APP_ID=app-id
SOKETI_KEY=app-key
SOKETI_SECRET=app-secret
# Used by the PHP backend to publish, over the docker-compose network.
SOKETI_HOST=soketi
SOKETI_PORT=6001
# Used by the browser to subscribe, from outside the docker-compose network.
SOKETI_BROWSER_HOST=localhost
SOKETI_BROWSER_PORT=6001
soketi:
image: quay.io/soketi/soketi:1.4-16-debian
environment:
SOKETI_DEFAULT_APP_ID: ${SOKETI_APP_ID:-app-id}
SOKETI_DEFAULT_APP_KEY: ${SOKETI_KEY:-app-key}
SOKETI_DEFAULT_APP_SECRET: ${SOKETI_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,
) {}
}
A class to publish it to Soketi:
<?php
declare(strict_types=1);
namespace App\Services;
use Kinetis\Config\Config;
use Pusher\Pusher;
final readonly class SoketiPublisher
{
public const CHANNEL = 'ping-pong';
public function __construct(
private Pusher $pusher,
) {}
public static function fromConfig(Config $config): self
{
$pusher = new Pusher(
$config->string('SOKETI_KEY', 'app-key'),
$config->string('SOKETI_SECRET', 'app-secret'),
$config->string('SOKETI_APP_ID', 'app-id'),
[
'host' => $config->string('SOKETI_HOST', 'soketi'),
'port' => $config->int('SOKETI_PORT', 6001),
'useTLS' => false,
],
);
return new self($pusher);
}
public function actionOccurred(string $stage, ?int $id, ?string $scenario = null): void
{
$this->pusher->trigger(self::CHANNEL, 'action', [
'stage' => $stage,
'id' => $id,
'scenario' => $scenario,
]);
}
}
And a listener that republishes every ActionEvent it sees:
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Events\ActionEvent;
use App\Services\SoketiPublisher;
use Kinetis\Events\Listener;
final readonly class ActionEventListener
{
public function __construct(
private SoketiPublisher $soketi,
) {}
#[Listener]
public function onActionEvent(ActionEvent $event): void
{
$this->soketi->actionOccurred($event->stage, $event->id, $event->scenario);
}
}
ActionEventListener needs nothing registered for it — any class anywhere
under your own PSR-4 root carrying a #[Listener] method is found
automatically. bootstrap.php only needs the services from the sections
above:
<?php
declare(strict_types=1);
use App\Services\SoketiPublisher;
use Amp\Mysql\MysqlConnectionPool;
use Kinetis\Config\Config;
use Kinetis\Container\AppScope;
use Kinetis\Persistence\SqlConnectionFactory;
use Kinetis\Queue\QueueInterface;
use Kinetis\Queue\RedisQueue;
use Kinetis\SimpleCache\RedisSimpleCache;
use function Amp\Redis\createRedisClient;
return static function (AppScope $app, Config $config): void {
$app->instance(MysqlConnectionPool::class, SqlConnectionFactory::fromConfig($config));
$redisConfig = RedisSimpleCache::buildRedisConfig($config);
if ($redisConfig !== null) {
$app->instance(QueueInterface::class, new RedisQueue(createRedisClient($redisConfig)));
}
$app->instance(SoketiPublisher::class, SoketiPublisher::fromConfig($config));
};
public/index.php needs one more line, though — EventDispatcher
resolves EventListenerRegistry through the container, which means it
has to be registered with $app->instance() before boot() locks
bindings, the same requirement Config and anything from bootstrap.php
already have. Skip this and nothing breaks loudly: EventDispatcher’s
container resolution silently falls back to an empty
EventListenerRegistry instead, so every dispatch() call still
“succeeds” — it just never reaches any listener, with no error to tell
you why:
use Kinetis\Events\EventListenerDiscovery;
use Kinetis\Events\EventListenerRegistry;
// ...
$app = new AppScope();
$config = Config::fromEnvironment();
$app->instance(Config::class, $config);
RoutesFile::loadBootstrap($projectRoot)($app, $config);
$app->instance(EventListenerRegistry::class, EventListenerDiscovery::discover($projectRoot));
$app->boot();
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\Events\ActionEvent;
use Amp\Mysql\MysqlConnectionPool;
use Kinetis\Events\EventDispatcher;
use Kinetis\QueryBuilder\Query;
final readonly class PingRepository
{
public function __construct(
private MysqlConnectionPool $db,
private EventDispatcher $events,
) {}
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'),
]);
$id = (int) $id;
$this->events->dispatch(new ActionEvent('db', $id));
return $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'),
]);
}
}
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);
}
}
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('SOKETI_KEY', 'app-key');
$host = $this->config->string('SOKETI_BROWSER_HOST', 'localhost');
$port = $this->config->int('SOKETI_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
{
$total = new Query($this->db)->table('ping_messages')->count();
$counts = [];
foreach (self::SCENARIOS as $scenario) {
$counts[$scenario] = new Query($this->db)->table('ping_messages')->where('scenario', '=', $scenario)->count();
}
return new ScenarioCounts($total, $counts);
}
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 same statistics can 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 actually reach a client over. This
application already serves HTTP, so the Streamable HTTP transport reaches
it with no extra process or container — Kernel’s $mcp parameter:
use Kinetis\Mcp\McpDiscovery;
use Kinetis\Mcp\McpDispatcher;
use Kinetis\Mcp\McpServer;
// ...
$mcp = new McpServer(McpDiscovery::discover($projectRoot), new McpDispatcher($app));
$adapter = RuntimeDetector::detect();
$kernel = new Kernel($app, $router, isPersistent: $adapter->isPersistent(), mcp: $mcp);
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-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:
claude mcp add --transport http --scope user ping-pong http://localhost:8080/mcp
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, a queued
job, a scheduled command, and an event published to a browser over a
WebSocket. Nothing here is scenario-specific plumbing either — the same
bootstrap.php convention, the same query builder, 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/skeleton instead¶
kinetis/skeleton 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 kinetis/skeleton my-app
cd my-app
cp .env.example .env
docker compose up --build
Everything from this tutorial — bootstrap.php, the migration, the
repository, the job, the scheduled command, the events, the Soketi
publisher, 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.
See also¶
Getting Started — a shorter path to a single working controller, without the database, queue, or Soketi pieces.
Configuration —
.envloading, typedConfigaccess, andbootstrap.phpin full.Persistence — connecting to MySQL, Postgres, and Redis directly.
Migrations — the migration runner used above, in full.
Query Builder — the query builder used above, 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.
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.