Working on a client project recently, I faced an all to common challenge in PHP development, how to make sure all errors especially fatal ones are hidden from users but still fully logged and consistently returned in JSON responses.
The goal was clear create a centralized, silent error handler for a PHP-based API server that wouldn’t expose internal messages to clients, while still logging the problem and gracefully returning a usable response. Let me walk you through what I did, why I did it, and how you can build on it.
The Problem
My client was building a lightweight API in pure PHP. The problem was, when an internal function failed or something catastrophic (like a typo in a function call) happened, PHP would still spit out raw errors even though display_errors
was set to 0
.
Here’s a quick example of what was happening. If I called a non-existent function like:
Edittest();
Instead of a clean JSON response, I got this mess:
<br />
<b>Fatal error</b>: Call to undefined function test() in <b>api_methods.php</b> on line <b>11</b><br />
And then after that, my JSON response would appear. That’s not ideal especially for a production API.
My First Attempt
I started by writing a simple error and exception handler to log issues and return a standard response:
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', 'logs/php-errors.log');
set_error_handler("ErrorHandler");
set_exception_handler("ExceptionErrorHandler");
register_shutdown_function("ShutdownFunction");
function ErrorHandler($errno, $errstr, $errfile, $errline) {
HandleError($errstr, $errfile, $errline);
}
function ExceptionErrorHandler(Throwable $e) {
HandleError($e->getMessage(), $e->getFile(), $e->getLine());
}
function ShutdownFunction() {
$error = error_get_last();
if ($error !== null) {
HandleError($error['message'], $error['file'], $error['line']);
}
}
function HandleError($message, $file, $line) {
$error_data = "File: " . basename($file) . "\nLine: $line\nError: $message\nTime: " . date('Y-m-d H:i:s');
file_put_contents('logs/' . date('d-m-Y') . '.log', $error_data . "\n------------------\n", FILE_APPEND);
if (!headers_sent()) {
header('Content-Type: application/json');
}
echo json_encode([
'IsError' => true,
'ErrorMsg' => $message . " | " . basename($file) . ":" . $line,
'Data' => ''
]);
exit;
}
It worked for warnings and exceptions but fatal errors (like Call to undefined function
) still leaked HTML into the output. That’s when I discovered the missing piece: output buffering.
The Fix Output Buffering
It turns out display_errors=0
just disables error display, but if a fatal error happens before your output buffer starts or your handlers are registered, PHP may still throw raw HTML to the browser.
So I moved everything to the very top of my entry file and added:
Editob_start();
Now, any output error or not is caught before it hits the browser, and I can safely clean it and respond with JSON.
Final Working Version
Here’s the fully working version I delivered to the client, including improvements like debug toggles, unique error IDs, and a 500 response code:
Edit<?php
// Config
define('DEBUG_MODE', false); // true for dev, false for production
// Output buffering
ob_start();
// Error reporting setup
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', 'logs/php-errors.log');
// Register handlers
set_error_handler("ErrorHandler");
set_exception_handler("ExceptionErrorHandler");
register_shutdown_function("ShutdownFunction");
// Handler implementations
function ErrorHandler($errno, $errstr, $errfile, $errline) {
HandleError($errstr, $errfile, $errline);
}
function ExceptionErrorHandler(Throwable $e) {
HandleError($e->getMessage(), $e->getFile(), $e->getLine());
}
function ShutdownFunction() {
$error = error_get_last();
if ($error !== null) {
HandleError($error['message'], $error['file'], $error['line']);
}
}
function HandleError($message, $file, $line) {
$error_id = uniqid('err_', true);
$error_data = "ID: $error_id\nFile: " . basename($file) . "\nLine: $line\nError: $message\nTime: " . date('Y-m-d H:i:s');
// Log to file
$logFile = 'logs/' . date('d-m-Y') . '.log';
file_put_contents($logFile, $error_data . "\n-------------------------\n", FILE_APPEND);
// Clean output buffer if needed
if (ob_get_length()) ob_clean();
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json');
}
echo json_encode([
'IsError' => true,
'ErrorID' => $error_id,
'ErrorMsg' => DEBUG_MODE ? $message . " | " . basename($file) . ":" . $line : "Internal Server Error",
'Data' => ''
]);
exit;
}
Extra Functionality I Added
To make the solution even more robust for the client, I included optional features they could enable later:
- Email critical errors to the dev team
mail('dev@example.com', 'Critical Error: ' . $error_id, $error_data);
- Insert logs into a database for a dashboard view.
- Include full stack trace for exceptions:
HandleError($e->getMessage() . "\n" . $e->getTraceAsString(), $e->getFile(), $e->getLine());
- Support for
.env
config using something likevlucas/phpdotenv
to avoid hardcoding log paths or debug flags.
Final Thoughts
What I learned from this client project is that PHP’s default error handling just isn’t good enough for serious APIs. You need to take control early literally at the top of your script with output buffering, proper shutdown handling, and a clean JSON response. This solution may not be perfect for every stack, but it’s solid, lightweight, and works great for traditional PHP applications and REST APIs without heavy frameworks.