Skip to content

Errors and Exceptions

Precious Lyson edited this page Nov 13, 2026 · 2 revisions

The Error Reporting Component is a robust, production-ready error handling system built for the Machinjiri PHP Framework. It provides:

  • Automatic error and exception catching — PHP errors, exceptions, and shutdown errors are all handled
  • Beautiful error pages — Development-friendly error pages with code snippets, stack traces, and environment info
  • Generic production pages — Safe, user-friendly error pages for production
  • Multiple rendering modes — HTML (dev/prod), JSON (API/AJAX), and CLI (console)
  • Throttling — Prevents log spam from repetitive errors
  • Reporting — Email notifications and event triggers
  • Extensibility — Custom exception renderers, categories, and context

Whether you're building a small web app or a large API, this component gives you the visibility you need without sacrificing user experience.


Features

Feature Description
Beautiful Error Pages Modern, responsive, dark-mode-aware error pages
Context Awareness Attach custom data to exceptions for debugging
Multi-format Rendering HTML, JSON, and CLI output
Error Throttling Prevents flooding logs with duplicate errors
Email Reports Automatic error notification emails
Error Categories Semantic categorization (validation, auth, database, etc.)
Custom Renderers Plug in your own rendering logic
Stack Trace Analysis Full and simplified stack traces
Environment Aware Different output for development vs production
Security First Sensitive data hidden in production
Structured Logging Logs with rich contextual information
Testable Dependency injection and static configuration

Throw an exception

use Mlangeni\Machinjiri\Core\Exceptions\MachinjiriException;

throw MachinjiriException::notFound('User with ID 42 not found');

3. Observe the output

  • In CLI → styled console output
  • In a browser (dev) → rich HTML error page
  • In a browser (prod) → generic 500 page
  • API request (Accept: application/json) → JSON response

That's it! You now have production-grade error handling. 🎉


Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                        ErrorHandler                          │
│  (entry point — registers handlers, orchestrates the flow)  │
└──────────────────────────┬──────────────────────────────────┘
                           │
        ┌──────────────────┼──────────────────┐
        ▼                  ▼                  ▼
┌───────────────┐  ┌───────────────┐  ┌────────────────┐
│ ErrorReporter │  │ ErrorRenderer │  │  ErrorLogger   │
│ (report/notify)│ │ (render HTML/ │  │ (structured    │
│               │  │  JSON/CLI)    │  │  logging)      │
└───────┬───────┘  └───────┬───────┘  └────────────────┘
        │                  │
        ▼                  ▼
┌───────────────┐  ┌───────────────┐  ┌────────────────┐
│ ErrorThrottle │  │ ErrorContext  │  │MachinjiriException│
│ (rate limiting)│ │ (shared data) │  │ (base exception)│
└───────────────┘  └───────────────┘  └────────────────┘

Component Responsibilities

Class Responsibility
ErrorHandler Registers set_error_handler, set_exception_handler, and register_shutdown_function. Orchestrates reporting and rendering.
ErrorRenderer Decides how to render: HTML error page, JSON, CLI, or generic page. Contains the entire UI.
ErrorReporter Sends emails, triggers events, delegates to ErrorLogger.
ErrorLogger Writes structured logs via the framework's logger.
ErrorThrottle Prevents log flooding by tracking error hashes.
ErrorContext Static key-value store of shared context across exceptions.
MachinjiriException Base exception with categories, context, and factory methods.

Beginner Guide

Welcome! This section assumes you've never seen this component. We'll walk through the basics step by step.

Step 1: Understanding What This Does

When your PHP application throws an error, PHP would normally print a message or show a white page. This component intercepts those errors and:

  1. Logs them to a file
  2. Renders them (as HTML, JSON, or CLI text)
  3. Optionally emails them to you
  4. Prevents spam by throttling duplicates

Step 2: Registering the Handler

Every PHP application has one entry point (index.php, or bootstrap.php). Register the error handler once, early on:

<?php
// public/index.php

require __DIR__ . '/../vendor/autoload.php';

use Mlangeni\Machinjiri\Core\Container;
use Mlangeni\Machinjiri\Core\Exceptions\ErrorHandler\ErrorHandler;

$app = new Container();

ErrorHandler::register(
    $app,
    displayErrors: true   // ⚠️ set to false in production
);

💡 Tip: displayErrors: true means users see the full error details. Use true only in development.

Step 3: Throwing Your First Exception

The component ships with MachinjiriException, which has convenience factory methods:

use Mlangeni\Machinjiri\Core\Exceptions\MachinjiriException;

// 404
throw MachinjiriException::notFound('Post #17 does not exist');

// 401
throw MachinjiriException::unauthorized();

// 403
throw MachinjiriException::forbidden();

// 422 with validation errors
throw MachinjiriException::validation([
    'email' => 'Email is required',
    'name'  => 'Name must be at least 3 characters'
]);

// 500 (database)
throw MachinjiriException::database('Could not connect to MySQL', $pdoException);

// 429 (rate limit)
throw MachinjiriException::rateLimit('Too many login attempts', retryAfter: 120);

Step 4: Seeing It In Action

Visit a URL that triggers an exception. In development mode, you'll see a full-featured error page with:

  • 🔴 The error message
  • 📍 The exact file & line
  • 🧾 Code snippet around the error
  • 📚 Full stack trace
  • 🛠 Debug tabs (Request, Session, Environment, Routes, Logs)

In production mode, you'll see a friendly "Something went wrong" page.

Step 5: CLI Output

Run a PHP script that throws:

php artisan migrate:run

You'll see:

Machinjiri - CLI Exception
==========================

Error Details
-------------
 [ERROR] Message: Database connection failed
 File: /app/Database.php:42
 Code: 500

Stack Trace   (with -v flag)
-------------------------
#0 /app/Database.php(42): connect()
#1 /app/bootstrap.php(12): ...

Beginner Cheat Sheet

I want to... Do this
Throw a 404 throw MachinjiriException::notFound();
Throw a 401 throw MachinjiriException::unauthorized();
Add context throw new MachinjiriException("Oops", context: ['user_id' => 42]);
Silence an exception $e->setShouldReport(false);
Categorize an exception $e->setCategory('payment');

Intermediate Guide

You've got the basics. Let's deepen your understanding.

Understanding the Exception Lifecycle

When an exception is thrown, this happens:

1. PHP calls `set_exception_handler` → ErrorHandler::handleException()
2. ErrorHandler::report($exception)
     → ErrorReporter::reportException()
         → Triggers "exception.reported" event
         → ErrorLogger::logError() (writes to log)
         → if reportErrors: sends email + triggers "error.notification"
3. ErrorRenderer::shouldRenderException() — should we render?
4. Yes → ErrorRenderer::renderException()
     → MachinjiriException? → $e->renderJson() | $e->show()
     → Other exceptions? → CLI | JSON | HTML | Generic

Registering With Full Configuration

The register() method accepts several options:

ErrorHandler::register(
    app: $app,
    displayErrors: false,
    logFile: '/custom/path/errors.log',
    detailLevel: 2, // 0 = minimal, 1 = +trace, 2 = +environment
    config: [
        'report_errors' => true,
        'throttle' => [
            'max'   => 5,   // Report same error max 5 times
            'decay' => 300, // ...within 300 seconds
        ],
    ]
);

Detail Levels

Level Shows
0 Message, file, line, code snippet
1 + Stack trace
2 + Debug tabs (Request, Session, Environment, Routes, Logs)

Attaching Context to Exceptions

Context is any data that helps you debug:

throw new MachinjiriException(
    message: "Payment processing failed",
    code: 500,
    context: [
        'user_id' => $userId,
        'order_id' => $orderId,
        'amount' => 99.99,
        'gateway' => 'stripe',
    ],
    category: 'payment'
);

You can also add context after creation:

$e = new MachinjiriException("Something broke");
$e->setContext(['step' => 'validate']);
$e->setContext(['retry' => 3]); // merges

Or add global context (visible to all later exceptions):

use Mlangeni\Machinjiri\Core\Exceptions\ErrorHandler\ErrorHandler;

ErrorHandler::addContext(['tenant_id' => 42, 'region' => 'us-east']);

Customizing the Should-Report Behavior

Every exception has a shouldReport() flag. If false, no email is sent (still logged):

$e = new MachinjiriException("Validation failed");
$e->setShouldReport(false); // no email, but still logged
throw $e;

Use this for expected errors (e.g., validation failures) to avoid inbox spam.

Handling Errors vs Exceptions

The component handles three scenarios:

Scenario Hook
PHP warnings/notices set_error_handler → converts to ErrorException
Uncaught exceptions set_exception_handler
Fatal errors register_shutdown_function

Custom Exception Renderer

Want your own output format? Register a custom renderer:

ErrorHandler::setExceptionRenderer(function (\Throwable $e) {
    header('Content-Type: text/plain');
    echo "Oops: " . $e->getMessage();
});

⚠️ Custom renderers bypass all built-in logic (JSON detection, CLI mode, etc.). Use carefully.

JSON API Mode

If your request has Accept: application/json or is an AJAX request, errors are returned as JSON automatically:

{
  "success": false,
  "error": {
    "code": 404,
    "message": "User not found",
    "category": "not_found",
    "timestamp": 1715000000
  }
}

In development, extra debug info is included:

{
  "success": false,
  "error": {
    "code": 404,
    "message": "User not found",
    "category": "not_found",
    "timestamp": 1715000000,
    "debug": {
      "file": "/app/User.php",
      "line": 42,
      "trace": [...],
      "context": { "user_id": 42 }
    }
  }
}

Advanced Guide

Ready for the deep end? Let's explore internals, customization, and production-hardening.

Mail Reporting Setup

For email reports to work, configure:

REPORT_ERRORS=true
APP_SUPPORT_EMAIL=errors@yourdomain.com

The MailManager must be bound in your container:

$app->bind(MailManager::class, fn() => new MailManager(...));

The email includes:

  • Exception class, message, code, file, line
  • Full stack trace
  • Request data (URI, method, IP, user agent)
  • Session ID
  • Memory usage
  • Additional context
  • Selected environment variables

Throttling Internals

ErrorThrottle uses a hash of the error's message + file + line:

$errorKey = md5($errstr . $errfile . $errline);
if (!ErrorThrottle::shouldReportError($errorKey)) {
    return true; // skip
}

Config:

'throttle' => [
    'max' => 10,    // Max reports per key
    'decay' => 60,  // Seconds before counter resets
]

Inspect live stats:

$stats = ErrorHandler::getErrorStats();
// [
//   'total_errors' => 42,
//   'throttled_errors' => 2,
//   'unique_errors' => 15,
//   'throttle_config' => ['max' => 10, 'decay' => 60],
// ]

Reset counters (useful in tests):

ErrorHandler::resetErrorCounters();

Custom Error Categories

Categories affect:

  1. The getSuggestion() message shown to users
  2. Log categorization
  3. Routing to different notification channels (via events)
$e = new MachinjiriException("Card declined");
$e->setCategory('payment');

The built-in categories and their suggestions:

Category Suggestion
validation Please check your input data and try again.
not_found The requested resource does not exist. Check the URL or parameters.
unauthorized Please log in to access this resource.
forbidden You do not have permission to access this resource.
database A database error occurred. Please try again later.
service_unavailable The service is temporarily unavailable. Please try again later.
rate_limit You have made too many requests. Please wait and try again.

You can add your own by editing getSuggestion() (extend the exception).

Events for Integration

Hook into the error lifecycle via events:

$listener->on('exception.reported', function ($payload) {
    // $payload['exception'], $payload['context']
    // → send to Slack, Sentry, etc.
});

$listener->on('error.notification', function ($payload) {
    // $payload['exception'], $payload['level'], $payload['timestamp']
});

error.notification fires only when report_errors is enabled.

Structured Exception Serialization

For logging to external services (Sentry, Datadog, etc.), use toArray():

$e->toArray();
// [
//   'class' => 'MachinjiriException',
//   'message' => '...',
//   'code' => 404,
//   'file' => '/app/User.php',
//   'line' => 42,
//   'category' => 'not_found',
//   'context' => [...],
//   'trace' => [...],
//   'timestamp' => 1715000000,
//   'should_report' => true,
// ]

Or ErrorHandler::dumpException($e) for the framework's richer format:

ErrorHandler::dumpException($e);
// Includes previous exception chain, memory, peak memory...

HTTP Status Detection

Convenience methods help decide behavior:

$e->isClientError();   // 400–499
$e->isServerError();   // 500–599
$e->getHttpStatusText(); // e.g. "Not Found"

Ensure your HTTP response uses the correct status:

$statusCode = $e->getCode();
if ($statusCode < 400 || $statusCode >= 600) {
    $statusCode = 500;
}

(This is exactly what renderJson() does internally.)

Security Considerations

What's Hidden in Production?

When displayErrors: false:

  • ✅ Generic message ("Something went wrong")
  • ❌ No file paths
  • ❌ No stack traces
  • ❌ No environment variables
  • ❌ No code snippets
  • ❌ No request/session data

What's in Logs?

Even in production, logs contain full details. Ensure your log file is:

  • Written to a directory outside public/
  • Not web-accessible
  • Rotated regularly

What's in Emails?

Emails contain everything in logs. If using external mail services (SendGrid, Mailgun), consider redacting sensitive data:

throw new MachinjiriException(
    "Payment failed",
    context: [
        'card_last4' => '4242',  // ✅ safe
        // 'card_cvv' => '...',   // ❌ never log
    ]
);

Custom Exception Classes

Extend MachinjiriException to create domain-specific exceptions:

namespace App\Exceptions;

use Mlangeni\Machinjiri\Core\Exceptions\MachinjiriException;

class PaymentDeclinedException extends MachinjiriException
{
    public function __construct(
        public readonly string $gatewayError,
        public readonly float $amount,
        ?\Throwable $previous = null
    ) {
        parent::__construct(
            message: "Payment declined: {$gatewayError}",
            code: 402, // Payment Required
            previous: $previous,
            context: [
                'gateway_error' => $gatewayError,
                'amount' => $amount,
            ],
            category: 'payment',
        );
    }
}

Registering Ignored Errors

Some PHP notices are harmless. You can suppress them:

// Not directly configurable in register(), but via reflection:
ErrorHandler::setIgnoredErrors([E_DEPRECATED, E_USER_DEPRECATED]);

Actually, we expose the concept but currently not the setter — you can add E_DEPRECATED directly by modifying the ErrorHandler::register() call to include a configurable list. If you need this, extend ErrorHandler:

class MyErrorHandler extends ErrorHandler {
    public static function ignore(array $levels): void {
        (new \ReflectionProperty(self::class, 'ignoredErrors'))
            ->setValue(null, $levels);
    }
}

Extending the Renderer

The renderer is intentionally customizable via three signals:

  1. Custom renderer — setExceptionRenderer(callable)
  2. Custom detail level — setDetailLevel(int)
  3. Custom MachinjiriException subclass — override show() / renderJson()

For a fully custom page, extend ErrorRenderer:

class MyRenderer extends ErrorRenderer
{
    public static function renderErrorPage(\Throwable $e): void
    {
        // Your custom HTML
    }
}

Then register your renderer at the ErrorHandler level (requires modifying register() to accept it — or override the static methods).

Testing Your Integration

In tests, you'll want deterministic output:

public function setUp(): void
{
    ErrorHandler::resetErrorCounters();
    ErrorHandler::clearContext();
}

public function testApiReturns404Json(): void
{
    // Configure a mock request with Accept: application/json
    $request = HttpRequest::createFromGlobals();
    $_SERVER['HTTP_ACCEPT'] = 'application/json';

    $exception = MachinjiriException::notFound('User #42');
    $exception->setShouldReport(false); // no email

    $this->expectOutputString(
        json_encode([
            'success' => false,
            'error' => [
                'code' => 404,
                'message' => 'User #42',
                'category' => 'not_found',
                'timestamp' => time(),
            ],
        ])
    );

    // Call renderJson directly:
    $exception->renderJson('production');
}

Performance Notes

  • Rendering is only triggered on error — zero overhead on happy paths
  • Throttling avoids filesystem I/O when errors repeat
  • Context is a small in-memory array merge
  • Emails are synchronous by default — consider queueing for high-traffic apps

For high-traffic apps, wrap the email logic with a queue:

ErrorHandler::setExceptionRenderer(function ($e) use ($queue) {
    $queue->push(new ReportExceptionJob($e));
    // then render normally
});

API Reference

ErrorHandler

Method Signature Description
register (Container $app, bool $displayErrors = false, ?string $logFile = null, int $detailLevel = 1, array $config = []) Registers all handlers
handleError (int $errno, string $errstr, string $errfile, int $errline): bool PHP error handler
handleException (\Throwable $e): void Uncaught exception handler
handleShutdown (): void Fatal error handler
renderErrorPage (\Throwable $e): void Renders full dev page
renderGenericErrorPage (): void Renders production page
getErrorStats (): array Throttle stats
resetErrorCounters (): void Clears throttle
dumpException (\Throwable $e): array Rich serialization
addContext (array $context): void Adds global context
clearContext (): void Clears global context
httpRequest (): HttpRequest Access the request
getLogFile (): ?string Returns log path

MachinjiriException

Method Description
__construct(string $message, int $code, ?Throwable $prev, array $context, string $category) Constructor
setContext/getContext Manipulate context
setShouldReport/shouldReport Control email reporting
setCategory/getCategory Set semantic category
show() Renders (auto-detects mode)
renderJson(string $env) JSON output
renderAsHtmlSnippet() HTML snippet for embedding
toArray() Structured array
getSuggestion(): ?string Human-friendly hint
isClientError() 4xx check
isServerError() 5xx check
getHttpStatusText() e.g., "Not Found"
Static factories: validation, notFound, unauthorized, forbidden, database, serviceUnavailable, rateLimit

ErrorRenderer

Method Description
shouldRenderException(\Throwable $e): bool Should it be rendered?
renderException(\Throwable $e): void Dispatches to correct mode
renderJson(\Throwable $e, bool $dev) JSON output
renderCli(\Throwable $e): void CLI output
renderErrorPage(\Throwable $e): void Full HTML
renderGenericErrorPage(): void Minimal HTML
shouldRenderAsJson(): bool Request detection
isCli(): bool CLI detection
getEnvironment(): string Env name

ErrorLogger

Method Description
logError(\Throwable $e, array $context) Structured log
getErrorLevel(\Throwable $e): string CRITICAL, DATABASE, ERROR

ErrorReporter

Method Description
reportException(\Throwable $e, array $context) Triggers event, logs, optionally emails

ErrorThrottle

Method Description
shouldReportError(string $key): bool Rate limit check
getErrorStats(): array Current stats
resetErrorCounters(): void Clear counters

ErrorContext

Method Description
addContext(array $ctx): void Merge
clearContext(): void Reset
getContext(): array Read

Configuration

Environment Variables

Variable Purpose Default
APP_ENV production, development, etc. production
APP_DEBUG Enable display of errors false
APP_NAME Shown in error pages Machinjiri
APP_VERSION Shown in error pages 1.0.0
APP_SUPPORT_EMAIL Where error emails go (none)
REPORT_ERRORS Enable email reporting false

Register Config Array

ErrorHandler::register($app, $displayErrors, $logFile, $detailLevel, [
    'report_errors' => true,
    'throttle' => [
        'max' => 10,
        'decay' => 60,
    ],
]);

Best Practices

✅ Do

  • Register the handler as the first thing in your bootstrap.
  • Set displayErrors: false in production.
  • Use semantic categories for exceptions (payment, auth, validation).
  • Attach meaningful context (IDs, values, steps — but never passwords or tokens).
  • Use setShouldReport(false) on expected failures (validation, 404s).
  • Log to a file outside the web root.
  • Set APP_SUPPORT_EMAIL and REPORT_ERRORS=true for critical apps.
  • Rotate logs regularly (e.g., logrotate).
  • Test your error page output in dev and prod before deploying.

❌ Don't

  • Don't echo inside exception constructors.
  • Don't throw MachinjiriException with a raw $exception->getMessage() containing sensitive data.
  • Don't rely on displayErrors: true in production.
  • Don't suppress emails globally — throttle them.
  • Don't catch exceptions and re-throw them without reattaching context.
  • Don't send raw PDO exceptions directly to users.
  • Don't log passwords, tokens, or full card numbers.
  • Don't use @ (error suppression) — this component respects error_reporting, but @ may still hide critical issues.

Troubleshooting

"Nothing appears when I throw an exception"

  1. Make sure ErrorHandler::register() is called before the exception.
  2. Ensure displayErrors: true in dev.
  3. Check that ob_start() isn't interfering — the component clears buffers, but custom output buffering can cause issues.

"My custom renderer isn't being called"

  • setExceptionRenderer only applies to non-MachinjiriException exceptions.
  • For MachinjiriException, override show() in a subclass.

"Emails aren't being sent"

  1. Check REPORT_ERRORS=true.
  2. Check APP_SUPPORT_EMAIL is set.
  3. Ensure MailManager is bound in the container.
  4. Check the log for "Failed to send error report email".

"I see JSON in a browser"

  • The request had Accept: application/json or is AJAX.
  • Remove the header or the X-Requested-With header.

"Logs are huge / filling up disk"

  • Enable report_errors: false if you don't need emails.
  • Lower throttle.max.
  • Increase throttle.decay.
  • Set up log rotation.

"Stack trace shows [internal]"

That's normal — internal PHP functions don't have file/line info.

"Error page renders, but my middleware ran first"

Make sure register() is called before any custom middleware registration.

"Production page is too plain"

Customize by overriding ErrorRenderer::renderGenericErrorPage() with a subclass, or use setExceptionRenderer() with custom logic (bypassing MachinjiriException).


FAQ

Q: Can I use this component without Machinjiri?

The component is tightly coupled to Machinjiri\Machinjiri\Core\Container, HttpRequest, and HttpResponse. If you're not on Machinjiri, you'll need to shim these or fork the component.

Q: Does it work with PHP 7.4?

No — it uses constructor property promotion, union types, and readonly. PHP 8.1+ is required.

Q: Is email reporting synchronous?

Yes. For high-traffic apps, consider wrapping in a queue.

Q: Can I add my own error category?

Yes — extend MachinjiriException and add a case to getSuggestion(). Or add your own suggestion logic in a subclass.

Q: How do I disable the "copy code" button?

Edit ErrorRenderer::getCodeSnippet() to remove it, or use CSS to hide .copy-btn.

Q: Is the error page XSS-safe?

Yes — all user input is htmlspecialchars(..., ENT_QUOTES, 'UTF-8') encoded. However, if you provide your own context via setContext(), ensure it doesn't contain trusted HTML (it's escaped when rendered).

Q: What about non-HTML content types (XML, CSV)?

Override shouldRenderAsJson() in a subclass of ErrorRenderer to detect Accept: application/xml and add a renderXml(). Alternatively, use setExceptionRenderer().

Q: Can I get notified in Slack?

Subscribe to error.notification:

$listener->on('error.notification', function ($payload) use ($slack) {
    $slack->send("#alerts", sprintf(
        "[%s] %s — %s",
        $payload['level'],
        $payload['exception']->getMessage(),
        $payload['timestamp']
    ));
});

Q: How do I test the error page in a local environment?

Set APP_DEBUG=true and APP_ENV=development, then throw any exception. To test the production page, temporarily set APP_DEBUG=false.


Clone this wiki locally