Write error handler functions for PHP

When writing PHP code for a web site it's important to know as soon as errors occur, so it makes sense for error messages to be output to the web page you're working on. But once your page goes live, visible to the public, it's best to hide error messages, partly to avoid advertising the fact your website has hit a snag, but also because error messages contain information that might allow malicious visitors to crack your site.

To avoid showing off PHP error messages to strangers, it's possible to configure your live server with display_errors set to a value of zero, so that errors are not sent to the browser. The problem with this option is that errors on the live server then go completely unseen, which means that problems go unnoticed and probably unfixed.

A better option is to create custom error handlers, one for use on your test server, and one for use on your live server.

Note: the code on this page requires PHP 5.2.0 or newer.

Error handler for use on a test server

For your test environment, you just want to output PHP errors to the browser, so you can see them as you create and test your pages. An error handler like the following ought to do:

function output_all_errors($errno, $errstr, $errfile, $errline) {
    global $error_type;
    $error_name = (isset($error_type[$errno]) ?
            $error_type[$errno] : 'Unknown error code');
    $html = '<div class="php_error">'.
            '<p><strong>'.$error_name.'</strong><br />' .
            $errstr.
            '<br />'.
            'In <strong>'.
            htmlspecialchars($errfile, ENT_NOQUOTES, 'UTF-8').
            '</strong> on line '.$errline.
            '</p></div>';
    echo $html;
}

The error handler functions on this page receive the following parameters, which are automatically passed to the error handler by PHP whenever an error is generated:

$errno
A numeric value which corresponds to the type of error (Notice, Fatal Error, User-generated warning, etc).
$errstr
A string that contains the error message text, ideally including details that identify the cause of the error.
$errfile
The full local path of the file which has triggered this error (such as /var/www/public_html/badscript.php).
$errline
The line number where the error was generated (within the file identified by $errfile).

(It doesn't actually matter what these parameters are named in your function, so long as your function is designed to receive these values into variables. See the PHP manual page for the set_error_handler function for more detail.)

Note that this error handler function does not call either of the PHP functions htmlspecialchars or htmlentities on the value of $errstr, so if you call trigger_error with an error string that's not safe to be output to the page, make sure you translate it yourself using one of these functions.

Also note that $error_type is just an array that matches error codes from $errno to a short string which names the error type (such as "User-generated error", or "User-generated warning"). Download the source code (below) to see this, as it's simple enough not to require explanation here.

Error handler for use on a live server

The previous version of this page offered an error handler function which simply emailed the details about every error from the live server to the webmaster. (The function was mail_all_errors and it can still be found in the source code which you can download below.) There were two problems with this solution:

The point about email volume became a major problem for me. A geolocation script I was using could not access its data file because of a permissions problem, but rather than fail gracefully it just kept going, generating hundreds of errors for each visitor to any page that called the script, and every single error generated an email message. My hosting company had to phone me to tell me I was single-handedly overwhelming the email server, and could I stop please? Because I didn't have time to modify the geolocation script, and I didn't know what other scripts might cause the same problem, I simply disabled the mail command in the error handler function and got on with deleting the 108,000 emails waiting on the server for me. Not at all ideal.

So I've now crafted an error handler function that will log all errors to a log file, using PHP's built-in logging mechanism, but it also keeps track of how long it's been since the last email alert was generated for the page in error. If more than twenty-four hours have passed, it gets the green light to send another error report by email for the offending page. This way, all errors are logged somewhere, and the webmaster is still gently informed that there is a problem and he needs to check the error log file on the server.

Very nice. The only downside is that the error handling function that does this is a bit of a beast. Here's the code:

function log_all_errors($errno, $errstr, $errfile, $errline) {
    if (get_magic_quotes_runtime()) {
        set_magic_quotes_runtime(false); // disable magic_quotes
    }
    
    /*
     * Stage one: log the error using PHP's error logger.
     */
    
    global $error_type;
    $error_name = (isset($error_type[$errno]) ?
            $error_type[$errno] : 'Unknown error code');

    // Structure the error message in the same way as PHP logs
    // fatal errors, because they'll be saved in the same file.
    $error_message = $error_name.': '.
            $errstr.' in '.
            $errfile.' on line '.$errline;
    error_log($error_message, 0);  // log to PHP_ERROR_LOG file
    
    
    /* 
     * Stage two: find out whether we need to email a warning
     * to the webmaster.
     */
    
    $lockfile = fopen(PHP_ERROR_LOCK, 'a+');
    if (!$lockfile) {
        error_log('FAIL: fopen failed in log_all_errors in '. 
                'error_handler.php');
        error_log('log_all_errors failed! Check server logs!',
                1, SEND_ERROR_EMAIL_TO);
        echo '<p>A failure occurred, and it\'s not possible to '.
                'continue.</p>';
        die();
    }
    $locked = flock($lockfile, LOCK_EX);
    if (!$locked) {
        error_log('FAIL: flock failed in log_all_errors in '.
                'error_handler.php');
        error_log('log_all_errors failed! Check server logs!',
                1, SEND_ERROR_EMAIL_TO);
        echo '<p>A failure occurred, and it\'s not possible to '.
                'continue.</p>';
        die();
    }
    rewind($lockfile);
    
    // Run through the lockfile and grab the date/errfile pairs.
    unset($lockfile_data);
    while (!feof($lockfile)) {
        $buffer = fgets($lockfile);
        if ($buffer == '') continue;  // EOF line is empty
        $match = preg_match('#^([0-9]{4}-[0-9]{2}-[0-9]{2}'.
                'T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\+|-)[0-9]{4})'.
                '\s+(.+)$#', $buffer, $matches);
        if (!$match) {
            error_log('FAIL: preg_match could not match '.
                    'expected pattern in error log file, in '.
                    'error_handler.php');
            continue;
        }
        $lockfile_data[$matches[2]] = $matches[1]; 
    }
    
    // If an alert date exists for the current $errfile value,
    // then calculate whether or not the last error alert was
    // sent (by email) less than twenty-four hours ago. If so,
    // we don't need to send another alert right now.
    $need_to_email_alert = true; // starting assumption
    if (count($lockfile_data) > 0) {
        if (isset($lockfile_data[$errfile])) {
            $alert_date = date_create($lockfile_data[$errfile]);
            if (!$alert_date) {
                error_log('FAIL: date_create could not create a'.
                        ' date object from the last alert date '.
                        ' in log_all_errors in '.
                        'error_handler.php.');
                error_log('log_all_errors failed! Check server '.
                        'logs!', 1, SEND_ERROR_EMAIL_TO);
                echo '<p>A failure occurred, and it\'s not '.
                        'possible to continue.</p>';
                die();
            }
            $alert_timestamp = date_format($alert_date, 'U');
            $current_timestamp = date('U');
            $diff_seconds = $current_timestamp -
                    $alert_timestamp;
            // Change this to TESTING_ONLY to check that it works
            // but remember to change it back to ONE_DAY (or some
            // other value you deem suitable) when testing is
            // completed.
            if ($diff_seconds < ONE_DAY) {
                // Last alert for this $errfile was less than
                // twenty-four hours ago, so we don't need to
                // send out another email alert. (Nor do we need
                // to update the alert date in the lockfile.)
                $need_to_email_alert = false;
            }
        }
    }
    
    
    /*
     * Stage three: send email and update lockfile, if necessary.
     */ 
    
    // Either we've never sent an alert about this page before,
    // or the last alert was sent more than 24 hours ago.
    // First, update the lockfile with the current date+time,
    // (so that lockfile can be released rather than hanging
    // around to wait for the email to be sent).
    if($need_to_email_alert) {
        // Write out an updated version of the locking file after
        // updating the alert date for this $errfile entry.
        $lockfile_data[$errfile] = gmdate(DATE_ISO8601);
        ftruncate($lockfile, 0);  // we want to start from blank
        foreach($lockfile_data as $page => $date) {
            fwrite($lockfile, $date.' '.$page."\n");
        }
    }
    
    // Now that lockfile has been updated (if it was necessary)
    // it's time to release the lock, and close the file.
    if(flock($lockfile, LOCK_UN) == false ||
            fclose($lockfile) == false) {
        error_log('FAIL: flock or fclose failed in '.
                'log_all_errors in error_handler.php');
        error_log('log_all_errors failed! Check server logs!',
                1, SEND_ERROR_EMAIL_TO);
        echo '<p>A failure occurred, and it\'s not possible to '.
                'continue.</p>';
        die();
    }

    // Now the lockfile has been released, send the alert email
    // if necessary.
    if($need_to_email_alert) {
        if (SEND_ERROR_EMAIL_TO) {
            $rtnl = "\r\n";  // carriage return + newline
            $headers = 'Content-type: text/plain; charset=utf-8';
            $message = 'An error of type "'.$error_name.'" has '.
                    'occurred on the page '.$rtnl.
                    "\t".$errfile.$rtnl.
                    'Check the error log on the server as soon '.
                    'as possible.'.$rtnl.$rtnl.
                    'NOTE: At most, one email alert per '.
                    'day will be generated'.$rtnl.
                    'for each page, so this will be the only '.
                    'error generated'.$rtnl.
                    'by this page in the next 24 hours. '.
                    '(Errors of more serious'.$rtnl.
                    'types may occur but not generate email '.
                    'alerts. Check the'.$rtnl.
                    'live error log.)';
            $sent = mail(SEND_ERROR_EMAIL_TO, 'PHP error alert',
                    $message, $headers);
            if (!$sent) {
                error_log('FAIL: mail failed to send error '.
                        'alert email.');
                // Good chance that if the mail command failed,
                // then error_log will also fail to send mail,
                // but we have to try.
                error_log('log_all_errors failed! Check server '.
                        'logs!', 1, SEND_ERROR_EMAIL_TO);
                echo '<p>A failure occurred, and it\'s not '.
                        'possible to continue.</p>';
                die();
            }
        }
    }
}

Just in case that block of code didn't scare you off or put you to sleep, I'll explain briefly what the code is doing.

Stage one: log all errors to file

Stage one is mercifully simple. It just uses PHP's error_log function to record the error message according to the server or runtime settings (which we cover further down this page). This way, every error message is saved to a log file, regardless of whether an email gets sent to the webmaster.

Stage two: work out whether an email alert is needed

Stage two is not as simple, but it's easy enough to break into parts. First of all a lock file (whose file path you need to define in a constant called PHP_ERROR_LOCK) is opened, and then the PHP function flock is used to acquire an exclusive lock on the file. This should stop any other processes from accessing this file while we're using it (which would likely make the file data unreliable), but note the warnings in the PHP manual about this not being possible to guarantee on some systems.

If a lock file doesn't exist, a new one is created. The lock file is either empty, or contains a list of page file paths (all taken from values of the $errfile variable) and the date and time at which an email alert was last sent for each page. This lets us check whether twenty-four hours have passed since the last email alert for the current page. The contents of the file will eventually look something like this:

2010-01-17T01:43:08+0000 /var/www/public_html/test-php-errors.php
2010-01-17T01:37:34+0000 /var/www/public_html/test-user-gen-errors.php
2010-01-17T17:14:49+0000 /var/www/include_php_scripts/process-feeds.php

Once the lock file is opened and locked, a loop runs through the file and reads each line. Any line which doesn't match the expected regex pattern is skipped (and a simple message logged using error_log). For each line that matches the pattern, $matches[2] should be a file path to the page or script that caused the error, and $matches[1] will be a date and time in the ISO8601 format. This date and time is exactly when the last email alert was generated for this page or script. An array is constructed so that each file path maps to its last email alert date and time.

The next block just uses this array to see if a match is found for the file path of the file that has triggered the current error (the value of $errfile). If the file path is found in the array, then the date is used to work out when the last email alert was sent. If the date and time is calculated as being more than twenty-four hours ago, or if the file path is not found in the array, then it's time to generate an email alert for this error, and $need_to_email_alert is left as true. But if the file path is in the array and its date and time is less than twenty four hours ago, then it's too soon to generate another email alert for this page or script, and $need_to_email_alert is set to false.

Note: I've defined a constant called ONE_DAY which has the value of twenty four hours in seconds: 86,400. But you can instead use any number of seconds you see fit.

Stage three: send email error alert if necessary

This final stage looks pretty ugly, but it's actually simple enough. If $need_to_email_alert is true, then the array is updated so that the current error file path ($errfile) points to the current date and time, and then the contents of the lock file are rewritten with the new values.

Next, whatever the value of $need_to_email_alert, we use flock to release the lock on the lock file, and close the file resource, because we're finished with the file now, and the sooner a file lock is released, the sooner any other scripts waiting to use it can proceed.

Finally, if $need_to_email_alert is true, we just execute some code to send a simple message to the webmaster. Note that you need to define a constant called SEND_ERROR_EMAIL_TO which contains your email address if you want the mail command to execute, or has the value false if you want the mail command to be skipped (during testing, for instance).

Error handling within the error handler

I've taken the view that if the error handler itself suffers an error, then things are in a pretty bad way, so execution should terminate. Before the die() command is issued, the error is logged to the log file, and an attempt is made to notify the webmaster by email using the PHP error_log function. Note that this does mean that if the error handler hits trouble, you could end up with more than one email alert per day for each page, so you may decide it's better to disable this feature. However, things are in bad shape if the error handler is generating errors, so I do recommend you terminate execution rather than continue regardless. (Especially because if you continue regardless you may end up with the same problem the geolocation script caused me: hundreds of errors per visitor.)

Runtime settings and error handler selection

The error handler functions above should be defined in an include file, and at the top of the include file you need to define the constants used in the functions, and also specify which error handler function PHP should send errors to.

/*
 * SETTINGS THAT REMAIN THE SAME ON BOTH TEST AND LIVE RIGS
 */

// Determine path to the directory one level above doc root.
// CHANGE THIS TO A PATH WHERE YOU WANT LOG FILES TO BE PLACED!
$log_dir = realpath($_SERVER['DOCUMENT_ROOT'].'/../');

// Tell PHP where its error logging system should write to
define('PHP_ERROR_LOG', $log_dir.'/php-error-log.txt');
ini_set('error_log', PHP_ERROR_LOG);

// Define a lock file location (used by log_all_errors, below)
define('PHP_ERROR_LOCK', $log_dir.'/php-error-logging-lock.txt');

// For use within log_all_errors, stage two.
define('ONE_DAY', 86400);  // one whole day, in seconds
define('TESTING_ONLY', 10); // ten seconds, for test purposes


/*
 * SETTINGS THAT NEED TO VARY BETWEEN TEST AND LIVE RIGS
 */

// Use one error handler for local test server, and another for
// the live, publicly visible server 
if ($_SERVER['HTTP_HOST'] == '127.0.0.1') {
    /* LOCAL TEST SERVER SETTINGS */
    
    // Show all errors on screen if they aren't caught by our
    // custom error handler (fatal errors, for instance)
    ini_set('display_errors', 'STDOUT');
    // Disable email sending on test rig
    define('SEND_ERROR_EMAIL_TO', false);
    // We want to be informed about every type of error
    error_reporting(E_ALL | E_STRICT);
    // Specify error handler to use on test rig
    set_error_handler('output_all_errors');
    
    // Tell PHP-generated HTML error messages to point hyperlinks
    // to the online PHP manual.
    ini_set('docref_root', 'http://www.php.net/manual/en/');
    ini_set('docref_ext', '.php');
    
} else {
    /* LIVE PUBLICLY VISIBLE SERVER SETTINGS */
    
    // Request that errors not be displayed on screen
    ini_set('display_errors', NULL);
    // Define the mail-to address for error messages.
    // !!! REPLACE THIS EMAIL ADDRESS WITH YOUR OWN !!!
    define('SEND_ERROR_EMAIL_TO', 'address@example.com');
    // Specify error handler to use on live server
    set_error_handler('log_all_errors');
}

This is all pretty simple. Just make sure to change the value of the SEND_ERROR_EMAIL_TO constant to an email address that you check regularly.

Also, I've used the value of $_SERVER['HTTP_HOST'] to determine whether the script is running on the test server or the live server. My test server runs on the loopback IP address of 127.0.0.1 but you need to change the value in the if statement if your test server runs with a different hostname, such as 'localhost', 'my.test.server' or whatever. When the HTTP_HOST value matches the address of the local test server, we specify runtime settings suitable for testing, and tell PHP to use the output_all_errors function as the error handler. In the else block, we specify runtime settings suitable for the live server, and tell PHP to use the log_all_errors function as the error handler.

For error handling on the test server, I've used ini_set to specify values for docref_root and docref_ext. These are useful because PHP (by default) generates HTML error messages with hyperlinks which point to the page in the manual relevant to the function which is causing the current error. The value of docref_root tells PHP where to find the documentation, and docref_ext tells PHP what extension the pages have. If you've downloaded the PHP manual to your local machine, it makes sense to change these values to point to the path on your machine where the manual can be found, and change docref_ext to the file extension the pages in the local copy use.

It's important to note that not all errors can be sent to a custom error handler. Fatal errors can only be handled by PHP in whatever way is configured in the server configuration file for PHP. If the fatal error occurs after the runtime settings (such as error_log path, and display_errors) have taken effect, then probably the error messages will be written to the path you choose, and suppressed from being output to the web page, but the PHP documentation suggests that this won't always be the case. Ideally you'd be able to specify such settings in the server configuration file (usually php.ini) but most shared hosting webmasters don't have the power to modify this, and have to make do with runtime settings.

Download the code

If you've read and understood all of the above, and you think the described code would be of use to you, you can download a copy here:

(Note: see the update below about handling errors of type E_RECOVERABLE_ERROR.)

Both of these compressed archives contain the file error_handler.php, which contains the functions described on this page, and a greater number of comments. The file uses UTF-8 character encoding, so check that your editor has opened it using that encoding, and then resave it using whatever encoding your other PHP scripts use. Also change the the file owners and permissions so that the file can execute on your web servers (easy to forget).

You have permission to use the code in your own scripts as you see fit, for any purpose which is neither illegal nor indecent. And you must accept that if you use the code, you do so entirely at your own risk.

Make use of the error handler

As well as catching any errors generated by PHP functions, you can and should take advantage of the fact that the error handlers can let you know about errors that occur within the logic of your own scripts. For instance, if your script requires that an XML feed be opened as a file resource, but the file could not be fetched, it's possible that none of the PHP functions will generate an error, just simply return a value of false. If you want to make sure that you're alerted to the problem (and you should want to make sure of this) you can use the PHP function trigger_error to cause an error message to be generated, like this:

if (!$xml) {
    echo '<p>An error occurred while processing the XML'.
            ' feed.</p>';
    trigger_error('curl_exec returned false when trying to'.
            ' fetch the XML feed.', E_USER_WARNING);
    return false;
}

Now, if you're using the log_all_errors function as an error handler, you'll receive an email on any day when this error occurs, and you'll know that it's time to check the error log on the server to find out whether it's a one-off or the result of a major problem. (Perhaps the URL of the XML feed has changed, or the target server has been decommissioned.) In this case, a simple message to the user is output to the page, and the script returns false. But in the case of critical errors, it might be more sensible just to output an apology to the user and then terminate execution using the die() command.

Test the error handler

Whatever error handler function you use, it's wise to put together a simple test script that deliberately triggers errors of different types, to make sure that your test and live servers are responding to errors in the expected way. Using trigger_error you can easily generate errors from the E_USER group of error types. Parse errors can be forced by missing out a semicolon at the end of a line of code (though note that parse errors stop the script from being executed at all, so parse errors are always handled in the way defined in your PHP configuration file, never by a custom error handling function). And fatal errors can be forced by using the require command and then giving it a file path that doesn't exist.

Once you've crafted such a bug-ridden test file, make sure to check that all error types are either being written to where you intend them to go, either the web page or to error log files (and don't forget that some errors will be written to log files found at locations according to the server configuration rather than just at the location you specify in your error handling script). If you're using log_all_errors, or a similar error handler that sends alerts by email, also check that you're not receiving alert email more often than you've specified in the script.

Bugs

As always, there are bound to be things I've overlooked, things I've got wrong, or bugs that could be dangerous. If you see any such problems in my code, please let me know.

Update

Handling E_RECOVERABLE_ERROR

I noticed that PHP handles errors of type E_RECOVERABLE_ERROR differently. It calls such an event a "catchable fatal error" and normally such an error causes execution to terminate just as if an error of type E_ERROR had occurred. However, if the error is intercepted by a custom error handler, such as the code described on this page, then execution does not terminate, as PHP assumes that the custom handler will decide what to do.

I don't think "catchable fatal error" makes much sense, especially when this is the error type thrown by a call to a class method which provides the wrong sort of object type (a violation of the method contract, and surely grounds for execution to be terminated). I can't imagine what a custom error handler is expected to do on encountering such a violation, so unless your code relies on a custom response to this error type, I recommend that you add a block, to the end of each of your error handler functions, which terminates execution if E_RECOVERABLE_ERROR arises. Something like this:

if($errno == E_RECOVERABLE_ERROR) {
    die();
}

That way, your custom error handlers will terminate execution, just as would PHP's default error handler, if this slightly bizarre error type is encountered.