Laravel JSON APIs the Clean Way: Form Requests + API Resources + Policies (with a Mini “Projects” API)
Laravel makes it easy to ship an API quickly, but “quick” can become “messy” if validation, authorization, and response formatting get scattered across controllers. In this hands-on guide, you’ll build a small JSON API for projects using three Laravel features that keep real-world codebases maintainable:
- Form Requests for validation (and input normalization)
- API Resources for consistent, versionable JSON responses
- Policies for authorization you can trust
You’ll end up with endpoints that are easy to read, test, and evolve—ideal for junior/mid devs building production APIs.
What we’re building
A simple projects API:
GET /api/projects— list projects (owned by the user) with pagination + filteringPOST /api/projects— create a projectGET /api/projects/{project}— show a projectPATCH /api/projects/{project}— update a projectDELETE /api/projects/{project}— delete a project
Assumptions: you already have a Laravel app with authentication (e.g., Sanctum) and a users table.
1) Create the model + migration
Create the Project model and migration:
php artisan make:model Project -m
Update 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->date('due_date')->nullable(); $table->timestamps(); $table->index(['user_id', 'status']); }); } public function down(): void { Schema::dropIfExists('projects'); } };
Run it:
php artisan migrate
Now define the model relationship and fillable fields:
<?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', 'due_date', ]; protected $casts = [ 'due_date' => 'date:Y-m-d', ]; public function user(): BelongsTo { return $this->belongsTo(User::class); } }
2) Route model binding + API routes
Add routes in routes/api.php (protected by auth):
<?php use App\Http\Controllers\Api\ProjectController; use Illuminate\Support\Facades\Route; Route::middleware('auth:sanctum')->group(function () { Route::apiResource('projects', ProjectController::class); });
3) Form Requests: keep controllers slim and validation reusable
Create requests for store/update:
php artisan make:request StoreProjectRequest php artisan make:request UpdateProjectRequest
StoreProjectRequest:
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class StoreProjectRequest extends FormRequest { public function authorize(): bool { // User must be logged in (route middleware already does this). // Authorization for create can be handled in a Policy as well. return true; } protected function prepareForValidation(): void { // Normalize input: trim name, lowercase status. $this->merge([ 'name' => is_string($this->name) ? trim($this->name) : $this->name, 'status' => is_string($this->status) ? strtolower($this->status) : $this->status, ]); } public function rules(): array { return [ 'name' => ['required', 'string', 'min:3', 'max:120'], 'description' => ['nullable', 'string', 'max:5000'], 'status' => ['nullable', 'string', Rule::in(['active', 'archived'])], 'due_date' => ['nullable', 'date', 'after_or_equal:today'], ]; } }
UpdateProjectRequest (mostly the same, but optional fields):
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class UpdateProjectRequest extends FormRequest { public function authorize(): bool { // We'll enforce ownership with Policies (see below). return true; } protected function prepareForValidation(): void { $this->merge([ 'name' => is_string($this->name) ? trim($this->name) : $this->name, 'status' => is_string($this->status) ? strtolower($this->status) : $this->status, ]); } public function rules(): array { return [ 'name' => ['sometimes', 'required', 'string', 'min:3', 'max:120'], 'description' => ['sometimes', 'nullable', 'string', 'max:5000'], 'status' => ['sometimes', 'nullable', 'string', Rule::in(['active', 'archived'])], 'due_date' => ['sometimes', 'nullable', 'date', 'after_or_equal:today'], ]; } }
Form Requests keep controllers focused on orchestration: take validated input → run business logic → return a response.
4) API Resources: consistent JSON output (and fewer “random arrays”)
Create a resource:
php artisan make:resource ProjectResource
Define the public JSON shape in one place:
<?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, 'due_date' => optional($this->due_date)->format('Y-m-d'), 'created_at' => $this->created_at->toISOString(), 'updated_at' => $this->updated_at->toISOString(), // Example of conditional fields: 'owner' => $this->whenLoaded('user', fn () => [ 'id' => $this->user->id, 'name' => $this->user->name, ]), ]; } }
Now every endpoint can return ProjectResource (single) or ProjectResource::collection(...) (list) without repeating formatting logic.
5) Policies: authorization that doesn’t rely on “hope”
Create a policy:
php artisan make:policy ProjectPolicy --model=Project
Implement ownership rules:
<?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; } public function create(User $user): bool { return true; } }
Laravel auto-discovers policies in modern versions. If your app doesn’t, register it in AuthServiceProvider.
6) The controller: thin endpoints with filtering + pagination
Create the controller:
php artisan make:controller Api/ProjectController --api
Implement endpoints using requests, policies, and resources:
<?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) { $user = $request->user(); $query = Project::query() ->where('user_id', $user->id); // Simple filtering: /api/projects?status=active if ($request->filled('status')) { $query->where('status', $request->string('status')->toString()); } // Basic search: /api/projects?search=landing if ($request->filled('search')) { $search = $request->string('search')->toString(); $query->where(function ($q) use ($search) { $q->where('name', 'like', "%{$search}%") ->orWhere('description', 'like', "%{$search}%"); }); } $projects = $query ->latest() ->paginate(perPage: min((int) $request->get('per_page', 10), 50)); return ProjectResource::collection($projects); } public function store(StoreProjectRequest $request) { $this->authorize('create', Project::class); $project = Project::create([ 'user_id' => $request->user()->id, ...$request->validated(), ]); return (new ProjectResource($project)) ->response() ->setStatusCode(201); } public function show(Request $request, Project $project) { $this->authorize('view', $project); $project->load('user'); 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(['message' => 'Deleted']); } }
Notice what’s not in the controller:
- No manual validation arrays
- No ownership checks like
if ($project->user_id !== auth()->id()) - No hand-built response arrays
7) Try it: cURL examples
Create a project:
curl -X POST "https://example.com/api/projects" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "New Landing Page", "description": "Rebuild marketing landing page", "status": "active", "due_date": "2026-04-01" }'
List with filtering + pagination:
curl "https://example.com/api/projects?status=active&search=landing&per_page=5" \ -H "Authorization: Bearer YOUR_TOKEN"
Update:
curl -X PATCH "https://example.com/api/projects/12" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "status": "archived" }'
8) Common production tips (small upgrades that matter)
- Use resources everywhere: once clients depend on your JSON shape, treating it as a contract saves pain later.
- Prefer Policies over inline checks: it centralizes rules and makes tests cleaner.
- Validate query params too: if
statusmust beactive|archived, consider a dedicated request for
Leave a Reply