Hyper is Arcanum's HTTP layer. It implements PSR-7 (HTTP messages), PSR-15 (middleware), and owns the rendering pipeline that turns handler results into HTTP responses. If Atlas decides which handler to call and Shodo decides how to format the output, Hyper is the bridge that turns raw PHP superglobals into typed request objects on the way in and typed response objects into bytes on the wire on the way out.
In most Arcanum apps, your handlers never touch Hyper directly. You receive a typed DTO, return data, and the rendering pipeline produces the HTTP response for you:
// app/Domain/Shop/Query/ProductsHandler.php
final class ProductsHandler
{
public function __invoke(Products $query): array
{
// Just return data. Hyper + Shodo turn it into an HTTP response.
return $this->products->findAll();
}
}Hyper matters when you're writing middleware, building a custom response renderer, hooking into lifecycle events, or debugging the request pipeline. If you're building a standard Arcanum app and things are working, you may never need this page. But when you need to understand how the request gets from the browser to your handler and back, this is the place.
Here's what happens from the moment PHP receives a request to the moment the response reaches the client:
$_SERVER, $_GET, $_POST, $_FILES, $_COOKIE
→ Server (parses superglobals via PHPServerAdapter)
→ ServerRequest (immutable PSR-7 object)
→ HyperKernel
→ RequestReceived event (mutable — listeners can enrich the request)
→ HttpMiddleware (PSR-15 onion: session, auth, CSRF, app middleware...)
→ RouteDispatcher → Atlas Router → handler
→ ResponseRenderer (Shodo formatter → Response)
→ RequestHandled event (read-only)
→ Server::composeResponse() (HEAD stripping, charset, cache headers)
→ Server::send() (headers + body to client, fastcgi_finish_request)
→ HyperKernel::terminate()
→ ResponseSent event (deferred work, metrics, cleanup)
Two classes anchor the beginning of this journey:
Serveris whatpublic/index.phpactually uses. It calls itsServerAdapterto parse superglobals into aServerRequest, and later sends theResponseback to the client.PHPServerAdapterwraps PHP's native functions (getallheaders(),header(),fastcgi_finish_request(), etc.) behind an interface. The adapter exists so the entire send path is testable — swap it for a mock and nothing touches real output.
Hyper implements the full PSR-7 specification. If you've worked with PSR-7 before, everything works the way you'd expect. If you haven't, the key idea is immutability — every with*() method returns a new object, leaving the original unchanged.
Message is the base that both requests and responses share. It holds headers, a body stream, and a protocol version.
use Arcanum\Hyper\Message;
use Arcanum\Hyper\Headers;
use Arcanum\Hyper\Version;
use Arcanum\Flow\River\Stream;
$message = new Message(
new Headers(['Content-Type' => ['application/json']]),
new Stream(LazyResource::for('php://memory', 'w+')),
Version::v11,
);
// Reading headers (case-insensitive)
$message->getHeaderLine('content-type'); // 'application/json'
$message->hasHeader('Content-Type'); // true
$message->getHeaders(); // ['Content-Type' => ['application/json']]
// Headers are immutable — with*() returns a new Message
$withAuth = $message->withHeader('Authorization', 'Bearer token123');Headers extends IgnoreCaseRegistry from the Gather package. Header names are case-insensitive per HTTP spec — Content-Type, content-type, and CONTENT-TYPE all refer to the same header. Header names and values are validated against RFC 7230.
Request is the basic HTTP request (method, URI, headers, body). ServerRequest wraps a Request and adds server-specific data — the things that come from PHP's superglobals:
// In middleware or a listener — you receive a ServerRequestInterface
$request->getMethod(); // 'POST'
$request->getUri()->getPath(); // '/shop/products'
$request->getHeaderLine('Accept'); // 'text/html'
$request->getQueryParams(); // ['page' => '2']
$request->getParsedBody(); // ['name' => 'Widget']
$request->getCookieParams(); // ['session_id' => 'abc123']
$request->getServerParams(); // $_SERVER contents
$request->getAttribute('auth.identity'); // set by middleware
// Immutable — returns new instance
$enriched = $request->withAttribute('start_time', microtime(true));You'll almost never construct these yourself — Server::request() builds them from the current PHP request. You interact with them in middleware or lifecycle event listeners.
Response wraps a Message with a status code and reason phrase:
use Arcanum\Hyper\Response;
use Arcanum\Hyper\StatusCode;
// You rarely build responses by hand — renderers do this.
// But if you're writing middleware that short-circuits:
$response = new Response(
new Message(
new Headers(['Content-Type' => ['application/json']]),
$bodyStream,
Version::v11,
),
StatusCode::OK,
);
$response->getStatusCode(); // 200
$response->getReasonPhrase(); // 'OK'
$notFound = $response->withStatus(StatusCode::NotFound->value);The URI class implements PSR-7's UriInterface. Parse a URI string and access or modify any component:
use Arcanum\Hyper\URI\URI;
$uri = new URI('https://example.com:8080/shop/products?page=2#results');
$uri->getScheme(); // 'https'
$uri->getHost(); // 'example.com'
$uri->getPort(); // 8080
$uri->getPath(); // '/shop/products'
$uri->getQuery(); // 'page=2'
$uri->getFragment(); // 'results'
// Immutable — with*() returns a new URI
$api = $uri->withScheme('http')->withPort(3000)->withPath('/api/v1');
(string) $api; // 'http://example.com:3000/api/v1?page=2#results'Under the hood, each component is a value object (Scheme, Host, Port, Path, Query, Fragment, UserInfo, Authority) with its own validation and RFC-compliant percent-encoding. You won't usually need these directly — URI exposes everything through its PSR-7 methods.
Spec is the static utility that parses URI strings and builds URIs from $_SERVER parameters. Server::request() uses it internally.
Arcanum uses a StatusCode enum instead of raw integers. Every HTTP status code is a named case:
use Arcanum\Hyper\StatusCode;
StatusCode::OK; // 200
StatusCode::Created; // 201
StatusCode::NoContent; // 204
StatusCode::NotFound; // 404
StatusCode::UnprocessableEntity; // 422
StatusCode::TooManyRequests; // 429
$status = StatusCode::NotFound;
$status->value; // 404
$status->reason(); // Phrase::NotFound ('Not Found')
$status->isInformational(); // falseThe enum removes magic numbers from your codebase and makes intent clear in renderers, exception handlers, and middleware. See the COMPENDIUM for why Arcanum embraces the full status code spectrum.
UploadedFile implements PSR-7's UploadedFileInterface. In practice, files arrive on the ServerRequest:
// In a handler or middleware
$files = $request->getUploadedFiles();
$avatar = $files['avatar']; // UploadedFileInterface
$avatar->getClientFilename(); // 'photo.jpg'
$avatar->getClientMediaType(); // 'image/jpeg'
$avatar->getSize(); // 245760
$avatar->getError(); // UPLOAD_ERR_OK (0)
// Move to permanent storage
$avatar->moveTo('/var/uploads/photo.jpg');UploadedFiles::fromSuperGlobal() normalizes PHP's notoriously awkward $_FILES structure into a clean nested array of UploadedFile instances. The Error enum maps PHP's UPLOAD_ERR_* constants to named cases with an isOK() helper.
Renderers turn handler return data into HTTP responses. Each one owns a format — the content type, the encoding, and the Content-Type header. All renderers extend ResponseRenderer and implement a single method:
abstract public function render(
mixed $data,
string $dtoClass = '',
StatusCode $status = StatusCode::OK,
): ResponseInterface;| Renderer | Content-Type | Template? | Notes |
|---|---|---|---|
JsonResponseRenderer |
application/json |
No | Wraps JsonFormatter. Most API responses. |
HtmlResponseRenderer |
text/html |
Yes | Resolves template via TemplateResolver, delegates to HtmlFormatter. |
PlainTextResponseRenderer |
text/plain |
Yes | Same pattern as HTML, with PlainTextFormatter. |
MarkdownResponseRenderer |
text/markdown |
Yes | Same pattern, with MarkdownFormatter. |
CsvResponseRenderer |
text/csv |
No | Wraps CsvFormatter. Tabular data. |
EmptyResponseRenderer |
(none) | No | Empty body. Used for void commands (204), accepted (202). |
Template-based renderers (HTML, PlainText, Markdown) compose two things:
- A
TemplateResolverthat maps the DTO class to a filesystem path — co-located templates discovered by PSR-4 convention (see Shodo README). - A Shodo
Formatterthat converts data into a string using the resolved template.
Status-specific template resolution works here too. When the status code isn't 200, the renderer tries resolveForStatus() first — so Products.422.html is found before Products.html when the status is 422.
You don't choose a renderer — the URL extension does. A request for /shop/products.json gets the JSON renderer; /shop/products.csv gets CSV; /shop/products (no extension) defaults to HTML. This is wired through FormatRegistry.
FormatRegistry maps file extensions to Format definitions. Each Format holds an extension, a content type, and the renderer class to resolve from the container:
// This is what Bootstrap\Routing registers (simplified)
$registry->register(new Format('html', 'text/html', HtmlResponseRenderer::class));
$registry->register(new Format('json', 'application/json', JsonResponseRenderer::class));
$registry->register(new Format('csv', 'text/csv', CsvResponseRenderer::class));When a request arrives, Atlas extracts the extension from the URL path. FormatRegistry::renderer() resolves the matching ResponseRenderer from the container. If the extension isn't registered, a 406 Not Acceptable is thrown — this is how Arcanum enforces format support.
DTOs can restrict their allowed formats with #[AllowedFormats('json', 'csv')]. The router checks this before dispatch and throws 406 for anything not in the list.
When something goes wrong, exception renderers turn Throwables into proper HTTP error responses. There are two primary renderers — one for each content type a browser or API client might expect.
Produces structured JSON error responses:
{
"error": {
"status": 404,
"message": "The page you're looking for doesn't exist."
}
}In debug mode, the response includes exception, file, line, and trace. When verboseErrors is enabled, ArcanumException instances include their title and suggestion fields.
Uses the same TemplateEngine as the success rendering path. Resolution order:
- Co-located template —
AddEntry.422.htmlnext to your DTO (when the route was resolved before the error) - App-wide template —
app/Templates/errors/422.html - htmx fragment — if the request has an
HX-Requestheader and no app template exists, renders a minimal<p>or<ul>fragment (no layout, no styles) that htmx can swap into the existing page - Built-in fallback — a self-contained styled error page that renders even when the template engine is broken
Error templates receive these variables: $code, $title, $message, $errors (validation field errors), $suggestion (from ArcanumException). In debug mode: $exception, $file, $line, $trace.
A decorator that intercepts ValidationException and returns a clean 422 JSON response with field-level errors:
{
"errors": {
"name": ["The name field is required."],
"email": ["The email field must be a valid email address."]
}
}All other exceptions pass through to the inner renderer. This is the default for JSON error responses — it wraps JsonExceptionResponseRenderer so validation errors get their own shape while everything else gets the standard error envelope.
Hyper's middleware follows the PSR-15 onion model. HttpMiddleware chains middleware around a core request handler:
use Arcanum\Hyper\HttpMiddleware;
$stack = new HttpMiddleware($coreHandler, $container);
$stack->add(SessionMiddleware::class); // outermost — runs first in, last out
$stack->add(AuthMiddleware::class);
$stack->add(CsrfMiddleware::class); // innermost — runs last in, first out
$response = $stack->handle($request);Middleware executes in the order added. The first middleware added is the outermost layer — it sees the raw request first and the final response last:
SessionMiddleware (in) →
AuthMiddleware (in) →
CsrfMiddleware (in) →
handler
CsrfMiddleware (out) ←
AuthMiddleware (out) ←
SessionMiddleware (out) ←
Class-string middleware is resolved from the Cabinet container at dispatch time, so middleware can have constructor dependencies injected. Middleware can short-circuit by returning a response without calling $handler->handle().
CallableHandler wraps a closure as a PSR-15 RequestHandlerInterface — useful when you need a quick handler without a class:
use Arcanum\Hyper\CallableHandler;
$handler = new CallableHandler(
fn(ServerRequestInterface $request) => new Response(/* ... */)
);The built-in Options middleware auto-handles HTTP OPTIONS requests. It asks the Atlas router which methods are available for the path and returns a 204 No Content with the Allow header:
OPTIONS /shop/products HTTP/1.1
HTTP/1.1 204 No Content
Allow: GET, POST, OPTIONS
It's registered as the innermost middleware layer by the framework, so app middleware (CORS headers, for example) can add headers on the way out. Returns 404 for paths that don't resolve to any route.
Hyper dispatches four events during the request lifecycle via Echo. You can listen for these to add cross-cutting behavior without touching middleware.
| Event | When | Mutable? |
|---|---|---|
RequestReceived |
Request enters the kernel, before middleware | Yes — setRequest() lets listeners enrich the request |
RequestHandled |
Middleware + handler produced a response | No — observe only (logging, metrics) |
RequestFailed |
An exception was thrown during handling | No — Glitch still handles rendering |
ResponseSent |
Response sent to client, during terminate() |
No — runs after fastcgi_finish_request() |
Example listener:
use Arcanum\Hyper\Event\RequestReceived;
class AddRequestTiming
{
public function __invoke(RequestReceived $event): void
{
$event->setRequest(
$event->getRequest()->withAttribute('start_time', microtime(true))
);
}
}RequestReceived is the only mutable event — it exists specifically so listeners can add request attributes that flow through the entire middleware and handler chain. The other three are read-only observation points.
ResponseSent fires during HyperKernel::terminate(), after fastcgi_finish_request() has already flushed the response to the client. This is the place for deferred work — logging, metrics, cleanup — that shouldn't slow down the response.
Server is the entry point for every HTTP request. It does two things:
-
Builds the request.
Server::request()reads PHP's superglobals ($_SERVER,$_GET,$_POST,$_FILES,$_COOKIE) through itsServerAdapterand assembles them into an immutableServerRequest. -
Sends the response.
Server::send()emits headers, writes the body, and callsfastcgi_finish_request()(orlitespeed_finish_request(), or flushes output buffers) to hand the response to the web server.
Between those two steps, Server::composeResponse() normalizes the response: stripping the body from HEAD requests, removing Content-Type from 1xx responses, appending charset to text content types, and adding Pragma/Expires for HTTP/1.0 cache control.
PHPServerAdapter is the production implementation — it calls PHP's native functions directly. The ServerAdapter interface exists so you can swap in a test double that captures output instead of writing to the real response stream.
Marks a DTO as HTTP-only. When the TransportGuard middleware is active, CLI dispatch of a DTO with this attribute is rejected with a clear error:
use Arcanum\Hyper\Attribute\HttpOnly;
#[HttpOnly]
final class UploadAvatar
{
public function __construct(
public readonly string $userId,
) {}
}The CLI counterpart is #[CliOnly] from the Rune package.
Server
├── request() → ServerRequest (via PHPServerAdapter + superglobals)
├── composeResponse() → normalize before sending
└── send() → headers + body + fastcgi_finish_request
Message (headers, body, protocol version)
├── Request (method, URI) → ServerRequest (server params, cookies, query, body, files)
└── Response (status code, reason phrase, charset)
Headers (case-insensitive, RFC 7230 validated, extends IgnoreCaseRegistry)
URI (PSR-7 UriInterface)
├── Scheme, Host, Port, Path, Query, Fragment, UserInfo, Authority
└── Spec (parsing, fromServerParams)
StatusCode enum → Phrase enum (reason phrases)
RequestMethod enum (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, TRACE, CONNECT)
Version enum (0.9, 1.0, 1.1, 2.0)
ResponseRenderer (abstract)
├── JsonResponseRenderer (JsonFormatter)
├── HtmlResponseRenderer (HtmlFormatter + TemplateResolver)
├── PlainTextResponseRenderer (PlainTextFormatter + TemplateResolver)
├── MarkdownResponseRenderer (MarkdownFormatter + TemplateResolver)
├── CsvResponseRenderer (CsvFormatter)
└── EmptyResponseRenderer (204, 202)
ExceptionRenderer (interface)
├── JsonExceptionResponseRenderer (debug/verbose modes)
├── HtmlExceptionResponseRenderer (app templates → htmx fragment → built-in fallback)
└── ValidationExceptionRenderer (decorator, 422 field errors)
FormatRegistry (extension → Format → renderer)
HttpMiddleware (PSR-15 onion via Flow Pipeline)
├── Middleware\Options (auto OPTIONS + Allow header)
└── CallableHandler (closure → RequestHandlerInterface)
MiddlewareStage (adapts PSR-15 middleware to Pipeline Stage)
Events
├── RequestReceived (mutable)
├── RequestHandled (read-only)
├── RequestFailed (read-only)
└── ResponseSent (read-only, post-response)
Files
├── UploadedFile (PSR-7 UploadedFileInterface)
├── UploadedFiles (normalizes $_FILES)
├── Normalizer (spec → UploadedFile)
├── Error enum (UPLOAD_ERR_* constants)
└── InvalidFile (exception)
Attribute\HttpOnly (#[HttpOnly] — reject from CLI)