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:
curl -i http://localhost:8000/nope
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"error": "Not Found"
}
curl -i -H 'Accept: text/html' http://localhost:8000/nope
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:
curl -i -X POST http://localhost:8000/orders
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:
<?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:
<?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:
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:
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:
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:
$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:
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 & Responses —
acceptsHtml(), theAcceptSpecificitymodes and sending a response - Applications & Bootstrap — the front controller and the application class the handlers live on
- Events — registering an
app.errorlistener, andAbortException - Views & Templates — the error and exception templates
- popphp README — the router and event API