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:
FormRequestclasses for validation (and input normalization)JsonResourceclasses for consistent API responsesPoliciesfor 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 projectsPOST /api/projects— create a projectGET /api/projects/{project}— show onePUT /api/projects/{project}— updateDELETE /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()overall(): 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(), usewhen()clauses to keep logic readable instead of nestedifs. -
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
HTTPtests 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