Laravel PHP in Practice: Build a Clean JSON API with Form Requests, Resources, Policies, and Feature Tests
Laravel makes it easy to ship an API quickly—but “quick” can turn into “messy” if validation, authorization, and response formatting get scattered across controllers. In this hands-on guide, you’ll build a small, clean JSON API for a Project model using four core tools:
FormRequestclasses for validation (and reusable rules)API Resourcesfor consistent JSON responsesPoliciesfor authorization (who can do what)Feature teststo lock in behavior and prevent regressions
The goal: controllers that stay thin and predictable, with logic living where it belongs.
1) Create the model + migration
Let’s build a projects table with a user_id owner, name, optional description, and a simple status.
php artisan make:model Project -m
Edit the migration (example):
<?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->enum('status', ['draft', 'active', 'archived'])->default('draft'); $table->timestamps(); $table->index(['user_id', 'status']); }); } public function down(): void { Schema::dropIfExists('projects'); } };
Run migrations:
php artisan migrate
In app/Models/Project.php, allow mass assignment safely:
<?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) Define API routes
In routes/api.php, add a protected resource route. This assumes you already have some API authentication set up (Sanctum, Passport, etc.).
<?php use App\Http\Controllers\Api\ProjectController; use Illuminate\Support\Facades\Route; Route::middleware('auth:sanctum')->group(function () { Route::apiResource('projects', ProjectController::class); });
3) Keep controllers thin with Form Requests
Create two request classes: one for creating, one for updating. This keeps validation out of controllers and makes rules reusable.
php artisan make:request StoreProjectRequest php artisan make:request UpdateProjectRequest
app/Http/Requests/StoreProjectRequest.php:
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class StoreProjectRequest extends FormRequest { public function authorize(): bool { // Authorization handled by Policy in controller via authorizeResource. return true; } public function rules(): array { return [ 'name' => ['required', 'string', 'min:3', 'max:120'], 'description' => ['nullable', 'string', 'max:2000'], 'status' => ['sometimes', Rule::in(['draft', 'active', 'archived'])], ]; } }
app/Http/Requests/UpdateProjectRequest.php:
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class UpdateProjectRequest 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'], 'status' => ['sometimes', Rule::in(['draft', 'active', 'archived'])], ]; } }
Notice the update rules: sometimes makes partial updates easy while still validating when a field is present.
4) Standardize responses with API Resources
Returning raw Eloquent models can leak fields you didn’t intend to expose and makes it harder to keep a consistent shape. Create a resource:
php artisan make:resource ProjectResource
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, 'status' => $this->status, 'ownerId' => $this->user_id, 'createdAt' => $this->created_at?->toISOString(), 'updatedAt' => $this->updated_at?->toISOString(), ]; } }
Now every endpoint can return the same JSON structure reliably.
5) Enforce “who can do what” with Policies
A common mistake is checking ownership inside every controller method. Policies centralize authorization logic.
php artisan make:policy ProjectPolicy --model=Project
app/Policies/ProjectPolicy.php (simple ownership rules):
<?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 the policy is registered (Laravel often auto-discovers, but if not, add it in AuthServiceProvider).
6) Build the controller (thin and predictable)
Create an API controller:
php artisan make:controller Api/ProjectController --api
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 __construct() { $this->authorizeResource(Project::class, 'project'); } 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'), 'status' => $request->validated('status', 'draft'), ]); return (new ProjectResource($project)) ->response() ->setStatusCode(201); } public function show(Project $project) { return new ProjectResource($project); } public function update(UpdateProjectRequest $request, Project $project) { $project->update($request->validated()); return new ProjectResource($project); } public function destroy(Project $project) { $project->delete(); return response()->json(null, 204); } }
Key takeaways:
authorizeResourcewires policy checks automatically for REST methods.$request->validated()ensures you only persist allowed fields.- Resources guarantee consistent output for
index,show,store, andupdate.
7) Add a factory for fast test data
Create a factory so tests can generate projects quickly:
php artisan make:factory ProjectFactory --model=Project
<?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->sentence(3), 'description' => $this->faker->optional()->paragraph(), 'status' => $this->faker->randomElement(['draft', 'active', 'archived']), ]; } }
8) Lock it in with feature tests
Feature tests verify the full HTTP behavior: routing, auth, validation, policies, and JSON shape. Create a test:
php artisan make:test ProjectApiTest
Example tests (trim or expand as needed):
<?php namespace Tests\Feature; use App\Models\Project; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class ProjectApiTest extends TestCase { use RefreshDatabase; public function test_user_can_create_a_project(): void { $user = User::factory()->create(); $payload = [ 'name' => 'My First Project', 'description' => 'API-built project', 'status' => 'active', ]; $res = $this->actingAs($user) ->postJson('/api/projects', $payload); $res->assertCreated() ->assertJsonPath('data.name', 'My First Project') ->assertJsonPath('data.status', 'active'); $this->assertDatabaseHas('projects', [ 'user_id' => $user->id, 'name' => 'My First Project', 'status' => 'active', ]); } public function test_user_cannot_view_someone_elses_project(): void { $owner = User::factory()->create(); $intruder = User::factory()->create(); $project = Project::factory()->for($owner)->create(); $this->actingAs($intruder) ->getJson("/api/projects/{$project->id}") ->assertForbidden(); } public function test_validation_rejects_short_name(): void { $user = User::factory()->create(); $this->actingAs($user) ->postJson('/api/projects', ['name' => 'No']) ->assertStatus(422) ->assertJsonValidationErrors(['name']); } }
Run tests:
php artisan test
9) Practical checklist for “clean API” Laravel code
-
Controllers: orchestrate only. No big validation arrays, no ownership checks everywhere.
-
Form Requests: define input contracts. Use
sometimesfor partial updates. -
Policies: centralize authorization. Keep rules readable and consistent.
-
Resources: return a stable JSON shape, hide internal fields, format timestamps.
-
Feature tests: prove your endpoints work end-to-end and protect you during refactors.
Wrap-up
You now have a clean Laravel API pattern you can repeat across features: validation in FormRequest, auth in Policy, serialization in Resource, and behavior locked by feature tests. The biggest win isn’t fewer lines of code—it’s that every new endpoint looks and behaves the same way, which makes your API easier to maintain as the project grows.
Leave a Reply