Laravel remains one of the most productive frameworks for building APIs. Version 12, released in early 2025, continues the framework’s focus on developer experience while running on PHP 8.2+. If you need a backend API with authentication, validation, database access, and testing in place fast, Laravel is hard to beat. This guide builds a complete API from scratch with patterns you can use in production.
Project Setup
# Create a new Laravel project
composer create-project laravel/laravel task-api
cd task-api
# Install Sanctum for API token authentication
php artisan install:api
# Create the Task model with migration, factory, seeder, and controller
php artisan make:model Task -mfsc --api
The --api flag generates a resource controller with index, store, show, update, and destroy methods but omits create and edit (which are for HTML forms). This is exactly what you want for a JSON API.
Database Migration
// database/migrations/xxxx_create_tasks_table.php
public function up(): void
{
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title', 255);
$table->text('description')->nullable();
$table->enum('status', ['pending', 'in_progress', 'completed'])->default('pending');
$table->enum('priority', ['low', 'medium', 'high'])->default('medium');
$table->date('due_date')->nullable();
$table->timestamps();
// Composite index for common query pattern
$table->index(['user_id', 'status', 'due_date']);
});
}
Model with Relationships and Scopes
// app/Models/Task.php
namespace AppModels;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentRelationsBelongsTo;
use IlluminateDatabaseEloquentBuilder;
class Task extends Model
{
use HasFactory;
protected $fillable = [
'title',
'description',
'status',
'priority',
'due_date',
];
protected $casts = [
'due_date' => 'date',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// Query scopes for reusable filtering
public function scopeStatus(Builder $query, string $status): Builder
{
return $query->where('status', $status);
}
public function scopeOverdue(Builder $query): Builder
{
return $query->where('due_date', '<', now())
->whereNot('status', 'completed');
}
}
Form Request Validation
Never validate in the controller. Laravel’s Form Requests separate validation logic into dedicated classes:
// app/Http/Requests/StoreTaskRequest.php
namespace AppHttpRequests;
use IlluminateFoundationHttpFormRequest;
use IlluminateValidationRule;
class StoreTaskRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Auth handled by middleware
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
'status' => ['sometimes', Rule::in(['pending', 'in_progress', 'completed'])],
'priority' => ['sometimes', Rule::in(['low', 'medium', 'high'])],
'due_date' => ['nullable', 'date', 'after:today'],
];
}
}
API Resource for Response Shaping
// app/Http/Resources/TaskResource.php
namespace AppHttpResources;
use IlluminateHttpRequest;
use IlluminateHttpResourcesJsonJsonResource;
class TaskResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'due_date' => $this->due_date?->toDateString(),
'is_overdue' => $this->due_date?->isPast() && $this->status !== 'completed',
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
];
}
}
Controller Implementation
// app/Http/Controllers/TaskController.php
namespace AppHttpControllers;
use AppHttpRequestsStoreTaskRequest;
use AppHttpRequestsUpdateTaskRequest;
use AppHttpResourcesTaskResource;
use AppModelsTask;
use IlluminateHttpRequest;
use IlluminateHttpResourcesJsonAnonymousResourceCollection;
class TaskController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$tasks = $request->user()
->tasks()
->when($request->query('status'), fn ($q, $status) => $q->status($status))
->when($request->boolean('overdue'), fn ($q) => $q->overdue())
->orderByDesc('created_at')
->paginate(25);
return TaskResource::collection($tasks);
}
public function store(StoreTaskRequest $request): TaskResource
{
$task = $request->user()->tasks()->create($request->validated());
return new TaskResource($task);
}
public function show(Request $request, Task $task): TaskResource
{
// Policy-based authorization
$this->authorize('view', $task);
return new TaskResource($task);
}
public function update(UpdateTaskRequest $request, Task $task): TaskResource
{
$this->authorize('update', $task);
$task->update($request->validated());
return new TaskResource($task);
}
public function destroy(Request $request, Task $task): IlluminateHttpJsonResponse
{
$this->authorize('delete', $task);
$task->delete();
return response()->json(null, 204);
}
}
Routes with Sanctum Auth
// routes/api.php
use AppHttpControllersTaskController;
use IlluminateSupportFacadesRoute;
// Public: issue tokens
Route::post('/login', function (IlluminateHttpRequest $request) {
$request->validate(['email' => 'required|email', 'password' => 'required']);
$user = AppModelsUser::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
return response()->json(['message' => 'Invalid credentials'], 401);
}
return response()->json([
'token' => $user->createToken('api')->plainTextToken,
]);
});
// Protected: require valid Sanctum token
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('tasks', TaskController::class);
});
Testing with Pest
// tests/Feature/TaskApiTest.php
use AppModelsTask;
use AppModelsUser;
beforeEach(function () {
$this->user = User::factory()->create();
$this->actingAs($this->user);
});
it('lists only the authenticated user tasks', function () {
Task::factory()->count(3)->for($this->user)->create();
Task::factory()->count(2)->create(); // Other user's tasks
$this->getJson('/api/tasks')
->assertOk()
->assertJsonCount(3, 'data');
});
it('creates a task with valid data', function () {
$this->postJson('/api/tasks', [
'title' => 'Ship feature',
'priority' => 'high',
'due_date' => now()->addWeek()->toDateString(),
])
->assertCreated()
->assertJsonPath('data.title', 'Ship feature');
$this->assertDatabaseHas('tasks', ['title' => 'Ship feature']);
});
it('rejects invalid data with 422', function () {
$this->postJson('/api/tasks', ['title' => ''])
->assertUnprocessable()
->assertJsonValidationErrors('title');
});
it('prevents access to another user task', function () {
$otherTask = Task::factory()->create();
$this->getJson("/api/tasks/{$otherTask->id}")
->assertForbidden();
});
This structure gives you clean separation of concerns: controllers stay thin, validation lives in Form Requests, response shaping lives in Resources, authorization lives in Policies, and tests verify the full HTTP contract. It scales cleanly from a side project to a production API serving thousands of requests per second.
Further reading: Laravel 12 Docs | Sanctum Auth | Pest PHP Testing

Leave a Reply