Here's the scenario that used to haunt me: a client messages at 10 PM saying "the form isn't working." I log into the site. Everything looks fine on my end. I check the server. Blank screen. No error. No clue. Just a form that silently swallowed whatever the user tried to do — and me, staring at it in the dark with a coffee going cold.
After a few too many of those nights, I built a proper error logging setup that I now deploy on every PHP project. It's not fancy — I'm on shared cPanel hosting, not some Kubernetes cluster with a Datadog dashboard. But it works. Errors get caught, logged with context, and I find out about them before users start screaming.
This is the exact setup I use. Real config, real code, real folder structure.
The Problem With Default PHP Error Handling on Shared Hosting
On most shared cPanel hosts, the default php.ini configuration is a mess for production work. Either display_errors is left On — which means raw PHP warnings get splatted directly into your HTML — or it's Off and errors disappear entirely with no trace anywhere useful.
I've inherited projects where the previous developer had display_errors = On in production. A user who knew what they were looking for could see full file paths like /home/username/public_html/includes/db.php on line 43 and database connection strings. That's not just embarrassing, it's a real security hole.
The other extreme — silencing everything — is just as bad. You end up with a broken feature and zero information to work with. The error happened. You just can't see it.
What you actually want is: errors hidden from users, but captured in full detail somewhere only you can access.
Step 1: Lock Down php.ini (or .htaccess)
On cPanel, you can edit php.ini directly via the MultiPHP INI Editor, or drop a .htaccess file in your document root. I usually use .htaccess for per-project control because it travels with the repo.
# .htaccess — production error config
php_flag display_errors Off
php_flag display_startup_errors Off
php_value error_reporting 32767
php_flag log_errors On
php_value error_log /home/yourusername/logs/php_errors.log
error_reporting 32767 is equivalent to E_ALL — I want to capture everything, including notices and deprecations. You can't fix problems you can't see. The log file path I'm pointing to is outside public_html, which means it's not web-accessible. That matters.
Create the logs directory via SSH or File Manager and make sure it's writable:
mkdir ~/logs
chmod 750 ~/logs
Step 2: A Custom Logger That Actually Gives You Context
The built-in error_log() function writes to the log file, but the output is minimal. You get a timestamp, an error level, and a message. What you often don't get: the URL that was requested, the POST data, the user's session state, or any meaningful stack trace for caught exceptions.
Here's the lightweight logger I include in every project. It lives at includes/logger.php:
<?php
function app_log(string $level, string $message, array $context = []): void {
$logFile = dirname(__DIR__) . '/logs/app.log';
$timestamp = date('Y-m-d H:i:s');
$url = $_SERVER['REQUEST_URI'] ?? 'CLI';
$method = $_SERVER['REQUEST_METHOD'] ?? 'CLI';
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$entry = "[{$timestamp}] [{$level}] {$message}";
$entry .= " | URL: {$method} {$url}";
$entry .= " | IP: {$ip}";
if (!empty($context)) {
$entry .= " | Context: " . json_encode($context);
}
$entry .= PHP_EOL;
file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX);
}
function app_exception_handler(Throwable $e): void {
app_log('ERROR', $e->getMessage(), [
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => array_slice($e->getTrace(), 0, 5)
]);
}
set_exception_handler('app_exception_handler');
The FILE_APPEND | LOCK_EX flag combination is important — LOCK_EX prevents two concurrent requests from writing garbled entries to the same line, which absolutely happens on traffic spikes.
I limit the trace to 5 frames with array_slice because full stack traces on deeply nested calls can write several kilobytes per error. On a busy site, that fills your disk faster than you'd expect.
Step 3: Register a Global Error Handler
Custom exceptions are covered, but what about PHP warnings and notices that don't throw? I convert those into logged entries too, using set_error_handler:
<?php
set_error_handler(function(int $errno, string $errstr, string $errfile, int $errline): bool {
$levels = [
E_WARNING => 'WARNING',
E_NOTICE => 'NOTICE',
E_USER_ERROR => 'ERROR',
E_USER_WARNING => 'WARNING',
E_USER_NOTICE => 'NOTICE',
E_DEPRECATED => 'DEPRECATED',
];
$level = $levels[$errno] ?? 'UNKNOWN';
app_log($level, $errstr, [
'file' => $errfile,
'line' => $errline
]);
// Return false to allow PHP's default handler to also run for fatal errors
return false;
});
I include both logger.php and this handler registration in a single bootstrap.php file that gets required at the top of every page. One require, everything covered.
Step 4: Structured Log Calls in Application Code
The handler catches uncaught errors automatically, but for business logic I log intentionally. Here's what that looks like in a real context — a payment processing function I worked on recently:
<?php
function process_payment(int $order_id, float $amount): bool {
app_log('INFO', 'Payment attempt started', [
'order_id' => $order_id,
'amount' => $amount
]);
try {
$result = call_payment_gateway($order_id, $amount);
if (!$result['success']) {
app_log('WARNING', 'Payment gateway returned failure', [
'order_id' => $order_id,
'gateway_code' => $result['code'],
'message' => $result['message']
]);
return false;
}
app_log('INFO', 'Payment succeeded', ['order_id' => $order_id]);
return true;
} catch (Exception $e) {
app_log('ERROR', 'Payment exception: ' . $e->getMessage(), [
'order_id' => $order_id,
'file' => $e->getFile(),
'line' => $e->getLine()
]);
return false;
}
}
Logging the intent ("attempt started") alongside outcomes means when I open the log during an incident, I can reconstruct exactly what the app was doing and where it stopped. Without that INFO entry at the top, all I see is a missing ERROR and no idea if the function was even called.
Step 5: Reading Logs Without Going Insane
A log file that grows without bounds is its own problem. On one project I inherited, there was a malformed query running on every page load generating a Notice — the log file was 2.3 GB. No one had looked at it in months.
I use a simple daily log rotation via a cron job (cPanel's cron UI makes this easy):
# Runs at midnight, keeps 14 days of logs
0 0 * * * mv ~/logs/app.log ~/logs/app_$(date +\%Y-\%m-\%d).log 2>&1
0 1 * * * find ~/logs/ -name "app_*.log" -mtime +14 -delete 2>&1
For reading the current log in real time during a debugging session, SSH and tail is your best friend:
tail -f ~/logs/app.log | grep ERROR
Pipe through grep to filter by level. When I'm chasing a specific bug I'll often filter by a route or order ID instead:
tail -f ~/logs/app.log | grep "order_id\":42"
That JSON context I'm logging suddenly becomes very useful for targeted searches.
Step 6: A Dead-Simple Error Notification Trigger
Real-time awareness matters. I don't want to discover errors by reading logs — I want to know the moment something breaks. On shared hosting you can't run a background process, but you can send an email alert from inside the error handler for critical-level events:
<?php
function app_log(string $level, string $message, array $context = []): void {
// ... existing logging code ...
if ($level === 'ERROR') {
$subject = '[ALERT] Production error on ' . ($_SERVER['HTTP_HOST'] ?? 'app');
$body = $entry;
mail('[email protected]', $subject, $body);
}
}
Yes, PHP's mail() function. I know. But on cPanel shared hosting it works reliably and it gets the job done at 3 AM when a payment flow breaks. I add a rate-limit check in real projects to avoid email floods — a simple flag in a temp file that expires after five minutes. But the core idea is solid: errors should find you, not the other way around.
If you're already thinking about the security side of all this — making sure error pages don't leak info, handling failed requests safely — my earlier post on PHP session and authentication patterns touches on related defensive practices around session handling and error exposure.
Concrete Takeaways
- Always disable
display_errorsin production. Log to a file outsidepublic_htmlinstead. - Use
set_exception_handlerandset_error_handlertogether. One catches uncaught exceptions, the other catches warnings and notices. You need both. - Log with context, not just messages. The URL, the IP, the relevant IDs — whatever helps you reconstruct the sequence of events.
- Rotate logs on a cron schedule. A 2 GB log file is not a debugging tool, it's a liability.
- Log intentions, not just failures. INFO entries before a critical operation let you confirm the function was reached at all.
- Set up email alerts for ERROR-level events. Checking logs manually is how you find out about problems from users instead of from your own monitoring.
The whole setup — .htaccess config, logger.php, bootstrap.php, and the cron entries — takes maybe 30 minutes to drop into a new project. That 30 minutes has saved me hours of blind guessing on production issues more times than I can count. The next time a client messages at 10 PM saying the form isn't working, I'm opening a log file with full context instead of staring at a blank screen wondering where to even start.
Frequently Asked Questions
Is it safe to display PHP errors on a live production server?
No — displaying errors publicly exposes file paths, database names, and logic that attackers can exploit. Always log errors to a private file and set display_errors to Off in production.
How do I read PHP error logs on cPanel shared hosting?
You can find them in cPanel under Logs ? Error Log, or via File Manager in your home directory as error_log. You can also SSH in and tail the file in real time with: tail -f ~/error_log
What's the difference between error_log() and a custom logging function?
error_log() writes a single line to the server's error log — fast but unstructured. A custom logger lets you add timestamps, severity levels, request context, and write to a dedicated file you control, making debugging significantly faster.