Architecture
How a Lift application boots, how a request travels through it, and how the container resolves dependencies. No surprises — everything is explicit.
Request lifecycle
From the moment PHP receives an HTTP request to the moment a response is emitted.
Middleware pipeline
PSR-15 onion model — each layer wraps the next. The innermost layer is the route handler.
┌─ GlobalMiddleware A ────────────────────┐ │ ┌─ GlobalMiddleware B ───────────────┐ │ │ │ ┌─ GroupMiddleware ─────────────┐ │ │ │ │ │ ┌─ RouteMiddleware ────────┐ │ │ │ │ │ │ │ ┌─ Handler ──────────┐ │ │ │ │ │ │ │ │ │ return $response │ │ │ │ │ │ │ │ │ └────────────────────── ┘ │ │ │ │ │ │ │ └────────────────────────────┘ │ │ │ │ │ └──────────────────────────────── ┘ │ │ │ └────────────────────────────────────── ┘ │ └────────────────────────────────────────────┘
// 1. Global — every request
$app->use(LoggingMiddleware::class);
$app->use(CorsMiddleware::class);
// 2. Group — /api/* only
$app->group('/api', function ($g) {
$g->middleware(AuthMiddleware::class);
// 3. Route — this endpoint only
$g->get('/admin', AdminHandler::class)
->middleware(RequireAdminMiddleware::class);
});
MiddlewareInterface::process(ServerRequestInterface, RequestHandlerInterface).
It can short-circuit by returning a response directly — the inner layers are never called.
Auth and rate-limit middleware exploit this to reject unauthenticated requests before any handler runs.
Router internals
Two-tier dispatch: an O(1) hash map for static routes, a scan of per-route compiled regexes for dynamic ones.
GET /,
POST /auth/login —
routes with no parameters are stored in a nested array keyed by [method][path].
Resolution is a single array read: O(1), no string scanning.
// lookup: isset($staticRoutes[$method][$path])
Routes with parameters (/users/{id:\d+}) each compile their own regex once
— cached on the route object, not rebuilt per request. Dispatch scans the dynamic routes in order, testing each compiled regex until one matches.
~^/users/(?<id>\d+)$~
{id:\d+}) are baked into the compiled regex —
a route only matches when the constraint passes, so no extra validation step is needed in your handler.
DI container
PSR-11 container with constructor autowiring. Three registration modes, one resolution algorithm.
Factory called every time make() is called. Use for stateless services.
$app->bind(Mailer::class, fn() => new Mailer($_ENV));
Factory called once; the instance is cached and reused on every subsequent make().
$app->singleton(DB::class, fn() => new DB($_ENV['DSN']));
Stores a pre-built object directly. Useful when you construct a value before registering it.
$app->instance(Config::class, $config);
Autowiring algorithm
When make(ClassName::class) is called with no registered binding:
Bootstrapping
What happens between new App() and the first request being served.
Creates an empty PSR-11 container and an empty router. No services instantiated yet — everything is lazy.
Registers factory closures in the container. Factories are NOT called at this point.
Registers route definitions in the router. Handler callables are NOT invoked at this point.
On FPM: builds PSR-7 ServerRequest from $_SERVER/$_FILES/$_POST. On persistent workers: receives the pre-built request from the runtime.
Global middleware resolves from the container (lazy), the router dispatches, the handler is autowired and called.
On FPM: headers + body written via header()/echo. On workers: handed back to the runtime loop.
<?php
require 'vendor/autoload.php';
use Lift\App;
// ① Container created, router created
$app = new App();
// ② Factories registered — nothing constructed yet
$app->singleton(DB::class, fn() => new DB($_ENV['DSN']));
$app->singleton(Mailer::class, fn() => new Mailer($_ENV));
// ③ Routes registered — handlers not called yet
$app->use(LoggingMiddleware::class);
$app->group('/api', function ($g) {
$g->middleware(AuthMiddleware::class);
$g->get('/users', [UserController::class, 'index']);
$g->post('/users', [UserController::class, 'store']);
});
// ④ First request received → pipeline runs
$app->run();
Memory model
The critical difference between php-fpm and persistent-worker runtimes — and what it means for your code.
- ▸ Full bootstrap cost on every request
- ▸ No shared state between requests
- ▸ Memory freed automatically — no leaks possible
- ▸ Bootstrap cost paid once — not per request
- ▸ DB connections, caches, compiled routes stay warm
- ▸ Shared singletons persist — don't store per-request state in them
Writing worker-safe code
Lift singletons survive across requests. Keep them stateless or append-only.
class DB {
private \PDO $pdo;
public function __construct(string $dsn) {
// connection is shared — fine
$this->pdo = new \PDO($dsn);
}
public function query(string $sql): array {
// stateless call — safe
return $this->pdo->query($sql)->fetchAll();
}
}
class UserContext {
// ⚠ survives across requests in workers
public ?User $current = null;
public function setUser(User $u): void {
$this->current = $u; // leaks to next request!
}
}
// Fix: use request attributes instead:
// $request->withAttribute('user', $user)