Getting Started

This page gets a minimal Kinetis application running under FrankenPHP, end to end. If you just want to see what Kinetis code looks like, skip to Your first controller.

Requirements

  • PHP 8.4 or later

  • Composer

  • FrankenPHP for the primary, persistent-worker deployment target — though everything in this guide also runs correctly under classic PHP-FPM, which Kinetis detects and falls back to automatically. See Runtime Adapters for the full detection story.

Installation

composer require kinetis/kinetis

Kinetis ships as a single package (kinetis/kinetis) — there’s no kinetis/http, kinetis/di, kinetis/routing to assemble separately.

Your first controller

Kinetis controllers are plain PHP classes. There’s no base class to extend and no interface to implement — routes are declared with attributes directly on public methods:

src/Http/UserController.php
<?php

declare(strict_types=1);

namespace App\Http;

use Kinetis\Http\Attributes\Body;
use Kinetis\Http\Attributes\Get;
use Kinetis\Http\Attributes\Post;
use Kinetis\Http\Attributes\Query;
use App\Requests\CreateUserRequest;
use App\Responses\UserResponse;

final readonly class UserController
{
    #[Post('/users', status: 201)]
    public function store(#[Body] CreateUserRequest $data): UserResponse
    {
        return new UserResponse(name: $data->name, email: $data->email);
    }

    #[Get('/users')]
    public function index(#[Query] int $page = 1, #[Query] int $limit = 20): array
    {
        return ['page' => $page, 'limit' => $limit];
    }

    #[Get('/users/{id}')]
    public function show(int $id): array
    {
        return ['id' => $id];
    }
}

A few things worth noticing already, since they come up throughout this documentation:

  • #[Body] marks a parameter as bound to the decoded JSON request body — its type (CreateUserRequest) is a DTO class that gets validated before the controller ever runs. See Routing & Validation.

  • #[Query] binds a query-string parameter, cast to the parameter’s declared scalar type.

  • show()’s $id parameter needs no attribute at all — Kinetis matches it against the {id} placeholder in the route path by name.

  • The controller class itself is final readonly with no constructor here, but if it needed dependencies, they’d be constructor-injected from the container — see Container.

The DTO referenced above is just as plain:

src/Requests/CreateUserRequest.php
<?php

declare(strict_types=1);

namespace App\Requests;

use Kinetis\Validation\Constraints\Email;
use Kinetis\Validation\Constraints\MinLength;

final readonly class CreateUserRequest
{
    public function __construct(
        #[MinLength(3)]
        public string $name,
        #[Email]
        public string $email,
    ) {}
}

If a request’s body fails validation — a name under three characters, an invalid email — Kinetis responds with a 422 and every failing field’s error, not just the first one it happens to hit:

POST /users with {“name”: “Al”, “email”: “not-an-email”}
{
    "errors": {
        "name": ["must be at least 3 characters."],
        "email": ["must be a valid email address."]
    }
}

Wiring it up

public/index.php is the one place every runtime adapter converges on. A minimal one looks like this:

public/index.php
<?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';

$app = new AppScope();
$app->boot();

$router = RouteDiscovery::discover(ProjectRoot::detect(__DIR__));

$adapter = RuntimeDetector::detect();
$kernel = new Kernel($app, $router, isPersistent: $adapter->isPersistent());

$adapter->run($kernel->handle(...));

RouteDiscovery::discover() finds UserController on its own — any class anywhere under one of your own PSR-4 roots is picked up automatically, with no required directory or namespace convention and nothing to register by hand (see CLI for restricting the scan on a large application). RuntimeDetector::detect() figures out which runtime it’s running under — FrankenPHP, AWS Lambda, or plain PHP-FPM — and returns the matching adapter, with zero configuration on your part. The exact same public/index.php runs unmodified in all three. See Runtime Adapters for how that detection actually works and what each adapter does differently.

Tip

bin/kinetis build pre-compiles routing (and everything else discovered by namespace) ahead of a production deploy — see Caching & AOT Compilation.

Running it under FrankenPHP

A minimal Caddyfile:

Caddyfile
{
    admin off
}

:8080 {
    root * public
    php_server {
        worker public/index.php
    }
}

Warning

The worker directive must point at the same file Caddy’s php_server directive would classically execute for an unmatched request — in this setup, public/index.php. Caddy falls back to classically re-executing index.php for any request path that doesn’t match a real static file before it ever routes to a worker pointed somewhere else. Point worker at a different script and every request will silently keep re-executing index.php from scratch instead of ever reaching your worker, with no error to indicate why.

docker run --rm -p 8080:8080 -v "$PWD":/app -w /app dunglas/frankenphp:latest \
    frankenphp run --config Caddyfile

Note

FrankenPHP’s worker mode keeps public/index.php — including route discovery — loaded in memory across every request it serves. If you edit a controller while this container is still running, restart it to see the change; PHP has no way to redeclare an already-loaded class with new content. For active local development where you’re editing code constantly, PHP-FPM’s classic boot-and-die model (which Kinetis detects and runs under automatically — no code changes needed) rebuilds this on every single request instead, at the cost of paying discovery’s cost every time rather than once. See Runtime Adapters.

Try it
curl -X POST http://localhost:8080/users \
    -H "Content-Type: application/json" \
    -d '{"name": "John Doe", "email": "john@example.com"}'
# {"name":"John Doe","email":"john@example.com"}

Every registered route also gets a free, zero-config OpenAPI document and Swagger UI — visit http://localhost:8080/docs right now, no annotations beyond the attributes already on UserController needed. More on that in Routing & Validation.

Next steps

  • Core Concepts — why persistent workers change the rules, and how Kinetis’s request lifecycle is built around that.

  • Container — the two-tier container, and the one PHPStan rule that keeps it an enforced guarantee instead of a convention.

  • Routing & Validation — the full attribute vocabulary, validation constraints, and the OpenAPI generator.

  • Authentication — opaque Bearer-token authentication middleware, for when you want your own token storage (and revocation).

  • JWT Authentication — stateless JWT authentication instead, verifying a signed token with no storage lookup at all.