Complete ErrorHandler rollout: wire into all endpoints, fix display_errors gaps, add styled 500 page (#38, #39, #105)

README documented ErrorHandler.php as a "global error/exception
handler", but ErrorHandler::init() had exactly one caller app-wide
(api/get_template.php). 13 endpoints never called
ini_set('display_errors', 0) at all, relying on the server's global
php.ini default, and index.php never registered any handler — a
genuine PHP fatal during a page render fell through to PHP's raw
default handling with no app-level 500 response, styled or otherwise.

Investigating the "13 endpoints" claim turned up that 9 of them
(assign_ticket.php, audit_log.php, check_duplicates.php,
get_comments.php, get_users.php, notifications.php, saved_filters.php,
user_preferences.php, watch_ticket.php) already require
api/bootstrap.php as their first statement, which itself calls
ini_set('display_errors', 0) — so they were never actually exposed;
the static grep just couldn't see through the require. The 3 that
were genuinely unprotected (bulk_operation.php, download_attachment.php,
health.php) are fixed here. ticket_dependencies.php already has its
own complete hand-rolled equivalent (shutdown handler, error handler,
exception handler, output-buffer aware) and was deliberately left
alone rather than risk double-registering handlers.

Rather than duplicate the fix 30+ times, wired ErrorHandler::init()
directly into api/bootstrap.php (covering all 9 files above at once)
and into each of the other endpoints' own ini_set/error_reporting
pair, replacing it in place — additive only: existing try/catch blocks
in every endpoint still handle what they already handled identically,
this only adds a safety net for genuinely uncaught fatals that fell
through everything else. Before doing this app-wide, removed
ErrorHandler::init()'s override of PHP's 'error_log' ini setting: it
redirected every error_log() call in the request to a fixed /tmp file,
which would have silently diverted logs away from wherever the server
is actually configured to send them the moment this got wired into
more than one endpoint. That override only existed to support
getRecentErrors(), which has zero callers app-wide.

For index.php (page views, not JSON), added an 'html' response mode to
ErrorHandler that renders a new views/error_500.php instead of a JSON
body. That view is deliberately self-contained (no layout_header.php,
no $GLOBALS/session/DB dependency) since a genuine fatal can happen
before config.php finishes loading or mid-session-start.

Verified: a real uncaught error with no prior output correctly
produces a clean JSON 500 (API mode) or the styled HTML page (page
mode) to the client while the full stack trace goes to error_log, not
the response; normal (non-fatal) requests through both a
bootstrap.php-based endpoint and index.php are byte-for-byte
unaffected. Full project phpcs pass is clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
This commit is contained in:
2026-09-11 12:17:15 -04:00
co-authored by Claude Sonnet 5
parent bcc732e605
commit 71bf64c1e2
26 changed files with 120 additions and 45 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
<?php
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -10,8 +10,8 @@
* // $conn, $currentUser, $userId, $isAdmin are now available
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+3
View File
@@ -1,5 +1,8 @@
<?php
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -5,8 +5,8 @@
* Creates a copy of an existing ticket with the same properties
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
header('Content-Type: application/json');
+2 -2
View File
@@ -5,8 +5,8 @@
* CRUD operations for custom field definitions
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -7,8 +7,8 @@
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -5,8 +5,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+3
View File
@@ -6,6 +6,9 @@
* Serves file downloads for ticket attachments
*/
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -8,8 +8,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -1,8 +1,8 @@
<?php
// API endpoint for generating API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+3
View File
@@ -11,6 +11,9 @@
* - 503 Service Unavailable: System has issues
*/
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Don't apply rate limiting to health checks - they should always respond
header('Content-Type: application/json');
header('Cache-Control: no-cache, no-store, must-revalidate');
+2 -2
View File
@@ -5,8 +5,8 @@
* CRUD operations for recurring_tickets table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -5,8 +5,8 @@
* CRUD operations for ticket_templates table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -5,8 +5,8 @@
* CRUD operations for status_transitions table
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
+2 -2
View File
@@ -1,8 +1,8 @@
<?php
// API endpoint for revoking API keys (Admin only)
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -18,8 +18,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -22,8 +22,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -14,8 +14,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Rate limiting (same pattern as the other Bearer API endpoints)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -5,8 +5,8 @@
*/
// Disable error display in the output
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -1,8 +1,8 @@
<?php
// Enable error reporting for debugging
error_reporting(E_ALL);
ini_set('display_errors', 0); // Don't display errors in the response
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -7,8 +7,8 @@
*/
// Capture errors for debugging
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
// Apply rate limiting (also starts session)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
+2 -2
View File
@@ -11,8 +11,8 @@
* Returns 404 if the user has no avatar set in lldap.
*/
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+2 -2
View File
@@ -2,8 +2,8 @@
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once __DIR__ . '/helpers/ErrorHandler.php';
ErrorHandler::init();
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
+26 -5
View File
@@ -10,26 +10,36 @@ class ErrorHandler
{
private static ?string $logFile = null;
private static bool $initialized = false;
private static string $responseMode = 'json';
/**
* Initialize error handling
*
* @param bool $displayErrors Whether to display errors (false in production)
* @param bool $displayErrors Whether to display errors (false in production)
* @param string $responseMode 'json' (API endpoints) or 'html' (page views —
* renders views/error_500.php instead of a JSON body)
*/
public static function init(bool $displayErrors = false): void
public static function init(bool $displayErrors = false, string $responseMode = 'json'): void
{
if (self::$initialized) {
return;
}
self::$responseMode = $responseMode;
// Set error reporting
error_reporting(E_ALL);
ini_set('display_errors', $displayErrors ? '1' : '0');
ini_set('log_errors', '1');
// Set up log file
self::$logFile = sys_get_temp_dir() . '/tinker_tickets_errors.log';
ini_set('error_log', self::$logFile);
// Deliberately does NOT override the 'error_log' ini setting: doing so
// used to redirect every error_log() call in the request to a fixed
// /tmp file, silently diverting logs away from wherever the server is
// actually configured to send them (php-fpm's error_log, stdout in a
// container, etc.) the moment this got wired into more than one
// endpoint. self::$logFile / getRecentErrors() are unused (no callers
// app-wide) and exist only as an opt-in helper if something later
// wants a dedicated log file.
// Register handlers
set_error_handler([self::class, 'handleError']);
@@ -151,6 +161,17 @@ class ErrorHandler
{
http_response_code($httpCode);
if (self::$responseMode === 'html') {
if (!headers_sent()) {
header('Content-Type: text/html; charset=utf-8');
}
// Deliberately not passed $message/$exception — see error_500.php's
// docblock on why the fatal-error page must render with zero
// dependency on request-specific state.
include dirname(__DIR__) . '/views/error_500.php';
exit;
}
if (!headers_sent()) {
header('Content-Type: application/json');
}
+7
View File
@@ -1,6 +1,13 @@
<?php
// Main entry point for the application
// Registered first, before anything else, so a genuine fatal anywhere below
// (including during config.php's own env parsing) renders the styled 500
// page instead of falling through to PHP's raw default error handling.
require_once 'helpers/ErrorHandler.php';
ErrorHandler::init(false, 'html');
require_once 'config/config.php';
require_once 'middleware/SecurityHeadersMiddleware.php';
require_once 'middleware/AuthMiddleware.php';
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* Standalone 500/fatal-error page, rendered by ErrorHandler for page-view
* (non-API) requests.
*
* Deliberately self-contained: a genuine fatal can happen before config.php
* finishes loading, mid-session-start, or mid-DB-query, so this view must
* not depend on $GLOBALS['config'], $GLOBALS['currentUser'], a session, or a
* DB connection being available/working. It links the static base.css
* stylesheet (served directly by the webserver, independent of PHP) to
* match the app's look without going through layout_header.php's app-state
* dependent setup.
*/
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 &mdash; Something Went Wrong</title>
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/assets/css/base.css">
</head>
<body>
<div class="lt-frame" style="max-width:32rem;margin:4rem auto">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header lt-text-danger">[ 500 ] SOMETHING WENT WRONG</div>
<div class="lt-section-body lt-text-center">
<p class="lt-text-muted lt-mb-md">
An unexpected error occurred. It's been logged; please try again shortly.
</p>
<a href="/" class="lt-btn lt-btn-primary">&larr; Dashboard</a>
</div>
</div>
</body>
</html>