io / framework
A lightweight PHP framework for building APIs and server-rendered UIs, with attribute-based routing.
Requires
- php: >=8.5
- psr/container: ^2.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/log: ^3.0
Requires (Dev)
- monolog/monolog: ^3.9
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^13.3
- tenma/psr: ^0.1.3
- tenma/stubs: ^0.2.0
Suggests
- monolog/monolog: Powerful logging with handlers, channels, and JSON output.
- phpunit/phpunit: Required by the Io\Testing kit for app and plugin tests.
- tenma/psr: Response::upgrade() through the PSR-15 handler under Tenma
Provides
None
Conflicts
None
Replaces
None
README
Io
A lightweight PHP framework for building APIs and server-rendered UIs, with attribute-based routing.
Documentation: the guides explain each part of Io with complete examples, and the reference lists every class and method. Start at docs/index.md.
Overview
Io is for a team that wants one small framework covering both sides of a typical app: a JSON API and a server-rendered UI (plain pages and forms, no separate frontend build), with no config files, service definitions or implicit magic. Routing, dependency injection, validation, and the OpenAPI spec are all driven directly off PHP attributes on your own classes; there's nothing else to wire up or keep in sync.
It also ships production-grade defaults for the things a real API needs (RFC 9457 structured error
responses, CSRF protection, security headers, request correlation IDs, JWT auth), on by default or a
few lines of configure() away, rather than left to every app to reimplement. And it's built to be fast
under real load: attribute discovery compiles to a cache with an opt-in "trust mode" for production,
benchmarked well over an order of magnitude faster than uncached reflection-based discovery.
The core stays small on purpose. Everything beyond routing, DI, views, security and the OpenAPI generator (translations, LDAP, sessions, rate limiting, exports) is a separate package that plugs into fixed hooks (see Plugins), and a shipped testing kit drives real requests through your app or plugin from PHPUnit.
Feature highlights
- Attribute-based routing and controllers (
#[Get]/#[Post]/#[Put]/#[Patch]/#[Delete]) - Simple, reachability-based dependency injection via
#[Inject](no separate "service" attribute) - Request validation attributes (
#[NotBlank],#[Email],#[Length],#[Regex],#[OneOf],#[SameAs]), read as$dto->isValid/$dto->errors - A
FromArraytrait for#[Body]-bound DTOs, so their array-hydrating constructor never has to be hand-written - A unified
HttpExceptionhierarchy with RFC 9457 ("Problem Details for HTTP APIs") JSON error responses out of the box, plus an explicit exception-handler registry for anything more specific - Middleware pipeline built on a mandatory
MiddlewareBase(before()/after()hooks, with a short-circuit veto), including per-route middleware via#[Middleware(Class::class)] - Built-in CORS support
- Security response headers (
X-Content-Type-Options,X-Frame-Options,Referrer-Policy, HSTS, opt-in CSP) viaSecurityHeadersMiddleware - Stateless, double-submit-cookie CSRF protection, with a per-route/controller
#[SkipCsrf]opt-out - Request/correlation ID tracking (
X-Request-Id/X-Correlation-Id), automatically stamped onto every log line for the duration of that request - JWT authentication (
useJwt()), with a required (never-defaulted) signing secret - An HTML-rendering MVC view layer, with layouts, sections, views in subdirectories, automatic scoped
CSS/JS per view,
Redirectresults, and the bundledio.jsclient for CSRF headers andio-*handlers - Static file serving from a configured public directory
- Gzip response compression, size-thresholded, with an opt-out
#[NoGzip]attribute per controller/route - PSR-3 compatible logging (plug in any PSR-3 logger, or use
LoggerOptions::addMono()for Monolog) - CLI task runner (
#[Manager]/#[Task]), for the same app to serve HTTP and run background jobs - A complete OpenAPI 3.1.0 generator (
OpenAPIOptions::$enabled), driven by a compiled attribute cache rather than live reflection - Optional route/DI compilation caching with an opt-in "trust mode" for production deploys
- A plugin system with fixed hooks (endpoints, route attributes, guards, argument binders, renderers, OpenAPI extensions, view helpers, client assets), so features ship as packages instead of growing the core
- A testing kit (
Io\Testing) with a request builder and fluent response assertions
Table of Contents
- Installation
- Basic Usage
- Routing & Parameter Binding
- Dependency Injection
- Middleware
- Exception Handling
- Views
- Static Files
- Gzip Compression
- JWT Authentication
- OpenAPI
- Request / Correlation ID
- Logging
- CLI Tasks
- Plugins
- Testing
- Types and PHPStan
- Environment Variables
- Route/DI Compilation Caching & Production Performance
- Options Reference
- Contributing
- License
- Contact
Installation
Add Io to your project via Composer:
composer require io/framework
If you want to develop Io locally against a checked-out copy, use a Composer path repository
(with "symlink": true on Windows this needs a directory junction, not a real symlink) instead of
the packaged release.
composer install composer dump-autoload -o
Basic Usage
Create a front controller (typically ./index.php) that registers your controllers and starts
the app:
<?php require __DIR__ . '/vendor/autoload.php'; use Bakery\Controllers\{ApiController, ShopController}; use Io\App; $app = App::create(); $app->attach([ShopController::class, ApiController::class]); $app->serve();
The Sonnenkorn bakery (Io Bakery), the example app these docs
draw on, splits this in two: app.php builds the app and index.php serves it.
attach() discovers every class reachable from your controllers via #[Inject] (see
Dependency Injection below) and wires up the router and container. Outside
Tenma, serve() branches on php_sapi_name(): under a real
HTTP SAPI it dispatches the current request (handleHttp()); under cli it runs the CLI task runner
against the process's own $argv (runCli(), see CLI Tasks).
A runtime that hands requests over itself, instead of filling the superglobals, builds the request with
Request::fromParts() and calls handle(). It returns the finished Response (gzip, HEAD and
Content-Length already applied) without sending anything:
$res = $app->handle(Request::fromParts('GET', '/products/croissant?live', $headers, $body, $cookies, https: true, ip: $peer)); // $res->getStatus(), $res->getHeaders(), $res->getCookies() (one Set-Cookie value each), $res->getBody()
$req->ip is the address the connection came from (REMOTE_ADDR under FPM): the client's, or a proxy's in front
of it. Behind a reverse proxy, name it in ProxyOptions; a request from it then carries the client's address
from X-Forwarded-For and the scheme from X-Forwarded-Proto ($req->https). From anyone else both headers
are ignored:
$app->configure(function (ProxyOptions $proxies) { $proxies->trusted = ['127.0.0.1', '::1', '10.0.0.0/8']; });
An entry that is no IP address or CIDR range ('10.0.0.0/', 'localhost') throws an InvalidArgumentException
right there. Assign the whole list; appending with [] is not possible.
Under Tenma (PHP with an event loop) the app itself is the request handler:
one process serves every request, each in a Fiber of its own, interleaved while they wait on I/O. The
request id and the CSRF token follow a request into the tasks it starts (Threading\Task::run()); files under the
static prefixes are served by Tenma itself, without PHP:
$app->configure(function (FileSystemOptions $fs) { $fs->publicDirectory = __DIR__ . '/public'; }); $app->attach([...]); $app->serve();
serve() is the one line for every runtime: under Tenma it starts the server on PORT from the Environment
(8080 without one, or serve(3000)) and hands FileSystemOptions::$publicDirectory to Tenma, which serves it
without PHP (ETag, ranges); under FPM, php -S or the CLI it is run(), and Io serves the directory.
A controller may then wait on Tenma's async I/O (Data\Postgres, Http\Client, Threading\Task::all()) while the
process serves other requests. Read Controllers per request, services shared
first. In a template, don't wait: fetch the data in the controller before render().
A route can hand the request over to Tenma with Response::upgrade(): a WebSocket
(Http\Response::websocket()), a stream or server-sent events (stream(), events()). The route's
guards, #[Authenticated] and middlewares run first as for any other route; Tenma's response then replaces
Io's. A stream keeps the headers and cookies set on $res (CORS, security headers, the request id), its
own headers and status win; a WebSocket answers with its handshake only. If an exception handler writes
the response instead (the controller or an after() threw after the handover), the error goes out and the
upgrade is dropped. The handler runs after the request ended, but with the request's context restored: the
request id (on every log line), the CSRF token and whatever a plugin keeps in a FiberLocal. Values from the
request itself, such as the JWT claims, go in through use:
#[Get('/backoffice/notes')] #[Authenticated] public function notes(Request $req, Response $res): void { $name = $req->jwt['name']; // "Ben", from the claims Users::verify() returned at sign-in $res->upgrade(Http\Response::websocket(function (Http\WebSocket $socket) use ($name) { while (($note = $socket->receive()) !== null) { $socket->send("$name: $note"); // "Ben: two more rye loaves for the market" } })); }
WebSockets don't carry the CSRF header, so a WebSocket route that acts for a logged-in user checks the
Origin header itself.
For any server that speaks PSR-7, Io\Psr\Handler makes the app a PSR-15 request handler: Tenma
through tenma/psr, any other PSR-7 server, or behind PSR-15 middlewares of other packages. It takes any
PSR-17 factory:
$factory = new \Nyholm\Psr7\Factory\Psr17Factory(); $handler = new \Io\Psr\Handler($app, $factory); new Http\Server(8080, \Tenma\Psr\Psr::handler($handler, $factory))->start(); // or hand $handler to your server
Each request is translated in and out, so the direct new Http\Server(8080, $app)->start() stays the faster way under
Tenma. PSR-15 middlewares run around the whole app, not per route, and see neither guards nor
#[Authenticated]. Response::upgrade() works through the handler only under Tenma with tenma/psr.
A minimal controller, shortened from the bakery's src/Controllers/ApiController.php:
<?php namespace Bakery\Controllers; use Bakery\Repositories\Products; use Io\Attributes\{Controller, Get, Inject, Version}; #[Controller] #[Version('v1')] // every route under /v1 final class ApiController { #[Inject] private Products $products; #[Get('/products')] public function products(): array { return $this->products->all(); // a list of Product objects, sent as JSON } }
Routing & Parameter Binding
A route method declares its path with #[Get]/#[Post]/#[Put]/#[Patch]/#[Delete]; that
attribute is the route (there is no separate #[Route] attribute). #[Controller('/prefix')]
optionally prefixes every route in the class. A HEAD request runs the GET route and sends its
headers without the body. A path that exists under other methods answers 405 with an Allow
header instead of 404. Parameters are then bound with one of three attributes
depending on where the value comes from:
| Attribute | Description | Example |
|---|---|---|
#[Param] |
Binds a route placeholder (e.g. {code}) |
#[Param] string $code |
#[Query] |
Binds a query string (?category=) parameter |
#[Query] ?string $category |
#[Body] |
Binds the request body to a DTO: JSON for a JSON Content-Type (application/json, application/*+json), form fields otherwise |
#[Body] StatusChange $change |
An unattributed scalar-typed parameter (string/int/float/bool) is bound implicitly: Io
tries the route placeholder first, then falls back to the query string. The text converts by its
meaning: bool accepts true/false, 1/0, yes/no and on/off (so ?active=false is false),
int accepts only whole numbers, float only numbers. Anything else, like ?page=abc, answers 422 with the
parameter in errors instead of silently becoming 0. A backed enum parameter (#[Query] OrderStatus $status, or unattributed) gets the case its value names via tryFrom(), and 422 listing the allowed values
otherwise. #[Body] array $data gets the decoded body as it came. Request/Response
type-hinted parameters (or properties) are injected directly, no attribute needed. A non-nullable parameter
none of this binds stops the app at boot with an InvalidArgumentException instead of failing each request.
Placeholder values arrive URL-decoded, once: /products/br%C3%B6tchen binds brötchen, /products/%2541
binds %41, and an encoded %2F stays inside its own segment. Everything around the placeholders matches
literally, so the bakery's /backoffice/orders.{format} matches /backoffice/orders.csv but not
/backoffice/ordersxcsv.
use Bakery\Requests\StatusChange; use Bakery\Responses\OrderSummary; use Io\Attributes\{Body, Controller, Get, Param, Patch, Query, Version}; #[Controller] #[Version('v1')] final class ApiController { #[Get('/products')] public function products( #[Query] ?string $category = null, // from ?category=bread ): array { // ... } #[Patch('/orders/{code}')] public function advance( #[Param] string $code, // from /orders/{code} #[Body] StatusChange $change, // hydrated from the JSON/form body ): OrderSummary { // ... } }
By default #[Param]/#[Query] match the parameter's own name; pass a name to bind a
differently-named placeholder or query key: #[Param('code')] string $orderCode, #[Query('q')] string $search.
Class-level attributes are inherited from base controllers, although PHP itself doesn't inherit
attributes: an #[Authenticated], #[Role], #[Middleware] or #[Version] on an abstract base
controller applies to every subclass route. The nearest declaration wins, and a base controller's
middlewares run outside the subclass's own. #[Controller] itself is never inherited.
Array/collection request bodies
#[Body] normally requires the parameter to be typed as a DTO class. For an endpoint whose body is a
JSON array of objects, type the parameter array and give #[Body] an of: element class instead.
Each element is hydrated into (and validated against) that DTO individually:
use Bakery\Requests\StockLevel; use Io\Attributes\{Body, Put}; final class ApiController { /** The morning's count from the till: [{"slug": "croissant", "stock": 40}, ...] */ #[Put('/stock')] public function restock( #[Body( of: StockLevel::class )] array $levels, ): array { // $levels is StockLevel[]; a validation failure on any element throws // ValidationException with per-index error details (e.g. "1" => [...]). foreach ($levels as $level) { $this->products->setStockBySlug($level->slug, $level->stock); } return ['updated' => count($levels)]; } }
The body must be a JSON list of objects. A single object answers 422 with a body error, and an
element that is no object ([1, "two"]) answers 422 with an error under its index.
This is reflected in the generated OpenAPI schema too: the request body documents as
{"type": "array", "items": {"$ref": "#/components/schemas/StockLevel"}} rather than a single $ref.
File uploads
A multipart/form-data request carries its fields in $req->body and its files in $req->files, nested as
the field names say (photos[] becomes a list). $req->file() returns one, named as in the form: 'picture',
'doc[a]', 'photos[1]'. An empty file input is left out, and a list stays numbered without gaps. The same works
under FPM, Tenma, any PSR-7 server and the testing kit (->withFile('picture', 'pretzel.png', $bytes, 'image/png')):
#[Post('/backoffice/products/{slug}/picture')] #[Role('owner')] public function picture(string $slug, Request $req): Redirect { $picture = $req->file('picture') ?? throw new ValidationException(errors: ['picture' => 'Choose a picture.']); if (!$picture->isValid() || getimagesizefromstring($picture->contents()) === false) { throw new ValidationException(errors: ['picture' => 'This is not a picture.']); } $picture->moveTo(__DIR__ . '/../../public/products/' . bin2hex(random_bytes(8)) . '.png'); return new Redirect("/products/$slug", 303); }
name, type and size come from the client: never use the name as a path, and check the content before
trusting the type. isValid() is false when PHP or the runtime refused the file ($error holds the
UPLOAD_ERR_* code). moveTo() works once; under Tenma it writes without blocking the worker. The size limit is
upload_max_filesize/post_max_size under FPM and --max-body under Tenma.
Validation
DTOs used with #[Body] can carry validation attributes on their public properties:
use Io\Attributes\{Email, Length, NotBlank, OneOf, Regex, SameAs}; use Io\FromArray; /** The owner adds someone to the bakery's staff. */ final class NewStaffMember { use FromArray; #[NotBlank] #[Length( min: 2, max: 80 )] public string $name = ''; #[NotBlank] #[Email] public string $email = ''; #[OneOf(['owner', 'staff'])] public string $role = 'staff'; #[Regex('/^\+?[0-9 ]{6,20}$/')] public ?string $phone = null; #[Length( min: 10 )] public string $password = ''; #[SameAs('password')] public string $passwordRepeat = ''; }
#[OneOf] compares strictly ('1' is not 1) and, like #[Email], #[Regex] and #[Length], skips null; #[SameAs] compares
against the named property of the same DTO.
A failing DTO throws ValidationException, which the default exception handler turns into a 422
RFC 9457 Problem Details response, with per-field errors under errors:
{"type": "about:blank", "title": "Validation failed", "status": 422, "detail": "Validation failed", "errors": {"email": "..."}}.
See Exception Handling. The messages are English by default; a plugin can bind
Io\Validation\Messages to translate them (the io-i18n plugin does, see Plugins).
Hydrating DTOs (FromArray)
#[Body]/#[Body(of: ...)] construct your DTO directly as new YourDTO($req->body()) (see
Array/collection request bodies above), so every DTO used that way
needs a constructor that hydrates its public properties from an array. use Io\FromArray; provides
exactly that, instead of writing (and keeping in sync across every DTO) the same foreach/property_exists
loop by hand:
use Io\Attributes\{Email, NotBlank}; use Io\FromArray; // src/Requests/LoginForm.php final class LoginForm { use FromArray; #[NotBlank] #[Email] public string $email = ''; #[NotBlank] public string $password = ''; }
Unknown keys in the input array are silently ignored, and so are keys naming a private, protected,
protected(set)/private(set) or static property: those are the DTO's own state, never request input. A writable
property without a default (public string $email;) is required: left unset, it fails This value is required.
(a 422, not an uninitialized-property 500). A JSON body that doesn't parse answers 400 Bad Request before
any DTO is built. A JSON body is decoded only when something reads it (#[Body], $req->body), after routing and
the route's guards; one larger than RequestOptions::$maxJsonBytes (256 KiB) answers 413 Content Too Large
instead, because keys that collide in PHP's array hash make decoding slow with the square of their number. A backed enum property (public OrderStatus $status) gets the case its value names via tryFrom(), and
This value should be one of: pending, received, baking, ready, picked_up, cancelled. otherwise. A property typed with another FromArray DTO is built
from the nested array, and an array property with #[ListOf(OrderLine::class)] (map: true for string
keys) gets each element built as a DTO or enum case; nested errors are reported under
dotted keys (customer.email, lines.0.quantity). A property with #[Ignore] is never set from input nor
required, and it is left out of JSON/XML results and the OpenAPI schema too. A property that must only never
be settable this way (e.g. a server-computed value) can be excluded by overriding fromArrayExcludes():
use Io\FromArray; final class CheckoutForm { use FromArray; public string $name = ''; public ?string $voucher = null; // filled in from the customer's earlier order, never from client input /** @return array<int, string> */ protected function fromArrayExcludes(): array { return ['voucher']; } }
A DTO that needs extra logic around hydration (not just the plain loop) can declare its own
__construct() (which overrides the trait's, same as any other method) and call
$this->hydrateFromArray($data) from it directly.
Validating by hand ($dto->isValid, $dto->errors, fail())
For an HTML form handler that re-renders the page with the errors, bind with #[Body(validate: false)]: the
method gets the DTO even when it is invalid, and a View it returns then answers with status 422. Built by
hand or bound that way, the DTO validates itself. isValid and errors are computed properties: every read applies the attribute
rules, so there is no validator to construct and no moment where the result is stale. fail() adds a
failure only your code can know about (a wrong password, an email the bakery already has) to the same list:
// from src/Controllers/SessionController.php #[Post('/login')] public function login( #[Body( validate: false )] LoginForm $form, Response $res, ): View|Redirect { $user = $form->isValid ? $this->users->verify($form->email, $form->password) : null; if ($user === null) { if ($form->isValid) { $form->fail('password', 'Wrong email or password.'); } return new View('Backoffice/Login', $form); // 422 with $form->errors } JWT::signIn($res, $user); return new Redirect('/backoffice', 303); }
errors is field => message, attribute failures first and fail() entries after them; a later fail()
for the same field replaces the earlier one. Input keys named errors, isValid or failures are never
hydrated, and the two computed properties never appear in OpenAPI schemas or redacted logs.
Dependency Injection
Io uses a simple, reachability-based dependency injection system: use #[Inject] on a property (or
constructor parameter) to declare a dependency. The container discovers what to instantiate by walking
#[Inject]-typed properties/parameters starting from your registered controllers. Any class reachable
that way is auto-instantiated and shared as a singleton. There is no separate "mark this class as a
service" attribute: reachability via #[Inject] is what registers a class.
<?php namespace Bakery\Services; use Bakery\Models\{Holiday, WeatherDay}; use Io\Attributes\Inject; // from src/Services/LastKnown.php: the last good answer of each web service, in this worker's memory final class LastKnown { #[Inject] private Holidays $holidayService; #[Inject] private Weather $weatherService; /** @var array<string, Holiday>|null */ public private(set) ?array $holidays = null; public function refreshHolidays(): void { $this->holidays = $this->holidayService->upcoming() ?? $this->holidays; } }
// from src/Controllers/ShopController.php #[Controller] final class ShopController { #[Inject] private Products $products; #[Inject] private LastKnown $lastKnown; #[Get('/products/{slug}')] public function product(string $slug): View { $product = $this->products->bySlug($slug) ?? throw new NotFoundException("No product $slug"); // ... the next pickup days, without the holidays in $this->lastKnown->holidays } }
ShopController reaches LastKnown, and LastKnown reaches Holidays and Weather: all four are in the
container without being listed anywhere.
Notes:
- A class only gets auto-registered if it's reachable via
#[Inject]from a controller you registered (directly, or transitively through another injected service). #[Inject]also works on middleware classes registered via#[Middleware](see below).Request,ResponseandRoutercan be injected into controllers and#[Middleware]classes; they are set per request, never taken from the container.- Container-managed classes are constructed with no arguments and populated purely through
#[Inject]-marked properties. There is currently no constructor-argument resolution; a constructor parameter marked#[Inject]is discovered (so its type is pulled into the container graph) but not itself resolved/passed as a constructor argument. Prefer property injection.
PHPStan would report an #[Inject] property as only read, since the container writes it: the package's
extension.neon counts such properties as written and initialized (loaded by phpstan/extension-installer, or
add vendor/io/framework/extension.neon to includes). It also reads #[Returns] and #[ListOf] as types
(see Types and PHPStan).
Outside controllers and services (the front controller, a script), $app->get(Service::class) returns the
container's instance and throws ServiceNotFoundException when it can't be built; $app->find(Service::class)
returns null instead.
Controllers per request, services shared
Controllers and #[Middleware] classes are created fresh for every request, so an #[Inject] Request
or #[Inject] Response on them always belongs to the request being handled. A controller you registered
in the container yourself (one with constructor arguments) is cloned per request, keeping what its
constructor set up. Under FPM nothing changes: there a controller never outlived its request anyway.
Services are the opposite: one instance per process, shared by every request. On a long-lived worker (one process that serves request after request, such as Tenma), and on one that runs requests interleaved while they wait on I/O, anything a service keeps in a property is visible to every other request. So a service must not keep per-request state in its properties:
final class Cart { private string $cartId = ''; public function open(Request $req): void { $this->cartId = $req->cookies['cart']; } // wrong public function lines(): array { return $this->carts->lines($this->cartId); } // another customer's cart }
The same goes for memoizing per request ($this->current ??= ... survives into the next request),
switching shared state per request ($translator->setLocale(...), $db->setTenant(...)), collecting per
request (a list of messages, a query counter) and half-built objects (set fields, wait, then build()).
Fine in a service: configuration and dependencies that never change after boot, caches whose content holds for everyone (compiled templates, the public holidays; nothing user-specific unless the user is part of the key), and stateless helpers that get everything they need as arguments.
Instead of per-request state in a service:
- pass it along as an argument:
$auth->user($req)rather than$auth->user(),$carts->lines($req->cookies['cart']); - keep it on the request, as
useJwt()does with$req->jwt; - or, for a value that really has to be implicitly "current" (a request id for log lines), hold it in a
Io\Support\FiberLocal, asRequestContextandCsrfdo.
Io can't enforce this rule; under FPM breaking it goes unnoticed, since every request starts with a fresh process.
Middleware
Global middleware runs on every request, registered via use() with an instance of a class that extends
Io\Http\MiddlewareBase (see below for before()/after()):
use Io\Attributes\Inject; use Io\Environment; use Io\Http\{MiddlewareBase, Request, Response}; /** The shop is closed for the summer holidays: every page says so. */ final class ClosedForHolidays extends MiddlewareBase { #[Inject] private Environment $env; protected function before(Request $req, Response $res): bool { if (!$this->env->bool('CLOSED_FOR_HOLIDAYS', false)) { return true; } $res->status(503) ->withHeader('Retry-After', '86400') ->withHeader('Content-Type', 'text/plain') ->write("Sonnenkorn is on holiday. We bake again from Monday.\n"); return false; } } $app->use(new ClosedForHolidays());
The instance is shared by all requests, so a middleware passed to use() keeps nothing per request in its
properties. The bakery's CartCookie (src/Services/CartCookie.php) is one: it gives every browser a cart id
in a cookie and keeps nothing itself.
useCors() and useJwt() (see below) are both just built-in middleware registered this way.
$req->path is the path the router matches: slashes at either end trimmed and percent-decoded, so //backoffice/
and /%62ackoffice are both /backoffice, and a check on it covers every spelling that reaches the same route. A path
with an encoded slash (%2F) or NUL in a segment stays encoded as a whole.
Per-route middleware is declared with #[Middleware(Class::class)] directly on a controller method
(repeatable). The class must extend Io\Http\MiddlewareBase: its handle() is what runs, and a
#[Middleware(...)] naming a class that doesn't exist or doesn't extend it fails the route compile with an
InvalidArgumentException, since a skipped middleware would leave the route running without its check. MiddlewareBase::handle() is final and always runs before() → $next() →
after(), in that fixed order; override before()/after() instead of reimplementing that plumbing
yourself:
use Io\Http\{MiddlewareBase, Request, Response}; use Io\Exceptions\ForbiddenException; final class DuringOpeningHours extends MiddlewareBase { protected function before(Request $req, Response $res): bool { $hour = (int) date('G'); if ($hour < 6 || $hour >= 18) { throw new ForbiddenException('The till only books sales while the shop is open.'); } return true; // false vetoes: $next() and after() are both skipped } }
use Io\Attributes\{Body, Middleware, Post}; final class ApiController { #[Post('/sales')] #[Middleware(DuringOpeningHours::class)] public function sale(#[Body] TillSale $sale): array { // ... } }
before() returning false is the veto point: $next() (and after()) are skipped entirely and the
chain short-circuits there, typically because before() already threw, or built its own response.
before()/after() are both no-ops by default, so a subclass only overrides whichever one it needs. The
class is resolved through the container at dispatch time (so it can itself use #[Inject]) rather than
being instantiated directly.
Because MiddlewareBase also implements __invoke() (delegating to handle()), the same subclass works
on routes and on the $app->use() pipe alike:
$app->use(new SecurityHeadersMiddleware()); // see below
Security Headers
SecurityHeadersMiddleware fills in the standard hardening response headers (X-Content-Type-Options,
X-Frame-Options, Referrer-Policy, and on HTTPS requests only Strict-Transport-Security), enabled by
default with sensible values; Content-Security-Policy is opt-in, since a wrong one-size-fits-all CSP
would break real pages rather than just fail open. Register it once, via use():
use Io\Http\SecurityHeadersMiddleware; use Io\Options\SecurityHeaderOptions; $app->configure(function (SecurityHeaderOptions $opts) { $opts->frameOptions = 'SAMEORIGIN'; // default: 'DENY' $opts->cspEnabled = true; // default: false $opts->csp = "default-src 'self'"; // required once cspEnabled is true }); $app->use(new SecurityHeadersMiddleware());
The headers are set on the way in, so an error response from a later middleware or the controller carries them too. A controller, or a middleware after this one, that sets one of these headers replaces it; one that a middleware before this one already set is kept.
CSRF Protection
Stateless, double-submit-cookie CSRF protection for state-changing requests, with no server-side session
store: a token is a self-contained, HMAC-signed {exp, nonce} pair, verified against
CsrfOptions::$secret. Configuring a secret is what opts an app into it at all. Unconfigured (the
default), every request passes through unchecked, so this never breaks an app that doesn't use it:
use Io\Environment; use Io\Options\CsrfOptions; $app->configure(function (CsrfOptions $opts, Environment $env) { $opts->secret = $env->string('CSRF_SECRET'); // required to actually enable checking, at least 32 bytes $opts->cookieSecure = null; // default: Secure exactly when the request came over HTTPS });
Once a secret is set, every POST/PUT/PATCH/DELETE request is checked automatically (GET/HEAD
never carry a body/side effect worth protecting). There is no middleware to register. A token is issued as a
cookie (CsrfOptions::$cookieName, default csrf_token) on every rendered View (see
Views), and must be repeated back by the client either as a request header
(CsrfOptions::$headerName, default X-CSRF-Token, the AJAX/fetch() path) or a body field
(CsrfOptions::$fieldName, default _csrf, for a classic no-JS <form>). A mismatch, missing token, or
expired token throws ForbiddenException (403).
With JWTOptions::$cookieName set, each token is signed for the session cookie it was issued with. A
token someone fetched for their own session and planted in another browser (from a sibling subdomain,
say) is rejected there. Signing in or out changes the session, so the next rendered view issues a new
token.
Inside a view template, $this->csrf renders the classic no-JS <form> half of that pattern:
<form method="post" action="/backoffice/logout"> {{ $this->csrf->field() }} <button>{{ $this->t('Sign out') }}</button> </form>
That is the sign-out form of the bakery's views/_Backoffice.php; field() renders the hidden input.
The AJAX/fetch() half needs no app code at all if you're loading
io.js (see $this->ioJsTag() in Views): it patches fetch()
itself so every same-origin POST/PUT/PATCH/DELETE call automatically carries the header, reading
the token straight from the cookie. A plain fetch() call needs to know nothing about CSRF:
fetch('/backoffice/stock', { method: 'POST', body: JSON.stringify(levels) }); // X-CSRF-Token attached automatically
Not using io.js? Read the cookie and attach the header yourself the same way it does.
Io.csrf.token() is also there directly, for a non-fetch() use (a WebSocket handshake, a
manually-built XMLHttpRequest). If CsrfOptions itself was reconfigured away from its default cookie/
header names, $this->ioJsTag() passes the new names to io.js automatically as
data-csrf-cookie/data-csrf-header on the <script> tag, with nothing to do in the template.
Opt a token-auth-only JSON API (or any route with no double-submit cookie to check) out entirely with
#[SkipCsrf], on the controller class or a single method:
use Io\Attributes\{Controller, SkipCsrf, Version}; // from src/Controllers/ApiController.php: the till and the delivery app send a bearer token, not cookies #[Controller] #[Version('v1')] #[SkipCsrf] // every route below skips CSRF verification final class ApiController { }
CORS
Configure CorsOptions, then enable the feature; useCors() takes no parameters:
use Io\Options\CorsOptions; $app->configure(function (CorsOptions $opts) { $opts->allowOrigin = 'https://till.sonnenkorn.test'; // the till's web app $opts->allowMethods = 'GET, POST, OPTIONS'; $opts->allowHeaders = 'Content-Type, Authorization'; $opts->allowCredentials = true; $opts->maxAge = 86400; // optional: cache preflight for a day $opts->exposeHeaders = 'ETag'; // optional }); $app->useCors();
Leaving a property unset omits its header entirely, so CORS is off by default. allowCredentials is a
plain bool: there's no meaningful false value for Access-Control-Allow-Credentials per the Fetch
spec (it's either present and exactly "true", or simply absent), so unlike the others it can't end up
sending a blank/malformed header value.
OPTIONS requests are answered directly with a 204 once the CORS headers are attached.
Exception Handling
Io\Exceptions\HttpException is the base class every one of Io's own HTTP-facing exceptions
extends (ForbiddenException, UnauthorizedException, NotFoundException, ValidationException), and
the one your own domain exceptions should extend too. Its fields map directly onto
RFC 9457 ("Problem Details for HTTP APIs"):
use Io\Exceptions\HttpException; final class SoldOutException extends HttpException { public function __construct(string $detail) { parent::__construct(statusCode: 409, title: 'Conflict', detail: $detail); // $type (default 'about:blank') and $instance are also constructor args, if you need them } }
Io's default handler (installed automatically, no setup) catches every HttpException and builds a
Content-Type: application/problem+json response, status = $statusCode, body =
{type, title, status, detail?, instance?}:
{"type": "about:blank", "title": "Conflict", "status": 409, "detail": "'Sourdough Loaf' is out of stock."}
A subclass with extra structured data overrides toProblemDetails() to add its own members alongside the
five standard ones. ValidationException does exactly this, adding errors (the per-field/per-index
messages):
| Exception | Status | Extra body member |
|---|---|---|
ValidationException |
422 | errors (per-field/per-index messages) |
ForbiddenException |
403 | none |
UnauthorizedException |
401 | none |
NotFoundException |
404 | none |
Any other Throwable |
500 | none (message never exposed, not even in development; see below) |
A non-HttpException Throwable (anything unexpected/unhandled) maps to a generic 500 Problem Details
body with a fixed "Internal Server Error" title, never $e->getMessage() or a stack trace, in any
environment: an unexpected exception's message can easily contain internal details (a file path, a query,
a raw driver error) never meant to reach whoever triggered the crash, so Environment::isDev() alone
isn't treated as a safe enough gate for that. It's still logged in full via the container's Logger. A
project that wants more than the generic message needs its own explicit
useExceptionHandler(Throwable::class, ...) override: an actual opt-in, not an implicit one.
Register your own handler with useExceptionHandler(), for an exact exception class or anything further
up its class/interface hierarchy. The most specific registered handler wins: exact class match
first, then parent classes, then implemented interfaces, then the Throwable catch-all. The handlers
cover app-level middleware ($app->use()) as well as routes, so an auth check there can simply throw.
use Bakery\Models\OutOfStock; use Io\Http\{Request, Response}; // Orders::place() throws OutOfStock (a plain RuntimeException) when the last croissant went to someone else $app->useExceptionHandler(OutOfStock::class, function (OutOfStock $e, Request $req, Response $res) { $res->status(409) ->withHeader('Content-Type', 'application/json') ->write(json_encode(['error' => "Not enough {$e->product} left."])); // no need to call ->send(), see below });
Calling useExceptionHandler() again for the same class overrides the previous handler, including
the built-in HttpException/Throwable defaults above.
A handler only needs to build $res (status/headers/body) and return: App::handleHttp() sends the
response exactly once, itself, after the whole middleware pipe has unwound. Response::send() stays
harmless for compatibility (inside App::handle() it does nothing, the caller sends the finished
response), but it's no longer the recommended shape for a new handler.
Views
Return Io\Http\View from a route method instead of an array to render server-side HTML, with
layouts, named sections, and per-view/per-layout scoped CSS/JS:
use Io\Http\View; // from src/Controllers/ShopController.php #[Get('/products/{slug}')] public function product(string $slug): View { $product = $this->products->bySlug($slug) ?? throw new NotFoundException("No product $slug"); return new View('Shop/Product', new ProductPage( product: $product, days: PickupCalendar::days($today, $hours, $holidays, 3), // ... )); }
Configure where views/layouts live via RendererOptions:
use Io\Options\RendererOptions; $app->configure(function (RendererOptions $opts) { $opts->directory = __DIR__ . '/views'; $opts->layout = '_Layout.php'; // default layout; #[Layout] on a controller or method, or View's layout argument, override it });
$opts->layout defaults to '_Layout' with no extension, which never matches a real file. Pass an
explicit .php name, or no layout is applied and every render falls back to the bare view content.
Inside a view/layout .php file, $model is whatever was passed to View's constructor (here the
ProductPage), and $this is the View instance itself. The bakery's back office has a layout of its own,
#[Layout('_Backoffice.php')] on its controllers; new View(..., layout: ...) sets one for a single render.
A view is PHP with a few directives on top:
@use (Bakery\Services\Format)
@model (Bakery\Models\ProductPage)
@code
$locale = $this->locale();
$product = $model->product;
@endcode
@section('title')
{{ $product->name($locale) }}
@endsection
<h1>
{{ $product->name($locale) }}
</h1>
<p class="price" @class.sold-out="$product->stock === 0">{{ Format::price($product->priceCents, $locale) }}</p>
<ul>
@foreach ($model->days as $day)
<li>{{ Format::day($day, $locale) }}</li>
@endforeach
</ul>
@if ($product->stock > 0)
<app-add-to-cart product="{{ $product->id }}"></app-add-to-cart>
@else
<p>{{ $this->t('Sold out for today') }}</p>
@endif
That is views/Shop/Product.php of the bakery, shortened ($this->locale() and $this->t() come from the
I18n plugin, <app-add-to-cart> is a live component)
, and its layout views/_Layout.php places the sections and the page: <title>@yield('title') · Sonnenkorn</title>,
<main>{{ $content }}</main>.
{{ $value }} and <?= $value ?> are HTML-escaped. Markup goes out as it is only as a Io\Html value
({{ new Html($trusted) }}) or through <?php echo $markup ?>; the layout's $content, @yield, section(),
assetTags(), scopeAttribute(), ioJsTag(), pluginAssetTags() and $this->csrf->field() return
Html already. Printing an array throws a TypeError. @if/@elseif/@else/@endif, @foreach,
@code ... @endcode, @use (Class), @class.name="$cond", {{/* comments */}} and @@ work as in Live
templates; plain PHP keeps working. @model (Type) names the type of $model for PHPStan and the editor, and
in development a model of another type is a TypeError. A mistake in the syntax keeps the view from rendering,
with its line, and an error while it renders keeps the view's file and line (View::origin(), the error log).
With a cache directory the compiled templates are files under {cacheDirectory}/views/, so OPcache keeps them.
vendor/bin/io-check checks every view without running the app: the compiler's mistakes, and PHPStan's
findings with $this as the View and $model of its @model type, at the view's lines. Under tenma lsp
the editor knows the views too (completion of $this-> and $model-> and their chains, hover, F12, the
mistakes while typing). The variable of a loop over a #[ListOf] property or a #[Returns(list: true)] method
gets the element class there, in views, Live templates and plain PHP files alike. See
Views for the full syntax.
$this->request(read-only),$this->envand$this->csrf: the request being answered, theEnvironmentand the CSRF helper.@section('name')...@endsection/@yield('name'), the same as$this->startSection('name')/$this->endSection()/$this->section('name', required: false): named content blocks a layout pulls in, e.g. for per-page<head>scripts.$this->assetTags()/$this->layoutAssetTags():<link>/<script>tags for the current view's own sibling.css/.js(Shop/Product.cssnext toShop/Product.php), and for the layout's own, if either has one.$this->scopeAttribute()/$this->layoutScopeAttribute(): adata-view="..."attribute for the element that content should be scoped to; the matching CSS is automatically wrapped in a[data-view="..."]nesting selector, so plain rules inShop/Product.cssonly ever apply where that attribute is present.
The client half, io.js (automatic CSRF headers, io-onclick/io-onsubmit handlers and
directives for plugin scripts), ships inside the package and is compiled into the same asset pipeline as view CSS/JS: served at
{assetsPath}/io.{hash}.js with immutable caching, rebuilt when the framework is upgraded. The
layout emits the tag with {{ $this->ioJsTag() }}; when CsrfOptions was changed from its
defaults the tag also carries data-csrf-cookie/data-csrf-header for the client, so nothing is
duplicated in the template.
A view or layout's sibling .css/.js is discovered automatically (same basename, same directory,
either role), with nothing to register. Views can live in subdirectories: views/Backoffice/Sales.php is
new View('Backoffice/Sales'), always /-separated, and its Backoffice/Sales.css is found, scoped
([data-view="Backoffice/Sales"]) under that same name. A name that starts with / or
contains a .. segment is rejected at construction, so a view name built from request input can never
reach outside the views directory. The layout is still resolved from the root of the directory only.
Compiled, content-hashed copies are written under
{cacheDirectory}/assets/ (kept in memory without a cache directory; see Route/DI Compilation Caching
below) and served through GET /assets/{hash}.css/.js with a year-long, immutable Cache-Control
header.
Passing partial: true skips the layout entirely and renders just the view, for a bare HTML fragment. directory: renders view and layout from another directory, such as a plugin's own views; such a view gets no compiled CSS/JS of its own.
Navigation between pages is plain browser navigation: links and forms load whole pages. For smooth
transitions between them, CSS covers it without JavaScript: @view-transition { navigation: auto; }.
Redirects
Return Io\Http\Redirect to send a redirect instead of a body, typically from a form POST
that succeeded (POST/redirect/GET), next to the View the same handler renders when it didn't:
use Io\Http\{Redirect, Response, View}; // from src/Controllers/SessionController.php #[Post('/login')] public function login( #[Body( validate: false )] LoginForm $form, Response $res, ): View|Redirect { $user = $form->isValid ? $this->users->verify($form->email, $form->password) : null; if ($user === null) { return new View('Backoffice/Login', $form); } JWT::signIn($res, $user); return new Redirect('/backoffice', 303); }
new Redirect($location, $status = 302) accepts any 3xx (e.g. 303 for an explicit See Other, 301/308
for a permanent move, 307 to preserve the original method). The response is just the status and a
Location header (no body, and the route's formatter is ignored), while anything the handler already
queued on the Response (cookies, headers) is kept. A $location that is empty or contains a line break
is rejected at construction. A redirect is an ordinary result, not an exception: the cases that should
redirect on failure (no/expired JWT on an #[Authenticated] route) already go through
JWTOptions::$loginRedirectUrl (see JWTOptions).
Static Files
Serve a plain public directory (images, fonts, a pre-built frontend bundle, etc.) by configuring
FileSystemOptions::$publicDirectory. It is checked before routing on GET and HEAD, so a matching file
always wins over a route at the same path; any other method goes to routing:
use Io\Options\FileSystemOptions; $app->configure(function (FileSystemOptions $fs) { $fs->publicDirectory = __DIR__ . '/public'; // the bakery's app.css, favicon.svg and products/*.svg });
Left unset (the default), no static directory is served at all. Only files inside that directory are
served: a path with a .. or . segment, a dotfile or dot-directory (.env, .git/) and PHP source
(.php, .phtml, .phar, …) always 404, and the resolved file (symlinks followed) must lie inside the
directory itself (a sibling such as public-backup doesn't count). So the front controller can sit in
the same directory without its source ever being handed out. .css/.js/.mjs/.json/.svg/.html get a fixed
Content-Type; anything else falls back to mime_content_type(), then application/octet-stream. Every file
carries Last-Modified, and If-Modified-Since with that date or later answers 304 Not Modified.
Gzip Compression
Every response funnels through Response::send(), which gzips the body when the client's
Accept-Encoding header allows gzip (named or by *, with a quality above 0, so gzip;q=0 refuses it) and the body meets a configurable size threshold. Compressing
a tiny response is a net loss once gzip's own framing overhead is counted, so anything shorter than
CompressionOptions::$minBytes (default 1024 bytes) is always sent uncompressed. On by default, no
setup required:
use Io\Options\CompressionOptions; $app->configure(function (CompressionOptions $opts) { $opts->minBytes = 2048; // default: 1024 $opts->enabled = false; // default: true; false turns compression off everywhere });
A compressed response gets Content-Encoding: gzip and Vary: Accept-Encoding; Content-Length always
reflects the actual (possibly compressed) bytes being sent. A response that already carries its own
Content-Encoding is left alone rather than double-encoded.
Opt individual routes out with #[NoGzip], on the controller class (every route on it) or a single
method (just that route):
use Io\Attributes\{Controller, Get, NoGzip}; #[Controller('/backoffice/pictures')] #[NoGzip] // every route below skips compression final class PictureController { #[Get('/{slug}.webp')] #[NoGzip] // equivalent here, since the whole class already opts out public function picture(string $slug): \SplFileInfo { // ... } }
XML Responses
#[SimpleXmlFormatter] sends the return value as XML under <root>. Nested arrays and objects become
nested elements, text is escaped, and a key that is no valid element name (a list index, say) becomes
<item key="…">. Properties with #[Ignore] are left out here as in JSON.
File Responses
#[FileFormatter] sends the return value as a raw body instead of JSON. Return an SplFileInfo to send
a file from disk. A returned string is always the content itself, even when it happens to name a file,
so text from a user can never turn into a file read:
use Io\Attributes\{FileFormatter, Get}; /** The price list the bakery hangs in the shop window, as a PDF. */ #[Get('/price-list')] #[FileFormatter( mimetype: 'application/pdf', download: true )] // filename defaults to the file's own name public function priceList(): \SplFileInfo { return new \SplFileInfo(__DIR__ . '/../../storage/price-list-2026-10.pdf'); }
JWT Authentication
Configure JWTOptions before calling useJwt(). The signing secret has no default: it's an
uninitialized typed property, so using it unconfigured throws immediately rather than silently signing
tokens with a guessable value. Tokens are always HS256, and a token is only valid with a numeric exp
in the future (JWT::encode() always sets one):
use Io\Options\JWTOptions; $app->configure(function (JWTOptions $opts, Environment $env) { $opts->secret = $env->string('JWT_SECRET'); // required, at least 32 bytes: bin2hex(random_bytes(32)) $opts->ttl = 3600; // default, seconds $opts->tokenType = 'Bearer'; // default $opts->header = 'Authorization'; // default $opts->cookieName = null; // default (disabled), see below }); $app->useJwt();
Once enabled, a valid token's decoded payload is available as $req->jwt (an array, or null) in any
controller/middleware that receives the Request. #[Claims] StaffClaims $staff binds it to a FromArray DTO
instead; it implies #[Authenticated] (401 without a token, or with claims that don't fill the DTO), and a
nullable ?StaffClaims makes the person optional.
A plain browser page navigation never sends a bearer Authorization header; only an explicit
fetch()/XHR call that sets one itself does. Set JWTOptions::$cookieName to also read the token back
out of a same-named cookie whenever the header didn't already produce one, so a signed-in session
(e.g. one your login endpoint issued via Set-Cookie: <name>=<token>; HttpOnly) survives a full page
load too:
// from the bakery's app.php: the back office signs in with a JWT in an HttpOnly cookie $app->configure(function (JWTOptions $jwt, CsrfOptions $csrf) use ($env) { $jwt->secret = $env->string('JWT_SECRET'); $jwt->cookieName = 'session'; $jwt->ttl = 12 * 3600; $jwt->loginRedirectUrl = '/backoffice/login'; $csrf->secret = $env->string('CSRF_SECRET'); });
A browser attaches that cookie to cross-site requests too, so signing in by cookie needs CSRF
protection: with cookieName set and no CsrfOptions::$secret, useJwt() fails every request with a
500 instead of running unprotected.
Issue that cookie from your own login endpoint with JWT::signIn($res, $claims): it signs the claims and
sets the cookieName cookie (HttpOnly, SameSite=Lax, Max-Age of ttl, Secure when the request came over
HTTPS). JWT::signOut($res) removes it on logout:
// from src/Controllers/SessionController.php #[Post('/login')] public function login(#[Body] LoginForm $form, Response $res): Redirect { $user = $this->users->verify($form->email, $form->password) // ['sub' => 2, 'name' => 'Ben', 'role' => 'staff'] ?? throw new UnauthorizedException('Wrong email or password.'); JWT::signIn($res, $user); // returns the token too, also usable as a Bearer token directly return new Redirect('/backoffice', 303); } #[Post('/logout')] public function logout(Response $res): Redirect { JWT::signOut($res); return new Redirect('/backoffice/login', 303); }
A program sends the token as a header instead: the bakery's POST /v1/tokens answers JWT::encode($user) for
the till, which then calls /v1/orders with Authorization: Bearer ….
OpenAPI
Switch the endpoint on via OpenAPIOptions (nothing to attach):
$app->configure(function (OpenAPIOptions $opts) use ($env) { $opts->enabled = $env->bool('OPENAPI', false); // the bakery's switch, off unless OPENAPI=true $opts->path = '/openapi'; // default });
GET /openapi/spec.json: the generated OpenAPI 3.1.0 documentGET /openapi/spec.{version}.json: one#[Version]'s document (404 for an unknown version)GET /openapi/swagger.html: bundled Swagger UI
The generator is driven entirely by a compiled attribute cache (see Route/DI Compilation Caching) rather than live reflection on every request, so serving the spec is cheap even under trust mode. It documents:
- Path/query parameters with their real PHP types (
#[Param]/#[Query], and unattributed scalars) 200responses from the return type (a DTO's public properties, an enum's values) or from#[Returns](type, list or map, description, status); routes returning aVieworRedirectare left out of the document#[Body]DTO request bodies, including nested and self-/mutually-referencing (cyclic) models- Backed enums as
{type, enum}, nullable types astype: [x, "null"],#[ListOf]arrays asitems/additionalProperties,requiredfor properties with neither a default nornull, andreadOnlyfor properties a request cannot set #[Body(of: ...)]array/collection request bodies (see above)#[Summary],#[Tags],#[Throws],#[Deprecated],#[Ignore](on a route or a DTO property)#[Schema(name: ...)]to override a model's generated component name; two DTOs with the same short name never share a schema (the second is named by its full class name with dots)operationIdasShortClass_method, the full class name only where two controllers share a short name#[Throws(NotFoundException::class)]without a status documents the exception's own (404 here)#[Property(description:, format:, example:, deprecated:)]to annotate individual model properties- Security schemes configured via
OpenAPIOptions::$securitySchemes/$security; routes with#[Authenticated],#[Role]or#[Permissions]require them per operation (a JWTbearerAuthscheme when none is configured), public ones opt out of a document-widesecuritywithsecurity: [] - Automatic
401/403responses for guarded routes and422for#[Body]DTOs and converted parameters
use Io\Options\OpenAPIOptions; // from the bakery's app.php $app->configure(function (OpenAPIOptions $api) { $api->title = 'Sonnenkorn bakery API'; $api->version = '1.0.0'; $api->description = 'The range, and the open orders for the people in the bakery.'; $api->servers = [['url' => 'https://shop.sonnenkorn.test']]; $api->securitySchemes = ['bearer' => ['type' => 'http', 'scheme' => 'bearer', 'bearerFormat' => 'JWT']]; });
A route of the bakery's API, with what the document says about it in its attributes:
// from src/Controllers/ApiController.php #[Patch('/orders/{code}')] #[Authenticated] #[Tags(['Orders'])] #[Summary('Move an order to its next step')] #[Throws(NotFoundException::class)] public function advance(string $code, #[Body] StatusChange $change): OrderSummary { // ... } #[Get('/orders')] #[Authenticated] #[Returns( type: OrderSummary::class, list: true )] public function openOrders(): array { return array_map(OrderSummary::of(...), $this->orders->open()); }
OrderSummary becomes a schema from its constructor's properties (its OrderStatus as an enum, its
#[ListOf(OrderLine::class)] array $lines as an array of OrderLine), and StatusChange the request body.
Request / Correlation ID
Every request gets a correlation id, available as $req->id in any controller/middleware that receives
the Request: the incoming X-Request-Id header if the client/upstream sent one (X-Correlation-Id as a
fallback header name; an upstream proxy's own, possibly non-UUID, id is exactly as valid a correlation id
as one Io would generate itself), otherwise a fresh UUID v4 (Io\Support\Uuid::v4(), public for
the app's own ids too). An incoming id is only taken when it
has at most 128 characters from A-Z a-z 0-9 . _ : -; anything else gets a fresh UUID, so a client can't
write into the logs. No configuration or middleware required.
The resolved id is mirrored back to the client as an X-Request-ID response header, and automatically
stamped onto every Logger call made during that request, under the request_id context key, so there is no need
to pass it around by hand:
#[Get('/orders/{code}')] public function order(string $code, Request $req): OrderSummary { $this->logger->info('Fetching order', ['code' => $code]); // logged with 'request_id' => $req->id automatically added to context // ... }
Logging
Io's Logger is PSR-3 based: register any Psr\Log\LoggerInterface implementation, optionally
filtered to specific levels. Registration happens on LoggerOptions, configured like any other Options
class via configure(); Logger itself is a stateless, container-resolvable service that dispatches to
whatever LoggerOptions currently holds. Inject it with #[Inject] into a controller/service.
As long as no logger is registered, errors and worse go to PHP's error_log (stderr under Tenma and
php -S, the server's error log under FPM), so a failure never vanishes unseen; add(new NullLogger()) silences it:
use Io\Options\LoggerOptions; use Psr\Log\LogLevel; $app->configure(function (LoggerOptions $opts) { $opts->add($pager, [LogLevel::ERROR, LogLevel::CRITICAL]); // any PSR-3 logger, here one that texts the owner // Or Monolog directly, without wrapping it yourself: $opts->addMono('app', new \Monolog\Handler\StreamHandler('php://stdout'), [LogLevel::INFO]); });
use Io\Attributes\Inject; use Io\Logger; // shortened from src/Services/Holidays.php: the shop carries on without the holidays when the service fails final class Holidays { #[Inject] private Logger $logger; /** @return array<string, Holiday>|null */ public function upcoming(): ?array { try { $response = new Http\Client(2.5)->get('https://date.nager.at/api/v3/NextPublicHolidays/DE'); // ... } catch (RuntimeException|JsonException $e) { $this->logger->warning('holidays: ' . $e->getMessage()); return null; } } }
CLI Tasks
The same front controller can serve HTTP and run background/maintenance jobs: under the cli SAPI,
$app->serve() (or $app->runCli(), which returns an exit code instead of calling exit()) reads the
process's own $argv and dispatches to a #[Task]-attributed method instead of routing an HTTP
request. Under Tenma, serve() with arguments (tenma index.php db:migrate) runs the task too, inside the
event loop, so a task can wait on Postgres or Http\Client; without arguments it starts the server.
use Data\Postgres; use Io\Attributes\{Inject, Manager, Task}; // shortened from src/Tasks/DatabaseTasks.php #[Manager('db')] final class DatabaseTasks { #[Inject] private Postgres $db; /** Applies database/migrations/*.sql in name order, each file once. */ #[Task('migrate')] public function migrate(): string { // ... return "Applied: 0003_order_totals.sql\n"; } /** Migrates, then loads database/seed.sql. */ #[Task('seed')] public function seed(): string { $this->migrate(); $this->db->script(new FileSystem\FileInfo(__DIR__ . '/../../database/seed.sql')->read()); return "Seeded the demo data.\n"; } }
$app->attach([ShopController::class, DatabaseTasks::class]); $app->serve();
tenma index.php db:migrate # under Tenma: runs the task in a Tenma task (Postgres pool, fetch) instead of serving php index.php db:migrate # without Tenma; this task needs Tenma's Postgres client, so not here
Plugins
Anything beyond the core (translations, LDAP, sessions, rate limiting, ...) is a separate Composer
package registered with one line before attach():
// from the bakery's app.php $app->plugin(new I18nPlugin()); $app->plugin(new FlashPlugin()); $app->plugin(new ChartsPlugin()); $app->configure(function (I18nOptions $i18n) { $i18n->directory = __DIR__ . '/lang'; $i18n->supported = ['en', 'de']; }); $app->attach([ShopController::class, SessionController::class, BackofficeController::class, ApiController::class]);
A plugin is one class implementing Io\Plugin\Plugin (extend PluginBase for the defaults). It
declares what it adds and does nothing else:
| Declares | Effect |
|---|---|
classes() |
Controllers and #[Manager] classes attached alongside the app's own. |
middlewares() |
App-level middlewares, added at attach(). |
boot(...) |
Optional; runs once at attach(): read its Options class, bind services. Each parameter gets what its type names: boot(Container $container, I18nOptions $options). |
cacheInputs() |
{path => mtime} of further files whose change must rebuild the route cache; the plugin's own class and its hooks' are watched already. |
hooks() |
Objects implementing any of the hook interfaces below. |
Hook (Io\Plugin\...) |
When | Typical use |
|---|---|---|
Endpoint |
Before routing, after static files. Runs without route guards: no #[Authenticated], no CSRF check, so an endpoint that changes state or shows private data checks the request itself. |
Health, metrics, an SSO callback. |
Fallback |
When no route matched, before the 404; the app's routes always win. No route guards, like Endpoint; response hooks run with an empty plan. |
Pages of a CMS, short links. |
RouteAttributeCompiler |
At compile time, per route; result cached under the plugin's name. | A #[RateLimit] attribute. |
Guard |
Per request before the controller, after auth/CSRF; gets that route's compiled data ([] if none). |
Enforce the attribute. |
ResponseHook |
After the result was rendered. Not when a guard or the controller threw: the exception handler writes that response afterwards, so count errors in an app-level middleware or exception handler. | Audit, metrics, idempotency store. |
ArgumentBinder |
Own parameter attributes/types for controller methods, asked after the core attributes and before the implicit scalar binding. | #[File], a table request. |
Renderer |
Own return types, asked after View/Redirect. |
Exports, PDFs. |
OpenApiExtension |
Describes the plugin's bound parameters and rendered return types for the spec; without it they show as string/object. |
text/csv for an export. |
ViewHelper |
Methods templates can call as $this->name(); markup comes back as Io\Html, or {{ }} escapes it. |
$this->t('key'). |
ClientAsset |
CSS/JS files shipped by the plugin, served content-hashed; render with $this->pluginAssetTags(). |
A table component. |
State that belongs to one request (a locale, a user, queued messages) lives in a Io\Support\FiberLocal,
not in a property of the plugin's service: under Tenma several requests run interleaved in one process, and
the handler of an upgraded request (WebSocket, stream) only gets back what is in a FiberLocal. Set it at the
start of every request rather than resetting it afterwards, since a reset in after() runs before that handler.
Rules: requires() names plugins that must come first; two plugins with the same name() or the
same view helper are a boot error; plugins never reach into each other or into the core's internals.
The core also exposes Container::bind(Interface::class, $service) and Io\Validation\Messages
(translates the validator's English messages, which double as catalog keys) for plugins to hook into. A plugin
that knows who may do what binds a Io\PermissionProvider: #[Permissions(...)] asks it unless the app set
PermissionOptions::$provider itself.
Available plugins:
| Package | Adds |
|---|---|
io/i18n |
$this->t('Source text') with JSON catalogs kept by CLI tasks, per-request locale, translated validation errors. |
io/flash |
Flash messages across a redirect, shown once, with and without JavaScript; the reference for the client plugin API. |
io/charts |
Bar and line charts as server-rendered SVG with a data table; $this->chart($chart) in views or return $chart; as image/svg+xml. |
io/export |
return Sheet::file('orders.xlsx')->column('code', 'Order')->rows($orders); downloads CSV, JSON Lines, HTML, XML with schema, XLSX or ODS by extension; $this->sheet() for an inline table. |
io/export-fixed |
Fixed-width record files (#[Fixed] layouts, Windows-1252/EBCDIC) as a sub-plugin of io-export; the reference for plugins that extend a plugin. |
Testing
Io\Testing\TestCase (needs phpunit/phpunit as a dev dependency) drives a real request through
the app with Request::fromParts() and App::handle(), so nothing goes through the superglobals or
header()/echo. Every test gets a fresh App and clean superglobals; cookies a test puts into
$_COOKIE and $_SERVER['HTTPS'] still apply as defaults:
use Io\Testing\TestCase; // after tests/Controllers/SessionControllerTest.php and ApiControllerTest.php of the bakery final class BackofficeTest extends TestCase { protected function setUp(): void { parent::setUp(); require __DIR__ . '/../app.php'; // configures $this->app(), the same App::create() } public function testAWrongPasswordStaysOnTheSignInPage(): void { $this->request('POST', '/backoffice/login') ->withBody(['email' => 'ben@sonnenkorn.test', 'password' => 'baguette']) ->withCsrf() ->send() ->assertStatus(422) ->assertSee('Wrong email or password.'); } public function testTheBoardNeedsASignIn(): void { $this->request('GET', '/backoffice')->asNavigation()->send()->assertRedirect('/backoffice/login'); $this->request('GET', '/backoffice')->withJwt(['sub' => 2, 'name' => 'Ben', 'role' => 'staff'])->send()->assertOk(); } public function testTheApiWantsAToken(): void { $this->get('/v1/orders')->assertStatus(401); $token = $this->json('POST', '/v1/tokens', ['email' => 'ben@sonnenkorn.test', 'password' => 'pretzel']) ->assertOk() ->json('token'); $this->assertIsString($token); $this->get('/v1/orders', ['Authorization' => "Bearer $token"])->assertOk(); } }
- Requests:
get(),post()(form fields),json()(raw JSON body), orrequest()for the builder:withHeader(),withBody()(form fields),withJson(),withCookie(),withJwt($payload),withCsrf()(a valid cookie + header pair),withHttps(),asNavigation()(what a browser sends), thensend(). - Responses:
status(),header(),cookie(),body(),json('errors.email'), and fluentassertStatus/Ok/Redirect/Header/HeaderMissing/ContentType/Cookie/See/DontSee/Body/Json/JsonPath. tempDir()gives a directory that is removed after the test, e.g. forFileSystemOptions::$cacheDirectory.
The framework's own suite and every plugin run on this kit.
Types and PHPStan
Every fact about a type is written once. Native types first: an array shape becomes a small readonly class. An
array Io sees (a DTO property, a route result) is typed by its attribute only, #[ListOf] or #[Returns], with
no docblock; an array Io does not see (a repository result) by its PHPDoc type only (@return list<Order>).
The package's extension.neon turns the attributes into real types: reading a #[ListOf(OrderLine::class)]
property gives list<OrderLine>, calling a #[Returns(type: Product::class, list: true)] method list<Product>
(nullable when the native type is). What is returned from a #[Returns] method or assigned to a #[ListOf]
property (also as a constructor argument of a promoted parameter) must fit: errors io.returns and
io.listOf.
Database rows and decoded JSON are mixed. Io\Support\Row reads them with typed getters instead of casts
or ...$row spreading, and throws an UnexpectedValueException naming the key and the type found:
use Io\Support\Row; // from src/Repositories/Orders.php $row = new Row($values); $order = new Order( id: $row->int('id'), code: $row->string('code'), status: $row->enum('status', OrderStatus::class), lines: array_map(fn (Row $line) => new OrderLine($line->int('product'), $line->string('name'), $line->int('priceCents'), $line->int('quantity')), $row->rows('lines')), // ... ); // from src/Services/Weather.php: the forecast of open-meteo $daily = Row::of($response->json())->row('daily'); $days = $daily->list('time', 'string'); // list<string>
The getters are int, string, float (also an int or numeric string, as Postgres sends numeric), bool,
enum, their nullable variants (null for a null value or a missing key), row, rows, list, map and has;
Row::of() and Row::each() take decoded JSON. More in the guide Types and PHPStan.
Environment Variables
Io\Environment reads env vars/.env/JSON config files through whatever EnvironmentOptions the
container holds. It is resolved through the container like any other service, so it's #[Inject]-able into
a controller/service. get($key)/isDev() lazily call load() on first use if nothing has been loaded
yet. load(?string $dir = null) reads, from $dir, each of these in order; later ones overlay (add
to/override) earlier ones:
config.json, thenconfig.$APP_ENV.json(committed defaults).env, then.env.$APP_ENV- System env vars, then
$_ENV, then$_SERVER(the real environment wins over the files) $opts->localConfigFile(defaultconfig.local.json), see below
A value given with set($key, $value) wins over all of them, also when it was set before the first or a
later load().
get($key, $default) returns the value as read (a string from .env, the JSON type from a JSON file);
string(), int(), float() and bool() ("false", "0", "off" are false) convert it, and without a
default throw a RuntimeException naming a missing key: $env->int('UNCONFIRMED_MINUTES', 15), $env->string('DATABASE_URL').
The files are read from the project root, the directory with composer.json, wherever the server was
started. load($dir) reads them from another directory, directly or via
$app->configure(fn (EnvironmentOptions $env) => $env->load($dir)):
// from the bakery's app.php: config.json, config.<APP_ENV>.json and .env in the project root $env = $app->get(Environment::class); date_default_timezone_set($env->string('BAKERY_TIMEZONE', 'Europe/Berlin'));
use Io\Attributes\Inject; use Io\Environment; // shortened from src/Services/Holidays.php final class Holidays { #[Inject] private Environment $env; public function upcoming(): ?array { $url = rtrim($this->env->string('HOLIDAYS_URL', 'https://date.nager.at'), '/') . '/api/v3/NextPublicHolidays/' . rawurlencode($this->env->string('HOLIDAY_COUNTRY', 'DE')); $response = new Http\Client($this->env->float('API_TIMEOUT', 2.5))->get($url); // ... } }
APP_ENV defaults to development under the cli/cli-server SAPI and under tenma --watch when unset
anywhere. Every other server, and Tenma without --watch (CLI tasks run as tenma index.php <task> included), defaults to production: the request never decides it, since Apache and Caddy fill SERVER_NAME
from the client's Host header. A local FPM setup sets APP_ENV=development itself. Request headers
(HTTP_*, which FPM puts into $_SERVER and $_ENV, and CGI into the process environment) are never read as
environment: no source's HTTP_* keys are.
The default 500 response never shows an unhandled exception's message, in development neither: it goes to
the log.
The JSON files are flat {"KEY": value, ...} objects (a value keeps its JSON type, such as bool, int or array,
unlike .env's always-string values). config.json/config.$APP_ENV.json are meant to be committed
(safe defaults, or demo/dev-only values); $opts->localConfigFile is meant to be gitignored: a
per-developer override for real secrets or local-only values, read last so it wins over everything else,
without having to write real system/shell env vars just to keep them out of version control:
$app->configure(function (EnvironmentOptions $opts) { $opts->localConfigFile = 'config.mine.json'; // default: 'config.local.json' // or: $opts->localConfigFile = null; // disable this layer entirely });
Missing files at any layer are silently skipped; nothing is required to exist.
Route/DI Compilation Caching & Production Performance
attach() normally discovers routes/DI/attributes via Reflection on every boot and keeps the result
in memory. Without a cacheDirectory nothing is written to or required from disk (there is
deliberately no fallback to the system temp directory, where anyone on the machine could plant a file
that would then run as code). FileSystemOptions, configured like any other Options class via
configure(), compiles that once to disk instead; set it in production:
use Io\Options\FileSystemOptions; $app->configure(function (FileSystemOptions $fs) { $fs->cacheDirectory = __DIR__ . '/var/cache'; // Re-validates the cache (reflects every controller, compares file mtimes) // on every request: safe default, but costs a Reflection pass per boot. $fs->cacheValidate = true; // "Trust mode": skips validation entirely and just reads the compiled // cache. You are responsible for clearing/rebuilding this directory on // deploy: a stale cache keeps serving the old routes until it's rebuilt. // $fs->cacheValidate = false; });
Trust mode reads the compiled route/service/attribute data once per request (shared between the router and the DI container setup) instead of re-deriving it via Reflection, and skips the file-mtime freshness check. This is the single largest throughput lever available: benchmarked well over an order of magnitude faster than uncached Reflection-based discovery under repeated requests.
The cache directory holds routes.php (route and task plans, the service list, plugin route data),
attributes.php (every controller attribute, for the OpenAPI generator), meta.php (the mtimes the
cache was built from) and assets/ (content-hashed view CSS/JS, io.js, plugin assets). With
cacheValidate = true the cache is rebuilt as soon as any controller file, base controller file or
trait they use, view asset, plugin class, hook class or cacheInputs() entry, the client bundle or the framework itself
changes, or a controller or plugin is added or removed; in trust mode
you clear the directory on deploy.
Also enable OPcache (opcache.enable=1, and opcache.enable_cli=1 if you're benchmarking or
running under php -S) in any environment where you care about request latency. In our benchmarks,
OPcache being off was consistently the single dominant cost, well beyond anything Io itself does. Trust-mode caching and OPcache are
complementary: OPcache skips re-parsing/re-compiling PHP source on every request; trust mode skips
Io's own Reflection-based discovery on top of that.
Options Reference
Every Io\Options\* class is a plain, container-resolved settings object, configured the same
way regardless of which one it is:
$app->configure(function (CsrfOptions $csrf, OpenAPIOptions $api) use ($env) { $csrf->secret = $env->string('CSRF_SECRET'); $api->title = 'Sonnenkorn bakery API'; });
An unconfigured Options class still resolves; every setting below already shows its default.
CompressionOptions
| Setting | Default | Description |
|---|---|---|
enabled |
true |
Master on/off switch for gzip response compression everywhere. |
minBytes |
1024 |
A response body shorter than this (bytes, before compression) is always sent uncompressed. |
CorsOptions
| Setting | Default | Description |
|---|---|---|
allowOrigin |
null |
Access-Control-Allow-Origin value. Unset omits the header entirely, so CORS is off by default. |
allowMethods |
null |
Access-Control-Allow-Methods value, e.g. 'GET, POST, OPTIONS'. |
allowHeaders |
null |
Access-Control-Allow-Headers value, e.g. 'Content-Type, Authorization'. |
exposeHeaders |
null |
Access-Control-Expose-Headers: response headers a cross-origin caller may read. |
maxAge |
null |
How long (seconds) a browser may cache a preflight OPTIONS response. |
allowCredentials |
false |
Sends Access-Control-Allow-Credentials: true when true; otherwise the header is omitted. |
CsrfOptions
| Setting | Default | Description |
|---|---|---|
secret |
(required, uninitialized) | HMAC signing key for issued tokens. Unconfigured (the default), CSRF checking is off entirely; every request passes through unchecked. |
ttl |
3600 |
How long an issued token stays valid, in seconds; also the CSRF cookie's own Max-Age. |
cookieName |
'csrf_token' |
Name of the double-submit cookie the token is issued under. Deliberately not HttpOnly: client-side JS must be able to read it. |
headerName |
'X-CSRF-Token' |
Request header checked first for the submitted token (the AJAX/fetch path). |
fieldName |
'_csrf' |
Request body field checked when the header isn't present (the classic no-JS <form> path). |
cookieSecure |
null |
The CSRF cookie's own Secure attribute; null follows the request ($req->https). |
cookieSameSite |
'Lax' |
The CSRF cookie's own SameSite attribute. |
EnvironmentOptions
| Setting | Default | Description |
|---|---|---|
localConfigFile |
'config.local.json' |
Gitignored, per-developer override file, read last so it wins over everything else. null disables this layer. |
Configuration mainly happens through load(?string $dir = null), not a plain property; see
Environment Variables for the full .env/config.json layering it drives.
FileSystemOptions
| Setting | Default | Description |
|---|---|---|
cacheDirectory |
null (no disk cache, compiled in memory) |
Where the compiled route/DI/attribute cache and compiled view assets are written. See Route/DI Compilation Caching. |
publicDirectory |
null (disabled) |
A plain directory served as static files ahead of routing. See Static Files. |
assetsPath |
'/assets' |
URL path prefix compiled view/layout CSS/JS is served under, e.g. GET {assetsPath}/{hash}.css. See Views. |
cacheValidate |
true |
Re-verify the cache is fresh (reflect + compare mtimes) on every request; false is "trust mode". |
JWTOptions
| Setting | Default | Description |
|---|---|---|
secret |
(required, uninitialized) | Signing secret. Accessing it unconfigured throws immediately rather than signing with a guessable default. |
ttl |
3600 |
Token lifetime, in seconds. |
tokenType |
'Bearer' |
Expected prefix on the Authorization header value. |
header |
'Authorization' |
Request header the token is read from. |
cookieName |
null (disabled) |
Cookie name to additionally read the token from when the header didn't produce one, so a signed-in session survives a plain page load, not just fetch()/XHR calls. |
loginRedirectUrl |
null (disabled) |
Where an UnauthorizedException (e.g. #[Authenticated] with no/expired JWT) sends a browser page navigation (Sec-Fetch-Mode: navigate) as a 302 instead of 401 JSON. Any other fetch()/XHR call still gets the 401. |
LoggerOptions
Loggers are registered through add($logger, $levels) / addMono($channel, $handler, $levels), not
plain properties; $levels is one PSR-3 level or a list of them, all levels without. See Logging.
OpenAPIOptions
| Setting | Default | Description |
|---|---|---|
enabled |
false |
Serve spec.json/spec.{version}.json/swagger.html at all. |
path |
'/openapi' |
URL prefix the endpoint lives under. |
title |
'API Documentation' |
OpenAPI info.title. |
version |
'1.0.0' |
OpenAPI info.version. |
description |
'Generated API documentation' |
OpenAPI info.description. |
servers |
[] |
List of server URL entries. |
tags |
[] |
Top-level tag definitions. |
securitySchemes |
[] |
components.securitySchemes entries; pair with #[Throws]/route-level auth as needed. |
security |
[] |
Top-level security requirement entries. |
externalDocs |
null |
Optional {description, url} external documentation link. |
RequestOptions
| Setting | Default | Description |
|---|---|---|
maxJsonBytes |
262144 |
A larger JSON body answers 413 instead of being decoded. |
RendererOptions
| Setting | Default | Description |
|---|---|---|
directory |
null |
Where view/layout .php templates (and their sibling .css/.js) live. |
layout |
'_Layout' (no extension, matches nothing) |
Default layout template; pass an explicit .php name, or every render falls back to bare view content. |
SecurityHeaderOptions
| Setting | Default | Description |
|---|---|---|
contentTypeOptionsEnabled / contentTypeOptions |
true / 'nosniff' |
X-Content-Type-Options. |
frameOptionsEnabled / frameOptions |
true / 'DENY' |
X-Frame-Options; set 'SAMEORIGIN' to allow same-origin framing. |
referrerPolicyEnabled / referrerPolicy |
true / 'strict-origin-when-cross-origin' |
Referrer-Policy. |
hstsEnabled / hsts |
true / 'max-age=31536000; includeSubDomains' |
Strict-Transport-Security, only ever sent on a request that itself arrived over HTTPS, regardless of this flag. |
cspEnabled / csp |
false / null |
Content-Security-Policy, opt-in only; a wrong one-size-fits-all default would break real pages instead of just failing open. |
Contributing
Contributions welcome! Please open issues or pull requests on GitHub.
Before opening a pull request:
composer test # PHPUnit composer stan # PHPStan level 10 composer coverage:text # 100% line coverage is the bar
The client (io.js) lives in ui/ (TypeScript, Vitest); composer build:ui writes the bundle
into src/Io/UI/io.js. Commit that file with the change, CI fails when it is stale.
New features that go beyond the core belong in a plugin package (see Plugins).
License
Io is licensed under either of MIT or Apache-2.0, at your option.
Contact
For questions or support, open an issue or contact the maintainer.
Happy coding with Io!