1
0
Files
breadcrumb-the-shire/core/Http/ErrorHandler.php
fs 0e86925464 refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root
immediately communicates which code is core platform and which lives
in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the
Composer PSR-4 path mapping moves from lib/ to core/.

Module-internal lib/ directories (modules/*/lib/) are untouched.

Updated across the full stack:
- composer.json PSR-4 mapping
- bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php)
- tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer)
- 26 architecture contract tests
- enforcement-policy, guard-catalog, quality-gates
- all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/)
- bin/qa-extended.sh search paths
- .claude/settings.local.json permission paths

Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner →
Executor → Code Review (4 findings fixed) → Security Review →
Acceptance Test → Finalizer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:20:42 +02:00

244 lines
7.3 KiB
PHP

<?php
namespace MintyPHP\Http;
use MintyPHP\Debugger;
final class ErrorHandler
{
/** @phpstan-ignore property.onlyWritten (Memory reserve freed on OOM to allow error rendering.) */
private static ?string $reservedMemory = null;
private static bool $registered = false;
private static bool $handling = false;
public static function register(): void
{
if (self::$registered) {
return;
}
self::$registered = true;
self::$reservedMemory = str_repeat('x', 64 * 1024); // 64 KB reserve for OOM recovery.
set_exception_handler([self::class, 'handleException']);
set_error_handler([self::class, 'handleError']);
register_shutdown_function([self::class, 'handleShutdown']);
}
public static function handleException(\Throwable $exception): void
{
// Prevent recursive handling if the error page itself throws.
if (self::$handling) {
self::renderMinimalFallback($exception);
return;
}
self::$handling = true;
try {
// Free reserved memory so we have room to work after OOM.
self::$reservedMemory = null;
// Discard any partial output from the failed request.
if (!self::isPhpUnitRuntime()) {
while (ob_get_level() > 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/');
}
}