Laravel Exceptions Enhanced

Β·

4 min read

Hi! Today I want to share the approach that I use when I need to handle custom Exception in my Laravel projects.

Why is it important to create and use custom Exceptions?

πŸ™ƒ
You may skip this section if you are just interested in the code.

The Exceptions that PHP provides out of the box offer little help to understand the issue from the logs.

They accept just two params: message and code. Usually the message contains context for us developers, and therefore cannot be given to the user. The code is not used by Laravel so will render the very generic 500 error page.

In addition, we do not have any control of what will be given to the user. The json response returns just a basic message property an the blade file shows a default message based on the statusCode.

Custom Exceptions

We first create an abstract class called BaseException; this class won't have any abstract functions, some of you may argue that it's unnecessary and it can be just a normal class, but making it abstract we make it clear that it should not be used by itself.

namespace App\Exceptions;

use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

abstract class BaseException extends Exception
{
    public const STATUS_CODE = Response::HTTP_INTERNAL_SERVER_ERROR;

    public function __construct()
    {
        parent::__construct($this->getTranslatedMessage(), static::STATUS_CODE);
    }

    public function render(Request $request): Response|false
    {
        if ($request->expectsJson()) {
            return new Response(
                [
                    'error' => [
                        'code' => static::STATUS_CODE,
                        'message' => $this->getTranslatedMessage(),
                    ],
                ],
                static::STATUS_CODE,
            );
        }

        abort(static::STATUS_CODE, $this->getTranslatedMessage());
    }

    protected function getTranslatedMessage(): string
    {
        return trans('exceptions.'.static::class);
    }
}

Let's analyze the class:

  • The getTranslatedMessage method will return the translated message that we want to give to our users.

  • The render method will make sure that the JSON response structure is returned always the same way. Obviously you can personalize it based on your needs.

  • The constant STATUS_CODE can be overridden when extending it or use the default 500 error code.

πŸ€”
Note that we're using static instead of self - this way we're making sure we're using the value from the extending class instead of using the abstract default values.

We can now create custom Exceptions like so

class DatabaseOffline extends BaseException
{
    public const STATUS_CODE = Response::HTTP_SERVICE_UNAVAILABLE;
}

We should name exceptions as descriptive as we can. This will immediately help us understand the problem when reading it in the logs. Sometimes, such as in this case, we can skip looking at the code entirely.

The translation files

The next step is setting up our language files. Create the file exceptions.php for each language that you support in your application.

// lang/en/exceptions.php
use App\Exceptions\DatabaseOffline;

return [
    DatabaseOffline::class => 'Unfortunately the system seems to be offline. Try again later.',
];

// lang/it/exceptions.php
<?php

use App\Exceptions\DatabaseOffline;

return [
    DatabaseOffline::class => "Purtroppo il sistema sembra essere offline. Riprova piΓΉ tardi.",
];

Using the exception fully qualified name class as the array key will make sure that if the class name is changed, your IDE will rename it here too; using a custom key like database-offline will then require us to manually change it.

Personalize Default Error Pages

Laravel offers blade pages render exception; this out of the box feature is limited and requires a bit of customisation from us.

To begin, let's publish them so we can modify them

php artisan vendor:publish --tag=laravel-errors

This will create the blade files in resources/views/errors/*.blade.php

I leave you to modify all of them, I'll show you an example:

@extends('errors::minimal')

@section('title', __('Server Error'))
@section('code', $exception->getStatusCode())
@section('message', $exception->getMessage() ?: __('Server Error'))

Let's throw the exception

throw new DatabaseOffline();

If we access the page from the browser we will see:

If we, instead, send the request from a javascript application, we will see:

{
    "error": {
        "code": 503,
        "message": "Unfortunately the system seems to be offline. Try again later."
    }
}

Test

I like to have my code tested to make sure it behaves correctly at any change I make. I leave the below Pest test in case you find it useful.

// tests/Pest.php
function supportedLocales(): array
{
    return [
        'en',
        'it',
        // add future supported locales here
    ];
}

// tests/Unit/Exceptions/TranslatableExceptions.php
use App\Exceptions\DatabaseOffline;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;

it('returns a translated message', function (string $locale, string $exception) {
    Config::set('app.locale', $locale);

    $exceptions = include_once lang_path(sprintf('%s/exceptions.php', $locale));

    $this->expectExceptionCode($exception::STATUS_CODE);
    $this->expectExceptionMessage($exceptions[$exception]);

    throw new $exception;
})->with(Arr::crossJoin(
    supportedLocales(),
    [
        DatabaseOffline::class,
        // Add future exceptions here
    ],
));

If you have any questions, please leave a comment below.