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:
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
subfolderFor 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:
Route::get('v1/travels', ...)
. But then we would need to add that prefix in all the routes later, repeating the same code.Route::prefix('v1')->group()
with all the Routes. A better solution, but I prefer an even better third one.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.
A few things could be improved:
created_at
and updated_at
timestamps of Travel;is_public
field because we're already filtering only public travels;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...
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.
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:
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
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