Lesson 24 — REST APIs, Resources and Validation: TaskFlow exposes JSON task listing, detail, creation and completion. We reuse existing Actions and Policies instead of creating a second business-logic implementation for the API.
1. Define the contract first
| Method and path | Result |
|---|---|
| GET /api/v1/projects/{project}/tasks | 200, data and pagination links/meta |
| GET /api/v1/projects/{project}/tasks/{task} | 200, one resource in data |
| POST /api/v1/projects/{project}/tasks | 201, data and Location |
| PATCH /api/v1/projects/{project}/tasks/{task}/complete | 200, done status; repeating returns 409 |
This milestone serves same-site clients using the existing session. Routes deliberately remain in web.php for sessions and CSRF despite their api/v1 prefix. A URL name does not create token authentication. Project creation APIs, arbitrary field updates and deletion are absent; Sanctum follows next.
2. Treat Resources as an output allowlist
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,
'project_id' => $this->project_id,
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'due_at' => $this->due_at?->toISOString(),
];
}
}TaskResource exposes only id, project_id, title, description, status, priority and due_at rather than serializing a complete model and relationships. New database columns do not silently enter the response. due_at is an ISO timestamp or null; current creation input does not accept deadlines.
A Resource does not replace authorization. Construct it only after access checks. JSON strings remain untrusted client data; render them safely rather than inserting them into innerHTML. This is an ordinary JsonResource, not a claim of JSON:API specification compliance.
3. Share Form Request validation with web forms
app/Http/Requests/StoreTaskRequest.php
<?php
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTaskRequest extends FormRequest
{
public function authorize(): bool
{
$project = $this->route('project');
return $project instanceof Project && ($this->user()?->can('update', $project) ?? false);
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'priority' => ['required', Rule::in(['low', 'normal', 'high'])],
];
}
}authorize checks update access to the route-bound project. Another user receives 403 even with an empty payload, whereas an owner's invalid input receives 422. Title, description and priority rules are shared; lesson 21's web controller now uses StoreTaskRequest too.
validated returns rule-covered input. DTO invariants still protect non-HTTP callers. Status, ownership and assignment fields are not forwarded to the Action. Extra fields are currently ignored, not automatically rejected with 422. Strict unknown-field rejection requires an explicit contract and tests.
4. Keep controllers thin
app/Http/Controllers/Api/TaskController.php
<?php
namespace App\Http\Controllers\Api;
use App\Actions\CompleteTask;
use App\Actions\CreateTask;
use App\Data\CreateTaskData;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreTaskRequest;
use App\Http\Resources\TaskResource;
use App\Models\Project;
use App\Models\Task;
use App\Queries\TaskListQuery;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class TaskController extends Controller
{
public function index(Request $request, Project $project, TaskListQuery $query)
{
Gate::authorize('view', $project);
return TaskResource::collection($query->paginate($project, $request->query()));
}
public function show(Project $project, Task $task): TaskResource
{
Gate::authorize('view', $project);
return new TaskResource($task);
}
public function store(StoreTaskRequest $request, Project $project, CreateTask $action)
{
$data = $request->validated();
$task = $action->handle($project, new CreateTaskData(
$data['title'], $data['description'] ?? null, $data['priority'],
));
return (new TaskResource($task))->response()->setStatusCode(201)
->header('Location', route('api.tasks.show', [$project, $task]));
}
public function complete(Project $project, Task $task, CompleteTask $action): TaskResource
{
Gate::authorize('update', $task);
$action->handle($task);
return new TaskResource($task);
}
}index authorizes before using lesson 13's project-scoped TaskListQuery, preserving sort/filter allowlists and the 50-item page cap. show checks project access. store invokes CreateTask with a DTO and returns 201 plus a detail Location. complete preserves lesson 18's transition rule.
Creation has no idempotency key: retrying can create another task. Clients must not blindly replay writes after timeouts or 500 responses. Repeated completion deliberately returns 409. HTTP method names do not automatically implement retry semantics.
5. Routes and authentication boundaries
routes/web.php
<?php
use App\Http\Controllers\TaskFlowOverviewController;
use App\Http\Controllers\TaskPreviewController;
use App\Http\Controllers\SessionController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ProjectTaskController;
use App\Http\Middleware\AssignRequestId;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::get('/taskflow', TaskFlowOverviewController::class)
->middleware(AssignRequestId::class)
->name('taskflow.overview');
Route::get('/tasks/create', [TaskPreviewController::class, 'create'])->name('tasks.create');
Route::post('/tasks/preview', [TaskPreviewController::class, 'preview'])
->middleware('throttle:task-preview')->name('tasks.preview');
Route::middleware('guest')->group(function () {
Route::get('/login', [SessionController::class, 'create'])->name('login');
Route::post('/login', [SessionController::class, 'store'])
->middleware('throttle:login')->name('login.store');
});
Route::view('/dashboard', 'dashboard')->middleware('auth')->name('dashboard');
Route::post('/logout', [SessionController::class, 'destroy'])->middleware('auth')->name('logout');
Route::middleware('auth')->scopeBindings()->group(function () {
Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
Route::post('/projects', [ProjectController::class, 'store'])->name('projects.store');
Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');
Route::post('/projects/{project}/tasks', [ProjectTaskController::class, 'store'])->name('project-tasks.store');
Route::patch('/projects/{project}/tasks/{task}/complete', [ProjectTaskController::class, 'complete'])
->name('project-tasks.complete');
});
// Same-origin session API: web middleware (including CSRF) remains active.
Route::prefix('api/v1/projects/{project}')->middleware('auth')->scopeBindings()->group(function () {
Route::get('/tasks', [\App\Http\Controllers\Api\TaskController::class, 'index'])->name('api.tasks.index');
Route::get('/tasks/{task}', [\App\Http\Controllers\Api\TaskController::class, 'show'])->name('api.tasks.show');
Route::post('/tasks', [\App\Http\Controllers\Api\TaskController::class, 'store'])->name('api.tasks.store');
Route::patch('/tasks/{task}/complete', [\App\Http\Controllers\Api\TaskController::class, 'complete'])
->name('api.tasks.complete');
});Scoped binding rejects a task under the wrong project with 404. auth requires a session, policies restrict resource access and CSRF remains active for writes. A session cookie is not a bearer token. Same-origin JSON clients must send the appropriate cookie/token; JSON bodies do not justify excluding api/* from CSRF.
Sign in at /login, create a personal project and open its GET endpoint to inspect the list. For writes, send Accept: application/json, Content-Type: application/json and the current session token through X-CSRF-TOKEN when fallback is needed. Do not publish real session credentials in documentation or shared shell commands.
6. Test the contract and rejection paths
tests/Feature/TaskApiTest.php
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class TaskApiTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
'database.connections.sqlite.url' => null]);
DB::purge('sqlite');
$this->artisan('migrate', ['--force' => true])->assertSuccessful();
}
public function test_create_show_list_and_complete_use_explicit_resource_fields(): void
{
$project = Project::factory()->create();
$this->actingAs($project->owner);
$response = $this->postJson(route('api.tasks.store', $project), [
'title' => 'API task', 'priority' => 'high', 'status' => 'done', 'owner_id' => 999,
])->assertCreated()->assertJsonPath('data.status', 'todo');
$task = $project->tasks()->sole();
$response->assertHeader('Location', route('api.tasks.show', [$project, $task]));
$this->getJson(route('api.tasks.show', [$project, $task]))->assertOk()->assertExactJson([
'data' => ['id' => $task->id, 'project_id' => $project->id, 'title' => 'API task',
'description' => null, 'status' => 'todo', 'priority' => 'high', 'due_at' => null],
]);
$this->getJson(route('api.tasks.index', $project))->assertOk()
->assertJsonCount(1, 'data')->assertJsonStructure(['data', 'links', 'meta']);
$this->patchJson(route('api.tasks.complete', [$project, $task]))->assertOk()
->assertJsonPath('data.status', 'done');
$this->patchJson(route('api.tasks.complete', [$project, $task]))->assertStatus(409);
}
public function test_api_denies_guest_other_owner_and_wrong_parent(): void
{
$project = Project::factory()->create();
$task = Task::factory()->for($project)->create();
$this->getJson(route('api.tasks.index', $project))->assertUnauthorized();
$this->actingAs(User::factory()->create());
$this->getJson(route('api.tasks.show', [$project, $task]))->assertForbidden();
$this->postJson(route('api.tasks.store', $project), [])->assertForbidden();
$this->patchJson(route('api.tasks.complete', [$project, $task]))->assertForbidden();
$another = Project::factory()->for($project->owner, 'owner')->create();
$this->actingAs($project->owner)->getJson(route('api.tasks.show', [$another, $task]))->assertNotFound();
$this->assertSame('todo', $task->fresh()->status);
}
public function test_validation_and_csrf_are_not_disabled_for_json_routes(): void
{
$project = Project::factory()->create();
$this->actingAs($project->owner)->postJson(route('api.tasks.store', $project), [])
->assertUnprocessable()->assertJsonValidationErrors(['title', 'priority']);
$this->app->bind(\Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class,
EnforcedApiCsrf::class);
$this->postJson(route('api.tasks.store', $project), ['title' => 'Blocked', 'priority' => 'normal'])
->assertStatus(419);
$this->assertDatabaseCount('tasks', 0);
}
}
class EnforcedApiCsrf extends \Illuminate\Foundation\Http\Middleware\PreventRequestForgery
{
protected function runningUnitTests()
{
return false;
}
}php artisan test --filter=TaskApiTest
php artisan test
assertExactJson fixes the detail field contract. Creation tests check 201/Location and reject client control of initial status; list tests check data/links/meta. Rejection tests cover guest 401, other-owner 403, wrong-parent 404, validation 422, repeated completion 409 and enforced missing-CSRF 419.
The suite passed 71 tests with 319 assertions after sharing the Form Request across web/API controllers. This is not token, CORS, performance or OpenAPI-schema verification. Query-specific tests separately cover project scope and sorting; these tests do not prove every pagination combination.
Exercises: test a non-null due_at, per_page above 50 and client handling of distinct error statuses. Reference: Laravel API Resources.
Navigation: Lesson 23 · Roadmap. Next: Sanctum API authentication.




No comments yet. Be the first to share your thoughts.