Laravel in Practice: Build a Versioned REST API with Form Requests, API Resources, and Policies
Laravel makes it easy to ship APIs fast, but “easy” can turn into “messy” if validation, authorization, and responses are scattered across controllers. In this hands-on guide, you’ll build a clean, versioned REST API for a Project resource using:
FormRequestclasses for validation + input normalizationAPI Resourcesfor consistent JSON outputPoliciesfor authorization rules- Basic API versioning (
/api/v1) and pagination
Target audience: junior/mid devs who can run Laravel locally and want a practical “good structure” baseline.
1) Setup: Model, Migration, and Ownership
Create a model and migration:
php artisan make:model Project -m
Edit the migration to include an owner reference and a few fields:
// database/migrations/xxxx_xx_xx_create_projects_table.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->string('status')->default('active'); // active|archived $table->timestamps(); $table->index(['user_id', 'status']); }); } public function down(): void { Schema::dropIfExists('projects'); } };
Run migrations:
php artisan migrate
In the model, define fillable fields and relationship:
// app/Models/Project.php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Project extends Model { protected $fillable = ['name', 'description', 'status']; public function user(): BelongsTo { return $this->belongsTo(User::class); } }
Why no user_id in $fillable? Because ownership is security-critical. We’ll set it server-side.
2) Versioned Routes + Controller Skeleton
Create a controller:
php artisan make:controller Api/V1/ProjectController --api
Define routes under /api/v1. In routes/api.php:
use App\Http\Controllers\Api\V1\ProjectController; use Illuminate\Support\Facades\Route; Route::prefix('v1') ->middleware('auth:sanctum') ->group(function () { Route::apiResource('projects', ProjectController::class); });
This assumes you’re using Laravel Sanctum. Any auth system works as long as you have an authenticated user via auth().
3) Validation with Form Requests (and a Tiny Normalization Trick)
Generate requests for create/update:
php artisan make:request StoreProjectRequest php artisan make:request UpdateProjectRequest
Store request:
// app/Http/Requests/StoreProjectRequest.php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class StoreProjectRequest extends FormRequest { public function authorize(): bool { // Policy handles authorization in controller via authorizeResource() return true; } protected function prepareForValidation(): void { // Normalize input once, early if ($this->has('name')) { $this->merge([ 'name' => trim($this->input('name')), ]); } } public function rules(): array { return [ 'name' => ['required', 'string', 'min:3', 'max:120'], 'description' => ['nullable', 'string', 'max:2000'], 'status' => ['nullable', 'in:active,archived'], ]; } }
Update request (often similar, but you can loosen requirements):
// app/Http/Requests/UpdateProjectRequest.php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class UpdateProjectRequest extends FormRequest { public function authorize(): bool { return true; } protected function prepareForValidation(): void { if ($this->has('name')) { $this->merge(['name' => trim($this->input('name'))]); } } public function rules(): array { return [ 'name' => ['sometimes', 'required', 'string', 'min:3', 'max:120'], 'description' => ['sometimes', 'nullable', 'string', 'max:2000'], 'status' => ['sometimes', 'in:active,archived'], ]; } }
sometimes prevents overwriting fields when clients send partial updates via PATCH.
4) Consistent Output with API Resources
Create a resource:
php artisan make:resource ProjectResource
// app/Http/Resources/ProjectResource.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, 'status' => $this->status, 'owner' => [ 'id' => $this->user_id, ], 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), ]; } }
Resources help you keep responses stable when your database changes. You can also add links, computed fields, or include related data in a controlled way.
5) Authorization with Policies
Create a policy:
php artisan make:policy ProjectPolicy --model=Project
// app/Policies/ProjectPolicy.php namespace App\Policies; use App\Models\Project; use App\Models\User; class ProjectPolicy { public function viewAny(User $user): bool { return true; // any logged-in user can list their own projects } public function view(User $user, Project $project): bool { return $project->user_id === $user->id; } public function create(User $user): bool { return true; } 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 it’s registered. Newer Laravel versions can auto-discover policies; otherwise, map it in AuthServiceProvider.
6) Controller: Clean CRUD with Pagination and Safe Ownership
Now implement ProjectController. Note: we call authorizeResource once and let Laravel apply policy checks automatically to each action.
// app/Http/Controllers/Api/V1/ProjectController.php namespace App\Http\Controllers\Api\V1; 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 __construct() { $this->authorizeResource(Project::class, 'project'); } public function index(Request $request) { $perPage = min((int) $request->query('per_page', 10), 50); $status = $request->query('status'); // optional filter $query = Project::query() ->where('user_id', $request->user()->id) ->latest(); if ($status) { $query->where('status', $status); } $projects = $query->paginate($perPage); return ProjectResource::collection($projects); } public function store(StoreProjectRequest $request) { $project = new Project($request->validated()); $project->user_id = $request->user()->id; // enforce ownership server-side $project->save(); return (new ProjectResource($project)) ->response() ->setStatusCode(201); } public function show(Project $project) { return new ProjectResource($project); } public function update(UpdateProjectRequest $request, Project $project) { $project->fill($request->validated()); $project->save(); return new ProjectResource($project); } public function destroy(Project $project) { $project->delete(); return response()->json(['message' => 'Deleted'], 200); } }
Notes that make this “practical”:
per_pageis capped to prevent clients from requesting huge pages.- Filtering is explicit and safe (only
statusallowed here). - Ownership is enforced in both query scoping (
index) and policy checks (show/update/delete).
7) Try It: Example Requests (cURL)
Assuming you have a Sanctum token in $TOKEN:
# Create curl -X POST "http://localhost:8000/api/v1/projects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Site Redesign", "description": "Landing page refresh + analytics", "status": "active" }' # List (with pagination + optional filter) curl "http://localhost:8000/api/v1/projects?per_page=5&status=active" \ -H "Authorization: Bearer $TOKEN" # Update (PATCH partial) curl -X PATCH "http://localhost:8000/api/v1/projects/1" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "status": "archived" }' # Delete curl -X DELETE "http://localhost:8000/api/v1/projects/1" \ -H "Authorization: Bearer $TOKEN"
8) Common Pitfalls (and How This Structure Avoids Them)
-
Putting validation in controllers: It starts small, then every endpoint grows a block of validation rules. Form Requests keep controllers focused on orchestration.
-
Leaking internal fields: If you return raw models, you might expose columns you add later (like internal flags). API Resources make responses intentional.
-
Authorization “oops” moments: Forgetting an
if ($project->user_id !== ...)check is common. Policies +authorizeResourcereduce the surface area for mistakes. -
Mass-assignment security: Not including
user_idin$fillableprevents clients from stealing ownership with a crafted payload.
9) Small Upgrades You Can Add Next
-
Sorting: allow
?sort=created_atand validate allowed columns to prevent SQL injection by column name. -
Search: add a
?q=...query and apply it tonamewithwhere('name', 'like', "%$q%")(but validate length and consider full-text search later). -
Soft deletes: use
SoftDeletesso “delete” becomes reversible. -
Rate limiting: add
->middleware('throttle:api')or a custom limit for write endpoints.
Wrap-Up
You now have a versioned Laravel API that’s easy to maintain: validation lives in Form Requests, authorization lives in Policies, and responses live in API Resources. This separation makes endpoints predictable, testable, and safer as your app grows—without adding a ton of boilerplate.
If you want to extend this into a real production pattern, the next best step is adding feature tests for each endpoint (especially policy rules and validation errors) and documenting your API with an OpenAPI spec.
Leave a Reply