# 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](#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/direct` replies in the same request. - `POST /pong/queued` replies 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 - [Composer](https://getcomposer.org) - 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 {doc}`appendix-ci`). 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 ```{code-block} bash 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`: ```{code-block} json :caption: composer.json { "require": { "kinetis/framework": "^1.0" }, "autoload": { "psr-4": { "App\\": "src/" } } } ``` ```{code-block} bash 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. ``` ## A minimal controller ```{code-block} php :caption: src/Http/PingController.php '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: ```{code-block} php :caption: public/index.php 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. {doc}`caching` 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: ```{code-block} dockerfile :caption: docker/Dockerfile 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"] ``` ```{code-block} bash :caption: docker/entrypoint.sh #!/bin/sh set -e composer install --no-interaction --no-progress exec "$@" ``` ```{code-block} bash chmod +x docker/entrypoint.sh ``` ```{code-block} nginx :caption: docker/nginx.conf 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; } } ``` ```{code-block} yaml :caption: docker-compose.yml 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 {doc}`runtime-adapters` for when to reach for FrankenPHP instead. ```{code-block} bash docker compose up --build ``` ```{code-block} bash :caption: Try it 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. Every route you add below appears there as you go. {doc}`routing-validation` covers what the generator reads. ## Storing pings: MySQL, migrations, and the query builder ```{code-block} bash 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: ```{code-block} text :caption: .env 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: ```{code-block} php :caption: migrations/20260810120000_create_ping_messages_table.php 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: ```{code-block} php :caption: src/Repositories/PingRepository.php 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/persistence` does that itself: its package bootstrap (see {doc}`cli`) reads the `DB_*` keys you just put in `.env` and binds the connection under `MysqlLink`, with no wiring of your own. What `public/index.php` does need is to load `.env`, build a `Config`, and run the bootstrap chain — every installed package's bootstrap, then your own optional `bootstrap.php` — before it boots the container. Replace everything up to (and including) `$app->boot();` with: ```{code-block} php :caption: public/index.php 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: ```{code-block} php :caption: src/Http/PingController.php 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: ```{code-block} yaml :caption: docker-compose.yml 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/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. ```{code-block} bash docker compose up --build ``` ```{code-block} bash :caption: Try it curl -X POST http://localhost:8080/pong/direct # {"id":1,"status":"ponged"} ``` ## Deferring the reply: Redis and the queue ```{code-block} bash composer require kinetis/queue kinetis/queue-redis ``` ```{code-block} text :caption: .env (additions) REDIS_HOST=redis REDIS_PORT=6379 QUEUE_CONNECTION=redis ``` A job that pongs a ping, run later by a worker instead of inline: ```{code-block} php :caption: src/Queue/PongJob.php 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/persistence` already bound `MysqlLink`. Add a second method that pushes a job instead of ponging inline: ```{code-block} php :caption: src/Http/PingController.php 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: ```{code-block} yaml :caption: docker-compose.yml (additions) 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"] healthcheck: disable: true ``` ```{code-block} bash docker compose up --build ``` ```{code-block} bash :caption: Try it 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: ```{code-block} php :caption: src/Console/PongCronCommand.php 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: ```{code-block} yaml :caption: docker-compose.yml (additions) 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 ``` ```{code-block} bash 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. ```{code-block} bash composer require pusher/pusher-php-server ``` ```{code-block} text :caption: .env (additions) 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 ``` ```{code-block} yaml :caption: docker-compose.yml (additions) 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: ```{code-block} php :caption: src/Events/ActionEvent.php 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: ```{code-block} php :caption: src/Listeners/ActionEventListener.php 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. `SoketiPublisher` is the first service that genuinely needs app-side wiring — the database and queue bindings come from their packages' own bootstraps — so create the optional `bootstrap.php` at the project root for it: ```{code-block} php :caption: bootstrap.php 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: ```{code-block} php :caption: public/index.php 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: ```{code-block} php :caption: src/Repositories/PingRepository.php 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: ```{code-block} php :caption: src/Http/PingController.php 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: ```{code-block} php :caption: src/Queue/PongJob.php dispatch(new ActionEvent('queue', $this->id)); $pings->markPonged($this->id); $events->dispatch(new ActionEvent('socket', $this->id, 'queued')); } } ``` ```{code-block} php :caption: src/Console/PongCronCommand.php 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: ```{code-block} php :caption: src/Http/PingController.php 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); } #[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); } } ``` ```{code-block} bash 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: ```{code-block} php :caption: src/Dto/ScenarioCounts.php $counts */ public function __construct( public int $total, public array $counts, ) {} } ``` ```{code-block} php :caption: src/Repositories/PingRepository.php (additions) use App\Dto\ScenarioCounts; use function Kinetis\Async\concurrently; private const array SCENARIOS = ['direct', 'queued', 'cron']; public function countByScenario(): ScenarioCounts { $tasks = [fn () => new Query($this->db)->table('ping_messages')->count()]; foreach (self::SCENARIOS as $scenario) { $tasks[] = fn () => new Query($this->db)->table('ping_messages')->where('scenario', '=', $scenario)->count(); } $results = concurrently($tasks); $total = array_shift($results); return new ScenarioCounts($total, array_combine(self::SCENARIOS, $results)); } ``` The total and each scenario's count are four independent queries — none needs another's result — so they run through `concurrently()` instead of one after another, completing in roughly the time the slowest single query takes rather than their sum. `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: ```{code-block} php :caption: src/Http/PingController.php (additions) 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. ```{code-block} bash :caption: Try it 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: ```{code-block} bash 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: ```{code-block} php :caption: src/Dto/ScenarioStat.php $byScenario */ public function __construct( public int $total, public array $byScenario, ) {} } ``` ```{code-block} php :caption: src/Mcp/PingStatsToolController.php 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: ```{code-block} php :caption: public/index.php (additions) 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); ``` ```{code-block} bash :caption: Try it 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: ```{code-block} bash 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: ```{code-block} text 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-pingpong-instead)= ## 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: ```{code-block} bash composer create-project kinetis/pingpong 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. 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. See {doc}`runtime-adapters` for more on when to reach for FrankenPHP. ## See also - {doc}`core-concepts` — why a persistent worker changes the rules, and what the request lifecycle you just used actually does. - {doc}`container` — `AppScope` and `RequestScope`, and why Kinetis bans `static` properties. - {doc}`routing-validation` — the full attribute vocabulary, validation constraints, and the OpenAPI generator. - {doc}`config` — `.env` loading, typed `Config` access, and `bootstrap.php` in full. - {doc}`persistence` — connecting to MySQL, Postgres, and Redis directly. - {doc}`concurrency` — `concurrently()` in full, including what happens when one of several concurrent tasks fails. - {doc}`migrations` — the migration runner used above, in full. - {doc}`query-builder` — the query builder used above, in full. - {doc}`queue` — the job queue used above, including multiple workers, named queues, and retry limits. - {doc}`events` — the event dispatcher used above, including stopping propagation and deferring a listener onto a queue. - {doc}`mcp` — tools and resources, transports, and progress notifications in full. - {doc}`cli` — how `#[Command]` classes are discovered, and `kinetis build` for pre-compiling everything ahead of time in production. - {doc}`caching` — pre-compiling routes, commands, and validation ahead of time for production.