April 23, 2021

JSON API Error Responses in Laravel with Httpable Exceptions

Today I'd like to talk about a pattern I've been enjoying lately.

One of my favourite pairs of helpers in Laravel is abort_if() and abort_unless().

public function update(Request $request, Record $record)
{
    abort_if(
        $condition, Response::HTTP_CONFLICT, 'The record was updated since reading.'
    );

    // ...
}

This will throw an instance of Symfony\Component\HttpKernel\Exception\HttpException with the status code set to 409 and the message set to 'The record was updated since reading.'.

Laravel's exception handler will then create a nice response with the specified status code and a standardised body of:

{
    "message": "The record was updated since reading."
}

As a bonus, when APP_DEBUG is set, it will also include additional debugging fields such as the file and line number where the exception occurred, as well as a stack trace.

Because it's a HttpException, Laravel won't report it by default, which is generally what we want with a 40x client error.

But as much as I love these helpers, there are two scenarios where I don't like to use them:

  1. When the scenario occurs outside of a controller.
    abort_if() throws a HttpException, so I believe it's only appropriate to throw this within files contained in the App\Http namespace. Anywhere else is not a HTTP-specific concern, so a HTTP-specific exception doesn't feel appropriate.
  2. When the same scenario occurs in multiple places and we want to centralise the error message and/or be able to report the error consistently.

So what are the alternatives?

Renderable Exceptions

Laravel allows you to create renderable exceptions with the artisan make:exception --render command that will generate an exception class like this:

<?php

namespace App\Exceptions;

use Exception;

class RecordConflictException extends Exception
{
    public function render($request)
    {
        return response(...);
    }
}

Which we can throw with another of my favourite pairs of helpers, throw_if() and throw_unless():

throw_if($condition, RecordConflictException::class);

The downside with renderable exceptions are:

  • It's now our responsibility to return that standard response structure and to keep it consistent. This is especially painful when you want the extra debugging fields.
  • The exception will be reported by default, which may not be what we want. This is easy enough to disable by implementing the report() method and returning false, but it's something we need to remember to do.

Extending HttpException

We could extend Symfony's HttpException class, or one of it's subclasses, so that Laravel will handle the response for us:

<?php

namespace App\Exceptions;

use Symfony\Component\HttpKernel\Exception\HttpException;

class RecordConflictHttpException extends ConflictHttpException
{
    public function __construct(
        ?string $message = 'The record was updated since reading.',
        \Throwable $previous = null,
        int $code = 0,
        array $headers = []
    ) {
        parent::__construct($message, $previous, $code, $headers);
    }
}

As above, this can be thrown as follows:

throw_if($condition, RecordConflictHttpException::class);

Because it's a HttpException, Laravel won't report it by default. The downside is that our exception is now a HttpException, so it's not really appropriate to use it outside of the HTTP-specific parts of our application, and certainly not within a command or job.

So how can we get the best of both worlds?

Implementing HttpExceptionInterface

Instead of extending HttpException, we can implement the HttpExceptionInterface which requires that we implement the getStatusCode() and getHeaders() methods:

<?php

namespace App\Exceptions;

use Exception;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

class RecordConflictException extends Exception implements HttpExceptionInterface
{
    protected $message = 'The record was updated since reading.';

    public function getStatusCode()
    {
        return 409;
    }

    public function getHeaders()
    {
        return [];
    }
}

By implementing HttpExceptionInterface, Laravel will automatically generate our response for us.

And because we're just extending Exception instead of HttpException, I feel like this is appropriate to throw from anywhere.

It's not a HTTP exception, it's a regular exception with HTTP abilities!

But there are still a few loose ends:

  • This exception will be reported by default, which may not be what we want.
  • There is too much boilerplate for my liking.

Introducing Httpable Exceptions

We can move the boilerplate code to a trait, which we'll call Httpable, where we can also handle reporting:

<?php

namespace App\Exceptions;

use Illuminate\Foundation\Application;

trait Httpable
{
    public function report(Application $app)
    {
        // Report only when running in a queued job or scheduled task.
        return $app->runningInConsole();
    }

    public function getStatusCode()
    {
        return $this->statusCode ?? 500;
    }

    public function getHeaders()
    {
        return $this->headers ?? [];
    }
}

And now we create a tidy exception, with a centralised message, that we can throw from any context, with a nice JSON response!

<?php

namespace App\Exceptions;

use App\Exceptions\Httpable;
use Exception;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

class RecordConflictException extends Exception implements HttpExceptionInterface
{
    use Httpable;

    protected $message = 'The record was updated since reading.';

    protected $statusCode = Response::HTTP_CONFLICT;
}

What do you think of this approach? Let me know on Twitter.