Comprehensive error handling for Forge Kernel. Provides automatic PHP error/exception/shutdown handler registration, debug and production error pages, error logging with rate limiting, and security features for sensitive data masking.
ForgeErrorHandler is a core module that provides comprehensive error handling for Forge Kernel applications. It automatically registers PHP error, exception, and shutdown handlers to catch and handle all errors gracefully, with different responses for debug and production environments.
storage/logs/errors.logCore Module: ForgeErrorHandler is a core module (core: true, order: 2), automatically loaded and initialized during Bootstrap. No manual installation is required.
ForgeErrorHandler uses PHP's native error handling mechanisms to catch all errors, exceptions, and fatal errors. It provides different responses based on the application's debug mode and includes comprehensive logging and security features.
The module automatically registers three PHP handlers on initialization:
The handler detects the application's debug mode via Environment::isDebugEnabled():
Each error is assigned a unique fingerprint based on:
This fingerprint is used for rate limiting to prevent log spam from duplicate errors.
Sensitive data is automatically masked in:
ForgeErrorHandler is a core module that is automatically loaded and initialized during Bootstrap. No manual installation is required.
The module is automatically initialized in Bootstrap::setupErrorHandling():
// engine/Core/Bootstrap/Bootstrap.php
if (file_exists(BASE_PATH . "/modules/ForgeErrorHandler/src/ForgeErrorHandler.php")) {
if (class_exists(\App\Modules\ForgeErrorHandler\Services\ForgeErrorHandlerService::class)) {
$container->get(\App\Modules\ForgeErrorHandler\Services\ForgeErrorHandlerService::class);
}
}
The module is marked as a core module:
#[Module(
name: 'ForgeErrorHandler',
version: '0.1.2',
description: 'An error handler by Forge',
order: 2,
core: true // Core module, automatically loaded
)]
ForgeErrorHandler automatically registers three PHP handlers to catch all types of errors and exceptions.
Converts PHP errors (warnings, notices, etc.) to exceptions:
set_error_handler([$this, 'phpErrorHandler']);
public function phpErrorHandler(int $severity, string $message, string $file, int $line): bool
{
if (!(error_reporting() & $severity)) {
return true; // Error reporting disabled for this severity
}
throw new \ErrorException($message, 0, $severity, $file, $line);
}
This ensures all PHP errors are handled consistently as exceptions.
Handles all uncaught exceptions:
set_exception_handler([$this, 'phpExceptionHandler']);
public function phpExceptionHandler(Throwable $e): void
{
try {
$request = Request::createFromGlobals();
$this->handle($e, $request)->send();
} catch (Throwable $fatal) {
$this->emergencyOutput($fatal);
} finally {
exit(1);
}
}
This is the main entry point for handling exceptions. It logs the error, builds an appropriate response, and exits the script.
Catches fatal errors that occur during script shutdown:
register_shutdown_function([$this, 'phpShutdownHandler']);
public function phpShutdownHandler(): void
{
$error = error_get_last();
if ($error === null) {
return; // No error occurred
}
$fatals = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR];
if (!in_array($error['type'], $fatals, true)) {
return; // Not a fatal error
}
$this->phpExceptionHandler(
new \ErrorException(
$error['message'],
$error['type'],
0,
$error['file'],
$error['line']
)
);
}
This catches fatal errors that cannot be caught by the exception handler, such as parse errors or memory exhaustion.
When an error occurs, ForgeErrorHandler follows a consistent flow to log the error and generate an appropriate response.
storage/logs/errors.log)public function handle(Throwable $e, Request $request): Response
{
// Log the error
$this->logThrowable($e, $request);
// Return appropriate response based on debug mode
return $this->debug
? $this->buildDebugResponse($e, $request)
: $this->buildProductionResponse();
}
When APP_DEBUG is true, ForgeErrorHandler displays a detailed error page with comprehensive debugging information.
$data = [
'error' => [
'message' => $e->getMessage(),
'type' => get_class($e),
'code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $trace, // Filtered trace with code snippets
],
'request' => [
'method' => $request->getMethod(),
'uri' => $request->getUri(),
'headers' => $request->getHeaders(),
'parameters' => $this->mask($request->serverParams),
'query' => $this->mask($request->queryParams),
],
'session' => $this->mask($_SESSION ?? []),
'environment' => $this->mask($_ENV),
];
The debug page extracts code snippets from the stack trace:
private function extractSnippets(Throwable $e): array
{
$out = [];
foreach ($e->getTrace() as $frame) {
$out[] = $this->codeSnippet(
$frame['file'] ?? $e->getFile(),
$frame['line'] ?? $e->getLine()
);
}
return $out;
}
private function codeSnippet(string $file, int $line, int $context = 5): array
{
$real = realpath($file);
if (!$real || !str_starts_with($real, $this->basePath) || !is_file($real)) {
return [];
}
$lines = file($real, FILE_IGNORE_NEW_LINES);
$start = max(1, $line - $context);
$end = min(count($lines), $line + $context);
$slice = [];
for ($i = $start; $i <= $end; $i++) {
$slice[$i] = $lines[$i - 1];
}
return $slice;
}
Code snippets show 5 lines before and after the error line, with the error line highlighted. Only files within BASE_PATH are shown for security.
When APP_DEBUG is false, ForgeErrorHandler displays a user-friendly error page without exposing sensitive information.
The production error page is simple and user-friendly:
private function buildProductionResponse(): Response
{
return new Response($this->renderUserFriendlyPage(), 500);
}
private function renderUserFriendlyPage(): string
{
return <<<HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Error</title>
<style>body{font-family:system-ui,sans-serif;background:#f8fafc;padding:2rem}.box{max-width:600px;margin:2rem auto;padding:2rem;background:#fff;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.1)}</style>
</head>
<body>
<div class="box">
<h1>Something went wrong</h1>
<p>We have been notified. Please try again later.</p>
<p><a href="/">Go home</a></p>
</div>
</body>
</html>
HTML;
}
Security: The production error page does not expose any sensitive information, stack traces, or file paths. All errors are still logged for debugging purposes.
ForgeErrorHandler logs all errors with comprehensive context information. It supports both PSR-3 loggers and file-based logging.
Each error log includes comprehensive context:
$context = [
'fingerprint' => $fingerprint, // Unique error fingerprint
'request_id' => $reqId, // Request ID (from header or generated)
'exception' => get_class($e), // Exception class name
'code' => $e->getCode(), // Exception code
'file' => $e->getFile(), // File where error occurred
'line' => $e->getLine(), // Line number
'trace' => $e->getTraceAsString(), // Full stack trace
'memory' => memory_get_peak_usage(true), // Peak memory usage
'duration_ms' => round((microtime(true) - $start) * 1000, 2), // Request duration
'source' => $source, // IP, method, URI (or CLI command)
'sapi' => PHP_SAPI, // Server API (cli, apache2handler, etc.)
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '', // User agent
'session' => $this->mask($_SESSION ?? []), // Session data (masked)
'get' => $this->mask($request->queryParams), // GET params (masked)
'post' => $this->mask($request->postData), // POST params (masked)
];
If a PSR-3 compatible logger is provided via dependency injection, it will be used:
public function __construct(?object $logger = null)
{
$this->logger = $logger && $this->implementsPsr3($logger) ? $logger : null;
}
private function logThrowable(Throwable $e, Request $request): void
{
$fingerprint = $this->fingerprint($e);
$context = $this->buildContext($e, $request, $fingerprint);
if ($this->isRateLimited($fingerprint, 300)) {
return; // Skip logging if rate limited
}
if ($this->logger) {
$this->logger->error($e->getMessage(), $context);
} else {
$this->fileLog($context);
}
}
If no PSR-3 logger is provided, errors are logged to a file:
private function fileLog(array $context): void
{
$dir = dirname($this->logFile);
is_dir($dir) || mkdir($dir, 0775, true);
$line = sprintf(
"[%s] %s [%s] %s – %s:%d | %s\n",
date('Y-m-d H:i:s'), // Timestamp
$context['request_id'], // Request ID
$context['fingerprint'], // Error fingerprint
$context['exception'], // Exception class
$context['file'], // File path
$context['line'], // Line number
str_replace(["\n", "\r"], ' ', $context['trace']) // Stack trace (single line)
);
file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
}
Log file location: storage/logs/errors.log
Log format: [timestamp] request_id [fingerprint] exception – file:line | trace
ForgeErrorHandler automatically masks sensitive data to prevent exposure in error logs and debug pages.
The following keys are automatically masked (replaced with *****):
passwordtokensecretauthorizationcookieprivate array $hiddenKeys = [
'password',
'token',
'secret',
'authorization',
'cookie'
];
private function mask(array $input): array
{
array_walk_recursive($input, function (&$v, $k) {
if (is_string($k) && in_array(strtolower($k), $this->hiddenKeys, true)) {
$v = '*****';
}
});
return $input;
}
Masking is applied recursively to nested arrays and affects:
You can customize the list of hidden keys by modifying the $hiddenKeys property in the service class:
private array $hiddenKeys = [
'password',
'token',
'secret',
'authorization',
'cookie',
'api_key', // Add custom keys
'private_key', // Add custom keys
];
ForgeErrorHandler creates unique fingerprints for each error to enable duplicate detection and rate limiting.
The fingerprint is generated from:
private function fingerprint(Throwable $e): string
{
return substr(
md5($e->getFile() . ':' . $e->getLine() . ':' . get_class($e)),
0,
8
);
}
The fingerprint is an 8-character MD5 hash, providing a unique identifier for each unique error location and type.
ForgeErrorHandler implements rate limiting to prevent log spam from duplicate errors.
private array $rateLimitMap = [];
private function isRateLimited(string $fingerprint, int $seconds): bool
{
$now = time();
if (isset($this->rateLimitMap[$fingerprint])
&& ($now - $this->rateLimitMap[$fingerprint]) < $seconds
) {
return true; // Rate limited
}
$this->rateLimitMap[$fingerprint] = $now;
return false; // Not rate limited
}
The default rate limit is 300 seconds (5 minutes). This means the same error (same fingerprint) will only be logged once every 5 minutes.
if ($this->isRateLimited($fingerprint, 300)) {
return; // Skip logging if rate limited
}
Rate limiting uses an in-memory map that is reset on each request. This is suitable for preventing log spam within a single request cycle.
Note: For persistent rate limiting across requests, consider implementing a database or cache-based solution.
ForgeErrorHandler extracts code snippets from the stack trace to provide context around error lines in debug mode.
private function codeSnippet(string $file, int $line, int $context = 5): array
{
$real = realpath($file);
// Security: Only show files within BASE_PATH
if (!$real || !str_starts_with($real, $this->basePath) || !is_file($real)) {
return [];
}
$lines = file($real, FILE_IGNORE_NEW_LINES);
if ($lines === false) {
return [];
}
$start = max(1, $line - $context); // 5 lines before
$end = min(count($lines), $line + $context); // 5 lines after
$slice = [];
for ($i = $start; $i <= $end; $i++) {
$slice[$i] = $lines[$i - 1];
}
return $slice;
}
BASE_PATH are shownDefault context is 5 lines before and after the error line. This provides enough context to understand the error without overwhelming the debug page.
ForgeErrorHandler captures comprehensive request context for error logging and debugging.
Each request is assigned a unique ID:
$reqId = $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(8));
The request ID is either taken from the X-Request-ID header (if present) or generated as a random 16-character hex string.
Source information varies based on SAPI:
$source = PHP_SAPI === 'cli'
? ['cli' => implode(' ', $_SERVER['argv'] ?? [])]
: [
'ip' => $this->clientIp(),
'method' => $request->getMethod(),
'uri' => $request->getUri()
];
Client IP is detected with proxy support:
private function clientIp(): string
{
return $_SERVER['HTTP_X_FORWARDED_FOR']
?? $_SERVER['HTTP_X_REAL_IP']
?? $_SERVER['REMOTE_ADDR']
?? '0.0.0.0';
}
ForgeErrorHandler integrates with the Forge Kernel Bootstrap process and can accept optional PSR-3 loggers.
The error handler is automatically initialized in Bootstrap::setupErrorHandling():
// engine/Core/Bootstrap/Bootstrap.php
public function setupErrorHandling(Container $container): void
{
ini_set(
"display_errors",
Environment::getInstance()->isDevelopment() ? "1" : "0",
);
error_reporting(E_ALL);
if (file_exists(BASE_PATH . "/modules/ForgeErrorHandler/src/ForgeErrorHandler.php")) {
if (class_exists(\App\Modules\ForgeErrorHandler\Services\ForgeErrorHandlerService::class)) {
$container->get(\App\Modules\ForgeErrorHandler\Services\ForgeErrorHandlerService::class);
}
}
}
You can inject a PSR-3 compatible logger via dependency injection:
// In your service provider or module
$container->bind(
\App\Modules\ForgeErrorHandler\Services\ForgeErrorHandlerService::class,
function (Container $container) {
$logger = $container->get(Psr\Log\LoggerInterface::class);
return new \App\Modules\ForgeErrorHandler\Services\ForgeErrorHandlerService($logger);
}
);
The logger must implement PSR-3 methods: error() and debug().
The module is registered as a core module:
#[Module(
name: 'ForgeErrorHandler',
version: '0.1.2',
description: 'An error handler by Forge',
order: 2,
core: true // Core module, automatically loaded
)]
#[Service]
#[Provides(interface: ForgeErrorHandlerInterface::class, version: '0.1.2')]
final class ForgeErrorHandlerModule
{
public function register(Container $container): void
{
$container->bind(
ForgeErrorHandlerInterface::class,
ForgeErrorHandlerService::class
);
}
}
ForgeErrorHandler automatically catches all errors, exceptions, and fatal errors. No manual configuration is needed:
// This exception will be automatically caught and handled
throw new \Exception('Something went wrong');
// This error will be converted to an exception and handled
trigger_error('This is an error', E_USER_ERROR);
// Fatal errors are also caught
// (e.g., calling undefined function, memory exhaustion, etc.)
use Psr\Log\LoggerInterface;
use App\Modules\ForgeErrorHandler\Services\ForgeErrorHandlerService;
// In your service provider
$container->bind(ForgeErrorHandlerService::class, function (Container $container) {
$logger = $container->get(LoggerInterface::class);
return new ForgeErrorHandlerService($logger);
});
Errors are logged to: storage/logs/errors.log
# View recent errors
tail -f storage/logs/errors.log
# Search for specific errors
grep "Exception" storage/logs/errors.log
# Count errors by fingerprint
grep -o "\[.*\]" storage/logs/errors.log | sort | uniq -c
Set APP_DEBUG in your .env file:
# Development - shows detailed error page
APP_DEBUG=true
# Production - shows user-friendly error page
APP_DEBUG=false
APP_DEBUG=false)storage/logs/errors.log$hiddenKeysrenderUserFriendlyPage() methodAPP_DEBUG=true in production