, request: array, environment: array, queries: array} $data */ public static function renderDev(array $data): string { $exc = $data['exception']; $req = $data['request']; $env = $data['environment']; $qry = $data['queries']; $requestId = self::esc((string) ($req['request_id'] ?? 'unknown')); $statusCode = http_response_code() ?: 500; $statusText = self::statusText($statusCode); $queryCount = $qry['count'] ?? count($qry['queries'] ?? []); $css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css'); $js = self::loadAsset(__DIR__ . '/../../web/js/components/app-error-debug.js'); $excClass = self::esc($exc['class'] ?? 'Exception'); $excMessage = self::esc($exc['message'] ?? 'An error occurred'); $excFile = self::esc(self::shortenPath($exc['file'] ?? '')); $excLine = self::esc((string) ($exc['line'] ?? '?')); $exceptionPanel = self::renderExceptionPanel($exc); $requestPanel = self::renderRequestPanel($req); $environmentPanel = self::renderEnvironmentPanel($env); $queryPanel = self::renderQueryPanel($qry); return << {$statusCode} {$statusText}
{$statusCode} {$statusText}
Request ID: {$requestId}
{$excClass}
{$excMessage}
in {$excFile} on line {$excLine}
{$exceptionPanel}
{$requestPanel}
{$environmentPanel}
{$queryPanel}
HTML; } /** * Renders a clean production error page with request ID only. * Uses the same design tokens as the dev page for visual consistency. */ public static function renderProd(string $requestId): string { $statusCode = http_response_code() ?: 500; $requestId = self::esc($requestId); $css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css'); return << {$statusCode} - Error
{$statusCode}
An unexpected error occurred. If the problem persists, please contact support with the reference below.
{$requestId}
Back to start
HTML; } private static function renderExceptionPanel(array $exceptionData): string { $html = '
Stack Trace
'; $html .= '
    '; $frames = $exceptionData['trace'] ?? []; foreach ($frames as $i => $frame) { $file = self::esc(self::shortenPath($frame['file'] ?? '[internal]')); $line = self::esc((string) ($frame['line'] ?? '?')); $function = ''; if (isset($frame['class'])) { $function = self::esc($frame['class'] . '::' . ($frame['function'] ?? '?')); } elseif (isset($frame['function'])) { $function = self::esc($frame['function']); } $codeHtml = ''; $snippet = $frame['code_snippet'] ?? []; if (!empty($snippet)) { $highlightLine = (int) ($frame['line'] ?? 0); $codeHtml = self::renderCodeBlock($snippet, $highlightLine); } $html .= <<
    #{$i} {$file}:{$line} {$function}
    {$codeHtml}
    FRAME; } $html .= '
'; // Chained exceptions. if (isset($exceptionData['previous'])) { $prev = $exceptionData['previous']; $html .= '
'; $html .= '
Caused by
'; $html .= '
' . self::esc($prev['class'] ?? 'Exception') . '
'; $html .= '
' . self::esc($prev['message'] ?? '') . '
'; $html .= '
in ' . self::esc(self::shortenPath($prev['file'] ?? '')) . ' on line ' . self::esc((string) ($prev['line'] ?? '?')) . '
'; $html .= '
'; } return $html; } /** * @param array $lines */ private static function renderCodeBlock(array $lines, int $highlightLine): string { $html = ''; foreach ($lines as $lineNo => $code) { $isHighlight = ($lineNo === $highlightLine) ? ' highlight' : ''; $lineNoEsc = self::esc((string) $lineNo); $codeEsc = self::esc($code); $html .= ""; } $html .= '
{$lineNoEsc}{$codeEsc}
'; return $html; } private static function renderRequestPanel(array $requestData): string { $html = ''; $html .= self::renderInfoSection('Request', [ 'Method' => $requestData['method'] ?? 'GET', 'URL' => $requestData['url'] ?? '', 'IP' => $requestData['ip'] ?? '', 'User Agent' => $requestData['user_agent'] ?? '', 'Channel' => $requestData['channel'] ?? 'web', ]); $headers = $requestData['headers'] ?? []; if (!empty($headers)) { $html .= self::renderInfoSection('Headers', $headers); } $get = $requestData['get'] ?? []; if (!empty($get)) { $html .= self::renderInfoSection('GET Parameters', array_map( fn ($v): string => is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_SLASHES), $get, )); } $post = $requestData['post'] ?? []; if (!empty($post)) { $html .= self::renderInfoSection('POST Parameters', array_map( fn ($v): string => is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_SLASHES), $post, )); } $session = $requestData['session_keys'] ?? []; if (!empty($session)) { $html .= self::renderInfoSection('Session (keys + types)', $session); } $html .= self::renderInfoSection('Context', array_filter([ 'User ID' => $requestData['user_id'] ?? null, 'Tenant ID' => $requestData['tenant_id'] ?? null, ], fn ($v): bool => $v !== null)); return $html; } private static function renderEnvironmentPanel(array $envData): string { $html = ''; $html .= self::renderInfoSection('PHP', [ 'Version' => $envData['php_version'] ?? PHP_VERSION, 'SAPI' => $envData['php_sapi'] ?? PHP_SAPI, 'Memory (current)' => self::formatBytes($envData['memory_current'] ?? 0), 'Memory (peak)' => self::formatBytes($envData['memory_peak'] ?? 0), ]); $env = $envData['env'] ?? []; if (!empty($env)) { $html .= self::renderInfoSection('Application', $env); } $modules = $envData['modules'] ?? []; if (!empty($modules)) { $html .= self::renderInfoSection('Active Modules', array_combine( array_map(fn ($m): string => (string) $m, $modules), array_fill(0, count($modules), 'active'), ) ?: []); } $extensions = $envData['extensions'] ?? []; if (!empty($extensions)) { $html .= '
Loaded Extensions (' . count($extensions) . ')
'; $html .= '
'; $html .= self::esc(implode(', ', $extensions)); $html .= '
'; } return $html; } private static function renderQueryPanel(array $queryData): string { if (!($queryData['available'] ?? false)) { return '
Query tracking is not available (Debugger disabled or not initialized).
'; } $queries = $queryData['queries'] ?? []; if (empty($queries)) { return '
No queries were executed before the error occurred.
'; } $html = '
Queries (' . count($queries) . ')
'; foreach ($queries as $i => $query) { $sql = is_string($query) ? $query : ($query[0] ?? $query['query'] ?? '?'); $duration = ''; if (is_array($query) && isset($query[1])) { $durationMs = round((float) $query[1] * 1000, 2); $duration = $durationMs . ' ms'; } $html .= '
'; $html .= '
'; $html .= '' . self::esc(self::truncate($sql, 120)) . ''; if ($duration !== '') { $html .= '' . self::esc($duration) . ''; } $html .= '
'; $html .= '
' . self::esc($sql) . '
'; $html .= '
'; } $html .= '
'; return $html; } /** * @param array $items */ private static function renderInfoSection(string $title, array $items): string { if (empty($items)) { return ''; } $html = '
' . self::esc($title) . '
'; $html .= ''; foreach ($items as $key => $value) { $keyEsc = self::esc((string) $key); $valueStr = (string) $value; $isRedacted = ($valueStr === '[REDACTED]'); $valueEsc = $isRedacted ? '[REDACTED]' : self::esc($valueStr); $html .= ""; } $html .= '
{$keyEsc}{$valueEsc}
'; return $html; } private static function loadAsset(string $path): string { $resolved = realpath($path); if ($resolved === false || !is_file($resolved)) { return '/* asset not found: ' . basename($path) . ' */'; } return (string) file_get_contents($resolved); } public static function esc(string $value): string { return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } private static function shortenPath(string $path): string { $root = getcwd(); if ($root !== false && str_starts_with($path, $root . '/')) { return substr($path, strlen($root) + 1); } return $path; } private static function statusText(int $code): string { return match ($code) { 400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden', 404 => 'Not Found', 405 => 'Method Not Allowed', 408 => 'Request Timeout', 422 => 'Unprocessable Entity', 429 => 'Too Many Requests', 500 => 'Internal Server Error', 502 => 'Bad Gateway', 503 => 'Service Unavailable', default => 'Error', }; } private static function formatBytes(int $bytes): string { if ($bytes < 1024) { return $bytes . ' B'; } if ($bytes < 1024 * 1024) { return round($bytes / 1024, 1) . ' KB'; } return round($bytes / (1024 * 1024), 1) . ' MB'; } private static function truncate(string $text, int $length): string { if (mb_strlen($text) <= $length) { return $text; } return mb_substr($text, 0, $length) . '...'; } }