Pop PHP
The Basics

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:

PHPapp/src/Http/Controller/ArticlesController.php
<?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:

PHP
$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():

PHP
$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.

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

PHPapp/src/Application.php
<?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:

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

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

PHP
$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.txt is stored as sample_1.txt, then sample_2.txt. overwrite(true) turns that off, and checkFilename('sample.txt') returns the name that would be used without uploading anything.
  • A disallowed extension is a soft failure. Uploading bad.php3 leaves getErrorCode() at 10 and getErrorMessage() at The 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.

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

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

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

PHPapp/src/Http/Controller/CommentsController.php
<?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:

PHP
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\ExceptionThe 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:

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

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

PHP
$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#