stave.dev

Error Handling

Throw an HTTPError from anywhere. The framework catches it and renders the right error page inside your module's frame.

The HTTPError Exception

Zero provides an HTTPError exception class that gives you clean control flow for error conditions. Throw it from anywhere in your module — a controller method, a model, a helper — and the framework handles the rest.

// Basic usage - just the HTTP status code
throw new HTTPError(404);

// With a detail message for debugging
throw new HTTPError(400, "Missing required parameter: name");

// In a real endpoint
public function show($id) {
    $item = $this->model->find($id);
    if (!$item) {
        throw new HTTPError(404, "No item with ID: $id");
    }
    $this->data = ['item' => $item];
    $this->respond($this->viewPath . 'show.php');
}

The constructor takes two arguments:

Parameter Type Description
$code int HTTP status code (400, 403, 404, 500, etc.)
$detail ?string Optional detail message. Shown in DEVMODE and included in JSON responses.

How It Works

The HTTPError exception bubbles up to Application::run()'s try/catch block, which calls renderError() on the already-instantiated module:

$moduleInstance = new $Module();
try {
    $this->checkForAttributes($Module, $endpoint);
    $moduleInstance->{$endpoint}(...$args);
} catch (HTTPError $e) {
    $moduleInstance->renderError($e);
} catch (\ArgumentCountError $e) {
    $moduleInstance->renderError(new HTTPError(400));
}
throw new HTTPError(404)
Caught in Application::run()
$module->renderError($e)
Error page in module's frame

The renderError() method on Response sets the HTTP status header, looks for a custom error view, and calls respond() to render it within the module's frame — just like a normal page.

Why HTTPError, Not \Exception

The key reason to use HTTPError instead of a generic \Exception or \Error is that the framework catches it after the module is instantiated. This means:

Don't Use Generic Exceptions

Throwing new \Exception('Not found') or new \Error(404) will bypass the framework's error handling entirely. The user will see a raw PHP error or a blank page instead of a styled error view within your module's frame.

ArgumentCountError Handling

When a URL doesn't provide enough arguments for a method's required parameters, PHP throws an ArgumentCountError. The framework catches this automatically and converts it to an HTTPError(400):

// Method requires $id
public function show($id) { ... }

// URL: /my-module/show (no $id provided)
// PHP throws ArgumentCountError
// Framework catches it → HTTPError(400) → "Bad Request"

This means you don't need to manually check whether required arguments were provided — just declare them as required parameters and the framework handles the error case.

Custom Error Views

You can create custom error views for specific HTTP status codes in your module's view/ directory. The naming convention is error{code}.php:

modules/MyModule/
view/
index.php
error404.php
error500.php

When renderError() is called, it checks for a matching custom view file:

// Use a custom error view if one exists in the already-resolved viewPath
$view = file_exists($v = $this->viewPath . "error" . $code . ".php")
    ? $v
    : "<h1>$code - $message</h1><hr>";

If no custom view exists, the framework renders a simple default error page with the status code and message.

Example Custom 404 Page

<div class="error-page">
    <h1>Page Not Found</h1>
    <p>The page you're looking for doesn't exist or has been moved.</p>
    <a href="/">Return Home</a>
</div>

Development Mode

When DEVMODE is true (the requesting IP matches DEV_SUBNET in app/config/constants.ini), the detail message from HTTPError is shown on the error page. In production, only the standard HTTP status message is displayed.

Context What's Shown
Production 404 - Not Found
Development 404 - Not Found: No item with ID: 99
JSON response Detail is always included in the JSON envelope
Safe Detail Messages

Always include useful detail messages in your HTTPError throws — they cost nothing in production (hidden from end users) but save debugging time in development.

Common Patterns

// 404 - Resource not found
public function show($id) {
    $item = $this->model->find($id);
    if (!$item) {
        throw new HTTPError(404, "Item not found: $id");
    }
}

// 400 - Bad input
public function update($id) {
    $name = $_POST['name'] ?? null;
    if (empty($name)) {
        throw new HTTPError(400, "Missing required field: name");
    }
}

// 403 - Custom permission check
public function admin() {
    if (!$this->user->isOwner()) {
        throw new HTTPError(403, "Owner access required");
    }
}

// 405 - Wrong HTTP method (prefer #[AllowedMethods] attribute)
public function delete($id) {
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
        throw new HTTPError(405, "POST required");
    }
}

// 500 - Something went wrong
public function process() {
    $result = $this->externalApi->call();
    if ($result === false) {
        throw new HTTPError(500, "External API unreachable");
    }
}
Prefer Attributes for Repetitive Checks

For authentication, HTTP method restrictions, and parameter validation, use PHP attributes like #[RequireLogin] and #[AllowedMethods('POST')] instead of manual checks. Reserve HTTPError throws for business logic and cases where attributes don't apply.

Supported Status Codes

The HTTPError class includes message mappings for all standard HTTP error codes from 400 through 599, including common non-standard codes like 418 (I'm a teapot), 429 (Too Many Requests), and 503 (Service Unavailable). If a code is not in the map, the message defaults to "Unspecified".