edit_document// BLOG_POST.md

Building REST APIs with Laravel 12: Structure, Auth, and Testing

//

, ,

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


arrow_circle_right// POST_NAVIGATION

forum// COMMENTS

Leave a Reply

Your email address will not be published. Required fields are marked *