Laravel API Patterns You’ll Reuse Everywhere: Form Requests + API Resources + Policies
Laravel makes it easy to ship a JSON API quickly—but “quickly” can turn into “messy” if validation logic lives in controllers, authorization is scattered, and responses are inconsistent. In this hands-on guide, you’ll build a small but realistic API using three Laravel building blocks that scale cleanly:
FormRequestfor validation (and request-level authorization)JsonResourcefor consistent API responsesPolicyfor centralized authorization rules
You’ll end up with endpoints that are easy to test, easy to extend, and hard to misuse.
Goal: a “Projects” API with ownership rules
We’ll build endpoints for Project where:
- Any authenticated user can list and create their own projects.
- Only the project owner can update or delete it.
- Validation stays out of controllers.
- Responses are returned in a consistent JSON shape.
Assumptions: you have a Laravel app with authentication (e.g., Laravel Breeze, Sanctum, Passport). Code below works with any auth as long as auth() is available.
1) Create the model + migration
Create a projects table tied to the user:
php artisan make:model Project -m
Edit the migration:
<?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
Update the model with fillable fields and a relationship:
<?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', 'status', ]; public function user(): BelongsTo { return $this->belongsTo(User::class); } }
2) Routes and controller skeleton
Create a controller:
php artisan make:controller Api/ProjectController
Add API routes (in routes/api.php):
<?php use App\Http\Controllers\Api\ProjectController; use Illuminate\Support\Facades\Route; Route::middleware('auth:sanctum')->group(function () { Route::get('/projects', [ProjectController::class, 'index']); Route::post('/projects', [ProjectController::class, 'store']); Route::get('/projects/{project}', [ProjectController::class, 'show']); Route::put('/projects/{project}', [ProjectController::class, 'update']); Route::delete('/projects/{project}', [ProjectController::class, 'destroy']); });
Notice we’re using route-model binding ({project}). We’ll lock access down via policies (not by manually checking user_id in every method).
3) Form Requests: validation you don’t repeat
Create two request classes:
php artisan make:request StoreProjectRequest php artisan make:request UpdateProjectRequest
StoreProjectRequest:
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class StoreProjectRequest extends FormRequest { public function authorize(): bool { // Auth is handled by middleware; you could add extra rules here. return $this->user() !== null; } public function rules(): array { return [ 'name' => ['required', 'string', 'min:3', 'max:120'], 'description' => ['nullable', 'string', 'max:2000'], 'status' => ['nullable', 'in:active,archived'], ]; } protected function prepareForValidation(): void { // Normalize status if missing if (!$this->has('status')) { $this->merge(['status' => 'active']); } } }
UpdateProjectRequest (partial updates via PUT is okay; we’ll validate as “sometimes”):
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class UpdateProjectRequest extends FormRequest { public function authorize(): bool { // Authorization will be enforced by policy in the controller. return $this->user() !== null; } public function rules(): array { return [ 'name' => ['sometimes', 'required', 'string', 'min:3', 'max:120'], 'description' => ['sometimes', 'nullable', 'string', 'max:2000'], 'status' => ['sometimes', 'required', 'in:active,archived'], ]; } }
Why this matters: your controller becomes “business logic only.” Validation stays in one place, is reusable, and is automatically returned as a proper 422 response when it fails.
4) Policies: one place for authorization
Create a policy:
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; } }
Register it (Laravel auto-discovers policies in many setups, but to be explicit, in app/Providers/AuthServiceProvider.php):
protected $policies = [ \App\Models\Project::class => \App\Policies\ProjectPolicy::class, ];
This eliminates the classic controller anti-pattern:
- “if user_id !== auth_id then 403” copied into every method
5) API Resources: consistent JSON responses
Create a resource:
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; /** @mixin \App\Models\Project */ class ProjectResource extends JsonResource { public function toArray(Request $request): array { return [ 'id' => $this->id, 'name' => $this->name, 'description' => $this->description, 'status' => $this->status, 'createdAt' => $this->created_at?->toISOString(), 'updatedAt' => $this->updated_at?->toISOString(), ]; } }
Now your API won’t accidentally leak internal columns, and you can evolve the response format without rewriting controllers.
6) Put it together in the controller
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) ->latest() ->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'] ?? null, 'status' => $request->validated()['status'] ?? 'active', ]); 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()->json(['deleted' => true]); } }
What you gained:
- Validation is handled automatically by
StoreProjectRequest/UpdateProjectRequest. - Authorization is centralized in
ProjectPolicy. - Output shape is centralized in
ProjectResource. - Controllers read like a story.
7) Quick manual testing with curl
Assuming you have a bearer token in $TOKEN:
# Create curl -X POST "http://localhost:8000/api/projects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"Docs Site","description":"Internal docs","status":"active"}' # List curl -X GET "http://localhost:8000/api/projects" \ -H "Authorization: Bearer $TOKEN" # Update curl -X PUT "http://localhost:8000/api/projects/1" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"status":"archived"}' # Delete curl -X DELETE "http://localhost:8000/api/projects/1" \ -H "Authorization: Bearer $TOKEN"
Try calling /api/projects/1 as another user. You should get a 403 because the policy blocks access.
Common pitfalls (and how this approach avoids them)
-
“My controller is 200 lines.” Form Requests and Resources remove boilerplate and keep controllers thin.
-
“We forgot to validate a field.” Requests make validation explicit and reusable across endpoints.
-
“One endpoint returns different JSON than another.” Resources enforce a consistent representation.
-
“Authorization checks are inconsistent.” Policies keep access rules in one place and make reviews easier.
Next upgrades (easy wins)
-
Filtering: allow
?status=activeinindex()with a small whitelist. -
Sorting: allow
?sort=created_atand?dir=asc|desc(never pass raw query params directly intoorderBywithout validating). -
API error shape: add a global exception handler to wrap
403/404with a consistent{ "error": { ... } }object. -
Feature tests: write tests that assert
403for non-owners and422for invalid input.
Wrap-up
If you’re building APIs in Laravel, these three patterns—FormRequest, JsonResource, and Policy—are the difference between “it works” and “it’s maintainable.” They reduce duplicated logic, make behavior predictable, and help teams move fast without breaking access control or response contracts.
Use this structure on your next endpoint and you’ll feel the payoff immediately: smaller controllers, clearer rules, and fewer surprises in production.
Leave a Reply