Laravel JSON API You Can Ship: Form Requests, API Resources, Policies, and Pest Tests (Hands-On)
Laravel makes it easy to “get something working,” but junior/mid devs often struggle to turn that into an API that stays clean as features pile up. In this tutorial, you’ll build a small but production-shaped JSON API for a Project resource using:
FormRequestfor validation + input shapingAPI Resourcesfor consistent responsesPoliciesfor authorizationPestfor practical feature tests
You’ll end with predictable endpoints, consistent JSON, and tests that catch regressions.
1) Create the model, migration, and factory
Assume you already have a Laravel app with a database configured. Create a Project model owned by a user:
php artisan make:model Project -m -f
Edit the migration (e.g., database/migrations/xxxx_create_projects_table.php):
<?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('is_active')->default(true); $table->timestamps(); $table->index(['user_id', 'is_active']); }); } public function down(): void { Schema::dropIfExists('projects'); } };
Update the model to define fillable fields and ownership:
<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Project extends Model { use HasFactory; protected $fillable = [ 'name', 'description', 'is_active', ]; protected $casts = [ 'is_active' => 'boolean', ]; public function user(): BelongsTo { return $this->belongsTo(User::class); } }
Define the factory so tests are painless:
<?php namespace Database\Factories; use App\Models\Project; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; class ProjectFactory extends Factory { protected $model = Project::class; public function definition(): array { return [ 'user_id' => User::factory(), 'name' => $this->faker->words(3, true), 'description' => $this->faker->optional()->paragraph(), 'is_active' => true, ]; } }
Run migrations:
php artisan migrate
2) Add routes for a real-world API surface
Create a controller and related classes:
php artisan make:controller Api/ProjectController --api php artisan make:request ProjectStoreRequest php artisan make:request ProjectUpdateRequest php artisan make:resource ProjectResource php artisan make:policy ProjectPolicy --model=Project
In routes/api.php, add endpoints. (This example assumes you’re using Laravel authentication already. If you use Sanctum, wrap with auth:sanctum.)
<?php use App\Http\Controllers\Api\ProjectController; use Illuminate\Support\Facades\Route; Route::middleware('auth:sanctum')->group(function () { Route::apiResource('projects', ProjectController::class); });
3) Validate and shape input with Form Requests
Instead of validating inside controllers, push that responsibility into FormRequest classes. Bonus: you can “shape” data (e.g., default values, trimming) in one spot.
app/Http/Requests/ProjectStoreRequest.php:
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class ProjectStoreRequest extends FormRequest { public function authorize(): bool { return true; // policy will handle resource-level auth } public function rules(): array { return [ 'name' => ['required', 'string', 'min:3', 'max:120'], 'description' => ['nullable', 'string', 'max:2000'], 'is_active' => ['sometimes', 'boolean'], ]; } public function validated($key = null, $default = null) { $data = parent::validated($key, $default); // Default is_active to true if not provided $data['is_active'] = $data['is_active'] ?? true; // Normalize whitespace $data['name'] = trim($data['name']); return $data; } }
app/Http/Requests/ProjectUpdateRequest.php:
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class ProjectUpdateRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'name' => ['sometimes', 'required', 'string', 'min:3', 'max:120'], 'description' => ['sometimes', 'nullable', 'string', 'max:2000'], 'is_active' => ['sometimes', 'boolean'], ]; } public function validated($key = null, $default = null) { $data = parent::validated($key, $default); if (array_key_exists('name', $data)) { $data['name'] = trim($data['name']); } return $data; } }
4) Return consistent JSON with API Resources
API responses get messy fast if every controller returns “whatever.” Use ProjectResource to standardize output and hide internal fields.
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, 'isActive' => $this->is_active, 'createdAt' => $this->created_at?->toISOString(), 'updatedAt' => $this->updated_at?->toISOString(), ]; } }
Notice the output uses isActive and ISO timestamps. That’s a small UX win for frontend consumers.
5) Enforce ownership with Policies
You don’t want controllers full of “if user owns it” checks. Policies centralize authorization rules.
app/Policies/ProjectPolicy.php:
<?php namespace App\Policies; use App\Models\Project; use App\Models\User; class ProjectPolicy { public function viewAny(User $user): bool { return true; } 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 your policy is registered. In recent Laravel versions, policies can auto-discover, but if not, register it in AuthServiceProvider.
6) Build a clean controller (thin and boring)
The controller should coordinate: authorize, validate, query, return resources.
app/Http/Controllers/Api/ProjectController.php:
<?php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Requests\ProjectStoreRequest; use App\Http\Requests\ProjectUpdateRequest; 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) { $projects = Project::query() ->where('user_id', $request->user()->id) ->when($request->boolean('active'), fn ($q) => $q->where('is_active', true)) ->orderByDesc('id') ->paginate(10); return ProjectResource::collection($projects); } public function store(ProjectStoreRequest $request) { $project = $request->user()->projects()->create($request->validated()); return (new ProjectResource($project)) ->response() ->setStatusCode(201); } public function show(Project $project) { return new ProjectResource($project); } public function update(ProjectUpdateRequest $request, Project $project) { $project->update($request->validated()); return new ProjectResource($project->refresh()); } public function destroy(Project $project) { $project->delete(); return response()->noContent(); } }
Also add the inverse relation on User for $user->projects() to work:
<?php namespace App\Models; use Illuminate\Database\Eloquent\Relations\HasMany; // ... class User extends Authenticatable { // ... public function projects(): HasMany { return $this->hasMany(Project::class); } }
7) Write feature tests with Pest (the “ship it” safety net)
Tests are where this structure pays off. You’ll check: auth, ownership, validation, and success responses.
Create a test:
php artisan make:test ProjectApiTest
tests/Feature/ProjectApiTest.php (Pest style):
<?php use App\Models\Project; use App\Models\User; it('requires auth', function () { $this->getJson('/api/projects')->assertStatus(401); }); it('lists only the authenticated user projects', function () { $userA = User::factory()->create(); $userB = User::factory()->create(); Project::factory()->count(2)->create(['user_id' => $userA->id]); Project::factory()->count(3)->create(['user_id' => $userB->id]); $this->actingAs($userA) ->getJson('/api/projects') ->assertOk() ->assertJsonPath('meta.per_page', 10) ->assertJsonCount(2, 'data'); }); it('validates project creation', function () { $user = User::factory()->create(); $this->actingAs($user) ->postJson('/api/projects', ['name' => '']) ->assertStatus(422) ->assertJsonValidationErrors(['name']); }); it('creates a project and returns a resource shape', function () { $user = User::factory()->create(); $resp = $this->actingAs($user) ->postJson('/api/projects', [ 'name' => ' My Project ', 'description' => 'Demo', ]) ->assertStatus(201) ->assertJsonPath('data.name', 'My Project') ->assertJsonStructure([ 'data' => ['id', 'name', 'description', 'isActive', 'createdAt', 'updatedAt'], ]); $id = $resp->json('data.id'); $this->assertDatabaseHas('projects', [ 'id' => $id, 'user_id' => $user->id, 'name' => 'My Project', ]); }); it('prevents accessing another user project', function () { $owner = User::factory()->create(); $intruder = User::factory()->create(); $project = Project::factory()->create(['user_id' => $owner->id]); $this->actingAs($intruder) ->getJson("/api/projects/{$project->id}") ->assertStatus(403); }); it('allows the owner to update and delete', function () { $user = User::factory()->create(); $project = Project::factory()->create(['user_id' => $user->id]); $this->actingAs($user) ->putJson("/api/projects/{$project->id}", ['is_active' => false]) ->assertOk() ->assertJsonPath('data.isActive', false); $this->actingAs($user) ->deleteJson("/api/projects/{$project->id}") ->assertNoContent(); $this->assertDatabaseMissing('projects', ['id' => $project->id]); });
Run tests:
php artisan test
8) Practical tips to keep this scalable
-
Keep controllers thin. If you start adding complicated logic, extract it into a Service class (e.g.,
ProjectCreator) and unit test it. -
Use Resources everywhere. Even for errors, keep a consistent error envelope if your frontend benefits from it.
-
Paginate by default. Returning huge arrays is a common “works on my machine” mistake that becomes a production outage later.
-
Prefer policies to manual checks. If you ever forget to filter by
user_id, policies still block access.
Wrap-up
You now have a Laravel API pattern that’s easy to extend:
FormRequestclasses keep validation and input normalization consistentProjectResourceguarantees a stable response shapePoliciesenforce ownership without controller clutterPestfeature tests confirm the API behaves the way you think it does
From here, you can add filters (status, search), soft deletes, or versioned routes—without turning your codebase into spaghetti.
Leave a Reply