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:
So this is precisely the plan.
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:
auth
Middleware)admin
role to access it (custom 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:
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:
/Auth
to separate the login/register/profile/password-reset Controllers in the future--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:
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:
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:
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.
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:
Here's the first one.
php artisan make:test LoginTest
We need to test two scenarios:
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?
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!