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:
<?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.
<?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:
php artisan make:resource TaskResource
<?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(),
];
}
}
<?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:
<?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— successfulGET/PUT/PATCH201 Created— successfulPOSTthat created a resource204 No Content— successfulDELETE, nothing to return422 Unprocessable Entity— validation failed (Laravel's default for a failedvalidate()call on an API request)403 Forbidden— authenticated, but not authorized (exactly whatauthorize()throws)404 Not Found— resource doesn't exist (whatfindOrFail()throws)
<?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:
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:
php artisan make:test TaskApiTest --pest
<?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.
php artisan test
# or, directly:
./vendor/bin/pest
6. Hands-on Exercise
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:
- Build a
v1-prefixedapiResourceroute group for tasks, secured withauth:sanctum. - Write
TaskResourceshaping the response, including tags viawhenLoaded(). - Return correct status codes:
201on create,204on delete,422on validation failure (Laravel's default — confirm it, don't override it). - 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.
- Run
php artisan testand confirm every test passes.
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?
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?
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?
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?
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.