Requests & Responses
Every HTTP request that reaches a Pop application arrives as a Pop\Http\Server\Request, and every answer
leaves as a Pop\Http\Server\Response. Inside a controller you never construct either one — the dispatch
trait has already built both by the time your action runs, populated from the superglobals and ready to read.
This page is about that pair: what you can ask the request, and how to assemble the answer.
The Request and Response in a Controller#
Pop\Dispatch\HttpTrait declares $this->request and $this->response and default-constructs both in its
constructor, so any controller with the trait anywhere in its ancestry has them:
<?php
namespace App\Http\Controller;
use Pop\Controller\AbstractController;
use Pop\Dispatch\HttpTrait;
class ArticlesController extends AbstractController
{
use HttpTrait;
public function index(): void
{
$page = $this->request->getQuery('page') ?? '1';
$this->response->setCode(200)
->addHeader('Content-Type', 'application/json')
->setBody(json_encode(['page' => (int)$page], JSON_PRETTY_PRINT));
$this->response->send();
}
}
$this->request() and $this->response() are accessor aliases for the same two objects, so either form
works. Controllers covers how the trait gets onto a controller in the first place.
Inside a controller, use $this->request. HttpTrait builds it as new Request(new Uri()), so the URI is
populated and getSegments(), getSegment(), getUriString() and getBasePath() all work.
This page is the inbound half. For calling out to another service — the client request and client response objects, handlers, promises and async — see HTTP Client.
Reading Request Data#
The request exposes one accessor per source. Each returns the whole array when called with no argument, and a single value when given a key:
| Method | Reads |
|---|---|
getQuery() |
$_GET, whatever the request method |
getPost() |
$_POST — populated only for form-encoded and multipart POSTs |
getPut(), getPatch(), getDelete() |
the parsed body, when the method matches |
getFiles() |
$_FILES |
getParsedData() |
the body parsed by its Content-Type, whatever the method |
getRawData() |
the unparsed body string |
getCookie(), getServer(), getEnv() |
$_COOKIE, $_SERVER, $_ENV |
A missing key returns null, so ?? supplies the default:
$page = $this->request->getQuery('page') ?? '1';
$sort = $this->request->getQuery('sort') ?? 'id';
$title = $this->request->getPost('title');
if ($this->request->isPost() && ($title !== null)) {
// create the article
}
isGet(), isPost(), isPut(), isPatch(), isDelete(), isOptions() and isHead() test the method, and
getMethod() returns it as a string. Route method constraints usually make those checks unnecessary — a route
registered under post never dispatches on a GET — so reach for them when one action deliberately handles
several methods.
JSON and Other Raw Bodies#
This is the one place the accessor names mislead. PHP populates $_POST only for application/x-www-form-urlencoded
and multipart/form-data, so a JSON POST leaves getPost() empty. The parsed body is in getParsedData():
$data = $this->request->getParsedData();
$username = $data['username'] ?? null;
$email = $data['email'] ?? null;
The request parses the body according to its Content-Type header, and where the result lands depends on the
method. A JSON POST of {"username":"bob"} gives an empty getPost(), a getParsedData() of
['username' => 'bob'], and a getRawData() of the original {"username":"bob"} string. A JSON PUT of the
same body fills both getPut() and getParsedData(); PATCH and DELETE behave the same way through
getPatch() and getDelete().
getParsedData() is therefore the accessor to reach for whenever the body might not be a form — an API
endpoint that accepts JSON, or one action serving both a browser form and a fetch call. It also takes a key,
so getParsedData('username') works.
A GET request has no body at all, and the request treats the query string as its raw data: getParsedData()
mirrors getQuery() and getRawData() returns page=2&sort=title.
Filtering Input on the Way in#
The request constructor takes a list of filters as its second argument and applies each one to every value in the query, post and parsed-data arrays as they are read.
$request = new Pop\Http\Server\Request(new Pop\Http\Uri(), ['strip_tags']);
A controller does not construct its own request, so hand the filtered one to the router in load(). The
'*' wildcard applies it to every dispatchable:
<?php
namespace App;
use Pop\Http\Server\Request;
use Pop\Http\Server\Response;
use Pop\Http\Uri;
class Application extends \Pop\Application
{
public function load(): Application
{
$this->router()->addDispatchableParams(
'*', [$this, new Request(new Uri(), ['strip_tags']), new Response()]
);
return $this;
}
}
A POST of bio=<script>bad</script>hi then reads back as badhi from both getPost('bio') and
getParsedData('bio'). Dispatchable Parameters covers the
registry in full, including how to filter for one controller rather than all of them. Filters are a blunt instrument
applied to everything — treat them as a backstop, not as validation. Forms & Validation covers per-field rules.
Headers, Cookies and Request Metadata#
Headers come back as strings by default, matching PSR-7, with the object form available separately:
if ($this->request->hasHeader('Content-Type')) {
$type = $this->request->getHeaderValueAsString('Content-Type'); // 'application/json'
}
$accept = $this->request->getHeaderLine('Accept'); // one comma-joined string
$values = $this->request->getHeader('Accept'); // array of values
$header = $this->request->getHeaderObject('Content-Type'); // Pop\Mime\Part\Header
An Authorization header is parsed for you during construction, so a bearer token needs no string handling:
if ($this->request->hasAuth()) {
$auth = $this->request->getAuth();
if ($auth->isBearer()) {
$token = $auth->getToken();
}
}
The rest of the request's metadata is one call each: getMethod(), getScheme(), getHost(),
getFullHost() (host plus port), getIp(), isSecure(), getUriString(), getSegments() and
getSegment($i) for one zero-indexed path segment, and getBasePath() for the subdirectory an application is
deployed under. getCookie() reads $_COOKIE — see Sessions & Cookies for writing them.
File Uploads#
Pop\Http\Server\Upload takes a destination directory and one entry from getFiles(). setDefaults()
applies a 10MB limit and the built-in allowed and disallowed extension lists. The skeleton's data/
directory is the writable scratch space to point it at, four levels up from a controller:
$file = $this->request->getFiles('document');
if ($file === null) {
return;
}
$upload = new Pop\Http\Server\Upload(__DIR__ . '/../../../../data/uploads');
$upload->setDefaults();
$filename = $upload->upload($file);
if ($filename !== false) {
$fullPath = $upload->getUploadedFullPath();
} else {
$message = $upload->getErrorMessage();
}
upload() returns the stored filename on success and false on failure — the failure is a return value
rather than an exception, so every upload needs both the null guard and the branch. getUploadedFile()
returns the same name afterward, and getUploadedFullPath() prefixes the destination directory.
Two behaviors are worth knowing before you rely on them, both of which you can see by uploading twice:
- Duplicates are renamed, not overwritten. A second
sample.txtis stored assample_1.txt, thensample_2.txt.overwrite(true)turns that off, andcheckFilename('sample.txt')returns the name that would be used without uploading anything. - A disallowed extension is a soft failure. Uploading
bad.php3leavesgetErrorCode()at10andgetErrorMessage()atThe uploaded file is not allowed, with nothing written to disk.
Passing a second argument — $upload->upload($file, 'invoice-1001.pdf') — stores under a name you choose
instead of the client's. setMaxSize(), setAllowedTypes() and addDisallowedType() adjust the rules that
setDefaults() seeded.
Guard for null first, then branch on what upload() returns rather than on isSuccess(). A field that
was not submitted at all makes getFiles('document') return null, and the return value is what carries
the outcome.
Building a Response#
A response is assembled and then sent. The setters chain; send() does not.
$response = new Pop\Http\Server\Response();
$response->setCode(201)
->addHeader('Content-Type', 'application/json')
->addHeader('X-Request-Id', 'abc123')
->setBody(json_encode(['id' => 1001], JSON_PRETTY_PRINT));
$response->send();
send() also takes the code and headers directly, which collapses the common case to one call, and a third
argument that adds a Content-Length header computed from the body:
$response = new Pop\Http\Server\Response();
$response->setBody('plain');
$response->send(200, ['Content-Type' => 'text/plain'], true);
The constructor accepts the same three as a config array — new Response(['code' => 404, 'message' => 'Nope', 'headers' => ['X-A' => 'b']]) — which is occasionally tidier than three setters. addHeaders() takes several
at once. sendAndExit() is send() followed by exit(), and render() returns the whole response, status
line and headers included, as a string instead of sending it:
HTTP/1.1 404 Nope
X-A: b
body here
Put together, a create endpoint reads the parsed body, writes, and answers with a 201 and a Location:
<?php
namespace App\Http\Controller;
use Pop\Controller\AbstractController;
use Pop\Dispatch\HttpTrait;
class CommentsController extends AbstractController
{
use HttpTrait;
public function create(): void
{
$data = $this->request->getParsedData();
if (empty($data['body'])) {
$this->response->setBody(json_encode(['error' => 'body is required']));
$this->response->send(422, ['Content-Type' => 'application/json']);
return;
}
$this->response->setBody(json_encode(['id' => 1001, 'body' => $data['body']]));
$this->response->send(201, [
'Content-Type' => 'application/json',
'Location' => '/comments/1001'
]);
}
}
getParsedData() rather than getPost() is what makes that action accept a JSON body and a form post
without caring which arrived.
Call setBody('') when the status line is the whole answer — a 204 or a bodiless 405 — since send()
always renders a body.
Send the response before anything else writes to the output buffer. A stray echo, a blank line after a
closing ?> in an included file, or a leftover var_dump() gets there first, and send() reports
The headers have already been sent.
Status Codes and Redirects#
setCode() sets the status and the reason phrase follows automatically.
Response::getMessageFromCode(422) returns Unprocessable Entity if you need the phrase without a response
object. Reading a response back — your own, or one from the HTTP client — there's a predicate per family and
per common code: isSuccess(), isRedirect(), isClientError(), isServerError(), and specific ones such
as isNotFound(), isUnauthorized(), isForbidden(), isConflict() and isUnprocessableEntity().
Redirects are static, because they send headers and nothing else:
Pop\Http\Server\Response::redirect('/articles', 302);
exit();
redirectAndExit() does both in one call, and is what a controller almost always wants — a redirect that
falls through to the rest of the action sends the Location header and then keeps running.
Both forms validate the code against the known status list and throw Pop\Http\Server\Exception — The header code 999 is not allowed. — for anything else, and both throw if the headers have already gone out.
Response::forward($clientResponse) sends a Pop\Http\Client\Response back out as the server's own response,
which is the short path for a proxy endpoint. See HTTP Client.
Content Negotiation#
The request parses the Accept header and can tell you what the caller actually wants:
if ($this->request->acceptsHtml()) {
$view = new View(__DIR__ . '/../../../view/articles.phtml');
$this->response->setBody($view->render());
$this->response->send();
} else {
$this->response->addHeader('Content-Type', 'application/json');
$this->response->setBody(json_encode(['articles' => []]));
$this->response->send();
}
That branch is what makes one action serve both a browser and an API client, and it's what the scaffolded
IndexController does. Negotiation is RFC 7231 compliant: quality values are honored, and an exact
type/subtype outranks type/*, which outranks */*, regardless of the order they appear in the header.
The important detail is how a bare */* is treated. acceptsHtml(), acceptsJson() and acceptsXml()
default to AcceptSpecificity::Loose, which requires at least a type/* match — so a wildcard alone is not
a preference for anything:
Request's Accept |
acceptsHtml() |
acceptsJson() |
|---|---|---|
text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 (a browser) |
true |
false |
application/json |
false |
true |
*/* (curl's default) |
false |
false |
no Accept header at all |
false |
false |
A plain curl call therefore falls through to the JSON branch instead of being mistaken for a browser, which
is the point. Pass AcceptSpecificity::Any to opt back into the permissive check, or
AcceptSpecificity::Exact to require a literal type/subtype:
use Pop\Http\Server\AcceptSpecificity;
$this->request->acceptsHtml(AcceptSpecificity::Any); // true even for a bare */*
$this->request->acceptsHtml(AcceptSpecificity::Exact); // true only if text/html was listed
accepts() and getPreferredType() are the general forms, and they default to AcceptSpecificity::Any
instead — they are negotiation primitives and leave the policy to the caller:
$this->request->accepts('application/json');
$this->request->accepts(['application/xml', 'text/xml']);
$this->request->getPreferredType(['text/html', 'application/json']);
getPreferredType() returns the best match among the types you can produce, or null if none is acceptable.
With no Accept header it returns the first type in your list, since a client that states no preference
accepts anything.
The router negotiates its own responses the same way. A 404, 405 or maintenance-mode 503 renders as HTML
when the request genuinely prefers HTML, and as {"error": "Not Found"} with a JSON content type for a
bare */* or a missing Accept header — so an API client gets JSON back.
The request and response objects carry more than this page covers — PSR-7 with*() immutability, streaming a
large body to a file, and the Pop\Http\Server wrapper that pairs them — see the pop-http README.
See Also#
- Controllers — how
$this->requestand$this->responseget there - Routing — method constraints, and the 404 and 405 the router generates
- Views & Templates — rendering the body an HTML response sends
- Forms & Validation — validating what the request carried
- Sessions & Cookies — reading and writing cookies
- HTTP Client — the outbound half of pop-http
- pop-http README — the full server request and response API