Responses
Building responses, status codes, headers, cookies, and the security defaults you get for free.
Response is immutable. Constructors are named for what you are sending.
<?php
use PhpOrbit\Http\Response;
use PhpOrbit\Http\Status;
Response::text('Hello.'); // text/plain; charset=utf-8
Response::html('<h1>Hello</h1>'); // text/html; charset=utf-8
Response::json(['id' => 42]); // application/json; charset=utf-8
Response::redirect('/articles'); // 302 with Location
Response::redirect('/articles', Status::MovedPermanently);
Response::noContent(); // 204, body suppressed
Response::make(Status::Created, $body); // anything elseEvery one of these states a charset. Omitting it invites the browser to sniff the encoding, which is an XSS vector.
Status codes
<?php
Response::text('Not found.', Status::NotFound);
Response::json($errors, Status::UnprocessableEntity);
$response->status; // Status enum
$response->status->value; // 404
$response->status->reasonPhrase(); // "Not Found"
$response->status->allowsBody(); // false for 204 and 304Available cases cover the common set: Ok, Created, NoContent, MovedPermanently, Found, NotModified, BadRequest, Unauthorized, Forbidden, NotFound, MethodNotAllowed, Conflict, PayloadTooLarge, UnprocessableEntity, TooManyRequests, InternalServerError, NotImplemented, ServiceUnavailable.
Security headers, by default
Every response carries these unless you override them:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; base-uri 'self'They are applied in Response itself rather than by a layer. A 500 built on the error path — where middleware may have been skipped entirely — still carries them. A header that only appears when everything went well is a header you cannot rely on.
Overriding is per response, when a page genuinely needs it:
<?php
return Response::html($embeddable)
->withHeader('X-Frame-Options', 'SAMEORIGIN')
->withHeader('Content-Security-Policy', "default-src 'self'; img-src https:");Headers
<?php
$response
->withHeader('Cache-Control', 'no-store') // replaces
->withAddedHeader('Vary', 'Accept-Encoding') // appends
->withStatus(Status::Created)
->withBody($newBody);Content-Length is computed by the adapter — you never set it.
Cookies
<?php
use PhpOrbit\Http\Cookie;
use PhpOrbit\Http\SameSite;
// Defaults: HttpOnly, SameSite=Lax, Path=/, Secure.
return Response::redirect('/')->withCookie(
Cookie::forRequest($request, 'theme', 'dark', expires: time() + 86400),
);forRequest()
Secure cannot simply default to true: a Secure cookie is never sent over plain HTTP, which would silently break the built-in server on http://localhost. forRequest() reads the scheme off the request, so the flag is right in development and in production without a conditional at the call site.
<?php
// Full control when you need it
new Cookie(
name: 'preferences',
value: $encoded,
expires: time() + 2592000,
path: '/',
domain: null,
secure: true,
httpOnly: false, // deliberately readable by script
sameSite: SameSite::Strict,
);
// Removing one — attributes must match those it was set with
Response::redirect('/')->withCookie(
Cookie::expired('theme', secure: $request->uri->isSecure()),
);Cookie names and values are validated: a control character, space, comma, semicolon, quote or backslash in a value throws rather than being silently encoded, because those characters end the attribute early and let the rest forge cookie attributes of its own. Encode the value first — base64 or URL-encoding — if it might contain them.
SameSite::None without Secure also throws, since browsers reject that combination anyway.
JSON and HTML injection
<?php
Response::json(['note' => '</script><script>alert(1)</script>']);
// {"note":"<\/script><script>alert(1)<\/script>"}JSON is encoded with the JSON_HEX_* flags, so a payload containing </script> cannot break out if the response is inlined into a page.
HEAD requests
A HEAD is routed to the matching GET handler; the kernel strips the body afterwards. Your handler never checks for it. Headers stay identical, which is the point of the method.
204 and 304
<?php
$response = Response::noContent()->withBody('ignored');
$response->body; // "ignored" — what you set
$response->wireBody(); // "" — what is actually sentResponses that must not carry a body do not, regardless of what the handler built. The adapters send wireBody().
Rendering templates
<?php
// TemplateEngine::respond() is Response::html() around a render.
return $this->view->respond('articles/show', [
'title' => $article['title'],
'article' => $article,
]);
return $this->view->respond('articles/new', $data, Status::UnprocessableEntity);Error responses
Uncaught exceptions become a 500. Outside debug mode the body says nothing about the cause:
Internal Server ErrorWith --debug, the class, message, file, line and stack trace are rendered instead. Exception messages routinely contain file paths, SQL and credentials, which is why that is opt-in and never the default.