Laravel PHP in Practice: Build a Clean JSON API with Form Requests, Resources, Policies, and Feature Tests

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:

  • FormRequest classes for validation (and reusable rules)
  • API Resources for consistent JSON responses
  • Policies for authorization (who can do what)
  • Feature tests to 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:

  • authorizeResource wires policy checks automatically for REST methods.
  • $request->validated() ensures you only persist allowed fields.
  • Resources guarantee consistent output for index, show, store, and update.

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 sometimes for 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

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