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
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:
<?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$idparameter needs no attribute at all — Kinetis matches it against the{id}placeholder in the route path by name.The controller class itself is
final readonlywith 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:
<?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:
{
"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:
<?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:
{
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.
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.