Laravel API Patterns You’ll Reuse Everywhere: Form Requests + API Resources + Policies

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:

  • FormRequest for validation (and request-level authorization)
  • JsonResource for consistent API responses
  • Policy for 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=active in index() with a small whitelist.

  • Sorting: allow ?sort=created_at and ?dir=asc|desc (never pass raw query params directly into orderBy without validating).

  • API error shape: add a global exception handler to wrap 403/404 with a consistent { "error": { ... } } object.

  • Feature tests: write tests that assert 403 for non-owners and 422 for 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

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