Pragmatism in the real world

Setting HTTP status code based on Exception in Slim 4

One thing that’s quite convenient is to be able to throw an exception with a valid HTTP code set and have that code sent to the client.

For example, you may have:

throw new \RuntimeException("Not Found", 404);

With the standard Slim 4 error handler, this response is sent to the client:

$ curl -i -H "Accept:application/json" http://localhost:8888
HTTP/1.1 500 Internal Server Error
Host: localhost:8888
Content-type: application/json

{
    "message": "Slim Application Error"
}

Ideally we want the status code to be 404.

Option 1: Use an HttpException

The simplest solution is to use one of Slim’s HttpException classes:

use Slim\Exception\HttpNotFoundException;

...

throw new HttpNotFoundException($request);

This is only useful in a Request Handler as you need a Request object, but the expected response is sent to the client:

$ curl -i -H "Accept:application/json" http://localhost:8888
HTTP/1.1 404 Not Found
Host: localhost:8888
Content-type: application/json

{
    "message": "404 Not Found"
}

Simple and easy!

Option 2: Override the ErrorMiddleware

There are situation when you can’t simply replace the exception thrown. For example, you’re updating an application from Slim 3 and you have hundreds of customised exceptions already throwing, or you throw from a class that doesn’t have a Request object instance available.

In these cases, the easiest solution is to extend the Slim ErrorMiddleware to wrap the exception in an

HttpException

and then the standard error handling and rendering will “just work”.

I’m feeling a little lazy, so let’s use an anonymous class to do replace the call to $app->addErrorMiddleware():

    $container = $app->getContainer();
    $logger = $container->has(LoggerInterface::class) ?$container->get(LoggerInterface::class) : null;

    $errorMiddleware = new class (
        callableResolver: $app->getCallableResolver(),
        responseFactory: $app->getResponseFactory(),
        displayErrorDetails: false,
        logErrors: true,
        logErrorDetails: true,
        logger: $logger
    ) extends \Slim\Middleware\ErrorMiddleware {
        public function handleException(
            ServerRequestInterface $request,
            Throwable $exception
        ): \Psr\Http\Message\ResponseInterface
        {
            // determine that this exception should be wrapped. I'm checking for code between 400 & 599
            if ($exception->getCode() >= 400 && $exception->getCode() < 600) {
                // wrap the exception in an HttpException
                $exception = new \Slim\Exception\HttpException(
                    $request,
                    $exception->getMessage(),
                    $exception->getCode(),
                    $exception
                );
                $exception->setTitle($exception->getMessage());
            }
            return parent::handleException($request, $exception);
        }
    };
    $app->addMiddleware($errorMiddleware);

Behind the scenes of $app->addErrorMiddleware(), the \Slim\Middleware\ErrorMiddleware is constructed and then added to the middleware stack. We replicate that we an anonymous class that overrides handleException() to wrap the thrown exception if required.

Looking at the code in detail

There’s quite a lot going on here, so let’s break it down into parts.

    $container = $app->getContainer();
    $logger = $container->has(LoggerInterface::class) ?$container->get(LoggerInterface::class) : null;

    $errorMiddleware = new class (
        callableResolver: $app->getCallableResolver(),
        responseFactory: $app->getResponseFactory(),
        displayErrorDetails: false,
        logErrors: true,
        logErrorDetails: true,
        logger: $logger
    ) extends \Slim\Middleware\ErrorMiddleware {

The constructor to \Slim\Middleware\ErrorMiddleware takes 6 parameters, so when we instantiate, we have to pass them all in though it’s not unusual for the $logger parameter to be left off in the call to $app->addErrorMiddleware(). The easiest way to get a logger instance if there is one, is to grab it from the container where it should be registered under the \Psr\Log\LoggerInterface key which is imported into the file with a use statement.

I've used PHP 8's named arguments as this constructor takes three booleans and it's easier to remember what they do if they are labelled.

We then define the method for our class:

        public function handleException(
            ServerRequestInterface $request,
            Throwable $exception
        ): \Psr\Http\Message\ResponseInterface
        {

Our anonymous class implements one method, handleException(), which extends the implementation in the parent. We then get to the meat of the method:

            if ($exception->getCode() >= 400 && $exception->getCode() < 600) {
                $exception = new \Slim\Exception\HttpException(
                    $request,
                    $exception->getMessage(),
                    $exception->getCode(),
                    $exception
                );
                $exception->setTitle($exception->getMessage());
            }

The new work our implementation of handleException does is to wrap the thrown exception inside an HttpException. We only want to do this when the needed. One way to determine this is to check the error code; if the thrown exception has a code between 400 and 599, then it's probably an HTTP error code. Of course this is not foolproof, so you may prefer to check that the class implements a particular interface.

To wrap, we instantiate a new HttpException, passing the thrown $exception as the last parameter, so that getPrevious() will find it. Then we set the message to display using setTitle() as that's what's used by the Slim error renderers.

Finally, we call through to our parent's handleException() to do the work with our shiny new HttpException.

            return parent::handleException($request, $exception);
        }
    };

We have defined and instantiated the class, so we add it to Slim's middleware stack:

    $app->addMiddleware($errorMiddleware);

and we're done.

Checking that it works

Putting it all together, if we now throw an exception like this:

throw new \RuntimeException("Not Found", 404);

then we get the expected HTTP response:

$ curl -i -H "Accept:application/json" http://localhost:8888
HTTP/1.1 404 Not Found
Host: localhost:8888
Content-type: application/json

{
    "message": "Not Found"
}

To sum up

The best way to get Slim's error handling system to send the correct HTTP status code when throwing an exception is to thrown an exception that extends Slim's HttpException. Otherwise, you can override Slim's ErrorMiddleware to wrap your exception in an HttpException and you're done.