Back to Course |
Laravel Travel Agency API From Scratch

Public Endpoint /travels with Feature Tests

Time for us to build the first API endpoint. The description from the client looks like this:

A public (no auth) endpoint to get a list of paginated travels. It must return only public travels

So let's transform it into a plan of action for this lesson:

  1. New API Controller and Route
  2. Return all Travels with Pagination and API Resource
  3. Write automated Tests for it with Factories

Controller and Route with Prefix

First, let's generate the Controller:

php artisan make:controller Api/V1/TravelController

As you can see, I immediately add two subfolders:

  • /Api, which means we will store all API controllers in there - maybe in the future, there will be separate /Web Controllers
  • /V1 is an immediate preparation for versioning: it's a personal habit to create API as v1 right away, with the idea that there may be v2 in the future, which would then land in the /Api/V2 subfolder

For now, we need only one method in the Controller, so we have a choice to make it an Invokable, but I have a feeling that there may be more methods like show() in the future, so let's stick to a typical Route::resource() naming and create an index() method:

app/Http/Controllers/Api/V1/TravelController.php:

namespace App\Http\Controllers\Api\V1;
 
use App\Http\Controllers\Controller;
use App\Models\Travel;
 
class TravelController extends Controller
{
public function index()
{
return Travel::where('is_public', true)->get();
}
}

We also need the Route for that Controller:

routes/api.php:

use App\Http\Controllers\Api\V1\TravelController;
 
// ...
 
Route::get('travels', [TravelController::class, 'index']);

At this point, the endpoint URL would be /api/travels. But I want to use the versioning in the Routes, the same as with namespaces above, to have /api/v1/travels.

We could add this prefix in three different places:

  1. In the Route itself - Route::get('v1/travels', ...). But then we would need to add that prefix in all the routes later, repeating the same code.
  2. Create a Route::prefix('v1')->group() with all the Routes. A better solution, but I prefer an even better third one.
  3. Specify the prefix in the RouteServiceProvider for all the Routes from the routes/api.php file. See below.

Here's the line that we need to change in the Service Provider:

app/Providers/RouteServiceProvider.php:

class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
// ...
 
$this->routes(function () {
Route::middleware('api')
->prefix('api')
->prefix('api/v1')
->group(base_path('routes/api.php'));
 
Route::middleware('web')
->group(base_path('routes/web.php'));
});
}
}

Great, now if we add a Travel record in the database and launch the endpoint /api/v1/travels in our Postman client, we should see the array like this:

But I see a few problems with just returning the Eloquent model "as is". Let's fix them.


Eloquent API Resource

A few things could be improved:

  • Client doesn't need to know the created_at and updated_at timestamps of Travel;
  • We don't need to return the is_public field because we're already filtering only public travels;
  • We need to return the number_of_nights, which is not returned by default.

To customize the returned result from the API, it's convenient to use so-called API Resources.

php artisan make:resource TravelResource

And then we change the Controller like this:

app/Http/Controllers/Api/V1/TravelController.php:

use App\Http\Resources\TravelResource;
 
// ...
 
public function index()
{
$travels = Travel::where('is_public', true)->get();
 
return TravelResource::collection($travels);
}

In the API Resource class, we define the fields exactly as we want them returned:

app/Http/Resources/TravelResource.php:

class TravelResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
'number_of_days' => $this->number_of_days,
'number_of_nights' => $this->number_of_nights,
];
}
}

And here's the result now in Postman:

Now all the fields are exactly as we described, including the "virtual" column number_of_nights.

You may have also noticed a new layer of the structure called data. It is called "Data wrapping" and is added by default with Eloquent API Resources. You can customize or remove it easily. Still, I encourage you to leave it this way, as it is pretty much a standard thing in the API world, leaving the possibility to add other non-data information later, like pagination.

Speaking of pagination...


Pagination

We're currently returning all the records with the ->get() Eloquent method, but the client asked for pagination. Luckily, in Laravel, it's very easy - just change that ->get() to ->paginate():

class TravelController extends Controller
{
public function index()
{
$travels = Travel::where('is_public', true)->get();
$travels = Travel::where('is_public', true)->paginate();
 
return TravelResource::collection($travels);
}
}

The result in Postman:

{
"data": [
{
"id": "9958e389-5edf-48eb-8ecd-e058985cf3ce",
"name": "First travel",
"slug": "first-travel",
"description": "Great offer!",
"number_of_days": 5,
"number_of_nights": 4
}
],
"links": {
"first": "http://travelapi.test/api/v1/travels?page=1",
"last": "http://travelapi.test/api/v1/travels?page=1",
"prev": null,
"next": null
},
"meta": {
"current_page": 1,
"from": 1,
"last_page": 1,
"links": [
{
"url": null,
"label": "« Previous",
"active": false
},
{
"url": "http://travelapi.test/api/v1/travels?page=1",
"label": "1",
"active": true
},
{
"url": null,
"label": "Next »",
"active": false
}
],
"path": "http://travelapi.test/api/v1/travels",
"per_page": 15,
"to": 1,
"total": 1
}
}

Now you see the point of having data as a wrapper? Laravel adds much information about pagination that API clients could use to form the links to the other pages.

By default, Laravel paginates data with 15 records per page, but you can customize it easily with a parameter like ->paginate(10) or any other number.


Automated Tests and Factory

So, we have our first API endpoint. As I mentioned before, I'm a fan of writing automated tests as soon as we finish the feature while it is "fresh" in the memory.

The client asked for "feature tests", so what features could we test here? I suggest two things:

  • Test that the API endpoint returns the 200 HTTP status code, at least one result, and pagination data
  • Test that it doesn't return the Travel records with is_public = false

But before we generate those tests, let's prepare the whole Laravel test suite for the launch.

We need to remove the default files: Laravel ships with tests/Feature/ExampleTest.php and tests/Unit/ExampleTest.php - I suggest deleting both files, leaving the tests/Unit folder empty for now. You can also watch my video Laravel Feature or Unit Tests: The Difference

We also need to configure a DB connection for the DB operations we will have in our testing methods. It's done in phpunit.xml with two parameters:

// ...
 
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>

These two are commented out by default, so I suggest you reactivate them so that all tests would be launched on an SQLite in-memory database, separately from your main local database.

Another option is to create a separate MySQL database called travelapi_testing, for example, then add it to the config/database.php and specify its name in this phpunit.xml file in the DB_CONNECTION and DB_DATABASE parameters. This is more suitable if you have MySQL-syntax-specific functions that may not work the same in SQLite.

Now, we're ready to generate our new Test file:

php artisan make:test TravelsListTest

You are free to structure the test files/methods however you want. I prefer to have one file around one Model and different methods inside that file, testing various cases of that Model endpoints.

In this case, we will add two methods in the same TravelsListTest.php file:

tests/Feature/TravelsListTest.php:

namespace Tests\Feature;
 
use App\Models\Travel;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
 
class TravelsListTest extends TestCase
{
use RefreshDatabase;
 
public function test_travels_list_returns_pagination(): void
{
// We need to create 15 + 1 records
Travel::factory(16)->create(['is_public' => true]);
 
$response = $this->get('/api/v1/travels');
 
$response->assertStatus(200);
 
// We check if data returns 15 records and not 16
$response->assertJsonCount(15, 'data');
 
// We also check there are 2 pages in total
$response->assertJsonPath('meta.last_page', 2);
}
 
public function test_travels_list_shows_only_public_records()
{
// We create two Travels: one public and one private
$nonPublicTravel = Travel::factory()->create(['is_public' => false]);
$publicTravel = Travel::factory()->create(['is_public' => true]);
 
$response = $this->get('/api/v1/travels');
 
$response->assertStatus(200);
 
// We check that only the public record is returned
$response->assertJsonCount(1, 'data');
$response->assertJsonFragment(['id' => $publicTravel->id]);
$response->assertJsonMissing(['id' => $nonPublicTravel->id]);
 
}
}

So I've posted the entire code. Now let's explain it piece by piece.

  • use RefreshDatabase is important: it will automatically launch php artisan migrate:fresh on every test launch, which means that we will have an empty database and can simulate our DB operations. Warning: please double-check that you're working on the testing database and not your main one, see the step above in phpunit.xml
  • In every test, we have three steps: prepare the data, launch the endpoint, and check the results.
  • For checking the results, we use various JSON assertion operations. You can find their list in the official docs
  • We use a Factory class for preparing the data, but it doesn't exist yet. Let's generate it now.
php artisan make:factory TravelFactory --model=Travel

In that class, we just specify the rules of "fake" data that would be used when generating records in tests:

database/factories/TravelFactory.php:

class TravelFactory extends Factory
{
public function definition(): array
{
return [
'is_public' => fake()->boolean(),
'name' => fake()->text(20),
'description' => fake()->text(100),
'number_of_days' => rand(1, 10),
];
}
}

Notice: The helper fake() appeared in Laravel 9 and replaced $this->faker that has been used before.

So now, when we launch Travel::factory(10)->create() it would add 10 Travel records into the database.

We can also override columns like we're doing with is_public above: Travel::factory()->create(['is_public' => true]);

So now, we launch php artisan test in the Terminal...

That's it! We've completed our first API endpoint, covered by automated tests.

If you're not that familiar with writing tests and want to dig deeper into it from the very beginning, you can take the 2-hour video course Laravel Testing For Beginners: PHPUnit, Pest, TDD


GitHub commit for this lesson:


Links to read/watch more: