, 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'] ?? self::tr('Exception')); $excMessage = self::esc((string) ($exc['message'] ?? self::tr('An error occurred'))); $excLocation = self::esc(self::tr( 'in %s on line %s', self::shortenPath((string) ($exc['file'] ?? '')), (string) ($exc['line'] ?? '?') )); $exceptionPanel = self::renderExceptionPanel($exc); $requestPanel = self::renderRequestPanel($req); $environmentPanel = self::renderEnvironmentPanel($env); $queryPanel = self::renderQueryPanel($qry); $lang = self::esc(self::htmlLang()); $statusTextEsc = self::esc($statusText); $requestIdLabel = self::esc(self::tr('Request ID')); $copyRequestIdTitle = self::esc(self::tr('Copy request ID')); $tabsLabel = self::esc(self::tr('Error details')); $tabException = self::esc(self::tr('Exception')); $tabRequest = self::esc(self::tr('Request')); $tabEnvironment = self::esc(self::tr('Environment')); $tabQueries = self::esc(self::tr('Queries')); return << {$statusCode} {$statusTextEsc}
{$statusCode} {$statusTextEsc}
{$requestIdLabel}: {$requestId}
{$excClass}
{$excMessage}
{$excLocation}
{$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'); $js = self::loadAsset(__DIR__ . '/../../web/js/components/app-error-debug.js'); $lang = self::esc(self::htmlLang()); $errorText = self::esc(self::tr('Error')); $requestIdLabel = self::esc(self::tr('Request ID')); $copyRequestIdTitle = self::esc(self::tr('Copy request ID')); $message = self::esc(self::tr('An unexpected error occurred. If the problem persists, please contact support with the reference below.')); $backLabel = self::esc(self::tr('Back to start')); return << {$statusCode} - {$errorText}
{$statusCode}
{$message}
{$requestIdLabel}: {$requestId}
{$backLabel}
HTML; } private static function renderExceptionPanel(array $exceptionData): string { $html = '
' . self::esc(self::tr('Stack Trace')) . '
'; $html .= '
    '; $frames = $exceptionData['trace'] ?? []; foreach ($frames as $i => $frame) { $file = self::esc(self::shortenPath((string) ($frame['file'] ?? '[internal]'))); $line = self::esc((string) ($frame['line'] ?? '?')); $function = ''; if (isset($frame['class'])) { $function = self::esc((string) $frame['class'] . '::' . (string) ($frame['function'] ?? '?')); } elseif (isset($frame['function'])) { $function = self::esc((string) $frame['function']); } $codeHtml = ''; $snippet = $frame['code_snippet'] ?? []; if (!empty($snippet)) { $highlightLine = (int) ($frame['line'] ?? 0); $codeHtml = self::renderCodeBlock($snippet, $highlightLine); } $codeId = 'frame-code-' . $i; $toggleId = 'frame-toggle-' . $i; $isOpen = ($i === 0); $expanded = $isOpen ? 'true' : 'false'; $openClass = $isOpen ? ' open' : ''; $html .= <<
    {$codeHtml}
    FRAME; } $html .= '
'; // Chained exceptions. if (isset($exceptionData['previous'])) { $prev = $exceptionData['previous']; $html .= '
'; $html .= '
' . self::esc(self::tr('Caused by')) . '
'; $html .= '
' . self::esc((string) ($prev['class'] ?? self::tr('Exception'))) . '
'; $html .= '
' . self::esc((string) ($prev['message'] ?? '')) . '
'; $html .= '
' . self::esc(self::tr( 'in %s on line %s', self::shortenPath((string) ($prev['file'] ?? '')), (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(self::tr('Request'), [ self::tr('Method') => $requestData['method'] ?? 'GET', self::tr('URL') => $requestData['url'] ?? '', self::tr('IP') => $requestData['ip'] ?? '', self::tr('User Agent') => $requestData['user_agent'] ?? '', self::tr('Channel') => $requestData['channel'] ?? 'web', ]); $headers = $requestData['headers'] ?? []; if (!empty($headers)) { $html .= self::renderInfoSection(self::tr('Headers'), $headers); } $get = $requestData['get'] ?? []; if (!empty($get)) { $html .= self::renderInfoSection(self::tr('GET Parameters'), array_map( fn ($v): string => is_string($v) ? $v : (string) json_encode($v, JSON_UNESCAPED_SLASHES), $get, )); } $post = $requestData['post'] ?? []; if (!empty($post)) { $html .= self::renderInfoSection(self::tr('POST Parameters'), array_map( fn ($v): string => is_string($v) ? $v : (string) json_encode($v, JSON_UNESCAPED_SLASHES), $post, )); } $session = $requestData['session_keys'] ?? []; if (!empty($session)) { $html .= self::renderInfoSection(self::tr('Session (keys + types)'), $session); } $html .= self::renderInfoSection(self::tr('Context'), array_filter([ self::tr('User ID') => $requestData['user_id'] ?? null, self::tr('Tenant ID') => $requestData['tenant_id'] ?? null, ], fn ($v): bool => $v !== null)); return $html; } private static function renderEnvironmentPanel(array $envData): string { $html = ''; $html .= self::renderInfoSection(self::tr('PHP'), [ self::tr('Version') => $envData['php_version'] ?? PHP_VERSION, self::tr('SAPI') => $envData['php_sapi'] ?? PHP_SAPI, self::tr('Memory (current)') => self::formatBytes((int) ($envData['memory_current'] ?? 0)), self::tr('Memory (peak)') => self::formatBytes((int) ($envData['memory_peak'] ?? 0)), ]); $env = $envData['env'] ?? []; if (!empty($env)) { $html .= self::renderInfoSection(self::tr('Application'), $env); } $modules = $envData['modules'] ?? []; if (!empty($modules)) { $html .= self::renderInfoSection(self::tr('Active Modules'), array_combine( array_map(fn ($m): string => (string) $m, $modules), array_fill(0, count($modules), self::tr('Active')), ) ?: []); } $extensions = $envData['extensions'] ?? []; if (!empty($extensions)) { $html .= '
' . self::esc(self::tr('Loaded Extensions (%d)', count($extensions))) . '
'; $html .= '
'; $html .= self::esc(implode(', ', $extensions)); $html .= '
'; } return $html; } private static function renderQueryPanel(array $queryData): string { if (!($queryData['available'] ?? false)) { return '
' . self::esc(self::tr('Query tracking is not available (Debugger disabled or not initialized).')) . '
'; } $queries = $queryData['queries'] ?? []; if (empty($queries)) { return '
' . self::esc(self::tr('No queries were executed before the error occurred.')) . '
'; } $html = '
' . self::esc(self::tr('Queries (%d)', 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'; } $querySqlId = 'query-sql-' . $i; $queryToggleId = 'query-toggle-' . $i; $html .= '
'; $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 => self::tr('Bad Request'), 401 => self::tr('Unauthorized'), 403 => self::tr('Forbidden'), 404 => self::tr('Not Found'), 405 => self::tr('Method Not Allowed'), 408 => self::tr('Request Timeout'), 422 => self::tr('Unprocessable Entity'), 429 => self::tr('Too Many Requests'), 500 => self::tr('Internal Server Error'), 502 => self::tr('Bad Gateway'), 503 => self::tr('Service Unavailable'), default => self::tr('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) . '...'; } private static function tr(string $text, mixed ...$args): string { if (function_exists('t')) { try { /** @var string $translated */ $translated = t($text, ...$args); return $translated; } catch (\Throwable) { // Rendering must stay resilient, even if translation bootstrap failed. } } if ($args === []) { return $text; } return (string) @vsprintf($text, $args); } private static function htmlLang(): string { $locale = trim((string) (I18n::$locale ?? I18n::$defaultLocale ?? 'en')); if ($locale === '') { return 'en'; } $locale = strtolower(str_replace('_', '-', $locale)); if (preg_match('/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/', $locale) === 1) { return $locale; } if (preg_match('/^[a-z]{2,3}/', $locale, $matches) === 1) { return $matches[0]; } return 'en'; } }