Repository navigation
Errors and Exceptions
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.
| 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');- 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. 🎉
┌─────────────────────────────────────────────────────────────┐
│ 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)│
└───────────────┘ └───────────────┘ └────────────────┘
| 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. |
Welcome! This section assumes you've never seen this component. We'll walk through the basics step by step.
When your PHP application throws an error, PHP would normally print a message or show a white page. This component intercepts those errors and:
- Logs them to a file
- Renders them (as HTML, JSON, or CLI text)
- Optionally emails them to you
- Prevents spam by throttling duplicates
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: truemeans users see the full error details. Usetrueonly in development.
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);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.
Run a PHP script that throws:
php artisan migrate:runYou'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): ...
| 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'); |
You've got the basics. Let's deepen your understanding.
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
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
],
]
);| Level | Shows |
|---|---|
0 |
Message, file, line, code snippet |
1 |
+ Stack trace |
2 |
+ Debug tabs (Request, Session, Environment, Routes, Logs) |
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]); // mergesOr add global context (visible to all later exceptions):
use Mlangeni\Machinjiri\Core\Exceptions\ErrorHandler\ErrorHandler;
ErrorHandler::addContext(['tenant_id' => 42, 'region' => 'us-east']);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.
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 |
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.
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 }
}
}
}Ready for the deep end? Let's explore internals, customization, and production-hardening.
For email reports to work, configure:
REPORT_ERRORS=true
APP_SUPPORT_EMAIL=errors@yourdomain.comThe 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
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();Categories affect:
- The
getSuggestion()message shown to users - Log categorization
- 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).
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.
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...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.)
When displayErrors: false:
- ✅ Generic message ("Something went wrong")
- ❌ No file paths
- ❌ No stack traces
- ❌ No environment variables
- ❌ No code snippets
- ❌ No request/session data
Even in production, logs contain full details. Ensure your log file is:
- Written to a directory outside
public/ - Not web-accessible
- Rotated regularly
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
]
);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',
);
}
}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);
}
}The renderer is intentionally customizable via three signals:
-
Custom renderer —
setExceptionRenderer(callable) -
Custom detail level —
setDetailLevel(int) -
Custom
MachinjiriExceptionsubclass — overrideshow()/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).
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');
}- 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
});| 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 |
| 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
|
| 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 |
| Method | Description |
|---|---|
logError(\Throwable $e, array $context) |
Structured log |
getErrorLevel(\Throwable $e): string |
CRITICAL, DATABASE, ERROR
|
| Method | Description |
|---|---|
reportException(\Throwable $e, array $context) |
Triggers event, logs, optionally emails |
| Method | Description |
|---|---|
shouldReportError(string $key): bool |
Rate limit check |
getErrorStats(): array |
Current stats |
resetErrorCounters(): void |
Clear counters |
| Method | Description |
|---|---|
addContext(array $ctx): void |
Merge |
clearContext(): void |
Reset |
getContext(): array |
Read |
| 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 |
ErrorHandler::register($app, $displayErrors, $logFile, $detailLevel, [
'report_errors' => true,
'throttle' => [
'max' => 10,
'decay' => 60,
],
]);- Register the handler as the first thing in your bootstrap.
- Set
displayErrors: falsein 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_EMAILandREPORT_ERRORS=truefor critical apps. - Rotate logs regularly (e.g.,
logrotate). - Test your error page output in dev and prod before deploying.
-
Don't
echoinside exception constructors. -
Don't throw
MachinjiriExceptionwith a raw$exception->getMessage()containing sensitive data. -
Don't rely on
displayErrors: truein 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 respectserror_reporting, but@may still hide critical issues.
- Make sure
ErrorHandler::register()is called before the exception. - Ensure
displayErrors: truein dev. - Check that
ob_start()isn't interfering — the component clears buffers, but custom output buffering can cause issues.
-
setExceptionRendereronly applies to non-MachinjiriExceptionexceptions. - For
MachinjiriException, overrideshow()in a subclass.
- Check
REPORT_ERRORS=true. - Check
APP_SUPPORT_EMAILis set. - Ensure
MailManageris bound in the container. - Check the log for
"Failed to send error report email".
- The request had
Accept: application/jsonor is AJAX. - Remove the header or the
X-Requested-Withheader.
- Enable
report_errors: falseif you don't need emails. - Lower
throttle.max. - Increase
throttle.decay. - Set up log rotation.
That's normal — internal PHP functions don't have file/line info.
Make sure register() is called before any custom middleware registration.
Customize by overriding ErrorRenderer::renderGenericErrorPage() with a subclass, or use setExceptionRenderer() with custom logic (bypassing MachinjiriException).
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.
Machinjiri Docs
- Home
- Learn
- Build
-
Tutorials
- Routing
- Controllers and Middleware
- Views and Templates
- Database ORM
- Eloquent Database ORM
- Errors and Exceptions
- Forms and Validation
- Http
- Events and Logging
- Services and DI
- Security
- Authentication
- Caching
- File System
- Date and Time
- Debugging
- Queue and Jobs
- Task Scheduling
- Transport (Mail, SMS)
- LDAP
- Notifications
- Webhooks
- UUID Generations
- Terminal
- Helper Function
- Reference