# 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](#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/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 ## 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/kinetis ``` Add a PSR-4 mapping for your own code to `composer.json`: ```{code-block} json :caption: composer.json { "require": { "kinetis/kinetis": "^1.0" }, "autoload": { "psr-4": { "App\\": "src/" } } } ``` ```{code-block} bash composer dump-autoload ``` ## 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"} ``` ## 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 `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: ```{code-block} php :caption: bootstrap.php 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: ```{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/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. ```{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 ``` ```{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); } } ``` Register the queue in `bootstrap.php`: ```{code-block} php :caption: bootstrap.php 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: ```{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/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. `bootstrap.php` only needs the services from the sections above: ```{code-block} php :caption: bootstrap.php 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: ```{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(<<