Laravel in Practice: Build a Clean REST API with Form Requests, API Resources, and Policies

Laravel in Practice: Build a Clean REST API with Form Requests, API Resources, and Policies

Laravel makes it easy to ship endpoints quickly—but “quick” can turn into “messy” if validation lives in controllers, responses are inconsistent, and authorization is forgotten. In this hands-on guide, you’ll build a small but production-friendly REST API using three Laravel features that keep code tidy and scalable:

  • FormRequest classes for validation (and input normalization)
  • JsonResource classes for consistent API responses
  • Policies for authorization rules you can trust

You’ll end up with a simple “Projects” API that supports listing, creating, updating, and deleting projects owned by users—without bloated controllers.

What we’re building

Endpoints (typical REST):

  • GET /api/projects — list your projects
  • POST /api/projects — create a project
  • GET /api/projects/{project} — show one
  • PUT /api/projects/{project} — update
  • DELETE /api/projects/{project} — delete

Assumptions:

  • You have a Laravel app set up with authentication (e.g., Breeze, Jetstream, or Sanctum)
  • You’ll protect routes with auth:sanctum (token auth) or session auth

Step 1: Model + migration

Create a Project model with a migration:

php artisan make:model Project -m

Edit the migration (in database/migrations/...):

<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('projects', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->string('name'); $table->text('description')->nullable(); $table->boolean('archived')->default(false); $table->timestamps(); $table->index(['user_id', 'archived']); }); } public function down(): void { Schema::dropIfExists('projects'); } };

Run it:

php artisan migrate

Define relationships and fillable fields in app/Models/Project.php:

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Project extends Model { protected $fillable = [ 'user_id', 'name', 'description', 'archived', ]; protected $casts = [ 'archived' => 'boolean', ]; public function user(): BelongsTo { return $this->belongsTo(User::class); } }

Step 2: Form Requests for validation (and cleaner controllers)

Create two request classes:

php artisan make:request StoreProjectRequest php artisan make:request UpdateProjectRequest

Store request (app/Http/Requests/StoreProjectRequest.php):

<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class StoreProjectRequest extends FormRequest { public function authorize(): bool { // Authorization is handled via route middleware + policies, // but returning true here is fine for "create" when authenticated. return $this->user() !== null; } public function rules(): array { return [ 'name' => ['required', 'string', 'min:3', 'max:120'], 'description' => ['nullable', 'string', 'max:2000'], 'archived' => ['sometimes', 'boolean'], ]; } protected function prepareForValidation(): void { // Normalize whitespace in 'name' so you don't store " My Project " if ($this->has('name')) { $this->merge([ 'name' => trim((string) $this->input('name')), ]); } } }

Update request (app/Http/Requests/UpdateProjectRequest.php):

<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class UpdateProjectRequest extends FormRequest { public function authorize(): bool { // We'll rely on policies for update authorization; allow validation to run. return $this->user() !== null; } public function rules(): array { return [ 'name' => ['sometimes', 'required', 'string', 'min:3', 'max:120'], 'description' => ['sometimes', 'nullable', 'string', 'max:2000'], 'archived' => ['sometimes', 'boolean'], ]; } protected function prepareForValidation(): void { if ($this->has('name')) { $this->merge([ 'name' => trim((string) $this->input('name')), ]); } } }

Key idea: controllers should not be manually checking fields. Requests validate and normalize input before you touch it.

Step 3: API Resources for consistent output

Resources help you control your public JSON shape and avoid “accidentally returning everything.” Create one:

php artisan make:resource ProjectResource

Edit app/Http/Resources/ProjectResource.php:

<?php namespace App\Http\Resources; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; class ProjectResource extends JsonResource { public function toArray(Request $request): array { return [ 'id' => $this->id, 'name' => $this->name, 'description' => $this->description, 'archived' => (bool) $this->archived, 'createdAt' => $this->created_at?->toISOString(), 'updatedAt' => $this->updated_at?->toISOString(), ]; } }

This gives you a stable contract. If the database changes later, your API doesn’t have to.

Step 4: Policies for authorization

Policies keep access rules out of controllers. Generate one:

php artisan make:policy ProjectPolicy --model=Project

Edit app/Policies/ProjectPolicy.php:

<?php namespace App\Policies; use App\Models\Project; use App\Models\User; class ProjectPolicy { public function view(User $user, Project $project): bool { return $project->user_id === $user->id; } public function update(User $user, Project $project): bool { return $project->user_id === $user->id; } public function delete(User $user, Project $project): bool { return $project->user_id === $user->id; } }

Make sure your policy is registered. In modern Laravel versions, policy auto-discovery is usually on; if not, register it in app/Providers/AuthServiceProvider.php.

Step 5: Controller with “thin” actions

Create an API controller:

php artisan make:controller Api/ProjectController --api

Now implement actions using Form Requests, Resources, and Policies. Edit app/Http/Controllers/Api/ProjectController.php:

<?php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Requests\StoreProjectRequest; use App\Http\Requests\UpdateProjectRequest; use App\Http\Resources\ProjectResource; use App\Models\Project; use Illuminate\Http\Request; class ProjectController extends Controller { public function index(Request $request) { $projects = Project::query() ->where('user_id', $request->user()->id) ->when($request->boolean('archived'), fn ($q) => $q->where('archived', true)) ->when($request->filled('q'), function ($q) use ($request) { $term = trim((string) $request->query('q')); $q->where('name', 'like', "%{$term}%"); }) ->orderByDesc('updated_at') ->paginate(10); return ProjectResource::collection($projects); } public function store(StoreProjectRequest $request) { $project = Project::create([ 'user_id' => $request->user()->id, 'name' => $request->validated('name'), 'description' => $request->validated('description'), 'archived' => $request->validated('archived', false), ]); return (new ProjectResource($project)) ->response() ->setStatusCode(201); } public function show(Request $request, Project $project) { $this->authorize('view', $project); return new ProjectResource($project); } public function update(UpdateProjectRequest $request, Project $project) { $this->authorize('update', $project); $project->update($request->validated()); return new ProjectResource($project); } public function destroy(Request $request, Project $project) { $this->authorize('delete', $project); $project->delete(); return response()->noContent(); } }

Notice what’s missing: no manual validation, no hand-built JSON arrays, no inline “is this user allowed?” checks.

Step 6: Routes + auth middleware

In routes/api.php:

<?php use App\Http\Controllers\Api\ProjectController; use Illuminate\Support\Facades\Route; Route::middleware('auth:sanctum')->group(function () { Route::apiResource('projects', ProjectController::class); });

If you’re using session auth instead of Sanctum for APIs, swap the middleware accordingly.

Try it: curl examples

Create a project:

curl -X POST "http://localhost:8000/api/projects" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Website Redesign", "description": "Landing pages + blog polish", "archived": false }'

List projects (with search):

curl "http://localhost:8000/api/projects?q=website" \ -H "Authorization: Bearer YOUR_TOKEN"

Update a project:

curl -X PUT "http://localhost:8000/api/projects/1" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "archived": true }'

Practical tips to keep this pattern solid

  • Prefer validated() over all(): Only update fields you explicitly allow.

  • Use Resources everywhere: Your API becomes consistent, and you avoid leaking internal columns.

  • Policies scale better than “if” statements: When requirements grow (admins, shared projects, teams), you’ll thank yourself.

  • Add filtering carefully: In index(), use when() clauses to keep logic readable instead of nested ifs.

  • Paginate by default: Returning thousands of rows is a performance bug disguised as a feature.

Where to go next

If you want to extend this into a more “real” API, here are good next steps:

  • Use route model binding scoped to user: Prevent loading another user’s project by customizing binding or using queries like Project::where('user_id', auth()->id()).

  • Add soft deletes: Replace hard deletes with use SoftDeletes; for safer recovery.

  • Add a service layer for complex actions: When “create project” starts doing more (events, audit logs, default tasks), move it to a dedicated class.

  • Write feature tests: A couple of HTTP tests around auth + validation will catch regressions fast.

This structure—Form Requests + Resources + Policies—hits a sweet spot for junior/mid developers: it’s easy to learn, easy to enforce in code reviews, and it scales cleanly as your app grows.


Leave a Reply

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