Exceptions

Overview

JiFramework provides a small set of typed exceptions that map cleanly to HTTP status codes and common error scenarios. Throwing the right exception type gives the framework enough information to render the correct error response automatically — no manual http_response_code() or die() calls needed.

  • Namespace: JiFramework\Exceptions
  • HTTP exceptions are caught by ErrorHandler and rendered as an HTML error page or JSON response depending on the request type.
  • Non-HTTP exceptions (DatabaseException, ValidationException) bubble up as 500 errors unless you catch them yourself.
  • Use $app->abort() as a shorthand for the most common HTTP exceptions — no use statement required.
ClassHTTP CodeWhen to use
HttpExceptionanyBase class — use when no named subclass fits
NotFoundException404Resource does not exist
ForbiddenException403User lacks permission
UnauthorizedException401Request requires authentication
DatabaseExceptionDatabase query or connection failure
ValidationException422Validation failed — carries field errors

HttpException

new HttpException(int $statusCode, string $message = '', ?Throwable $previous = null)

The base class for all HTTP-level errors. Stores the status code and makes it available via getStatusCode(). The ErrorHandler catches any HttpException and sends the appropriate HTTP response automatically.

  • $statusCode(int) Any valid HTTP status code, e.g. 404, 429, 503.
  • $message(string) Error message. Shown in development mode; hidden from users in production.
  • $previous(?Throwable) Optional chained exception for debugging.
use JiFramework\Exceptions\HttpException;

// Throw directly for status codes without a named subclass
throw new HttpException(429, 'Too many requests, please slow down.');
throw new HttpException(503, 'Service temporarily unavailable.');
throw new HttpException(410, 'This resource has been permanently removed.');
getStatusCode(): int

Retrieve the HTTP status code stored in the exception.

try {
    // ... some operation
} catch (HttpException $e) {
    $code    = $e->getStatusCode(); // e.g. 404
    $message = $e->getMessage();
}

Prefer the named subclasses (NotFoundException, ForbiddenException, UnauthorizedException) for the common cases — they are more expressive and require less code. Use HttpException directly only when no subclass fits.

NotFoundException

new NotFoundException(string $message = 'Not Found', ?Throwable $previous = null)

Thrown when a requested resource cannot be found. Always maps to HTTP 404. The ErrorHandler catches it and renders the 404 error page automatically.

use JiFramework\Exceptions\NotFoundException;

$user = User::find($id);

if ($user === null) {
    throw new NotFoundException('User not found.');
}
Shorthand via $app->abort()
// Equivalent -- no use statement needed
$app->abort(404, 'User not found.');
In a router file handler
// pages/post.php  ($slug is injected by the router)
$post = Post::where('slug', $slug)->where('published', 1)->first();

if (!$post) {
    $app->abort(404, 'Post not found.');
}

ForbiddenException

new ForbiddenException(string $message = 'Forbidden', ?Throwable $previous = null)

Thrown when an authenticated user attempts an action they do not have permission to perform. Always maps to HTTP 403.

Use 403 when the user is known but not allowed. Use 401 when the user is not authenticated at all.

use JiFramework\Exceptions\ForbiddenException;

$user = User::find($app->session->get('user_id'));

if ($user['role'] !== 'admin') {
    throw new ForbiddenException('Admin access required.');
}
Shorthand via $app->abort()
$app->abort(403, 'You do not have permission to perform this action.');
Protecting a page
// pages/admin/dashboard.php
$userId = $app->session->get('user_id');

if (!$userId) {
    $app->abort(401); // not logged in
}

$user = User::find($userId);

if ($user['role'] !== 'admin') {
    $app->abort(403); // logged in but not an admin
}

// proceed with admin dashboard...

UnauthorizedException

new UnauthorizedException(string $message = 'Unauthorized', ?Throwable $previous = null)

Thrown when a request requires authentication but the user is not logged in. Always maps to HTTP 401.

The semantic difference from ForbiddenException: 401 means “you need to log in”, 403 means “you are logged in but not allowed”.

use JiFramework\Exceptions\UnauthorizedException;

$userId = $app->session->get('user_id');

if (!$userId) {
    throw new UnauthorizedException('Please log in to continue.');
}
Shorthand via $app->abort()
$app->abort(401, 'Authentication required.');
API bearer token guard
$token = $app->request->getBearerToken();

if (!$token || !ApiToken::where('token', $token)->exists()) {
    $app->abort(401, 'Invalid or missing API token.');
}

DatabaseException

DatabaseException extends \RuntimeException

Thrown automatically by QueryBuilder when a database operation fails — connection errors, malformed SQL, constraint violations, and so on. You do not throw this yourself; it is raised internally and surfaces through the normal exception chain.

If not caught, DatabaseException is handled by ErrorHandler as a 500 error. In development mode the full message and SQL are shown; in production only a generic error page is displayed.

Catching for graceful fallback
use JiFramework\Exceptions\DatabaseException;

try {
    User::insert(['email' => $email]);
} catch (DatabaseException $e) {
    // e.g. duplicate email on a UNIQUE column
    $app->logger->error('Insert failed: ' . $e->getMessage());
    echo 'Could not save your data. Please try again.';
}
Catching inside a transaction
use JiFramework\Exceptions\DatabaseException;

$app->db->beginTransaction();

try {
    Order::create($orderData);
    OrderItem::insert($itemData);
    $app->db->commit();

} catch (DatabaseException $e) {
    $app->db->rollBack();
    $app->logger->error('Order transaction failed: ' . $e->getMessage());
    $app->abort(500, 'Order could not be placed.');
}

The exception message always includes the failing SQL statement to make debugging straightforward, e.g.: Database query error: ... | SQL: SELECT * FROM ...

ValidationException

new ValidationException(string $message = 'Validation failed.', array $errors = [], int $code = 422, ?Throwable $previous = null)

Thrown by Validator::throw() and Validator::checkOrFail() when validation fails. Carries a structured errors array keyed by field name. Maps to HTTP 422 by default.

getErrors(): array

Returns the full validation errors array in the shape ['field' => ['message', ...]].

Thrown automatically by the Validator
// Validator::throw() raises ValidationException if validation fails
$app->validator->make($_POST, [
    'name'  => 'required|min:2|max:100',
    'email' => 'required|email',
])->throw();

// Execution continues here only if all rules pass
Catching and handling the errors
use JiFramework\Exceptions\ValidationException;

try {
    $app->validator->make($_POST, [
        'name'     => 'required|min:2',
        'email'    => 'required|email',
        'password' => 'required|min:8|strongPassword',
    ])->throw();

    User::create([
        'name'     => $_POST['name'],
        'email'    => $_POST['email'],
        'password' => password_hash($_POST['password'], PASSWORD_BCRYPT),
    ]);

    header('Location: /dashboard');

} catch (ValidationException $e) {
    $errors = $e->getErrors();
    // ['email' => ['The Email field must be a valid email address.'], ...]

    // Re-render the form with error messages
    include 'views/register.php';
}
JSON API validation response
use JiFramework\Exceptions\ValidationException;

try {
    $app->validator->make($_POST, [
        'title'   => 'required|max:200',
        'content' => 'required',
    ])->throw();

} catch (ValidationException $e) {
    $app->json(422, [
        'message' => 'Validation failed.',
        'errors'  => $e->getErrors(),
    ]);
}

Using $app->abort()

$app->abort(int $statusCode, string $message = '): void

abort() is the recommended shorthand for throwing HTTP exceptions. It throws an HttpException internally, which the ErrorHandler catches and renders automatically. No use statement or explicit throw needed.

// 404 -- resource not found
$user = User::find($id);
if (!$user) {
    $app->abort(404, 'User not found.');
}

// 403 -- forbidden
if ($user['role'] !== 'admin') {
    $app->abort(403, 'Admin access required.');
}

// 401 -- unauthenticated
if (!$app->session->get('user_id')) {
    $app->abort(401, 'Please log in.');
}

// 500 -- internal error (use sparingly)
$app->abort(500, 'Something went wrong.');

// Custom codes
$app->abort(429, 'Too many requests.');
$app->abort(503, 'Under maintenance.');
abort() vs throw

Both are equivalent — choose the style that suits your context:

// These are identical in behaviour:
$app->abort(404, 'Not found.');

throw new NotFoundException('Not found.');

abort() always terminates the current request. Code after it will not run.

Catching Exceptions

Most of the time you do not need to catch HTTP exceptions — the ErrorHandler does it for you and renders the right error page. Catch exceptions yourself only when you want a custom recovery path instead of the default error page.

Catch a specific type
use JiFramework\Exceptions\NotFoundException;
use JiFramework\Exceptions\DatabaseException;

try {
    $post = Post::find($id);
    if (!$post) {
        throw new NotFoundException();
    }
} catch (NotFoundException $e) {
    // show a custom inline "not found" message instead of the 404 page
    echo '<p>Post not found. <a href="/posts">Browse all posts</a></p>';
} catch (DatabaseException $e) {
    $app->logger->error($e->getMessage());
    echo '<p>Could not load post. Please try again later.</p>';
}
Catch the HttpException base class
use JiFramework\Exceptions\HttpException;

try {
    // some operation that may throw 401, 403, or 404
    requireAuth($app);
    requireRole($app, 'editor');
    $post = loadPost($id);

} catch (HttpException $e) {
    $code = $e->getStatusCode();

    if ($code === 401) {
        header('Location: /login');
        exit;
    }

    // Re-throw everything else so ErrorHandler renders it
    throw $e;
}
Catch-all for logging and re-throw
use JiFramework\Exceptions\HttpException;

try {
    runExpensiveOperation();
} catch (HttpException $e) {
    throw $e; // let ErrorHandler deal with it
} catch (Throwable $e) {
    $app->logger->error('Unexpected error: ' . $e->getMessage());
    $app->abort(500, 'An unexpected error occurred.');
}

Always re-throw HttpException when you catch \Throwable — otherwise the framework's error handler never sees it and cannot send the correct HTTP status code.

Examples

Complete real-world examples.

Page guard: authentication + role check
<?php
// pages/admin/users.php
$userId = $app->session->get('user_id');

if (!$userId) {
    $app->abort(401, 'You must be logged in.');
}

$user = User::find($userId);

if (!$user || $user['role'] !== 'admin') {
    $app->abort(403, 'Admin access only.');
}

$users = User::all();
include 'views/admin/users.php';
API endpoint with validation and typed exceptions
<?php
// api/posts/create.php
use JiFramework\Exceptions\ValidationException;
use JiFramework\Exceptions\UnauthorizedException;

$token = $app->request->getBearerToken();
if (!$token || !ApiToken::where('token', $token)->exists()) {
    throw new UnauthorizedException('Invalid API token.');
}

try {
    $app->validator->make($_POST, [
        'title'   => 'required|max:200',
        'content' => 'required|min:10',
        'status'  => 'required|in:draft,published',
    ])->throw();
} catch (ValidationException $e) {
    $app->json(422, ['errors' => $e->getErrors()]);
}

$id = Post::create([
    'title'      => $_POST['title'],
    'content'    => $_POST['content'],
    'status'     => $_POST['status'],
    'created_at' => date('Y-m-d H:i:s'),
]);

$app->json(201, ['id' => $id, 'message' => 'Post created.']);
Database transaction with full error handling
<?php
use JiFramework\Exceptions\DatabaseException;
use JiFramework\Exceptions\ValidationException;

try {
    $app->validator->make($_POST, [
        'name'  => 'required|max:100',
        'email' => 'required|email',
    ])->throw();

    $app->db->beginTransaction();

    $userId = User::create([
        'name'       => $_POST['name'],
        'email'      => $_POST['email'],
        'created_at' => date('Y-m-d H:i:s'),
    ]);

    Profile::create(['user_id' => $userId]);

    $app->db->commit();

    header('Location: /users/' . $userId);

} catch (ValidationException $e) {
    $errors = $e->getErrors();
    include 'views/form.php';

} catch (DatabaseException $e) {
    $app->db->rollBack();
    $app->logger->error('Registration failed: ' . $e->getMessage());
    $app->abort(500, 'Could not complete registration.');
}
Custom 404 page inline (without abort)
<?php
// pages/product.php
use JiFramework\Exceptions\NotFoundException;

try {
    $product = Product::find($id);

    if (!$product) {
        throw new NotFoundException('Product #' . $id . ' not found.');
    }

    include 'views/product.php';

} catch (NotFoundException $e) {
    http_response_code(404);
    include 'views/product_not_found.php'; // custom inline 404 view
}