Response
Lift\Http\Response is an immutable HTTP-response object. It implements Psr\Http\Message\ResponseInterface and provides factory methods for the common cases (JSON, HTML, text, redirect, no-content) plus cookie helpers and a fluent builder.
Mental model: build a
Response, return it from your handler, Lift sends it to the client. LikeRequest, it is immutable — everywith*()method returns a new instance.
The shortest possible response
$app->get('/', fn() => Response::json(['hello' => 'world']));
That's all. If you don't need to set custom headers or status codes, the factory methods are the cleanest API.
Factory methods
Response::json($data, $status = 200, $flags = ...)
Sends an array/object as JSON with Content-Type: application/json; charset=utf-8.
Response::json(['status' => 'ok']); // 200 OK
Response::json(['error' => 'Conflict'], 409); // custom status
Response::json($data, 200, JSON_PRETTY_PRINT); // custom encode flags
The default flags include JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES (you almost always want those). Encoding errors throw JsonException — never silently produce broken output.
Response::html($content, $status = 200)
Response::html('<h1>Hello</h1>');
Response::html($view->render('home'), 200);
Content-Type: text/html; charset=utf-8 is set automatically.
Response::text($content, $status = 200)
Response::text('pong');
Response::text("Hello, $name", 200);
Content-Type: text/plain; charset=utf-8.
Response::redirect($url, $status = 302, $headers = [])
Response::redirect('/login'); // 302 Found
Response::redirect('/new-home', 301); // 301 Moved Permanently
Response::redirect('/after-post', 303); // 303 See Other (POST → GET pattern)
Response::redirect('/short-cache', 307); // 307 Temporary Redirect (preserves method)
Response::redirect('/forever', 308); // 308 Permanent Redirect (preserves method)
The third $headers argument merges additional headers into the redirect response:
// Redirect and clear a cookie in one shot
Response::redirect('/login', 302, ['Clear-Site-Data' => '"cookies"']);
// Redirect with a custom cache control
Response::redirect('/new-home', 301, ['Cache-Control' => 'no-store']);
You can also chain ->withHeader(...) / ->withCookie(...) on the result:
return Response::redirect('/dashboard')
->withCookie('flash', 'Welcome back!');
Response::noContent()
return Response::noContent(); // 204, empty body
Use this when a DELETE / PUT etc. succeeds but has nothing to return.
Fluent builder (PSR-7 style)
For everything the factories don't cover, use with*() chains. Each call returns a new instance:
return (new Response())
->withStatus(201)
->withHeader('Location', '/users/42')
->withHeader('X-Request-Id', $id)
->withJson(['id' => 42]); // sets body + Content-Type, keeps status
// ->withJson(['id' => 42], 201); // optional second arg overrides status code
A subtle but common bug:
// ❌ WRONG — withHeader returns a new object; this throws it away.
$res = Response::json($data);
$res->withHeader('X-Custom', 'value');
return $res;
// ✅ RIGHT
$res = Response::json($data)->withHeader('X-Custom', 'value');
return $res;
Auto-conversion
If a handler returns something that isn't a Response, Lift converts it for you:
| Return value | What you get back |
|---|---|
Response |
passed through unchanged |
array, object |
Response::json(...) |
string |
Response::html(...) |
null |
Response::noContent() (204) |
| anything else | Response::text((string) $v) |
So these two handlers are identical:
fn() => ['ok' => true]
fn() => Response::json(['ok' => true])
Pick whichever reads better. Tip: explicit Response::json(...) shines whenever you also need a status code or a header — those force you to use a Response anyway.
Cookies
Lift's response carries first-class cookie helpers — you don't need PHP's setcookie().
return Response::json($user)
->withCookie('remember_token', $token, [
'max_age' => 86400 * 30, // 30 days
'http_only' => true, // default true
'same_site' => 'Lax', // default 'Lax'
'secure' => true, // send only over HTTPS
'path' => '/', // default '/'
'domain' => 'example.com',// optional
]);
Quickly delete a cookie:
return Response::noContent()->withoutCookie('remember_token');
Read the value on the next request via
$req->cookie('remember_token').
Cookie option reference
| Key | Type | Default | Effect |
|---|---|---|---|
max_age |
int | — | Max-Age=N seconds. Recommended over expires. |
expires |
int | — | Unix timestamp. Ignored when max_age is set. |
path |
string | / |
URL prefix the cookie applies to. |
domain |
string | — | Cookie domain (sub-domain control). |
secure |
bool | false |
Adds Secure flag (HTTPS only). |
http_only |
bool | true |
Adds HttpOnly flag (no JS access). |
same_site |
string | Lax |
Strict / Lax / None. |
Custom status codes
return Response::json(['error' => 'I refuse to brew coffee.'], 418);
// Custom reason phrase
return (new Response())->withStatus(418, "I'm a teapot");
Lift knows the standard phrases (200 OK, 404 Not Found, etc.) — you only pass a phrase if you want to override.
Accessing / mutating the body
$stream = $res->getBody(); // Psr\Http\Message\StreamInterface
$content = (string) $res->getBody(); // string
// Replace the body
$newRes = $res->withBody(\Lift\Http\Stream::fromString('hello'));
Most code never touches the body directly — the factory methods + withJson() cover 99% of cases.
Setting headers
$res = Response::json($data)
->withHeader('Cache-Control', 'public, max-age=3600')
->withHeader('X-Total-Count', '42')
->withAddedHeader('Set-Cookie', 'a=1') // append (don't replace)
->withAddedHeader('Set-Cookie', 'b=2');
withHeader() replaces any existing value; withAddedHeader() appends (use this when a header legitimately appears more than once, like Set-Cookie).
Streaming and Server-Sent Events
For long-lived responses (Server-Sent Events, log tailing, etc.) use SseResponse — see Server-Sent Events.
Sending custom binary / file responses
Lift doesn't ship a Response::file() helper (it's a micro-framework, not a CMS), but it's a one-liner:
use Lift\Http\Stream;
$path = '/storage/exports/report.csv';
return (new Response())
->withHeader('Content-Type', 'text/csv')
->withHeader('Content-Disposition', 'attachment; filename="report.csv"')
->withHeader('Content-Length', (string) filesize($path))
->withBody(Stream::fromFile($path));
(See Lift\Http\Stream for factory methods — fromString, fromFile, fromInput, empty.)
Status code cheat sheet
| Code | Use for |
|---|---|
| 200 | OK — anything with a body that didn't create a resource |
| 201 | Created — POST that created a resource |
| 202 | Accepted — queued for async processing |
| 204 | No Content — successful DELETE / PUT with nothing to return |
| 301 | Moved Permanently — old URL, forever |
| 302 | Found — temporary redirect (browsers may switch method to GET) |
| 303 | See Other — POST → GET redirect after form submission |
| 307 | Temporary Redirect — like 302 but preserves the HTTP method |
| 308 | Permanent Redirect — like 301 but preserves the HTTP method |
| 400 | Bad Request — malformed request |
| 401 | Unauthorized — missing/invalid credentials |
| 403 | Forbidden — authenticated but not allowed |
| 404 | Not Found |
| 405 | Method Not Allowed |
| 409 | Conflict — e.g. duplicate unique constraint |
| 422 | Unprocessable Entity — validation failed (Lift's ValidationException default) |
| 429 | Too Many Requests — rate-limited |
| 500 | Internal Server Error |
| 502 | Bad Gateway — upstream failure |
| 503 | Service Unavailable — maintenance / overload |
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Headers don't appear | You called withHeader() but didn't capture the return |
Assign back: $res = $res->withHeader(...);. |
JSON_THROW_ON_ERROR blows up |
Non-UTF-8 string in payload | Sanitise input; or Response::json($data, 200, JSON_INVALID_UTF8_IGNORE). |
Browser ignores Set-Cookie |
Cookie attributes are wrong (Secure on HTTP, mismatched domain) |
Drop secure for local dev, double-check domain/path. |
Empty JSON {} instead of array [] |
json_encode([]) is correct; happens when you pass an empty associative array |
Pass a list<...> (e.g. array_values($items)) when you want []. |
| Status text says nothing | You passed an empty reason phrase | Either pass nothing (Lift fills it in) or supply your own string. |
Cheat sheet
// Factories
Response::json($data, $status?, $flags?);
Response::html($html, $status?);
Response::text($text, $status?);
Response::redirect($url, $status?);
Response::noContent();
// Fluent
(new Response())
->withStatus(201)
->withHeader('X-Foo', 'bar')
->withAddedHeader('Set-Cookie', '...')
->withJson($data);
// Cookies
$res->withCookie($name, $value, [...]);
$res->withoutCookie($name);
// Body
$res->getBody(); // StreamInterface
(string) $res->getBody();
$res->withBody(Stream::fromString($html));