Pop PHP
The Basics

Error Handling

Four things can go wrong between a request arriving and a response leaving, and each has a different owner. No route matches; a route matches but the method does not; the action throws; or the application is deliberately down. The router handles the first two on its own, your catch-all route can take the first one over, the front controller catches the third, and maintenance mode is the fourth. This page is about which is which.

What the Router Answers on Its Own#

With no route registered for the requested path, Pop\Router\Match\Http::noRouteFound() writes a 404. If a path matched but every route on it constrains the method away, methodNotAllowed() writes a 405 with an Allow header listing the methods that path does accept. Neither needs any code from you, and both pick their body format from the request's Accept header:

BASH
curl -i http://localhost:8000/nope
TEXT
HTTP/1.1 404 Not Found
Content-Type: application/json

{
    "error": "Not Found"
}
BASH
curl -i -H 'Accept: text/html' http://localhost:8000/nope
TEXT
HTTP/1.1 404 Not Found
Content-type: text/html; charset=UTF-8

<!DOCTYPE html>
<html>
    <head>
        <title>Page Not Found</title>
    </head>
<body>
    <h1>Page Not Found</h1>
</body>
</html>

Router\Match\Http::acceptsHtml() asks Pop\Http\Server\AcceptHeader whether the request accepts text/html under AcceptSpecificity::Loose, which needs at least a type/* match. HTML wins on a real preference; everything else, including the bare */* that curl sends, gets JSON.

Request's Accept 404 and 405 body
no Accept header at all JSON
*/* JSON
application/json JSON
text/plain JSON
text/* HTML
text/html HTML
text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 (a browser) HTML

text/plain landing on JSON is the rule working as intended rather than an oddity: Loose needs text/html or text/*, and an exact subtype that is not html matches neither.

The 405 sets Allow on both branches, so a client that asked for HTML still gets the header it needs. The JSON body repeats the same list:

BASH
curl -i -X POST http://localhost:8000/orders
TEXT
HTTP/1.1 405 Method Not Allowed
Allow: GET
Content-Type: application/json

{
    "error": "Method Not Allowed",
    "allowed": [
        "GET"
    ]
}

Both methods exit() when they are done, which is their $exit parameter defaulting to true — the same $exit that Application::run(false) passes through when a long-lived worker needs to survive the response.

The Catch-All Route#

The router's built-in pages are a fallback, not a design. Registering a '*' route takes the 404 over and gives it your layout, your logging and your JSON shape:

PHPapp/config/app.http.php
<?php
return [
    'routes' => [
        '[/]' => [
            'controller' => 'App\Http\Controller\IndexController',
            'action'     => 'index'
        ],
        '*' => [
            'controller' => 'App\Http\Controller\IndexController',
            'action'     => 'error'
        ]
    ]
];

error is also the default action a dispatcher falls back to when a route names an action the controller does not have, so the same method absorbs both a missing page and a mistyped route table entry — Controllers covers that fallback.

The scaffolded IndexController::error() makes the same HTML-or-JSON decision the router does, using acceptsHtml() on the request object, which is the same AcceptSpecificity::Loose test:

PHPapp/src/Http/Controller/IndexController.php
<?php

namespace App\Http\Controller;

use Pop\Http\Server\Response;

class IndexController extends AbstractController
{

    public function index(): void
    {
        if ($this->request->acceptsHtml()) {
            $this->response->setBody('<h1>Welcome</h1>');
        } else {
            $this->response->addHeader('Content-Type', 'application/json');
            $this->response->setBody(json_encode(['message' => 'Index page']));
        }

        $this->response->send();
    }

    public function error(int $code = 404, ?string $message = null): void
    {
        $this->response->setCode($code);
        $this->response->setBody($message ?? Response::getMessageFromCode($code));
        $this->response->send();
    }

}

parent::error() is the base controller's JSON error — it sends {"code": 404, "message": "Not Found"} with the matching status and exits. app/view/error.phtml is an ordinary template; the scaffold ships one, and $title is the only variable it needs. See Views & Templates.

A '*' route answers a method mismatch too, rendering your error() as a 404 rather than a 405 with an Allow header — the router reports a mismatch only when it has produced no dispatchable at all. Leave the catch-all out if you want API clients to see the 405.

Where the distinction matters, register the path under the other methods too and answer from the action, which puts the method check somewhere you can also choose the body:

PHP
if (!$this->request->isGet()) {
    $this->response->setBody('');
    $this->response->send(405, ['Allow' => 'GET']);
    return;
}

The setBody('') is load-bearing — a Pop\Http\Server\Response has no body object until one is set, and send() renders it unconditionally. Requests & Responses covers that, and Routing covers method constraints.

Give a CLI route table its own '*' entry so a mistyped command reaches your error action — see Routing.

Uncaught Exceptions#

Application::run() wraps everything it does in a try. On a Throwable it dispatches the app.error event and then rethrows, so the exception leaves run() and the front controller is what finally handles it:

PHP
try {
    $app = new App\Application($autoloader, include __DIR__ . '/../app/config/app.http.php');
    $app->load();
    $app->run();
} catch (\Throwable $exception) {
    $app = new App\Application();
    $app->httpError($exception);
}

httpError() is an application-level convention, not a framework method — Pop\Application declares nothing of the kind. The scaffold writes one on App\Application, and it negotiates content exactly as everything else on this page does. Merge this into the application class rather than replacing it:

PHP
namespace App;

use Pop\Http\Server\Request;
use Pop\Http\Server\Response;
use Pop\View\View;

class Application extends \Pop\Application
{

    public function httpError(\Throwable $exception): void
    {
        $request  = new Request();
        $response = new Response();

        if ($request->acceptsHtml()) {
            $view          = new View(__DIR__ . '/../view/exception.phtml');
            $view->title   = 'Exception';
            $view->message = $exception->getMessage();

            $response->addHeader('Content-Type', 'text/html');
            $response->setBody($view->render());
        } else {
            $response->addHeaders($this->config['http_options_headers'] ?? []);
            $response->setBody(json_encode(['error' => $exception->getMessage()], JSON_PRETTY_PRINT) . PHP_EOL);
        }

        $response->send(500);
        exit();
    }

}

A console application's equivalent is cliError(), which prints the message in a red block and exits with status 127 rather than sending a response.

Catch \Throwable in the front controller, as above, and take \Throwable in httpError()'s signature to match. \Error — a TypeError, a call on null — is not an \Exception, and run() itself already catches \Throwable.

Note the second new App\Application() in that block. The failing application may be the thing that threw, so the handler builds a fresh one — with no config. Anything httpError() reads out of $this->config therefore has to tolerate its absence, which is why the sample above uses ?? on http_options_headers rather than indexing straight into it.

The app.error Event#

run() dispatches app.error before rethrowing, which is where logging and alerting belong. The listener receives the throwable first and the application second:

PHP
$app->on('app.error', function (\Throwable $exception, Pop\Application $application) {
    $application->getService('logger')->error($exception->getMessage());
});

The event fires for anything Throwable — an Error as readily as an Exception — and it fires before the exception escapes run(), so a listener sees every failure the front controller will go on to render. It does not swallow anything: the exception is rethrown regardless of what listeners do.

One exception is genuinely swallowed. Pop\Event\AbortException makes run() return quietly and does not fire app.error, which is how a middleware handler or an event listener stops a request deliberately rather than by failing. Events covers listener registration and priorities.

Maintenance Mode#

Setting MAINTENANCE_MODE=true in .env puts the application down. run() checks it after routing but before dispatch, so the route still resolves and the controller still exists — what changes is which method runs.

A dispatchable that implements Pop\Dispatch\MaintenanceInterface — every Pop\Controller\AbstractController subclass does — has its maintenance action called instead of the routed one. The scaffolded controller negotiates as usual, rendering maintenance.phtml for a browser and JSON for anything else, both under a 503. A route whose target is a closure or a plain callable has no such method, so the framework renders a generic 503 Service Unavailable page itself, HTML or JSON by the same rule.

Two escape hatches exist. MAINTENANCE_MODE_SECRET in .env names a value that unlocks the site for whoever knows it:

BASH
curl 'http://localhost:8000/?secret=letmein'

That request dispatches normally, and Pop\App::isSecretRequest() stores the value in a pop_mm_secret cookie as it goes — so the browser that used the query string once keeps its access for the rest of the session without repeating it. A wrong secret is ignored and the 503 stands.

The other hatch is per-controller. A controller with $bypassMaintenance set to true runs its routed action regardless, which is what a health check or a status endpoint needs so an uptime monitor is not itself taken down. Pop\App::isDown() and isUp() read the flag if you need to branch on it, and Application::isDown() and isUp() forward to them.

Errors have more surface than this page covers — the match objects behind the router's decisions, and the event manager's own API — see the popphp README.

See Also#

  • Routing — the catch-all route, method constraints and where the 405 comes from
  • Controllers — the default action, and per-controller maintenance behavior
  • Requests & ResponsesacceptsHtml(), the AcceptSpecificity modes and sending a response
  • Applications & Bootstrap — the front controller and the application class the handlers live on
  • Events — registering an app.error listener, and AbortException
  • Views & Templates — the error and exception templates
  • popphp README — the router and event API