Valkyrja's HTTP layer uses PSR-7-shaped message objects and a structured pipeline that gives you predictable, observable control over every phase of request handling. Whether a route matches, fails to match, throws an exception, or completes normally, there is a dedicated middleware stage for it.
PSR-7: Valkyrja's request and response classes follow the PSR-7 interface shape but are their own implementations — they are not a direct binding to the
psr/http-messageinterfaces. Code that depends onPsr\Http\Message\*types directly will not receive Valkyrja objects without additional adaptation.PSR-15: Valkyrja does not use PSR-15 middleware internally. The middleware system is Valkyrja's own, with seven named pipeline stages (described below). PSR-15
MiddlewareInterfaceis not used. A bridge class (Http\Server\Psr\RequestHandler) is provided for integration scenarios where third-party code expects aPsr\Http\Server\RequestHandlerInterface— see the PSR Compatibility section.
The two primary concerns in this component are routing — matching an incoming request to a handler — and middleware — operating on the request and response before, during, and after dispatch. Routes are dispatched directly via a handler callable — no separate Dispatcher object — keeping the execution path simple and the handler target explicit and statically checkable.
HTTP applications are bootstrapped using the HttpConfig typed configuration
class. Rather than reading from environment variables at runtime, all
configuration is expressed as PHP constructor arguments with sensible defaults.
use Valkyrja\Application\Data\HttpConfig;
use Valkyrja\Application\Entry\Http;
Http::run(new HttpConfig(
namespace: 'App',
dir: __DIR__,
environment: 'production',
debugMode: false,
timezone: 'UTC',
key: 'your-application-key',
dataPath: 'App/Provider/Data',
dataNamespace: 'App\\Provider\\Data',
));The dataPath and dataNamespace properties tell the framework where to write
and load generated data files (for all components that have a data file) — the
compiled PHP classes that make production routing allocation-free for this
specific http application.
Http::run() is the single entry point for an HTTP application. It boots the
application, resolves the RequestHandlerContract from the container, creates a
ServerRequest from PHP's superglobals via RequestFactory::fromGlobals(), and
hands it to the handler:
// public/index.php
use Valkyrja\Application\Data\HttpConfig;
use Valkyrja\Application\Entry\Http;
Http::run(new HttpConfig(
dir: __DIR__,
));Everything that happens after that point — middleware, routing, dispatch,
response sending — is managed by RequestHandler.
Routes are registered through route providers — classes that implement
ProviderContract and return a list of controller classes and/or pre-built
route objects. The framework iterates over all registered providers during
bootstrap to build the route collection.
use Valkyrja\Http\Routing\Provider\Contract\HttpRouteProviderContract;
class ApiRouteProvider implements HttpRouteProviderContract
{
public static function getControllerClasses(): array
{
return [
UserController::class,
PostController::class,
];
}
public static function getRoutes(): array
{
return [];
}
}When getControllerClasses() returns classes, the framework's
AttributeCollector reflects on each class, extracts #[Route] attributes from
its methods, and adds the resulting routes to the collection. When getRoutes()
returns Route objects directly, those are passed through the Processor and
added as well. You can use either mechanism or both.
Route providers are wired into the application through a component provider's
getHttpProviders() method. The component provider itself is listed in
HttpConfig's providers array, which is the same mechanism used for container
service providers and event providers.
The idiomatic way to define routes in Valkyrja is to annotate controller methods
with the #[Route] attribute:
use Valkyrja\Http\Message\Enum\RequestMethod;
use Valkyrja\Http\Routing\Attribute\DynamicRoute;
use Valkyrja\Http\Routing\Attribute\Parameter;
use Valkyrja\Http\Routing\Attribute\Route;
use Valkyrja\Http\Routing\Attribute\Route\RequestMethod\Patch;
class UserController
{
#[Route(path: '/users', name: 'users.index')]
public function index(): ResponseContract
{
// GET /users
}
#[Route(path: '/users', name: 'users.store', requestMethods: [RequestMethod::POST])]
public function store(): ResponseContract
{
// POST /users
}
#[Patch]
#[Route(path: '/users', name: 'users.update')]
public function store(): ResponseContract
{
// POST /users
}
// The route collector will automatically know this is a dynamic route via
// the parameter attribute and {id}
#[Route(path: '/users/{id}', name: 'users.show')]
#[Parameter(name: 'id', regex: '\d+')]
public function show(int $id): ResponseContract
{
// GET /users/{id}
}
// However using the DynamicRoute directly can allow you to define Parameter
// objects directly on the Route attribute via the parameters argument, just
// remember to use the Parameter data class, not the attribute if you do this.
#[DynamicRoute(path: '/users/{id}', name: 'users.delete')]
public function delete(
// The parameter attribute can either go on the method, or the parameter
// itself; whatever you feel is best for readability on your project
#[Parameter(name: 'id', regex: '\d+')]
int $id
): ResponseContract
{
// GET /users/{id}
}
}The #[Route] attribute is repeatable — a single method can handle multiple
paths or methods by stacking attributes. The default requestMethods are
[RequestMethod::HEAD, RequestMethod::GET].
Note: Any other attributes placed on the method will be added to ALL routes defined on that method. If a certain route requires specific configuration unique to that route and not others you will need to use that route's arguments
Every route must have a handler — a callable with the signature:
callable(ContainerContract $container, array<string, mixed> $arguments): ResponseContractThe $container gives the handler access to all registered services. The
$arguments array carries matched path parameters (e.g. ['id' => '42'] for a
/users/{id} route). The handler is responsible for resolving the controller
from the container and calling it.
The idiomatic way to wire a handler to a route method is the companion
#[RouteHandler] attribute, placed on the same method as #[Route]:
use Valkyrja\Http\Routing\Attribute\Route;
use Valkyrja\Http\Routing\Attribute\Route\RouteHandler;
class UserController
{
#[Route(path: '/users/{id}', name: 'users.show')]
#[Parameter(name: 'id', regex: '\d+')]
#[RouteHandler([UserRouteProvider::class, 'showHandler'])]
public function show(int $id): ResponseContract { ... }
}The referenced static method on the provider receives the container and arguments and returns the response:
use Valkyrja\Container\Manager\Contract\ContainerContract;
use Valkyrja\Http\Message\Response\Contract\ResponseContract;
use Valkyrja\Http\Routing\Provider\Contract\HttpRouteProviderContract;
class UserRouteProvider implements HttpRouteProviderContract
{
public static function getControllerClasses(): array
{
return [UserController::class];
}
public static function getRoutes(): array
{
return [];
}
public static function showHandler(ContainerContract $container, array $arguments): ResponseContract
{
return $container->getSingleton(UserController::class)->show((int) $arguments['id']);
}
}The #[Route] attribute also accepts a handler parameter directly, which is
convenient for inline definitions:
#[Route(
path: '/users/{id}',
name: 'users.show',
handler: [UserRouteProvider::class, 'showHandler'],
)]How you express the handler determines whether it participates in Valkyrja's data file cache. The cache captures the full route collection in a generated PHP class so that production boots require no reflection.
Array callables can be cached. A handler expressed as
[ClassName::class, 'method'] is a plain array — serialisable, writable to a
file, and loadable without loss of fidelity:
handler: [UserRouteProvider::class, 'showHandler'],Closures cannot be cached. A handler expressed as static fn (...) is an
anonymous function — it cannot be serialised. Use closures during development or
when inline definitions are clearer, but prefer array callables in production
code that will be cached.
Several companion attributes refine how individual routes behave:
#[Route\Path] — Use this on the class itself to prepend a path to all
routes defined on methods in that class. When used on a method it prepends
to all routes defined on that method.
#[Route\Name] — Use this on the class itself to prepend a common name to
all routes defined on methods in that class. When used on a method it prepends
to all routes defined on that method.
#[Route\RequestMethod] — Sets allowed HTTP methods on a method, separate
from the #[Route] declaration:
use Valkyrja\Http\Routing\Attribute\Route\RequestMethod;
use Valkyrja\Http\Message\Enum\RequestMethod as Method;
use Valkyrja\Http\Routing\Attribute\Route\RequestMethod\Head;
#[Route(path: '/posts/{id}', name: 'posts.update')]
#[RequestMethod(Method::PUT, Method::PATCH)]
#[Head]
public function update(int $id): ResponseContract { ... }#[Route\Middleware] — Attaches a middleware class to a route. The
middleware type determines which pipeline stage it runs in:
use Valkyrja\Http\Routing\Attribute\Route\Middleware;
#[Route(path: '/admin', name: 'admin.dashboard')]
#[Middleware(AuthMiddleware::class)]
public function dashboard(): ResponseContract { ... }Routes with {param} segments are automatically treated as dynamic routes. The
#[Route\Parameter] attribute allows you to declare regex constraints and cast
rules per parameter:
use Valkyrja\Http\Routing\Attribute\Route\Parameter;
use Valkyrja\Http\Routing\Constant\Regex;
#[Route(path: '/articles/{slug}', name: 'articles.show')]
#[Parameter(name: 'slug', regex: Regex::SLUG)]
public function show(string $slug): ResponseContract { ... }All standard HTTP methods are available via the RequestMethod enum:
| Case | Value |
|---|---|
GET |
GET |
HEAD |
HEAD |
POST |
POST |
PUT |
PUT |
DELETE |
DELETE |
PATCH |
PATCH |
OPTIONS |
OPTIONS |
CONNECT |
CONNECT |
TRACE |
TRACE |
ANY |
ANY |
During development (debugMode: true), the framework collects routes fresh on
every request by reflecting on all registered controller classes. In production,
the route collection is compiled into a generated PHP data class — a static file
that the container loads directly, requiring no reflection at runtime.
You generate this file using the built-in CLI command:
php cli http:data:generateThe generated class is written to the path defined by dataPath and
dataNamespace in your configuration and is loaded automatically when
debugMode is false.
The UrlContract service generates URLs from route names. Inject it wherever
needed:
use Valkyrja\Http\Routing\Url\Contract\UrlContract;
public function __construct(private UrlContract $url) {}
public function someAction(): ResponseContract
{
$url = $this->url->getUrl('users.show', ['id' => 42]);
// /users/42
}The ServerRequest class follows the shape of PSR-7's ServerRequestInterface
but is Valkyrja's own implementation — it does not implement the
Psr\Http\Message\ServerRequestInterface type directly. It is created from
PHP's superglobals at the entry point and is immutable — all with* methods
return a new instance.
use Valkyrja\Http\Message\Request\ServerRequest;
$method = $request->getMethod(); // RequestMethod enum
$uri = $request->getUri(); // UriContract
$query = $request->getQueryParams();
$body = $request->getParsedBody();
$cookies = $request->getCookieParams();
$files = $request->getUploadedFiles();
$attrs = $request->getAttributes();
$isAjax = $request->isXmlHttpRequest();Valkyrja provides several named response types, all implementing
ResponseContract:
use Valkyrja\Http\Message\Response\Response;
use Valkyrja\Http\Message\Response\JsonResponse;
use Valkyrja\Http\Message\Response\HtmlResponse;
use Valkyrja\Http\Message\Response\TextResponse;
use Valkyrja\Http\Message\Response\RedirectResponse;
use Valkyrja\Http\Message\Response\EmptyResponse;
use Valkyrja\Http\Message\Enum\StatusCode;
// Plain response with a body
$response = new Response(body: $stream, statusCode: StatusCode::OK);
// Typed convenience responses
$json = new JsonResponse(['user' => $user]);
$html = new HtmlResponse('<h1>Hello</h1>');
$text = new TextResponse('Hello');
$redirect = new RedirectResponse('/dashboard');
$empty = new EmptyResponse();The ResponseFactory available via injection provides a fluent interface for
building redirect and other common responses, including one that resolves URLs
by route name.
For routes that need input validation or structured output, Valkyrja provides
the struct system. A class implementing RequestStructContract carries the
route's validation rules and knows how to extract typed data from the request. A
class implementing ResponseStructContract shapes what goes into the response.
Both are attached to a route via companion attributes:
#[Route(path: '/users', name: 'users.store', requestMethods: [RequestMethod::POST])]
#[Route\RequestStruct(CreateUserRequest::class)]
#[Route\ResponseStruct(UserResponse::class)]
public function store(): ResponseContract { ... }Or inline in the #[Route] declaration:
#[Route(
path: '/users',
name: 'users.store',
requestMethods: [RequestMethod::POST],
requestStruct: CreateUserRequest::class,
responseStruct: UserResponse::class,
)]
public function store(): ResponseContract { ... }Note: You will need to use the
\Valkyrja\Http\Server\Middleware\RouteMatched\RequestStructMiddlewareor\Valkyrja\Http\Server\Middleware\RouteMatched\ResponseStructMiddlewareto automatically validate and hydrate the request and response based on the provided structs.
Valkyrja ships a complete set of adapter classes so its HTTP message objects can
be passed to any third-party library that depends on psr/http-message or
psr/http-server-handler interfaces. The wrappers live in Psr/ subdirectories
alongside their native counterparts and are never used internally — Valkyrja's
own pipeline works exclusively with its own contracts.
Each wrapper holds a Valkyrja object and delegates every method call to it, implementing the corresponding PSR-7 interface so the object is accepted wherever a PSR-7 type is required.
| Wrapper class | Implements | Wraps |
|---|---|---|
Http\Message\Stream\Psr\Stream |
StreamInterface |
StreamContract |
Http\Message\Uri\Psr\Uri |
UriInterface |
UriContract |
Http\Message\Request\Psr\Request |
RequestInterface |
RequestContract |
Http\Message\Request\Psr\ServerRequest |
ServerRequestInterface |
ServerRequestContract |
Http\Message\Response\Psr\Response |
ResponseInterface |
ResponseContract |
Http\Message\File\Psr\UploadedFile |
UploadedFileInterface |
UploadedFileContract |
Construct any wrapper by passing the corresponding Valkyrja object:
use Valkyrja\Http\Message\Response\Psr\Response as PsrResponse;
$psrResponse = new PsrResponse($valkyrjaResponse);
// $psrResponse now satisfies Psr\Http\Message\ResponseInterfaceStatic factory classes convert in both directions between Valkyrja objects and raw PSR-7 representations. They are abstract and provide only static methods.
// PSR StreamInterface → Valkyrja StreamContract
PsrStreamFactory::fromPsr(StreamInterface $stream): StreamContract;// PSR UriInterface → Valkyrja UriContract
PsrUriFactory::fromPsr(UriInterface $psrUri): UriContract;// PSR ServerRequestInterface → Valkyrja ServerRequest
PsrRequestFactory::fromPsr(ServerRequestInterface $psrRequest): ServerRequest;// PSR headers array → Valkyrja HeaderContract[]
PsrHeaderFactory::fromPsr(array $headers): array;
// Valkyrja HeaderCollectionContract → PSR headers array
PsrHeaderFactory::toPsr(HeaderCollectionContract $headers): array;
// Single Valkyrja HeaderContract → PSR string[] values
PsrHeaderFactory::toPsrValues(HeaderContract $header): array;// PSR UploadedFileInterface → Valkyrja UploadedFileContract
PsrUploadedFileFactory::fromPsr(UploadedFileInterface $file): UploadedFileContract;
// Array of PSR files → Valkyrja UploadedFileCollectionContract
PsrUploadedFileFactory::fromPsrArray(array $files): UploadedFileCollectionContract;
// Valkyrja UploadedFileCollectionContract → PSR array format
PsrUploadedFileFactory::toPsrArray(UploadedFileCollectionContract $collection): array;Although Valkyrja does not use PSR-15 internally, a bridge class is provided for
integration scenarios where third-party code expects a
Psr\Http\Server\RequestHandlerInterface:
use Valkyrja\Http\Server\Psr\RequestHandler as PsrRequestHandler;
$psrHandler = new PsrRequestHandler($valkyrjaRequestHandler);
// $psrHandler satisfies Psr\Http\Server\RequestHandlerInterfacehandle(ServerRequestInterface $request): ResponseInterface converts the
incoming PSR-7 ServerRequestInterface to a Valkyrja ServerRequest, runs it
through the Valkyrja request handler, and wraps the resulting response in a
Psr\Http\Message\Response wrapper before returning.
Every HTTP request passes through a structured seven-stage middleware pipeline. Each stage has a dedicated contract, and middleware classes implement whichever contracts correspond to the stages they participate in. A single class can implement multiple contracts.
RequestReceivedMiddlewareContract fires the moment a request enters the
handler, before any route matching occurs. It receives the raw ServerRequest
and can either return a modified request (to continue) or return a
ResponseContract directly (short-circuiting the pipeline):
use Valkyrja\Http\Middleware\Contract\RequestReceivedMiddlewareContract;
use Valkyrja\Http\Middleware\Handler\Contract\RequestReceivedHandlerContract;
class MaintenanceModeMiddleware implements RequestReceivedMiddlewareContract
{
public function requestReceived(
ServerRequestContract $request,
RequestReceivedHandlerContract $handler
): ServerRequestContract|ResponseContract {
if ($this->isUnderMaintenance()) {
return new HtmlResponse('Service unavailable.', StatusCode::SERVICE_UNAVAILABLE);
}
return $handler->requestReceived($request);
}
}RequestReceived middleware is global — it runs on every request regardless of
which route is matched. Configure it in RequestHandler.
The response cache (CacheResponseMiddleware) operates at this stage: on the
way in, it checks whether a cached response file exists for the request path and
method; if so, it returns it immediately without executing any further pipeline
stages.
RouteMatchedMiddlewareContract fires after a route has been matched but before
its handler is dispatched. It receives both the request and the matched
RouteContract, and can return a modified route or short-circuit with a
response:
use Valkyrja\Http\Middleware\Contract\RouteMatchedMiddlewareContract;
use Valkyrja\Http\Middleware\Handler\Contract\RouteMatchedHandlerContract;
class AuthMiddleware implements RouteMatchedMiddlewareContract
{
public function routeMatched(
ServerRequestContract $request,
RouteContract $route,
RouteMatchedHandlerContract $handler
): RouteContract|ResponseContract {
if (! $this->isAuthenticated($request)) {
return new RedirectResponse('/login');
}
return $handler->routeMatched($request, $route);
}
}RouteMatched middleware can be declared globally or per-route via the
routeMatchedMiddleware parameter of #[Route].
RouteNotMatchedMiddlewareContract fires when the router cannot match the
incoming request to any registered route. It receives the request and a default
404 response. This is the right place to implement custom 404 pages or fallback
logic:
use Valkyrja\Http\Middleware\Contract\RouteNotMatchedMiddlewareContract;
use Valkyrja\Http\Middleware\Handler\Contract\RouteNotMatchedHandlerContract;
class NotFoundMiddleware implements RouteNotMatchedMiddlewareContract
{
public function routeNotMatched(
ServerRequestContract $request,
ResponseContract $response,
RouteNotMatchedHandlerContract $handler
): ResponseContract {
return new HtmlResponse(
$this->renderNotFoundPage($request),
StatusCode::NOT_FOUND
);
}
}RouteNotMatched middleware is global — it applies to all unmatched requests.
RouteDispatchedMiddlewareContract fires after the route's handler has been
called and a response has been produced. It receives the request, the response,
and the matched route. This is the right place for post-dispatch concerns:
response transformation, logging, adding headers:
use Valkyrja\Http\Middleware\Contract\RouteDispatchedMiddlewareContract;
use Valkyrja\Http\Middleware\Handler\Contract\RouteDispatchedHandlerContract;
class JsonApiMiddleware implements RouteDispatchedMiddlewareContract
{
public function routeDispatched(
ServerRequestContract $request,
ResponseContract $response,
RouteContract $route,
RouteDispatchedHandlerContract $handler
): ResponseContract {
return $handler->routeDispatched($request, $response->withHeader('Content-Type', 'application/json'), $route);
}
}Can be declared globally or per-route via the via routeDispatchedMiddleware.
ThrowableCaughtMiddlewareContract fires when any Throwable is caught during
request handling. It receives the request, a default error response, and the
throwable itself:
use Valkyrja\Http\Middleware\Contract\ThrowableCaughtMiddlewareContract;
use Valkyrja\Http\Middleware\Handler\Contract\ThrowableCaughtHandlerContract;
class ErrorReportingMiddleware implements ThrowableCaughtMiddlewareContract
{
public function throwableCaught(
ServerRequestContract $request,
ResponseContract $response,
Throwable $throwable,
ThrowableCaughtHandlerContract $handler
): ResponseContract {
$this->logger->error($throwable->getMessage(), ['exception' => $throwable]);
return $handler->throwableCaught($request, $response, $throwable);
}
}Can be declared globally or per-route via throwableCaughtMiddleware.
SendingResponseMiddlewareContract fires after the response is finalized but
before it is written to the output buffer. This is the right place to add
universal headers, compress bodies, or strip sensitive data:
use Valkyrja\Http\Middleware\Contract\SendingResponseMiddlewareContract;
use Valkyrja\Http\Middleware\Handler\Contract\SendingResponseHandlerContract;
class CorsPolicyMiddleware implements SendingResponseMiddlewareContract
{
public function sendingResponse(
ServerRequestContract $request,
ResponseContract $response,
SendingResponseHandlerContract $handler
): ResponseContract {
return $handler->sendingResponse(
$request,
$response->withHeader('Access-Control-Allow-Origin', '*')
);
}
}Can be declared globally or per-route via sendingResponseMiddleware.
The built-in NoCacheResponseMiddleware operates at this stage — it adds
Cache-Control: no-cache, no-store, Pragma: no-cache, and a past Expires
header to prevent client-side caching of sensitive responses for example.
TerminatedMiddlewareContract fires after the response has been sent to the
client. At this point the user has already received the response; work done here
does not affect what they see. It is the appropriate stage for deferred side
effects: writing logs, dispatching queued events, clearing caches:
use Valkyrja\Http\Middleware\Contract\TerminatedMiddlewareContract;
use Valkyrja\Http\Middleware\Handler\Contract\TerminatedHandlerContract;
class ActivityLogMiddleware implements TerminatedMiddlewareContract
{
public function terminated(
ServerRequestContract $request,
ResponseContract $response,
TerminatedHandlerContract $handler
): void {
$this->activityLog->record($request, $response);
$handler->terminated($request, $response);
}
}Can be declared globally or per-route via terminatedMiddleware.
The CacheResponseMiddleware also hooks into this stage: if the response was a
success (not a 5xx) and has not yet been cached, it serializes the response to a
PHP file on disk, making it available for instantaneous replay on future
requests.
| Stage | When it fires | Can short-circuit | Scope |
|---|---|---|---|
RequestReceived |
Before route matching | Yes | Global |
RouteMatched |
After match, before dispatch | Yes | Per-route |
RouteNotMatched |
When no route matches | No | Global |
RouteDispatched |
After dispatch, before sending | No | Per-route |
ThrowableCaught |
When a throwable is caught | No | Per-route |
SendingResponse |
Before writing response to output | No | Per-route |
Terminated |
After response is sent | No | Per-route |
Valkyrja ships a full-response cache middleware — CacheResponseMiddleware —
that stores serialized response objects on the filesystem and replays them on
subsequent identical requests. Because it operates at the RequestReceived
stage, a cache hit bypasses route matching, dispatch, and goes straight to the
sending and terminated middleware stages.
Enable it by registering it as global RequestReceived middleware and pointing
it at a writable directory:
use Valkyrja\Http\Server\Middleware\CacheResponseMiddleware;
// In your RequestHandler configuration or middleware provider:
new CacheResponseMiddleware(
filePath: '/var/cache/responses',
debug: false,
);The cache key is an MD5 hash of the request path combined with the HTTP method.
Cached entries expire after 30 minutes (1800 seconds); expired files are deleted
and the request proceeds normally. Responses with a 5xx status code are never
cached. Enabling debug: true disables cache reads (writes still occur, so
caches warm even in dev, but stale caches are never served).
To prevent caching on routes that return user-specific or sensitive data, add
NoCacheResponseMiddleware to those routes' sendingResponseMiddleware:
use Valkyrja\Http\Server\Middleware\SendingResponse\NoCacheResponseMiddleware;
#[Route(
path: '/account',
name: 'account.show',
sendingResponseMiddleware: [NoCacheResponseMiddleware::class],
)]
public function show(): ResponseContract { ... }When application code needs to produce a specific HTTP error response the preferred method is to create a response with that status code.
However, you can also throw an HttpException. The RequestHandler detects it
and uses its embedded StatusCode and optional response body, rather than
falling back to a generic 500:
use Valkyrja\Http\Message\Throwable\Exception\HttpException;
use Valkyrja\Http\Message\Enum\StatusCode;
throw new HttpException(StatusCode::NOT_FOUND, 'Resource not found.');ThrowableCaught middleware sees the exception before the handler's default
behavior takes effect, so you can override the response further at that stage.
Note: Sending and terminated middleware still run after the throwable is caught.
From Http::run() to process exit, the lifecycle is:
HttpConfigis validated and the application is bootstrapped.- Component providers register services into the container.
- Route providers register routes into the collection (or load the compiled data file).
RequestFactory::fromGlobals()builds aServerRequestfrom$_SERVER,$_GET,$_POST,$_COOKIE, and$_FILES.RequestHandler::run()is called.RequestReceivedmiddleware runs (cache check, maintenance mode, etc.).- The
Routerasks theMatcherto find a matching route. - If no route matches:
RouteNotMatchedmiddleware runs and produces a 404 response and goes straight to SendingResponse. - If a route matches:
RouteMatchedmiddleware runs (authentication, authorization). - The route's handler callable is invoked as
$handler($container, $arguments), where$argumentscontains the matched path parameters. The handler resolves the controller from the container and calls it. RouteDispatchedmiddleware runs (response transformation, logging).- If a throwable is caught at any point:
ThrowableCaughtmiddleware runs. SendingResponsemiddleware runs (final header injection, compression).- The response is written to the output buffer; the session is closed; FastCGI or Litespeed finish-request is called if available.
Terminatedmiddleware runs (deferred work, cache writes, analytics).
flowchart TD
A([Http::run]) --> B[Bootstrap - build ServerRequest from globals]
B --> C[Stage 1 - RequestReceived]
C -->|"cache hit / short-circuit"| G[Stage 6 - SendingResponse]
C -->|throwable| J[Stage 5 - ThrowableCaught]
C --> D{"Router: route matched?"}
D -->|"no match"| E["Stage 3 - RouteNotMatched (404/405 response)"]
D -->|matched| F[Stage 2 - RouteMatched]
E --> G
F -->|"short-circuit / throwable"| J
F --> H[Route handler callable]
H -->|throwable| J
H[Route handler callable] --> I[Stage 4 - RouteDispatched]
I -->|throwable| J
I --> G
J --> G
G --> K[Write response to output buffer]
K --> L[Stage 7 - Terminated]
L --> M([Process ends])