How to Set Up a Laravel REST API: A Step-by-Step Tutorial
Posted: August 14, 2024 • 11 min read 2062 word count
By: Ormel Flores
laravelapimysqlIn this tutorial, we’ll walk you through the process of setting up a basic REST API using Laravel. Whether you're new to Laravel or looking to refresh your skills, this guide will help you understand how to create a functional API from scratch. By the end of this tutorial, you’ll have a working API that can handle CRUD (Create, Read, Update, Delete) operations, and you'll be equipped with the knowledge to extend it further as needed.
Task
Let's say we need to do a simple web-based IP address management that will allow us to record an IP address and add a label or comment. For example, we might create an entry for 192.168.1.1, and label it local-development. Or we might label it Test. Additionally, we might need to add audit trails to track the changes.
Step 1: Install Laravel
composer create-project --prefer-dist laravel/laravel ip-address-api
This command will set up a new Laravel project in a directory named ip-address-api.
Step 2: Set Up the Environment
Navigate into your project directory:
cd ip-address-api
Open the .env
file to configure your environment settings. You'll need to set up your database connection here. For example, if you’re using MySQL, update the following lines:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=your_database_name
DB_USERNAME=your_database_user
DB_PASSWORD=your_database_password
Make sure to create a new database in your MySQL server with the name specified in DB_DATABASE
.
Step 3: Create a Migration and Model
Let's create a migration and model for a simple resource, such as IpAddress
and AuditLog
. Run the following command to create both:
php artisan make:model IpAddress -m
php artisan make:model AuditLog -m
The -m
flag creates a migration file along with the model. Open the newly created migration file located in database/migrations
and define the columns for your ip_addresses
and audit_logs
table. For example:
IP Address Table
public function up()
{
Schema::create('ip_addresses', function (Blueprint $table) {
$table->id();
$table->string('ip_address')->unique();
$table->string('label');
$table->timestamps();
});
}
Audit Log Table
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(User::class)->constrained()->cascadeOnDelete();
$table->string('action');
$table->longText('description')->nullable();
$table->json('details');
$table->timestamps();
});
}
Run the migration to create the table in your database:
php artisan migrate
Add the mass assignment within IpAddress.php
and AuditLog.php
and define relationship if needed.
AuditLog.php
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'action',
'description',
'details',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
IpAddress.php
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'ip_address',
'label',
];
Step 4: Setup API Authentication
On this part, we will be using sanctum for simplicity.
- Install Laravel Sanctum
You may install Laravel Sanctum via the install:api
Artisan command:
php artisan install:api
- Sanctum Configuration
To begin issuing tokens for users, your User model should use the Laravel\Sanctum\HasApiTokens
trait:
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}
Step 5: Create a Controller
Let's create a controllers for the following: Login Controller
, IP Address Controller
and Audit Log Controller
.
- Run the following command to create a controller for your API:
php artisan make:controller LoginController
php artisan make:controller IpAddressController
php artisan make:controller AuditLogController
- Let's add a
login
andlogout
function forLogin Controller
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\AuthenticateUserRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
class LoginController extends Controller
{
// Login user and create access token
public function login(AuthenticateUserRequest $request): JsonResponse
{
$credentials = $request->only('email', 'password');
if (! Auth::attempt($credentials))
{
return $this->failedLoginAttemptResponse();
}
$user = Auth::user();
$token = $user->createToken('authToken')->plainTextToken;
return response()->json([
'message' => 'Login successful.',
'data' => $this->loginSuccessfulResponse($user, $token),
], 201);
}
// Logout user and revoke current access token
public function logout(): JsonResponse
{
if (Auth::check())
{
Auth::user()->currentAccessToken()->delete();
}
return response()->json([
'message' => 'Logout successful.',
], 201);
}
// Failed login attempt response
protected function failedLoginAttemptResponse(): JsonResponse
{
return response()->json([
'message' => 'Invalid login credentials.',
'errors' => [
'details' => 'These credentials do not match our records.',
],
], 422);
}
// Successful response
protected function loginSuccessfulResponse(User $user, string $token): array
{
return [
'user' => [
'email' => $user->email,
'name' => $user->name,
],
'accessToken' => $token,
];
}
}
You might have notice, that we have a separate file for request. To do this, you need to run the following command:
php artisan make:request AuthenticateUserRequest
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => 'required|email',
'password' => 'required|string',
];
}
- Configure IP address controller, what we needed is to list, create and update IP address.
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\StoreIpAddressRequest;
use App\Http\Requests\Api\UpdateIpAddressRequest;
use App\Http\Resources\IpAddressResource;
use App\Models\IpAddress;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class IpAddressController extends Controller
{
// IP Address lists
public function index(): AnonymousResourceCollection
{
return IpAddressResource::collection(IpAddress::latest('id')->paginate(20, ['*'], 'ip_addresses'));
}
// Store IP address
public function store(StoreIpAddressRequest $request): JsonResponse
{
IpAddress::create($request->only(['ip_address', 'label']));
return response()->json([
'message' => 'The IP address has been saved successfully.',
], 201);
}
// Update IP address label
public function update(IpAddress $ipAddress, UpdateIpAddressRequest $request): JsonResponse
{
$ipAddress->update($request->only(['label']));
return response()->json([
'message' => 'The IP address label has been updated.',
], 201);
}
}
Since we needed to make sure that the request is sanitized properly, add the following request validation:
StoreIpAddressRequest.php
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'ip_address' => 'required|ip|max:255|unique:ip_addresses,ip_address',
'label' => 'required|string|max:255',
];
}
UpdateIpAddressRequest.php
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'label' => 'required|string|max:255',
];
}
- Add functionality to
AuditLogController
where we can see lists of audit logs.
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\AuditLogResource;
use App\Models\AuditLog;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class AuditLogController extends Controller
{
// Audit Log lists
public function index(): AnonymousResourceCollection
{
return AuditLogResource::collection(AuditLog::latest('id')->paginate(20, ['*'], 'audit_logs'));
}
}
To create a resource file, we needed to run the following command:
php artisan make:resource AuditLogResource
We only needed to customize the response for
AuditLogResource
, here's the changes we needed to do:
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'action' => $this->action,
'user' => $this->user->email,
'description' => $this->description,
'date_created' => date('F d, Y', strtotime($this->created_at)),
'time_created' => date('h:i:s:A', strtotime($this->created_at)),
];
}
Step 6: Integrate Laravel Observer
As I've said at the beginning, we needed to track the changes within the IP address and user activity on the system.
If you do not know what laravel observer is, try to visit our previous article about it: Laravel Observer
Login Controller
login function
$token = $user->createToken('authToken')->plainTextToken;
(new UserActivityObserver)->login(); //+++
return response()->json([
'message' => 'Login successful.',
'data' => $this->loginSuccessfulResponse($user, $token),
], 201);
logout function
if (Auth::check())
{
(new UserActivityObserver)->logout(); //+++
Auth::user()->currentAccessToken()->delete();
}
IP Address Model
#[ObservedBy([IpAddressObserver::class])] //+++
class IpAddress extends Model
UserActivityObserver
// UserActivityObserver.php
<?php
namespace App\Observers;
use App\Actions\StoreAuditLog;
use hisorange\BrowserDetect\Parser as Browser;
class UserActivityObserver
{
/**
* Handle the Login event.
*/
public function login(): void
{
StoreAuditLog::run(
'Login',
'Login using '.Browser::browserFamily().' on '.Browser::platformFamily(),
[]
);
}
/**
* Handle the Logout event.
*/
public function logout(): void
{
StoreAuditLog::run(
'Logout',
'Logout from '.Browser::browserFamily().' on '.Browser::platformFamily(),
[]
);
}
}
I used third-party package to trackdown the user browser and device, but you can define your own functionality.
IpAddressObserver
// IpAddressObserver.php
<?php
namespace App\Observers;
use App\Actions\StoreAuditLog;
use App\Models\IpAddress;
class IpAddressObserver
{
/**
* Handle the IpAddress "created" event.
*/
public function created(IpAddress $ipAddress): void
{
StoreAuditLog::run(
'Created',
"Added a new IP address: {$ipAddress->ip_address} with label: {$ipAddress->label}",
[
'ip_address' => $ipAddress->ip_address,
'old_label' => $ipAddress->label,
'new_label' => $ipAddress->label,
]
);
}
/**
* Handle the IpAddress "updated" event.
*/
public function updated(IpAddress $ipAddress): void
{
StoreAuditLog::run(
'Updated',
"Edited IP address: {$ipAddress->ip_address}; Old label: {$ipAddress->getOriginal()['label']}; New label: {$ipAddress->getChanges()['label']}",
[
'ip_address' => $ipAddress->ip_address,
'old_label' => $ipAddress->getOriginal()['label'],
'new_label' => $ipAddress->getChanges()['label'],
]
);
}
}
// StoreAuditLog.php
<?php
namespace App\Actions;
use App\Actions\Traits\AsObject;
use App\Models\AuditLog;
class StoreAuditLog
{
use AsObject;
/**
* Store audit log action
*/
public function handle(string $action, string $description, array $data): void
{
AuditLog::create([
'action' => $action,
'description' => $description,
'details' => json_encode([
'user_ip_address' => $this->getIpAddress(),
'user_agent' => request()->header('User-Agent'),
'data' => $data,
]),
'user_id' => auth()->user()->id,
]);
}
// Get user ip address
protected function getIpAddress(): string
{
return (isset($_SERVER['HTTP_CF_CONNECTING_IP']))
? $_SERVER['HTTP_CF_CONNECTING_IP']
: request()->ip();
}
}
Step 6: Define routes
Add routes inside routes/api.php
:
<?php
use App\Http\Controllers\Api\AuditLogController;
use App\Http\Controllers\Api\IpAddressController;
use App\Http\Controllers\Api\LoginController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () {
Route::controller(LoginController::class)->group(function () {
Route::post('/login', 'store')->middleware('guest')->name('api.user.authenticate');
Route::post('/logout', 'logout')->middleware('auth:sanctum')->name('api.user.logout');
});
Route::middleware('auth:sanctum')->group(function () {
Route::controller(IpAddressController::class)->group(function () {
Route::get('/ip-address', 'index')->name('api.ip_address.index');
Route::post('/ip-address', 'store')->name('api.ip_address.store');
Route::patch('/ip-address/{ipAddress}', 'update')->name('api.ip_address.update');
});
Route::get('/audit-logs', [AuditLogController::class, 'index'])->name('api.audit_logs.index');
});
});
Step 8: Implement Unit Testing
To ensure that our api endpoints are working properly, we needed to apply unit testing. You can use laravel default PHPUnit
or PestPHP
. Right now, I will be using PestPHP because of it's elegant style when creating a test cases. If you want to go deeper with PestPHP, just visit their documentation.
- Install PestPHP:
composer remove phpunit/phpunit
composer require pestphp/pest --dev --with-all-dependencies
- You'll need to initialize Pest in your current PHP project. This step will create a configuration file named Pest.php at the root level of your test suite, which will enable you to fine-tune your test suite later.
./vendor/bin/pest --init
- You can run your tests by executing the pest command.
./vendor/bin/pest
- On
phpunit.xml
, you'll notice that there's the comment section on the file. To run the test faster, remove those comments.
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
- Let's create test cases for
audit log
,ip address
andauthentication process
. To create a test file, run the following command:
php artisan pest:test AuditLogTest
php artisan pest:test IpAddressTest
php artisan pest:test AuthenticationTest
AuditLogTest
<?php
use App\Http\Resources\AuditLogResource;
use App\Models\AuditLog;
use App\Models\User;
use function Pest\Laravel\json;
it('can not access audit log api lists without logged in user', function () {
json('GET', route('api.audit_logs.index'))
->assertStatus(401)
->assertJsonFragment(['message' => 'Unauthenticated.']);
});
it('can access audit log api lists successfuly', function () {
$user = User::factory()->create();
json('POST', route('api.user.authenticate'), ['email' => $user->email, 'password' => 'password'])
->assertStatus(201)
->assertJsonFragment(['message' => 'Login successful.']);
$data = AuditLogResource::collection(AuditLog::latest('id')->paginate(20, ['*'], 'audit_logs'))->toArray(request());
json('GET', route('api.audit_logs.index'))
->assertStatus(200)
->assertJsonFragment($data);
});
AuthenticationTest
<?php
use App\Models\AuditLog;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
use function Pest\Laravel\assertDatabaseCount;
use function Pest\Laravel\json;
it('will validate required data', function ($key, $value) {
json('POST', route('api.user.authenticate'), [$key => $value])
->assertStatus(422)
->assertJsonValidationErrors($key);
})->with([
['email', 'not-valid-email'],
['password', ''],
]);
it('can authenticate user successfully', function () {
$user = User::factory()->create();
json('POST', route('api.user.authenticate'), ['email' => $user->email, 'password' => 'password'])
->assertStatus(201)
->assertJsonFragment(['message' => 'Login successful.']);
assertDatabaseCount(AuditLog::class, 1);
});
it('will not be able to authenticate user with incorrect password', function () {
$user = User::factory()->create();
json('POST', route('api.user.authenticate'), ['email' => $user->email, 'password' => 'incorrect-password'])
->assertStatus(422)
->assertJsonFragment(['message' => 'Invalid login credentials.']);
assertDatabaseCount(AuditLog::class, 0);
});
it('can logout user successfully', function () {
$user = User::factory()->create();
Sanctum::actingAs($user);
json('POST', route('api.user.logout'))
->assertStatus(201)
->assertJsonFragment(['message' => 'Logout successful.']);
assertDatabaseCount(AuditLog::class, 1);
});
IpAddressTest
<?php
use App\Http\Resources\IpAddressResource;
use App\Models\AuditLog;
use App\Models\IpAddress;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
use function Pest\Laravel\assertDatabaseCount;
use function Pest\Laravel\assertDatabaseHas;
use function Pest\Laravel\json;
use function PHPUnit\Framework\assertNotEquals;
it('will validate required data', function ($key, $value) {
$user = User::factory()->create();
Sanctum::actingAs($user);
json('POST', route('api.ip_address.store'), [$key => $value])
->assertStatus(422)
->assertJsonValidationErrors($key);
})->with([
['ip_address', 'not-valid-ip-address'],
['label', ''],
]);
it('can not access ip address api without logged in user', function () {
json('POST', route('api.ip_address.store'), ['ip_address' => '127.0.0.1', 'label' => 'Default'])
->assertStatus(401)
->assertJsonFragment(['message' => 'Unauthenticated.']);
assertDatabaseCount(IpAddress::class, 0);
});
it("can not save same ip address if it's already existing", function () {
$user = User::factory()->create();
Sanctum::actingAs($user);
$ip = IpAddress::factory()->create();
json('POST', route('api.ip_address.store'), ['ip_address' => $ip->ip_address, 'label' => 'Default'])
->assertStatus(422)
->assertJsonFragment(['message' => 'The ip address has already been taken.']);
assertDatabaseCount(IpAddress::class, 1);
});
it('can successfully create ip address', function () {
$user = User::factory()->create();
Sanctum::actingAs($user);
$data = IpAddress::factory()->make()->toArray();
json('POST', route('api.ip_address.store'), $data)
->assertStatus(201)
->assertJsonFragment(['message' => 'The IP address has been saved successfully.']);
assertDatabaseCount(IpAddress::class, 1);
assertDatabaseCount(AuditLog::class, 1);
assertDatabaseHas('ip_addresses', $data);
});
it('can not change ip address label with null value', function () {
$user = User::factory()->create();
Sanctum::actingAs($user);
$ip = IpAddress::factory()->create();
$data = ['label' => null];
json('PATCH', route('api.ip_address.update', $ip->id), $data)
->assertStatus(422)
->assertJsonFragment(['message' => 'The label field is required.']);
assertNotEquals($ip->label, $data['label']);
});
it('can change ip address label', function () {
$user = User::factory()->create();
Sanctum::actingAs($user);
$ip = IpAddress::factory()->create();
$data = ['label' => fake()->name()];
json('PATCH', route('api.ip_address.update', $ip->id), $data)
->assertStatus(201)
->assertJsonFragment(['message' => 'The IP address label has been updated.']);
assertDatabaseHas('ip_addresses', $data);
assertDatabaseCount(AuditLog::class, 2);
});
it('can view lists of ip addresses', function () {
$user = User::factory()->create();
Sanctum::actingAs($user);
IpAddress::factory()->create();
$data = IpAddressResource::collection(IpAddress::latest('id')->paginate(20, ['*'], 'ip_addresses'))->toArray(request());
json('GET', route('api.ip_address.index'))
->assertStatus(200)
->assertJsonFragment($data);
});
Conclusion
You’ve successfully integrated API authentication into your Laravel application using Sanctum. With this setup, your API routes are now protected, and only authenticated users with valid tokens can access them. Laravel Sanctum provides a simple and effective solution for API token management and is a great choice for many applications.
Feel free to customize and expand your authentication system based on your application’s needs. Happy coding!
You can check the repository here: ormelflores/ip-address-back-end
We'll continue on the front-end, where we will integrate the API we have created into Nuxt.js. See you!
This post is licensed under CC BY 4.0 by the author.
Related Topics