XPSR HTTP pipeline
A request travels through public/index.php, the HTTP kernel, global middleware, the router, route middleware, and the handler. The response goes back the same way and is then sent by the response emitter.
public/index.php ─ creates the request from PHP globals (Nyholm PSR-7)
└ HttpKernel ─ error boundary: any exception becomes a rendered error response
└ global middleware (app.middleware), first listed runs first
└ RouteDispatcher ─ 404 / 405 / automatic OPTIONS, then route middleware and the handler
└ HEAD requests: body removed after middleware and error rendering
ResponseEmitter ─ headers, status line, bodyRequest bodies
PHP parses application/x-www-form-urlencoded and multipart/form-data only for POST. The skeleton enables Crystoline\Xpsr\Http\Middleware\BodyParsingMiddleware in config/app.php to cover the rest:
| Content type | Methods | getParsedBody() |
|---|---|---|
application/json, application/*+json |
all | Decoded to arrays. Integers too large for PHP become strings. |
application/x-www-form-urlencoded |
POST | $_POST, parsed by PHP |
application/x-www-form-urlencoded |
PUT, PATCH, DELETE, … | Parsed by the middleware, with the same array syntax (tags[]=a) |
multipart/form-data |
POST | $_POST and uploaded files, parsed by PHP |
multipart/form-data |
other methods | Not parsed |
| anything else | all | Unchanged |
Malformed JSON, or JSON that is a string, number, or null rather than an object or array, produces a 400 response. A body larger than the limit (1 MiB by default) produces a 413 response. An empty body leaves the parsed body unset. The raw body remains readable afterwards: (string) $request->getBody().
Because this is global middleware, it runs before routing. A malformed JSON body sent with an unsupported method gets 400, not 405. To change the limit, bind the middleware in config/factory.php:
Crystoline\Xpsr\Http\Middleware\BodyParsingMiddleware::class => static fn() => new Crystoline\Xpsr\Http\Middleware\BodyParsingMiddleware(5 * 1024 * 1024),
Remove it from app.middleware to handle bodies yourself.
Errors
The kernel catches every exception from middleware, routing, and handlers and passes it to Crystoline\Xpsr\Exception\ExceptionHandlerInterface. The default ExceptionHandler:
- uses the exception's status and headers (such as
Allow) for HTTP exceptions, and 500 for everything else; - returns JSON (
{"message": ...}) when theAcceptheader prefersapplication/jsonor a+jsontype, and HTML otherwise; - shows only the standard reason phrase, such as "Internal Server Error", unless
APP_DEBUG=true. With debug enabled, JSON includes the exception message. HTML shows the Whoops error page iffilp/whoopsis installed (the skeleton installs it as a development dependency); otherwise it shows a plain built-in page with each exception in the chain, its location, and its stack trace. Debug is off unless explicitly enabled; - logs 5xx errors, but not 4xx errors, through PSR-3.
HTTP exceptions
Throw these from handlers and middleware to send an error status. They are in Crystoline\Xpsr\Http\Exception:
| Class | Status | Extra constructor argument |
|---|---|---|
BadRequestHttpException |
400 | |
UnauthorizedHttpException |
401 | $challenge → WWW-Authenticate, for example 'Bearer realm="api"' |
ForbiddenHttpException |
403 | |
NotFoundHttpException |
404 | |
MethodNotAllowedHttpException |
405 | $allowedMethods → Allow |
ConflictHttpException |
409 | |
PayloadTooLargeHttpException |
413 | |
UnsupportedMediaTypeHttpException |
415 | |
UnprocessableEntityHttpException |
422 | |
TooManyRequestsHttpException |
429 | $retryAfterSeconds → Retry-After |
ServiceUnavailableHttpException |
503 | |
HttpException |
any 400–599 | $statusCode first |
The other arguments are $message, $previous, $headers, and $code. The message reaches clients only when APP_DEBUG=true. Statuses 500 and above are logged; client errors are not. To create your own, implement Crystoline\Xpsr\Http\Exception\HttpExceptionInterface or extend HttpException.
An exception implementing Crystoline\Xpsr\Http\Exception\PublicErrorInterface is written for the client: its message and details are shown in every environment, as a normal response rather than a debug page. Crystoline\Xpsr\Validation\ValidationException is one, rendered as {"message": "The given data is invalid.", "errors": {"title": ["..."]}} with status 422; see the validation package.
Unmatched routes (404) and unsupported methods (405) are thrown as NotFoundHttpException and MethodNotAllowedHttpException, so they receive the same JSON or HTML body as other errors.
Symfony's HttpKernel exceptions (Symfony\Component\HttpKernel\Exception\*) are also rendered with their status and headers when symfony/http-kernel is installed. The framework no longer depends on that package; it was required only for these classes.
If the application cannot even boot, public/index.php logs the cause with error_log() and returns a plain-text "Internal Server Error" with status 500.
CORS
Browsers let a page on one origin (https://app.example.com) call an API on another (https://api.example.com) only when the API answers with CORS headers. Crystoline\Xpsr\Http\Middleware\CorsMiddleware provides them. New projects list it first in app.middleware, and it does nothing until an origin is allowed:
CORS_ALLOWED_ORIGINS=https://app.example.com,https://*.example.com
The other settings are in config/cors.php:
| Setting | Default | Meaning |
|---|---|---|
allowed_origins |
none | Exact origins, subdomain patterns such as https://*.example.com (subdomains only, same scheme and port), or * for any origin. A list, or a comma-separated string. |
allowed_methods |
GET, HEAD, POST, PUT, PATCH, DELETE |
Methods a preflight may ask for. |
allowed_headers |
Content-Type, Authorization, X-Requested-With |
Request headers a preflight may ask for; ['*'] allows whatever it asks for. |
exposed_headers |
none | Response headers browser code may read besides the simple ones, such as X-Total-Count. |
allow_credentials |
false |
Let browsers send cookies and HTTP authentication. With * origins the request's own origin is echoed, since * cannot be combined with credentials. |
max_age |
600 |
Seconds a browser may cache a preflight answer; 0 sends no Access-Control-Max-Age. |
What it does:
- Preflight requests (
OPTIONSwithOriginandAccess-Control-Request-Method) from an allowed origin are answered with 204 and the allowed methods and headers, before the rest of the application runs. - Other requests from an allowed origin get
Access-Control-Allow-Originand the other headers. Error responses get them too: the middleware renders exceptions from later middleware and routes itself, through the same exception handler, so a browser can read a 404 or 422 instead of reporting a CORS failure. - Requests from other origins, or without
Origin, pass through unchanged except forVary: Origin, which keeps shared caches from serving one origin's answer to another.
Invalid settings, such as an origin with a path, fail when the HTTP kernel is built, with the setting's name.
Sessions and cookies
The skeleton's routes/web.php puts its routes in a group with Crystoline\Xpsr\Session\SessionMiddleware, which gives each request its browser's session (Session::of($request)), and CsrfMiddleware, which rejects form posts and other unsafe requests without the session's CSRF token (403). routes/api.php has neither. Crystoline\Xpsr\Http\Cookie writes other cookies with safe defaults, and ResponseBuilder::redirect() answers 303 after a form post. See SESSIONS.md.
Authentication
Crystoline\Xpsr\Auth\Http\Authenticate recognizes the user by a guard and puts Crystoline\Xpsr\Auth\Identity on the request; guests get 401 with a WWW-Authenticate challenge, or browsers are sent to the login page. Guest keeps signed-in users off pages such as the login form, and RequireScopes::of(...) answers 403 for API tokens without a scope. Policies answer 403 through Gate::authorize(). See AUTH.md.
Proxies
Crystoline\Xpsr\Factory\ServerRequestFactory builds the request from PHP's globals. It keeps Forwarded and X-Forwarded-* headers, which set the scheme (for example HTTPS behind a TLS-terminating load balancer), only when the client address matches app.trusted_proxies. Configure that list with TRUSTED_PROXIES, comma-separated IP addresses or CIDR ranges. From any other address, those headers are removed from the request and its server parameters. See DEPLOYMENT.md.
Logging
The default Psr\Log\LoggerInterface is Crystoline\Xpsr\Log\ErrorLogLogger. It writes through PHP's error_log(), so messages go to the web server's error log (Apache, PHP-FPM), to stderr for the CLI and development server, or to the file named by the error_log ini setting. Logged exceptions include their stack trace. LOG_LEVEL sets the minimum level (debug by default; for example LOG_LEVEL=warning). Replace the binding in config/factory.php to use another PSR-3 logger, or install crystoline/xpsr-log-monolog for Monolog with stderr, file, daily, or syslog output, JSON lines, and request IDs.
Sending the response
Crystoline\Xpsr\Http\ResponseEmitter sends:
- Every header value in order. Repeated values become separate header lines, and
Set-Cookienever replaces earlier cookies, including ones set by PHP's session functions. - The status line, last, with the response's status passed on every header call. This prevents PHP from changing the status, for example turning a
Locationheader into a 302. - The body, in 8 KiB chunks. Seekable bodies are rewound first. A valid
Content-Lengthcaps the bytes sent. Non-seekable (streamed) bodies are flushed after each chunk so clients receive them as they are produced. Emission stops if the client disconnects. Statuses 1xx, 204, 205, and 304 never send a body.
If output has already started, for example from an echo or a PHP warning, the emitter throws instead of sending a corrupted response. public/index.php then logs Cannot send the response: output already started (…). What clients receive depends on the setting:
output_buffering=Off: the output was already sent with headers, so the client receives that output, and the log says where it started (sent at file:line).output_bufferingon (the productionphp.inidefault): the output is still in a buffer. The emitter detects it (N bytes in an output buffer), and the front controller discards it and returns a clean 500 "Internal Server Error". Without that check, the stray text would silently precede the response body.
Return output in the response body instead of echoing it.
The emitter does not add Content-Length, compress, or handle range requests; the web server or middleware can do that.
Verification
tests/Integration/FrontControllerTest.php serves a skeleton copy with PHP's built-in server and checks raw HTTP responses:
- status lines with custom reasons;
- repeated headers and cookies;
- 301 with
Location, 204, streamed bodies, HEAD; - JSON 404s and body parsing;
- production and debug errors with logging;
- bootstrap failures and output that starts too early.
Unit tests cover the emitter, body parser, logger, and routing errors in detail.