Back to Course |
Laravel Travel Agency API From Scratch

Admin Endpoint: Create Travels

As we have our Admin users created with the Artisan command, we can log in and create Travel records.

Task Description from Client is extremely simple: "A private (admin) endpoint to create new travels".

I'm a fan of doing things in this order:

  1. Create the endpoint without Auth
  2. Only then protect it with Middleware

So this is precisely the plan.


Controller / Route / Request

We generate the Controller:

php artisan make:controller Api/V1/Admin/TravelController

In addition to previously used Api/V1 prefixes, I added the third one, /Admin, to separate the Controllers for administrators.

Then, in the Routes, we add this:

routes/api.php:

use App\Http\Controllers\Api\V1\Admin;
 
// ...
 
Route::get('travels', [TravelController::class, 'index']);
Route::get('travels/{travel:slug}/tours', [TourController::class, 'index']);
 
Route::prefix('admin')->group(function () {
Route::post('travels', [Admin\TravelController::class, 'store']);
});

As you can see, now we have two Controllers with the same name of TravelController, just in different namespaces, so be careful which one you use in the Routes.

Did you know you can import use XXXXX as the entire namespace and then use it as a prefix for the route itself, like Admin\TravelController in my example above?

Alternatively, you can name those Controllers differently for clarity.

Next, let's generate a Form Request class for the validation and immediately use it in the Controller method.

php artisan make:request TravelRequest

I deliberately called it TravelRequest and not StoreTravelRequest because soon, in the following lessons, we will build the update() method with the same validation rules.

Now, inside the Controller, we will have this:

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

namespace App\Http\Controllers\Api\V1\Admin;
 
use App\Http\Controllers\Controller;
use App\Http\Requests\TravelRequest;
use App\Models\Travel;
 
class TravelController extends Controller
{
public function store(TravelRequest $request)
{
$travel = Travel::create($request->validated());
 
return new TravelResource($travel);
}
}

And guess what: we're reusing the same TravelResource we used in the previous lesson to return the Travels list. Cool!

Now, here are the validation rules:

app/Http/Requests/TravelRequest.php:

class TravelRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
 
public function rules(): array
{
return [
'is_public' => 'boolean',
'name' => ['required', 'unique:travels'],
'description' => ['required'],
'number_of_days' => ['required', 'integer'],
];
}
}

Time to try it out in Postman. Works!

Now, the protection of this endpoint will come in two steps:

  1. We will allow only logged-in users to access it (auth Middleware)
  2. We will allow only users with an admin role to access it (custom Middleware)

Login and Auth Middleware

In a typical API project, developers use Laravel Sanctum for Auth. Different options exist to implement it, depending on the project structure of who would consume the API.

In this case, we don't have this context from the client, so we assume it will be any client: mobile application, front-end website, and more.

So this is how all Auth endpoints will work:

  1. The client calls the Login endpoint and gets a personal access token
  2. For every auth-protected endpoint, they use that token as a Bearer token

So, let's create the Login Controller and Route to implement that:

php artisan make:controller Api/V1/Auth/LoginController --invokable

Two things to comment here:

  • Another new sub-namespace of /Auth to separate the login/register/profile/password-reset Controllers in the future
  • The flag --invokable will generate a Single Action Controller with only one method __invoke(). I envision there won't be more methods in that Controller, so it's a good example for being Invokable.

app/Http/Controllers/Api/V1/Auth/LoginController.php:

namespace App\Http\Controllers\Api\V1\Auth;
 
use App\Http\Controllers\Controller;
 
class LoginController extends Controller
{
public function __invoke()
{
// ...
}
}

Then, in the Routes, we call it like this:

routes/api.php:

use App\Http\Controllers\Api\V1\Auth\LoginController;
 
// ...
 
Route::prefix('admin')->group(function () {
Route::post('travels', [Admin\TravelController::class, 'store']);
});
 
Route::post('login', LoginController::class);

Notice: you may add a prefix, like /api/v1/auth/login instead of just /api/v1/login if you wish.

Now, let's fill in the Controller with code.

The Form Request class of LoginRequest will be the first "wave" of validation.

php artisan make:request LoginRequest

With these rules:

app/Http/Requests/LoginRequest.php:

class LoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
 
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
}

And we use it inside the Controller:

app/Http/Controllers/Api/V1/Auth/LoginController.php:

use App\Http\Requests\LoginRequest;
 
class LoginController extends Controller
{
public function __invoke(LoginRequest $request)
{
// ...
}
}

And then, the plan of action:

  1. We get the User from the DB by their email
  2. We checked the provided password against the hashed password in the DB
  3. If the passwords don't match, we return the 422 status code with a validation error
  4. If passwords are ok, we generate and return a personal access token with Laravel Sanctum

Here's the plan above in the code version:

app/Http/Controllers/Api/V1/Auth/LoginController.php:

use App\Http\Requests\LoginRequest;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
 
class LoginController extends Controller
{
public function __invoke(LoginRequest $request)
{
$user = User::where('email', $request->email)->first();
 
if (! $user || ! Hash::check($request->password, $user->password)) {
return response()->json([
'error' => 'The provided credentials are incorrect.',
], 422);
}
 
$device = substr($request->userAgent() ?? '', 0, 255);
 
return response()->json([
'access_token' => $user->createToken($device)->plainTextToken,
]);
}
}

You can read more about generating such tokens in the official Laravel Sanctum docs.

Let's try it in Postman.

Here's what it will return in case of an invalid email/password:

And if we provide the correct credentials, it will return the token:


Using Sanctum Token for Endpoints

Now as we have the access token, we need to use it for authentication.

First, we add the auth:sanctum Middleware to the endpoint of creating Travels.

routes/api.php:

Route::prefix('admin')->middleware(['auth:sanctum'])->group(function () {
Route::post('travels', [Admin\TravelController::class, 'store']);
});

Now, if someone just calls the endpoint without any token, they will get a 401 status code error:

The "trick" is adding the above token as a Bearer token. Here's how it looks in Postman:

If we provide the token, we should see a successful result, similar to the pre-auth example a few sections ago:


Checking Role

Finally, let's add the role check and allow only admins to access the endpoint.

Laravel doesn't provide this functionality inside Sanctum or auth Middleware because the framework knows nothing about our custom role logic. So we need to create this check ourselves manually.

php artisan make:middleware RoleMiddleware

Notice: again, as with every class, I like to add suffixes, like XXXXXMiddleware, at the end. It helps to immediately understand what the file does, looking just at the filename.

Inside that Middleware, we have this check:

app/Http/Middleware/RoleMiddleware.php:

namespace App\Http\Middleware;
 
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
 
class RoleMiddleware
{
public function handle(Request $request, Closure $next, string $role): Response
{
if (! auth()->check()) {
abort(401);
}
 
if (! auth()->user()->roles()->where('name', $role)->exists()) {
abort(403);
}
 
return $next($request);
}
}

The logic of Middlewares is to add the checks with if-statements or similar, and in some cases, "abort the mission". If no error happens, the request proceeds further with the $next($request) sentence.

In this case, we check if the user is logged in and has the specific role, which is a parameter of the function.

So now we can use it in our Routes, like this:

Route::prefix('admin')->middleware(['auth:sanctum', 'role:admin'])->group(function () {
Route::post('travels', [Admin\TravelController::class, 'store']);
});

To make it work, we also need to register the "role" name of our Middleware:

app/Http/Kernel.php:

use App\Http\Middleware\RoleMiddleware;
 
// ...
 
class Kernel extends HttpKernel
{
// ...
 
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
 
// ... other middlewares
 
'role' => RoleMiddleware::class,
];
}

Now, only the admin role user can access our endpoint. In case of being logged in with another role, editor, they would get a 403 status code error.


Automated Tests

For this lesson, writing tests is very important.

If you have limited time for writing tests and must choose which features to write them for, the auth and permission check should be the top option.

There's a great quote by Matt Stauffer: "First, test the features that, if they fail, would make you lose your job".

And in this case, if someone adds/updates the records without permission, it may be a huge security risk with significant consequences.

So, we will have two testing files in this lesson:

  • LoginTest
  • AdminTravelTest

Here's the first one.

php artisan make:test LoginTest

We need to test two scenarios:

  • If we pass invalid credentials, Laravel returns a 422 code with a validation error
  • If we pass valid credentials, Laravel returns a 200 code with the access token

tests/Feature/LoginTest.php:

class LoginTest extends TestCase
{
use RefreshDatabase;
 
public function test_login_returns_token_with_valid_credentials(): void
{
$user = User::factory()->create();
 
$response = $this->postJson('/api/v1/login', [
'email' => $user->email,
'password' => 'password',
]);
 
$response->assertStatus(200);
$response->assertJsonStructure(['access_token']);
}
 
public function test_login_returns_error_with_invalid_credentials(): void
{
$response = $this->postJson('/api/v1/login', [
'email' => 'nonexisting@user.com',
'password' => 'password',
]);
 
$response->assertStatus(422);
}
}

Simple but essential tests to ensure the app is working in the future.

Next, the second test file for this lesson:

php artisan make:test AdminTravelTest

What scenarios do we need to test here?

  • Test that the non-logged-in user can't access the endpoint
  • Test that a logged-in user with a non-admin role can't access the endpoint
  • Test that the admin user can successfully use the endpoint

tests/Feature/AdminTravelTest.php:

use App\Models\Role;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
 
class AdminTravelTest extends TestCase
{
use RefreshDatabase;
 
public function test_public_user_cannot_access_adding_travel(): void
{
$response = $this->postJson('/api/v1/admin/travels');
 
$response->assertStatus(401);
}
 
public function test_non_admin_user_cannot_access_adding_travel(): void
{
$this->seed(RoleSeeder::class);
$user = User::factory()->create();
$user->roles()->attach(Role::where('name', 'editor')->value('id'));
$response = $this->actingAs($user)->postJson('/api/v1/admin/travels');
 
$response->assertStatus(403);
}
 
public function test_saves_travel_successfully_with_valid_data(): void
{
$this->seed(RoleSeeder::class);
$user = User::factory()->create();
$user->roles()->attach(Role::where('name', 'admin')->value('id'));
 
$response = $this->actingAs($user)->postJson('/api/v1/admin/travels', [
'name' => 'Travel name',
]);
$response->assertStatus(422);
 
$response = $this->actingAs($user)->postJson('/api/v1/admin/travels', [
'name' => 'Travel name',
'is_public' => 1,
'description' => 'Some description',
'number_of_days' => 5,
]);
 
$response->assertStatus(201);
 
$response = $this->get('/api/v1/travels');
$response->assertJsonFragment(['name' => 'Travel name']);
}
}

The interesting bit here is seeding the Roles. By default, the use RefreshDatabase; will only prepare the structure of the DB tables but not the data. So if you need the seeders, you should call them manually, like this.

Alternatively, you may add a protected $seed = true; property globally in your TestCase, so the seeders would run automatically along with RefreshDatabase.

Now, we launch the test suite. And we already have 15 successful tests, great!


GitHub commits for this lesson:


Links to read/watch more: