Excerpt from PHP for the Web: Error handling

Posted on by Matthias Noback

This is an excerpt from my book PHP for the Web. It's a book for people who want to learn to build web applications with PHP. It doesn't focus on PHP programming, but shows how PHP can be used to serve dynamic web pages. HTTP requests and responses, forms, cookies, and sessions. Use it all to build a CRUD interface and an authentication system for your very first web application.

Chapter 11: Error handling

As soon as we started using a PHP server to serve .php scripts in Chapter 2 we had to worry about errors and showing them in the browser. I mentioned back then that you need to make a distinction between the website as it is still running on your own computer and the website as it is running on a publicly accessible server. You may find that people talk about this distinction in different ways. When you're working on your website on your own computer you're running it "locally" or on your "development server". When it runs on a publicly accessible server it has been "deployed" to the "production server". We use different words here because these are different contexts or environments and there will be some differences in server configuration and behavior of the website depending on whether it runs locally or on the production server. In this chapter we'll improve the way our website handles errors and we'll make this dependent on the environment in which the website runs.

Producing an error

Before we can improve error handling, let's create a file that produces an error, so we can see how our website handles it. Create a new script in pages/ called oops.php. Also add it to the $urlMap in index.php so we can open the page in the browser:

$urlMap = [
    '/oops' => 'oops.php',
    // ...
];

This isn't going to be a real page, and we should remove it later, but we just need a place where we can freely produce errors. The first type of error we have to deal with is an exception. You can use an exception to indicate that the script can't do what it was asked to do. We already saw one back in Chapter 9 where the function load_tour_data(). The function "throws" an exception when it is asked to load data for a tour that doesn't exist:

function load_tour_data(int $id): array
{
    $toursData = load_all_tours_data();

    foreach ($toursData as $tourData) {
        if ($tourData['id'] === $id) {
            return $tourData;
        }
    }

    throw new RuntimeException('Could not find tour with ID ' . $id);
}

In oops.php we'll also throw an exception to see what that looks like for a user:

<?php

throw new RuntimeException('Something went wrong');

Start the PHP server if it isn't already running:

php -S 0.0.0.0:8000 -t public/ -c php.ini

Then go to http://localhost:8000/oops. You should see the following:

Fatal error: Uncaught RuntimeException

The reason the error shows up on the page is because we have set the PHP setting display_errors to On in our custom php.ini file. We loaded this file using the -c command-line option.

Seeing error messages on the screen is very useful for a developer like yourself: it will help you fix issues quickly. But it would be quite embarrassing if this showed up in the browser of an actual visitor of the website.

Using different configuration settings in production

Once the website has been deployed to a production environment, the display_errors setting should be Off. While developing locally you can simulate this by using multiple .ini files. The php.ini file we've been using so far will be the development version. We'll create another .ini file called php-prod.ini that will contain settings that mimic the production environment.

; Show no errors in the response body
display_errors = Off

When you supply multiple php.ini files using the -c command line option, PHP will merge all these configuration values. By loading php-prod.ini last, this file's settings will win from the settings in php.ini. You can verify that this works by running php -i, which displays all the current PHP settings:

php -c php.ini -c php-prod.ini -i

This produces a lot of output, but we can use grep to limit it to lines that contain display_errors:

php -c php.ini -c php-prod.ini -i | grep display_errors

This should produce the following output:

display_errors => Off => Off

Now try it again with only the original php.ini:

php -c php.ini -i | grep display_errors

The output of this command is:

display_errors => STDOUT => STDOUT

STDOUT here means that PHP errors will be added to the output of the script, and this means they will show up in the browser.

So we have a development environment that shows PHP errors, and a production-like environment that doesn't show them. Now let's restart the PHP server and this time include the php-prod.ini file too:

php -S 0.0.0.0:8000 -t public/ -c php.ini -c php-prod.ini

Go to http://localhost:8000/oops and you should see nothing:

Nothing

Yes, nothing is what we wanted: no error message, no file names or line numbers, great! But also not so great, because what if a user sees this big empty page? It's not very informative, and they don't know what they should try next.

I think it's best that an error page shows the normal page layout, including a navigation bar. Pretty much like the 404.php page does, but this time we don't say "Page not found", but something like "An error occurred". This would not leak any details to the user about what exactly went wrong but still gives them something to work with. Normally, the way to catch exceptions is to wrap code that might throw an exception inside a try/catch block:

<?php
try {
    // Some code that throws an exception
} catch (Throwable $exception) {
    // Show an error page
}

But if we solve it like this, we'd have to add this code to every page script, since every page might throw exceptions. So it's better to use a more generic solution that will work everywhere, without having to change existing page scripts. PHP has the set_exception_handler() function for this. You can set up an exception handler once and any exception that is thrown will be handled inside the callable that you provide:

<?php

set_exception_handler(function (Throwable $exception) {
    // Show the error page
});

// ...

throw new RuntimeException('Something went wrong');

Now where to call this set_exception_handler() function? It should be as soon as we can, before we run any other code, because if we do it later an exception might already be thrown. Actually, we could do it in the bootstrap.php file that we already have. This file contains a call to session_start() because we wanted to start the session on every page. But since we also want to have exception handling on every page, we could call set_exception_handler() in that file too:

<?php

set_exception_handler(function (Throwable $exception) {
    // Show the error page
});

session_start();

We need to be absolutely certain that bootstrap.php is always loaded. Therefore, we'd better not include it manually in every page script. Instead, we should include it once, at the top of index.php:

<?php
include(__DIR__ . '/../bootstrap.php');

$urlMap = [
    // ...

Don't forget to remove all the other include(__DIR__ . '/../bootstrap.php'); statements!

Great, now let's improve the exception handler by showing the error page. First, create a new page script: pages/error.php. Paste the following code in it:

<?php
$title = 'Error';
include(__DIR__ . '/../_header.php');

?>
<h1>An error occurred</h1>
<?php

include(__DIR__ . '/../_footer.php');

We don't need to add a URL for it to the $urlMap because the error page isn't a normal page. We'll only show it when we catch an exception. To do this, we have to modify the call to set_exception_handler() in bootstrap.php:

<?php

set_exception_handler(
    function (Throwable $exception) {
        include(__DIR__ . '/pages/error.php');
    }
);

session_start();

Go to http://localhost:8000/oops and indeed, it now shows a nice error page:

An error occurred

For regular users this is good, but for developers this isn't great. For us it's important to know what exactly went wrong. So far we were able to look up the issue in the server log:

... [500]: GET /oops - Uncaught RuntimeException: Something went wrong
Stack trace:
0 /app/public/index.php(23): include()
1 {main}
  thrown in /app/pages/oops.php on line 3

But unfortunately, now that we have set our own exception handler, PHP will no longer log the exception. And looking at the output of the PHP server in the Terminal you may notice something else that has changed:

... [200]: GET /oops

The response status code used to be 500, which stands for "Internal Server Error". Now it's 200, which stands for "OK". But the response certainly isn't okay; it's an error response. From this we lean that, if you don't register your own exception handler, PHP handles exceptions for you by showing them, logging them, and by setting the correct response status code. Now that we have our own exception handler, we need to do all this work manually.

First, let's add a call to error_log():

set_exception_handler(
    function (Throwable $exception) {
        error_log('Uncaught ' . (string)$exception);

        include(__DIR__ . '/pages/error.php');
    }
);

Go to http://localhost:8000/oops again and you should see the error page again. When you take a look at the output of the PHP server in the Terminal, you should also see that it logs the exception there:

... Uncaught RuntimeException: Something went wrong in ...
Stack trace:
0 /app/public/index.php(23): include()
1 {main}

The response status is still 200 though. We can change that by calling header():

set_exception_handler(
    function (Throwable $exception) {
        error_log('Uncaught ' . (string)$exception);

        header(
            $_SERVER['SERVER_PROTOCOL']
            . ' 500 Internal Server Error'
        );

        include(__DIR__ . '/pages/error.php');
    }
);

Looking at the Terminal again, we should see that the response status code is now correct:

... [500]: GET /oops

The sad thing is that as developers we no longer see the error message on the page. Again, that's because printing the error on the page is part of PHP's own exception handler, but we've replaced it with our own handler. So we have to write this functionality in our own exception handler. Because we include error.php inside the exception handler, and there the $exception variable contains the actual exception, we can modify pages/error.php to show the exception on the page:

<?php
/** @var Throwable $exception */

$title = 'Error';
include(__DIR__ . '/../_header.php');

?>
<h1>An error occurred</h1>
<pre><?php echo (string)$exception; ?></pre>
<?php

include(__DIR__ . '/../_footer.php');

I'm wrapping the exception in a <pre> element, which preserves the original formatting. Bootstrap takes care of the styling of <pre> elements, which makes the error page look rather nice. Note that I've also added an @var annotation at the top of error.php. This is to indicate that this variable will be defined when including this file. It helps your IDE to figure out what the type of this variable is.

Let's go to http://localhost:8000/oops again to see the result:

Showing the exception

We've made a mistake though: we said we wanted to show errors on screen only in a development environment. And now we unconditionally show the full exception in error.php. We should change this to only echo the exception if display_errors is On. We can find out if this is the case by using the ini_get() function. Inside the exception handler we fetch the current display_errors setting and put it in a variable:

set_exception_handler(
    function (Throwable $exception) {
        error_log('Uncaught ' . (string)$exception);

        header(
            $_SERVER['SERVER_PROTOCOL']
            . ' 500 Internal Server Error'
        );

        $displayErrors = (bool)ini_get('display_errors');

        include(__DIR__ . '/pages/error.php');
    }
);

In error.php we can use this $displayErrors variable to determine if we should echo the exception:

<?php
/** @var Throwable $exception */
/** @var bool $displayErrors */

$title = 'Error';
include(__DIR__ . '/../_header.php');

?>
<h1>An error occurred</h1>
<?php
if ($displayErrors) {
    ?><pre><?php echo (string)$exception; ?></pre><?php
}

include(__DIR__ . '/../_footer.php');

Note that I also included an @var annotation for the new $displayErrors variable.

PHP errors

As a programmer you can use exceptions to stop the execution of a function or an entire script. Throwing an exception means there is a problem and you can't continue. Once you throw an exception, the remaining code in the function or script will no longer be executed:

throw new RuntimeException('Something went wrong');

echo 'This will never appear on the page';

Besides exceptions PHP has another mechanism for dealing with problems: PHP errors. PHP errors are used to indicate that something is wrong, but they don't necessarily stop the execution of the script. When you open a file using fopen() but the file doesn't exist, you'll get a PHP error of type "warning". But this doesn't stop the execution of the script. Let's find out how that works by putting the following code in oops.php:

<?php

fopen(__DIR__ . '/this-file-does-not-exist', 'r');

echo 'PHP does not stop execution, even when it triggers an error';

//throw new RuntimeException('Something went wrong');

Note that I commented out the throw new RuntimeException(...) statement so this script will no longer throw an exception. Now start the PHP server in development mode (with only php.ini):

php -S 0.0.0.0:8000 -t public/ -c php.ini

Go to http://localhost:8000/oops and you should see the PHP warning on your screen:

Warning: fopen([...]/pages/this-file-does-not-exist): 
failed to open stream:
    No such file or directory in [...]/pages/oops.php on line 3
PHP does not stop execution, even when something went wrong

On line 3 we try to open a file that doesn't exist and PHP will trigger a PHP error for this. The "level" of this error is "warning". For PHP this isn't a reason to stop the execution of the script. We know this because the code on line 5 gets executed: echo 'PHP does ...'; This message still appears on the screen, directly below the PHP warning.

There are different ways to deal with PHP errors:

  1. You can configure PHP to ignore PHP errors by changing the error_reporting setting. If you do this, you can't log them nor show them on the page.
  2. You can log PHP errors but not show them on the page by changing the display_errors setting (something you should do in a production environment anyway).
  3. You can let PHP errors happen and continue with the execution of the script, or you can make them stop execution as if they were exceptions.

The first option is strongly discouraged, because if you ignore errors your code will have problems that you don't even know about. This can lead to serious data integrity and security issues. That's why we started in Chapter 2 by setting error_reporting to -1, which means: report all errors. We already discussed the second option, and the best practice here is to display errors only in a local development environment. So this brings us to option 3. Here the recommendation is to always "escalate" PHP errors. That way, no problem will go unnoticed and you have to deal with the issue before you can continue. The solution is to write a custom error handler which will be notified about any PHP error that occurs. This error handler will then throw an exception, effectively making errors behave as if they were exceptions.

PHP will give us all the necessary information to transform the error into an exception (see the $errno, $errstr, $errfile and $errline arguments below). We should register our own error handler before we register the exception handler in bootstrap.php:

<?php

set_error_handler(
    function (
        int $errno,
        string $errstr,
        ?string $errfile = null,
        ?int $errline = null
    ) {
        $message = $errstr;

        if ($errfile !== null) {
            $message .= ' in ' . $errfile;
        }
        if ($errno !== null) {
            $message .= ' on line ' . $errline;
        }

        throw new RuntimeException($message);
    }
);

// ...

The $errno argument will contain a number that corresponds to the error level (e.g. E_WARNING). $errstr contains the error message itself (fopen([...]): failed to open stream ...). $errfile and $errno are optional arguments, so we should only use them if they are provided. In the error handler we build up a proper error message. We then throw a RuntimeException with the full message.

Let's take a look at the result. Go to http://localhost:8000/oops and, surprise! Now that we convert the PHP error to a RuntimeException our own exception handler will catch it and show a nice error page:

A PHP error converted to a <code>RuntimeException</code>

This is great because from now on PHP errors and exceptions will be handled in a uniform way:

  • We stop the execution of the script in both cases: when a PHP error is triggered and when an exception is thrown.
  • We show errors and exceptions on the page if display_errors is set to On.
  • We always log errors and exceptions.

Even though this is great and will work in most situations, PHP errors aren't always easy to catch and handle. There are several libraries (e.g. the Symfony ErrorHandler component or Whoops) that will do a much better job than any custom solution. So when you start creating websites that are going to be deployed to a production server, I advise you to use one of those libraries in your project (by the way, in the next chapter you'll find out how to install libraries in your project).

Summary

  • Exceptions stop the execution of a PHP script. You can catch exceptions locally using try/catch or globally by setting a custom exception handler. This allows you to show a user-friendly error page.
  • Whether or not to show an exception on the page should be determined by the display_errors setting. This setting should be On in development environments, and Off in production environments.
  • Set the PHP error_reporting setting to -1 so PHP will report all types of errors.
  • Most PHP errors won't stop the execution of a PHP script. To overcome this problem you can set a custom error handler which converts a PHP error into an exception.
  • When you have a custom exception handler, make sure to log the exception and set the response status to 500 Internal Server Error.
PHP error handling exceptions
Comments
This website uses MailComments: you can send your comments to this post by email. Read more about MailComments, including suggestions for writing your comments (in HTML or Markdown).