0) { ob_end_clean(); } echo json_encode(DEBUG ? ['error' => $message] + $debug : ['error' => $message]); }; // Uncaught exceptions become JSON errors (the SPA surfaces them) instead // of blank 500 pages. // The message itself is DEBUG-only too: a PDOException spells out the DB // user, host and table names, and this handler also runs for visitors who // never logged in (the sign-in call touches the database). set_exception_handler(function (Throwable $e) use ($emit): void { error_log($e); $emit(DEBUG ? 'Server error: ' . $e->getMessage() : 'Server error — the details are in the PHP error log.', [ 'type' => get_class($e), 'file' => $e->getFile() . ':' . $e->getLine(), 'trace' => explode("\n", $e->getTraceAsString()), ]); }); // set_exception_handler never sees fatals (parse errors, exhausted memory, // failed require) — those end the request silently and are the usual cause // of an endpoint returning an empty body. Catch them on shutdown. register_shutdown_function(function () use ($emit): void { $err = error_get_last(); if ($err === null || !($err['type'] & (E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR))) { return; } $emit(DEBUG ? 'Fatal error: ' . $err['message'] : 'Server error — the details are in the PHP error log.', [ 'file' => $err['file'] . ':' . $err['line'], ]); }); } function db(): PDO { static $pdo = null; if ($pdo === null) { $dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=utf8mb4'; $pdo = new PDO($dsn, DB_USER, DB_PASS, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); } return $pdo; }