0) { ob_end_clean(); } } // Set HTTP 500 if no error status has been set yet. $currentStatus = http_response_code(); if ($currentStatus === false || $currentStatus < 400) { http_response_code(500); } // Collect diagnostic data. $data = ErrorDataCollector::collect($exception); // Log to file (always, in both dev and prod). ErrorLogger::log($exception, $data); // Bail out if headers were already sent (cannot render a page). if (headers_sent()) { self::$handling = false; return; } // API requests get a JSON response, not HTML. if (self::isApiRequest()) { self::sendApiError($data['request']['request_id'] ?? null); self::$handling = false; return; } // Render HTML error page. if (self::shouldShowDebugPage()) { echo ErrorRenderer::renderDev($data); } else { $requestId = (string) ($data['request']['request_id'] ?? 'unknown'); echo ErrorRenderer::renderProd($requestId); } } catch (\Throwable $inner) { self::renderMinimalFallback($exception); } self::$handling = false; } /** * Converts PHP errors into ErrorException. * Respects the error_reporting() bitmask (honors @ suppression operator). */ public static function handleError(int $severity, string $message, string $file, int $line): true { if (!(error_reporting() & $severity)) { // Error was suppressed with @. return true; } if (self::shouldSuppressVendorIssue($severity, $file)) { return true; } throw new \ErrorException($message, 0, $severity, $file, $line); } /** * Catches fatal errors that bypass set_error_handler (E_PARSE, E_COMPILE_ERROR, etc.). */ public static function handleShutdown(): void { self::$reservedMemory = null; $error = error_get_last(); if (!is_array($error)) { return; } $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR]; if (!in_array($error['type'], $fatalTypes, true)) { return; } // Only act if we haven't already handled this (e.g. via set_exception_handler). if (self::$handling) { return; } $exception = new \ErrorException( $error['message'], 0, $error['type'], $error['file'], $error['line'], ); self::handleException($exception); } private static function isApiRequest(): bool { if (defined('MINTY_API_REQUEST')) { return true; } $uri = $_SERVER['REQUEST_URI'] ?? ''; $path = ltrim(parse_url($uri, PHP_URL_PATH) ?? '', '/'); return str_starts_with($path, 'api/'); } private static function shouldShowDebugPage(): bool { // Check the Debugger flag, which mirrors APP_DEBUG. if (class_exists(Debugger::class, false) && Debugger::$enabled) { return true; } return false; } private static function sendApiError(?string $requestId): void { header('Content-Type: application/json; charset=utf-8'); $payload = [ 'ok' => false, 'error_code' => 'internal_error', ]; if ($requestId !== null) { $payload['request_id'] = $requestId; } echo json_encode($payload, JSON_UNESCAPED_SLASHES); } private static function renderMinimalFallback(\Throwable $exception): void { // Last-resort plain-text response when everything else is broken. $requestId = 'unknown'; try { $requestId = RequestContext::currentId() ?? RequestContext::id(); } catch (\Throwable) { } if (!headers_sent()) { http_response_code(500); header('Content-Type: text/plain; charset=utf-8'); } echo "500 Internal Server Error\nRequest ID: {$requestId}\n"; // In debug mode, also show the exception for developers. if (class_exists(Debugger::class, false) && Debugger::$enabled) { echo "\n" . $exception->getMessage() . "\n" . $exception->getTraceAsString() . "\n"; } } private static function isPhpUnitRuntime(): bool { return defined('PHPUNIT_COMPOSER_INSTALL') || defined('__PHPUNIT_PHAR__'); } private static function shouldSuppressVendorIssue(int $severity, string $file): bool { if (!self::isVendorPath($file)) { return false; } return match (self::vendorIssueMode()) { 'suppress_deprecations' => self::isDeprecationSeverity($severity), 'suppress_deprecations_warnings' => self::isDeprecationSeverity($severity) || self::isWarningSeverity($severity), default => false, }; } private static function vendorIssueMode(): string { $raw = getenv('APP_VENDOR_PHP_ISSUES_MODE'); if ($raw === false) { return 'strict'; } $mode = strtolower(trim((string) $raw)); return in_array($mode, ['strict', 'suppress_deprecations', 'suppress_deprecations_warnings'], true) ? $mode : 'strict'; } private static function isDeprecationSeverity(int $severity): bool { return in_array($severity, [E_DEPRECATED, E_USER_DEPRECATED], true); } private static function isWarningSeverity(int $severity): bool { return in_array($severity, [E_WARNING, E_USER_WARNING], true); } private static function isVendorPath(string $file): bool { $normalized = str_replace('\\', '/', $file); return str_contains($normalized, '/vendor/'); } }