404 Responses in a Laravel API

Tutorials

August 16th, 2018

laravel-api-fallback-route-featured.png

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:

1curl \
2-H"Content-Type:application/json" \
3-H"Accept: application/json" \
4-i http://apidemo.test/not/found
5
6HTTP/1.1 404 Not Found
7Content-Type: application/json
8Transfer-Encoding: chunked
9Connection: keep-alive
10Vary: Accept-Encoding
11Cache-Control: no-cache, private
12Date: Thu, 16 Aug 2018 06:00:42 GMT
13
14{
15 "message": ""
16}

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:

1laravel new apidemo
2cd apidemo/
3
4# Valet users...
5valet link

We’ll configure a MySQL database for this project:

1mysql -u root -e'create database apidemo'

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

1DB_CONNECTION=mysql
2DB_HOST=127.0.0.1
3DB_PORT=3306
4DB_DATABASE=apidemo
5DB_USERNAME=root
6DB_PASSWORD=

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

1<?php
2
3use Illuminate\Database\Seeder;
4
5class DatabaseSeeder extends Seeder
6{
7 /**
8 * Seed the application's database.
9 *
10 * @return void
11 */
12 public function run()
13 {
14 factory('App\User', 10)->create();
15 }
16}

Finally, you can run migrations and the seeder:

1php 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:

1php artisan make:test Api/FallbackRouteTest

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

1<?php
2
3namespace Tests\Feature\Api;
4
5use Tests\TestCase;
6use Illuminate\Foundation\Testing\WithFaker;
7use Illuminate\Foundation\Testing\RefreshDatabase;
8
9class FallbackRouteTest extends TestCase
10{
11 /** @test */
12 public function missing_api_routes_should_return_a_json_404()
13 {
14 $this->withoutExceptionHandling();
15 $response = $this->get('/api/missing/route');
16
17 $response->assertStatus(404);
18 $response->assertHeader('Content-Type', 'application/json');
19 $response->assertJson([
20 'message' => 'Not Found.'
21 ]);
22 }
23}

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

1phpunit --filter=ApiFallbackRouteTest
2...
3There was 1 error:
4
51) Tests\Feature\ApiFallbackRouteTest::missing_api_routes_should_return_a_json_404
6Symfony\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:

1Route::fallback(function(){
2 return response()->json(['message' => 'Not Found.'], 404);
3})->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:

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

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

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

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

1<?php
2
3namespace App\Http\Controllers;
4
5use App\User;
6use App\Http\Resources\User as UserResource;
7use Illuminate\Http\Request;
8
9class UsersController extends Controller
10{
11 public function show(User $user)
12 {
13 return new UserResource($user);
14 }
15}

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:

1php 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:

1<?php
2
3namespace Tests\Feature\Api;
4
5use Tests\TestCase;
6use Illuminate\Foundation\Testing\WithFaker;
7use Illuminate\Database\Eloquent\ModelNotFoundException;
8use Illuminate\Foundation\Testing\RefreshDatabase;
9
10class ViewUserTest extends TestCase
11{
12 /** @test */
13 public function requesting_an_invalid_user_triggers_model_not_found_exception()
14 {
15 $this->withoutExceptionHandling();
16 try {
17 $this->json('GET', '/api/users/123');
18 } catch (ModelNotFoundException $exception) {
19 $this->assertEquals('No query results for model [App\User].', $exception->getMessage());
20 return;
21 }
22
23 $this->fail('ModelNotFoundException should be triggered.');
24 }
25}

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:

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

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:

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

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

1Failed asserting that an array has the subset Array &0 (
2 'message' => 'Not Found.'
3).
4--- Expected
5+++ Actual
6@@ @@
7 Array
8 (
9- [message] => Not Found.
10+ [message] => No query results for model [App\User].

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

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

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

1phpunit --filter=invalid_user_uri_triggers_fallback_route
2...
3OK (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:

1use RefreshDatabase;
2
3/** @test */
4public function guests_can_view_a_valid_user()
5{
6 $user = factory('App\User')->create([
7 'name' => 'LeBron James',
8 'email' => 'lebron@lakers.com',
9 ]);
10
11 $response = $this->json('GET', "/api/users/{$user->id}");
12 $response->assertOk();
13 $response->assertJson([
14 'data' => [
15 'name' => 'LeBron James',
16 'email' => 'lebron@lakers.com',
17 ]
18 ]);
19}

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:

1# app/Exceptions/Handler.php
2use Illuminate\Database\Eloquent\ModelNotFoundException;
3use Illuminate\Support\Facades\Route;
4
5public function render($request, Exception $exception)
6{
7 if ($exception instanceof ModelNotFoundException && $request->isJson()) {
8 return Route::respondWithRoute('api.fallback.404');
9 }
10
11 return parent::render($request, $exception);
12}

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.

Filed in:

Paul Redmond

Full stack web developer. Author of Lumen Programming Guide and Docker for PHP Developers.