Skip to content
← Blog Development September 13, 2026

How I Debug PHP Errors on Production Without Losing My Mind (or My Users)

9 min read
How I Debug PHP Errors on Production Without Losing My Mind (or My Users)

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_errors in production. Log to a file outside public_html instead.
  • Use set_exception_handler and set_error_handler together. 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.

Share Twitter / X LinkedIn

Enjoyed this? Let's build something.

Start a project
Keep reading

More articles

Before & After: How I Rewrote My cPanel Caching Strategy and Cut Server Load by 60%
Development August 11, 2026
Before & After: How I Rewrote My cPanel Caching Strategy and Cut Server Load by 60%
Shared hosting doesn't have Redis or Varnish — but that doesn't mean you're stuck. Here's the exact caching rewrite that transformed a sluggish cPanel app.
Read →
The CSRF Token Bug That Only Broke in Safari: A Production Post-Mortem
Development July 29, 2026
The CSRF Token Bug That Only Broke in Safari: A Production Post-Mortem
A CSRF protection bug that only appeared in Safari on iOS cost me two days of debugging. Here's exactly what happened and how I fixed it.
Read →
14 Session and Authentication Patterns I Wish I'd Known Earlier in PHP
Development July 18, 2026
14 Session and Authentication Patterns I Wish I'd Known Earlier in PHP
PHP sessions look simple until they betray you in production. Here are 14 patterns I've learned the hard way building real authentication systems.
Read →