At Creative Group we have several high traffic PHP web shops. If a web shop is offline for just a couple of minutes, we lose a lot of money. When there’s a technical problem, error messages are very important to fix it asap. We discovered that just using set_error_handler() and set_exception_handler() is not enough, as in some situations the PHP error handler is not called. In this post I would like to share some practices we’ve implemented throughout the years to prevent this:

  • Register error handler first
  • Catch fatal errors
  • Catch errors raised during error handling
  • Prevent memory exhaustion during error handling
  • Respect PHP settings

Basic ErrorHandler class

First I want to show a basic ErrorHandler class in which I will add code to illustrate the use of each practice. This article is focused on catching errors; handling and recovering are not discussed.

class ErrorHandler {
  protected $isRegistered = false;

  public function register() {
    set_error_handler(array($this, '_handleErrorCallback'));
    set_exception_handler(array($this, '_handleExceptionCallback'));
    $this->isRegistered = true;
  }

  public function unregister() {
    $this->isRegistered = false;
    restore_error_handler();
    restore_exception_handler();
  }

  public function _handleErrorCallback($errno, $errstr, $errfile, $errline) {
    // ...
  }

  public function _handleExceptionCallback(\Exception $e) {
    // ...
  }
}

Register error handler first

The first thing you should do before doing anything else is to manually include your error class(es) and get the handler up and running. If you are using an web application framework then it’s elegant to do this in the initializer (e.g. a bootstrap). But the PHP error handler is not called for errors that occur before the initializer is executed. Another problem are autoloaders. These also might cause errors, so the safest way is to manually load the required classes. If loading and registering your error classes fail then you’re screwed. The only way to see what’s wrong is to look at your log files. It’s safer not to include large code libraries while handling errors and exceptions, as each include file increases the risk for errors.

<?php
require_once('ErrorHandler.php');
$errorHandler = new ErrorHandler();
$errorHandler->register();

// Your script, run your initializer and start your application here

$errorHandler->unregister();

Fatal errors

One of the first problems you encounter are fatal errors. For this type of error the PHP error handler is not called using set_error_handler(). On the internet you quickly find the best way to catch it is to combine register_shutdown_function() and error_get_last(). This solution will process any last error, even one already processed by _handleErrorCallback(). To prevent such an error being processed twice, we compare it’s hash with the hash of the last processed error.

class ErrorHandler {
  // ...

  protected $lastError;

  public function register() {
    register_shutdown_function(array($this, '_checkForUncaughtErrorCallback'));
    // ...
  }

  protected function hashError(\ErrorException $e) {
    return md5($e->getCode() . ';' . $e->getMessage() . ';' . $e->getFile() . ';' . $e->getLine());
  }

  public function _handleErrorCallback($errno, $errstr, $errfile, $errline) {
    $e = new \ErrorException($errstr, $errno, 0, $errfile, $errline);
    $this->lastError = $e;
    // ...
  }

  public function _checkForUncaughtErrorCallback() {
    if (!$this->isRegistered) {
      return;
    }

    $error = error_get_last();
    if ($error) {
      $e = new \ErrorException($error['message'], $error['type'], 0, $error['file'], $error['line']);
      if ($this->lastError !== null && $this->hashError($e) == $this->hashError($this->lastError)) {
        return;
      }
      // ...
    }
  }

  // ...
}

Catch errors raised during error handling

Exceptions might be thrown and errors might be raised during handling of an error. Of course you want catch those too. Any raised error automatically becomes a fatal that will be caught with _checkForUncaughtErrorCallback(). Any exception might simply be caught using try..catch and handled another way (as the default handling causes an exception). There is one limitation: errors raised during _checkForUncaughtErrorCallback() are impossible to catch.

class ErrorHandler {
  // ...

  public function _handleErrorCallback($errno, $errstr, $errfile, $errline) {
    if (!$this->isRegistered) {
      return false;
    }

    $e = new \ErrorException($errstr, $errno, 0, $errfile, $errline);
    $this->lastError = $e;

    try {
      $this->handleError($e);
    } catch (Exception $e2) {
      $this->handleInternalException($e2);
    }
  }

  public function _handleExceptionCallback(\Exception $e) {
    if (!$this->isRegistered) {
      return false;
    }

    try {
      $this->handleException($e);
    } catch (Exception $e2) {
      $this->handleInternalException($e2);
    }
  }

  public function _checkForUncaughtErrorCallback() {
    if (!$this->isRegistered) {
      return;
    }

    $error = error_get_last();
    if ($error) {
      $e = new \ErrorException($error['message'], $error['type'], 0, $error['file'], $error['line']);
      if ($this->lastError !== null && $this->hashError($e) == $this->hashError($this->lastError)) {
        return;
      }

      try {
        $this->handleError($e);
      } catch (Exception $e2) {
        $this->handleInternalException($e2);
      }
    }
  }

  abstract public function handleError(\ErrorException $e);

  abstract public function handleException(\Exception $e);

  abstract protected function handleInternalException(\Exception $e);

  // ...
}

Prevent memory exhaustion during error handling

While handling an error or exception, PHP could run out of memory. There is only a slight chance, but with high traffic websites such things are bound to occur. The solution is very simple: temporary increase the memory limit by the expected maximum memory usage of your error handler. Do this asap after the start of error handling, as any line of code could require memory.

class ErrorHandler {
  // ...

  abstract protected function getMemoryRequirement(); // e.g. return '1M';

  protected function increaseMemoryLimit() {
    $currentLimit = trim(ini_get('memory_limit'));
    $unit = strtolower(substr($currentLimit, - 1));
    $factor = 1;

    switch ($unit) {
      case 'g':
        $factor = 1024 * 1024 * 1024;
        break;
      case 'm':
        $factor = 1024 * 1024;
        break;
      case 'k':
        $factor = 1024;
        break;
      default:
        $factor = 1;
    }

    ini_set('memory_limit', ($currentLimit * $factor) + ($this->getMemoryRequirement() * 1024 * 1024));
    return $currentLimit;
  }

  protected function restoreMemoryLimit($limit) {
    ini_set('memory_limit', $limit);
  }

  public function _handleErrorCallback($errno, $errstr, $errfile, $errline) {
    if (!$this->isRegistered) {
      return false;
    }

    $currentLimit = $this->increaseMemoryLimit();
    // ...
    $this->restoreMemoryLimit($currentLimit);
  }

  public function _handleExceptionCallback(\Exception $e) {
    if (!$this->isRegistered) {
      return false;
    }

    $currentLimit = $this->increaseMemoryLimit();
    // ...
    $this->restoreMemoryLimit($currentLimit);
  }

  public function _checkForUncaughtErrorCallback() {
    if (!$this->isRegistered) {
      return;
    }

    $currentLimit = $this->increaseMemoryLimit();

    $error = error_get_last();
    if ($error) {
      $e = new \ErrorException($error['message'], $error['type'], 0, $error['file'], $error['line']);
      if ($this->lastError !== null && $this->hashError($e) == $this->hashError($this->lastError)) {
        $this->restoreMemoryLimit($currentLimit);
        return;
      }

      // ...
    }

    $this->restoreMemoryLimit($currentLimit);
  }

  // ...
}

Respect PHP settings

Which errors should be handled can be set using error_reporting(). Errors can also be suppressed using the @ (at) sign. Regardless of these settings, the handler functions will always be invoked by PHP. So to prevent surprises for the developer, the error handler should respect these settings. The @ (at) sign temporary changes the result of error_reporting(), so only the result of this function needs to be checked.

class ErrorHandler {
  // ...

  public function _handleErrorCallback($errno, $errstr, $errfile, $errline) {
    if (!$this->isRegistered) {
      return false;
    }

    $currentLimit = $this->increaseMemoryLimit();

    if ((error_reporting() & $errno) == 0) {
      $this->restoreMemoryLimit($currentLimit);
      return false;
    }

    // ...
    $this->restoreMemoryLimit($currentLimit);
  }

  public function _checkForUncaughtErrorCallback() {
    if (!$this->isRegistered) {
      return false;
    }

    $currentLimit = $this->increaseMemoryLimit();

    $error = error_get_last();
    if ($error) {
      if ((error_reporting() & $error['type']) == 0) {
        $this->restoreMemoryLimit($currentLimit);
        return;
      }

      // ..
    }

    $this->restoreMemoryLimit($currentLimit);
  }

  // ...
}

Composer package

I’ve created a Composer package on GitHub you can use to catch errors. It has some other useful features and some sample handlers.