404 Responses in a Laravel API
Published on by Paul Redmond
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 FoundContent-Type: application/jsonTransfer-Encoding: chunkedConnection: keep-aliveVary: Accept-EncodingCache-Control: no-cache, privateDate: 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 apidemocd 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=mysqlDB_HOST=127.0.0.1DB_PORT=3306DB_DATABASE=apidemoDB_USERNAME=rootDB_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_404Symfony\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 UsersControllerphp 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.phpuse 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.