# 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(<<