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
<?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 else

Every one of these states a charset. Omitting it invites the browser to sniff the encoding, which is an XSS vector.

Status codes

PHP
<?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 304

Available 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:

Output
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'
Why not middleware

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
<?php
return Response::html($embeddable)
    ->withHeader('X-Frame-Options', 'SAMEORIGIN')
    ->withHeader('Content-Security-Policy', "default-src 'self'; img-src https:");

Headers

PHP
<?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
<?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),
);
Why 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
<?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
<?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
<?php
$response = Response::noContent()->withBody('ignored');

$response->body;        // "ignored"  — what you set
$response->wireBody();  // ""         — what is actually sent

Responses that must not carry a body do not, regardless of what the handler built. The adapters send wireBody().

Rendering templates

PHP
<?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:

Output
Internal Server Error

With --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.