Week 15: Building REST APIs & Testing with Pest

Everything through Week 14 rendered Blade views. This week turns the same task-tracker domain into a JSON API — the shape a mobile app or separate frontend would actually consume — using the Sanctum tokens from Week 13, then proves it works with a real automated test suite instead of manual browser clicking.

Module 14 of 28 Week 15 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Shape JSON responses consistently with API Resources
  • Secure a JSON API with Sanctum token authentication
  • Write feature & unit tests with Pest

1. API Routes

routes/api.php is a separate file from routes/web.php — every route inside it is automatically prefixed with /api and uses stateless authentication instead of sessions:

routes/api.php
<?php

use App\Http\Controllers\Api\TaskController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('tasks', TaskController::class);
});

apiResource is resource from Week 10, minus the create and edit routes — an API has no use for "show me a form," since the client renders its own UI and just needs data back.

app/Http/Controllers/Api/TaskController.php
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Task;
use Illuminate\Http\Request;

class TaskController extends Controller
{
    public function index(Request $request)
    {
        return $request->user()->tasks()->with('tags')->get(); // eager-loaded, per Week 12
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:200',
        ]);

        return $request->user()->tasks()->create($validated);
    }
}

Returning an Eloquent model or collection directly from a controller auto-serializes to JSON — convenient, but as the next section covers, it also leaks every column on the model with no control over shape.

2. API Resources

Returning a model directly means every column — including ones like attachment_path or timestamps a client shouldn't need to care about — gets serialized as-is. An API Resource defines exactly what shape a model takes in a response, independent of its actual columns:

terminal
php artisan make:resource TaskResource
app/Http/Resources/TaskResource.php
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class TaskResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'is_done' => $this->is_done,
            'due_date' => $this->due_date?->toDateString(),
            'tags' => $this->whenLoaded('tags', fn () => $this->tags->pluck('name')),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}
using it in the controller
<?php

use App\Http\Resources\TaskResource;

public function index(Request $request)
{
    $tasks = $request->user()->tasks()->with('tags')->get();
    return TaskResource::collection($tasks);
}

public function show(Task $task)
{
    $this->authorize('view', $task);
    return new TaskResource($task->load('tags'));
}

whenLoaded('tags', ...) only includes the tags key if the relationship was actually eager-loaded — this avoids accidentally triggering the N+1 problem from Week 12 by lazy-loading a relationship purely for serialization.

3. Versioning & Status Codes

Once an API has real clients, breaking its response shape breaks them too. Prefixing routes with a version lets you introduce breaking changes in v2 without touching existing v1 clients:

routes/api.php — versioned
<?php

Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
    Route::apiResource('tasks', \App\Http\Controllers\Api\V1\TaskController::class);
});

Return the correct HTTP status code for each outcome — clients depend on this, not just the response body:

  • 200 OK — successful GET/PUT/PATCH
  • 201 Created — successful POST that created a resource
  • 204 No Content — successful DELETE, nothing to return
  • 422 Unprocessable Entity — validation failed (Laravel's default for a failed validate() call on an API request)
  • 403 Forbidden — authenticated, but not authorized (exactly what authorize() throws)
  • 404 Not Found — resource doesn't exist (what findOrFail() throws)
setting a status code explicitly
<?php

public function store(Request $request)
{
    $task = $request->user()->tasks()->create($request->validate(['title' => 'required|max:200']));
    return (new TaskResource($task))->response()->setStatusCode(201);
}

4. Securing the API

With the token issued in Week 13, a client authenticates every request with a header — no session cookie involved at all:

terminal — testing with curl
curl -X GET http://localhost:8000/api/v1/tasks \
  -H "Authorization: Bearer 1|abc123..." \
  -H "Accept: application/json"

auth:sanctum on the route validates that header, resolves it to a User model, and populates $request->user() — from that point on, the rest of the controller code is identical whether the request came in via session or token.

5. Testing with Pest

Pest is a testing framework built on PHPUnit with a more expressive syntax — Laravel ships with it configured by default. A feature test exercises a full HTTP request through the application, database included:

terminal
php artisan make:test TaskApiTest --pest
tests/Feature/TaskApiTest.php
<?php

use App\Models\Task;
use App\Models\User;

test('an authenticated user can list their own tasks', function () {
    $user = User::factory()->has(Task::factory()->count(3))->create();

    $response = $this->actingAs($user, 'sanctum')->getJson('/api/v1/tasks');

    $response->assertOk()->assertJsonCount(3);
});

test('a user cannot see another user\'s tasks', function () {
    $userA = User::factory()->has(Task::factory()->count(2))->create();
    $userB = User::factory()->has(Task::factory()->count(1))->create();

    $response = $this->actingAs($userA, 'sanctum')->getJson('/api/v1/tasks');

    $response->assertOk()->assertJsonCount(2); // only userA's own 2, not userB's
});

test('creating a task requires a title', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user, 'sanctum')->postJson('/api/v1/tasks', []);

    $response->assertStatus(422)->assertJsonValidationErrors('title');
});

This runs against a real (typically SQLite in-memory, for speed) test database, using the factories from Week 12 — exactly the kind of realistic, disposable data factories were built for. assertJsonCount(2) in the second test is doing real work: it's the automated proof that the ownership scoping from Week 13 actually holds, not just something that looked right in manual testing.

terminal
php artisan test
# or, directly:
./vendor/bin/pest

6. Hands-on Exercise

Hands-on

Build a versioned, tested JSON API for the task tracker

Expose the task tracker as an API, with resource shaping and a real Pest test suite.

Requirements:

  1. Build a v1-prefixed apiResource route group for tasks, secured with auth:sanctum.
  2. Write TaskResource shaping the response, including tags via whenLoaded().
  3. Return correct status codes: 201 on create, 204 on delete, 422 on validation failure (Laravel's default — confirm it, don't override it).
  4. Write at least 5 Pest feature tests: listing only your own tasks, creating a task, validation failure on an empty title, updating your own task, and a 403 when attempting to update another user's task.
  5. Run php artisan test and confirm every test passes.
Hint

Write the "403 on another user's task" test before confirming it passes by eye in the browser — a failing test here is exactly the kind of regression an automated suite exists to catch the moment someone (including future you) accidentally weakens the Policy check.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why use an API Resource instead of returning an Eloquent model directly from a controller?

Returning a model directly serializes every column on it, with no control over shape — including columns a client shouldn't see or doesn't need. An API Resource explicitly declares the response shape, independent of the model's actual columns, and lets you conditionally include relationships (via whenLoaded) without risking an accidental lazy-load.

Q2

Why does a failed validate() call on an API request return 422 rather than redirecting, unlike the web routes from Week 10?

An API client isn't a browser rendering the previous page — there's nothing to "redirect back" to. Laravel detects that the request expects a JSON response (based on the Accept header, and the route being in api.php) and returns a 422 status with the validation errors as a JSON body instead, which is what an API consumer actually needs to handle the failure programmatically.

Q3

What does the second Pest test in this lesson (a user cannot see another user's tasks) actually prove that manual browser testing wouldn't reliably catch?

It's an automated, repeatable proof that the ownership-scoping logic keeps working every time the code changes — not just a one-time manual check that happened to pass once. If a future refactor accidentally removes the ->where('user_id', ...) scoping (or the equivalent relationship-based query), this test fails immediately in CI, long before it would be noticed by chance during manual testing.

Q4

What problem does prefixing routes with v1 solve?

Once real clients (a mobile app, a third-party integration) depend on an API's exact response shape, changing that shape breaks them. Versioning lets a new, incompatible shape ship as v2 while v1 keeps working unchanged for existing clients — they migrate to the new version on their own schedule instead of breaking the moment a change deploys.