404 Responses in a Laravel API

Published on by

404 Responses in a Laravel API image

A useful feature that shipped in Laravel 5.5 is fallback routing. You can learn about fallback routing in Better 404 responses using Laravel +5.5 by Mohamed Said (the author of the feature) to get the full picture of why it’s useful and how to use fallback routes.

When you are creating an API, you probably want a 404 route that responds with JSON (or whatever format you are serving via content negotiation) instead of the default 404 JSON response.

Here’s what you get if you request an undefined route with a JSON content type:

curl \
-H"Content-Type:application/json" \
-H"Accept: application/json" \
-i http://apidemo.test/not/found
 
HTTP/1.1 404 Not Found
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
Vary: Accept-Encoding
Cache-Control: no-cache, private
Date: Thu, 16 Aug 2018 06:00:42 GMT
 
{
"message": ""
}

You can see that we get an empty message that isn’t necessarily super helpful, but the framework returns valid JSON without any work on our part. Let’s cover a couple of scenarios and walk through how you can ensure your API responds with a fallback 404 response with a useful message when an API route doesn’t match.

Setup

Using the Laravel CLI, we’ll create a new project to walk through adding a 404 response to your API:

laravel new apidemo
cd apidemo/
 
# Valet users...
valet link

We’ll configure a MySQL database for this project:

mysql -u root -e'create database apidemo'

Update the .env to the credentials that match your database environment, for me it would be:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=apidemo
DB_USERNAME=root
DB_PASSWORD=

We’re going to use the users table to demonstrate a few things. Update the database/seeds/DatabaseSeeder.php to the following:

<?php
 
use Illuminate\Database\Seeder;
 
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*
* @return void
*/
public function run()
{
factory('App\User', 10)->create();
}
}

Finally, you can run migrations and the seeder:

php artisan migrate:fresh --seed

API Routes

We’ll set up a few API routes, including the fallback route for our API, complete with a test. First, let’s create the following test:

php artisan make:test Api/FallbackRouteTest

Add a test case to look for a JSON response and verify a 404 error:

<?php
 
namespace Tests\Feature\Api;
 
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
 
class FallbackRouteTest extends TestCase
{
/** @test */
public function missing_api_routes_should_return_a_json_404()
{
$this->withoutExceptionHandling();
$response = $this->get('/api/missing/route');
 
$response->assertStatus(404);
$response->assertHeader('Content-Type', 'application/json');
$response->assertJson([
'message' => 'Not Found.'
]);
}
}

If you run the test suite at this point this test will fail because we haven’t defined a fallback route:

phpunit --filter=ApiFallbackRouteTest
...
There was 1 error:
 
1) Tests\Feature\ApiFallbackRouteTest::missing_api_routes_should_return_a_json_404
Symfony\Component\HttpKernel\Exception\NotFoundHttpException: GET http://localhost/api/missing/route

Let’s define a fallback route at the end of the routes/api.php file:

Route::fallback(function(){
return response()->json(['message' => 'Not Found.'], 404);
})->name('api.fallback.404');

We’ve created a fallback route that responds with JSON and returns a message which we assert in our test. We also assert the content type to be application/json as well.

Note the name that we’ve defined for the route api.fallback.404. We’ll need to use this name shortly to respond to a few exception types in our exception handler.

A Valid Users Endpoint

To illustrate further how the fallback route is used, we’ll define a valid route for our User model:

Route::get('/users/{user}', 'UsersController@show')
->name('api.users.show');

Next, we’ll create a controller and user resource:

php artisan make:controller UsersController
php artisan make:resource User

We rely on implicit route model binding, and our controller uses the User resource to respond with JSON:

<?php
 
namespace App\Http\Controllers;
 
use App\User;
use App\Http\Resources\User as UserResource;
use Illuminate\Http\Request;
 
class UsersController extends Controller
{
public function show(User $user)
{
return new UserResource($user);
}
}

Next, we’re going to create a test to verify the user endpoint and also verify that we get a 404 back when we request an invalid user:

php artisan make:test Api/ViewUserTes

This is where it gets interesting: let’s create a test to verify our route triggers a ModelNotFoundException via route model binding:

<?php
 
namespace Tests\Feature\Api;
 
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
 
class ViewUserTest extends TestCase
{
/** @test */
public function requesting_an_invalid_user_triggers_model_not_found_exception()
{
$this->withoutExceptionHandling();
try {
$this->json('GET', '/api/users/123');
} catch (ModelNotFoundException $exception) {
$this->assertEquals('No query results for model [App\User].', $exception->getMessage());
return;
}
 
$this->fail('ModelNotFoundException should be triggered.');
}
}

This test is valid and should pass—it’s mostly making sure that somehow our controller is either using route model binding or is triggering a model exception somehow. We’ll come back to it in a second, but note that you may want a custom response message for the ModelNotFoundException.

A somewhat redundant test, we will not disable exception handling and make sure we are getting back a 404 and the “no query results” error message back from the API:

/** @test */
public function requesting_an_invalid_user_returns_no_query_results_error()
{
$response = $this->json('GET', '/api/users/123');
$response->assertStatus(404);
$response->assertHeader('Content-Type', 'application/json');
$response->assertJson([
'message' => 'No query results for model [App\User].'
]);
}

At this point both tests should be passing—we didn’t necessarily use TDD to drive out the route model binding.

Next, let’s write a failing test for our route model binding which ensures an invalid user id doesn’t match the route, and we get the fallback route:

/** @test */
public function invalid_user_uri_triggers_fallback_route()
{
$response = $this->json('GET', '/api/users/invalid-user-id');
$response->assertStatus(404);
$response->assertHeader('Content-Type', 'application/json');
$response->assertJson([
'message' => 'Not Found.'
]);
}

If you run this test you’ll get (partially) the following error:

Failed asserting that an array has the subset Array &0 (
'message' => 'Not Found.'
).
--- Expected
+++ Actual
@@ @@
Array
(
- [message] => Not Found.
+ [message] => No query results for model [App\User].

We can get this test passing by constraining the {user} route parameter:

Route::get('/users/{user}', 'UsersController@show')
->name('api.users.show')
->where('user', '[0-9]+');

If you run the test it will be passing, thus, successfully hitting the API fallback route:

phpunit --filter=invalid_user_uri_triggers_fallback_route
...
OK (1 test, 4 assertions)

It’s a good idea to add conditions to your route parameters so that the route will only match valid parameters and hit the fallback route otherwise. If using a fallback isn’t essential to you, the route model binding will still return a 404 error, but some databases might trigger an error while querying a table with an invalid value.

You would probably have something like this test as well, ensuring that your endpoint works for valid users:

use RefreshDatabase;
 
/** @test */
public function guests_can_view_a_valid_user()
{
$user = factory('App\User')->create([
'name' => 'LeBron James',
'email' => 'lebron@lakers.com',
]);
 
$response = $this->json('GET', "/api/users/{$user->id}");
$response->assertOk();
$response->assertJson([
'data' => [
'name' => 'LeBron James',
'email' => 'lebron@lakers.com',
]
]);
}

Customizing ModelNotFoundException Responses

You might be fine with the error message that the API responds with when a ModelNotFoundException is triggered through our route model binding. If you’re interested in triggering the fallback route, you could update the exception handler with a check that resembles the following:

# app/Exceptions/Handler.php
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Route;
 
public function render($request, Exception $exception)
{
if ($exception instanceof ModelNotFoundException && $request->isJson()) {
return Route::respondWithRoute('api.fallback.404');
}
 
return parent::render($request, $exception);
}

Again, this is an optional step that gives you an idea of how you can use exception handling coupled with a fallback route. You might be perfectly fine with the JSON response provided by default for a ModelNotFoundException. The main point is demonstrating how to use fallback routes from the exception handler.

If you update the exception handler to include the check for a model not found, you’ll need to update the broken test to match the fallback response to get the test passing again.

Learn More

I’d encourage you to read through Mohamed Said’s Better 404 responses using Laravel +5.5 post and his pull request for the feature 5.5 Add ability to set a fallback route by themsaid . Our release announcement post Laravel 5.5.5 Released With a New Route Fallback to Help Customize Your 404 Views has more information on this feature as well.

Paul Redmond photo

Staff writer at Laravel News. Full stack web developer and author.

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

image
No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project.

Visit No Compromises
Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $7500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $2500/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Lucky Media logo

Lucky Media

Bespoke software solutions built for your business. We ♥ Laravel

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
LaraJobs logo

LaraJobs

The official Laravel job board

LaraJobs
Larafast: Laravel SaaS Starter Kit logo

Larafast: Laravel SaaS Starter Kit

Larafast is a Laravel SaaS Starter Kit with ready-to-go features for Payments, Auth, Admin, Blog, SEO, and beautiful themes. Available with VILT and TALL stacks.

Larafast: Laravel SaaS Starter Kit
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
Rector logo

Rector

Your partner for seamless Laravel upgrades, cutting costs, and accelerating innovation for successful companies

Rector

The latest

View all →
Microsoft Clarity Integration for Laravel image

Microsoft Clarity Integration for Laravel

Read article
Apply Dynamic Filters to Eloquent Models with the Filterable Package image

Apply Dynamic Filters to Eloquent Models with the Filterable Package

Read article
Property Hooks Get Closer to Becoming a Reality in PHP 8.4 image

Property Hooks Get Closer to Becoming a Reality in PHP 8.4

Read article
Asserting Exceptions in Laravel Tests image

Asserting Exceptions in Laravel Tests

Read article
Reversible Form Prompts and a New Exceptions Facade in Laravel 11.4 image

Reversible Form Prompts and a New Exceptions Facade in Laravel 11.4

Read article
Basset is an alternative way to load CSS & JS assets image

Basset is an alternative way to load CSS & JS assets

Read article