Lift v1.3.0
Internals

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.

HTTP request
PHP receives the raw request (FPM, RoadRunner, Swoole, FrankenPHP)
PSR-7 ServerRequest
Lift wraps PHP superglobals in an immutable PSR-7 object
Global middleware stack
Each registered global middleware runs in registration order
Router
Static routes → O(1) hash lookup. Dynamic → compiled regex
Route middleware
Per-route / per-group middleware runs inside the global stack
Handler resolution
DI container autowires constructor deps; handler is called
Auto-response cast
array → JSON 200, string → HTML 200, null → 204, Response → passthrough
PSR-7 Response
Immutable response object passed back up the middleware chain
Emit
Headers + body written to the client (or returned to the runtime)

Middleware pipeline

PSR-15 onion model — each layer wraps the next. The innermost layer is the route handler.

// Request flows inward ↓, Response flows outward ↑
┌─ GlobalMiddleware A ────────────────────┐
│  ┌─ GlobalMiddleware B ───────────────┐ │
│  │  ┌─ GroupMiddleware ─────────────┐ │ │
│  │  │  ┌─ RouteMiddleware ────────┐ │ │ │
│  │  │  │  ┌─ Handler ──────────┐ │ │ │ │
│  │  │  │  │   return $response  │ │ │ │ │
│  │  │  │  └────────────────────── ┘ │ │ │ │
│  │  │  └────────────────────────────┘ │ │ │
│  │  └──────────────────────────────── ┘ │ │
│  └────────────────────────────────────── ┘ │
└────────────────────────────────────────────┘
middleware registration
// 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);
});
PSR-15 contract: Every middleware implements 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.

Tier 1 — Static routes

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.

$staticRoutes['GET']['/'] = $handler;
// lookup: isset($staticRoutes[$method][$path])
Tier 2 — Dynamic routes

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.

// per-route regex, compiled & cached once
~^/users/(?<id>\d+)$~
Route constraints: Inline regex constraints ({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.

bind()

Factory called every time make() is called. Use for stateless services.

$app->bind(Mailer::class, fn() => new Mailer($_ENV));
singleton()

Factory called once; the instance is cached and reused on every subsequent make().

$app->singleton(DB::class, fn() => new DB($_ENV['DSN']));
instance()

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:

1
Check binding registry
If a bind/singleton/instance exists → use it. No reflection.
2
Check reflection cache
Constructor parameters cached after first resolution. No re-reading on repeat calls.
3
Read constructor params
ReflectionClass inspects type hints. Primitives without defaults → ContainerException.
4
Recursively resolve deps
Each typed parameter is resolved via make() — the whole tree is wired automatically.
5
Instantiate
new ClassName(...$resolvedArgs) — standard PHP construction, no proxy objects.
6
Cache reflection result
Parameter list stored; next make() skips steps 2–4 entirely.

Bootstrapping

What happens between new App() and the first request being served.

1 new App()

Creates an empty PSR-11 container and an empty router. No services instantiated yet — everything is lazy.

2 bind / singleton / instance

Registers factory closures in the container. Factories are NOT called at this point.

3 get / post / group

Registers route definitions in the router. Handler callables are NOT invoked at this point.

4 run() / RoadRunner serve()

On FPM: builds PSR-7 ServerRequest from $_SERVER/$_FILES/$_POST. On persistent workers: receives the pre-built request from the runtime.

5 Middleware pipeline executes

Global middleware resolves from the container (lazy), the router dispatches, the handler is autowired and called.

6 Response emitted

On FPM: headers + body written via header()/echo. On workers: handed back to the runtime loop.

bootstrap.php (typical)
<?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.

php-fpm (traditional) per-request
// Request 1
boot → new App()new DB() → handle → 💀 destruct all
// Request 2
boot → new App()new DB() → handle → 💀 destruct all
// Request N — same story
  • Full bootstrap cost on every request
  • No shared state between requests
  • Memory freed automatically — no leaks possible
RoadRunner / Swoole / FrankenPHP persistent
// Boot once
new App()new DB()✓ stay alive
// Request 1..N — reuse warm state
receive → handle → emit → loop
receive → handle → emit → loop
receive → handle → emit → loop
  • 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.

Safe — stateless singleton
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();
    }
}
Unsafe — per-request state in singleton
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)