Laravel JSON API the “Production-ish” Way: Form Requests, API Resources, Policies, and Feature Tests

Laravel JSON API the “Production-ish” Way: Form Requests, API Resources, Policies, and Feature Tests

If you’ve built a quick Laravel API before, you’ve probably done some combination of “validate inside the controller”, “return the Eloquent model directly”, and “hope authorization works out later”. That’s fine for a prototype—but it gets messy fast.

This hands-on guide shows a practical Laravel pattern for building a clean JSON API endpoint set using:

  • FormRequest classes for validation + input normalization
  • API Resources to control your response shape (and avoid leaking fields)
  • Policies for authorization you can trust
  • Feature tests so refactors don’t break everything

We’ll build a simple “Projects” API: list, create, show, update, delete—scoped to the authenticated user.

1) Create the model + migration

Generate a Project model with migration:

php artisan make:model Project -m

Edit the migration (database/migrations/xxxx_create_projects_table.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 it:

php artisan migrate

Update the model (app/Models/Project.php):

namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Project extends Model { protected $fillable = ['name', 'description', 'status']; public function user(): BelongsTo { return $this->belongsTo(User::class); } }

2) Define API routes

We’ll use token/session auth depending on your app. For API auth, Laravel Sanctum is common; but the pattern below works regardless. Add routes to routes/api.php:

use App\Http\Controllers\Api\ProjectController; use Illuminate\Support\Facades\Route; Route::middleware('auth:sanctum')->group(function () { Route::apiResource('projects', ProjectController::class); });

Create the controller:

php artisan make:controller Api/ProjectController --api

3) Use Form Requests for validation (and cleaner controllers)

Generate request classes:

php artisan make:request StoreProjectRequest php artisan make:request UpdateProjectRequest

StoreProjectRequest (app/Http/Requests/StoreProjectRequest.php):

namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class StoreProjectRequest extends FormRequest { public function authorize(): bool { return true; // policy will handle per-project access later } public function rules(): array { return [ 'name' => ['required', 'string', 'max:120'], 'description' => ['nullable', 'string', 'max:2000'], 'status' => ['nullable', 'in:active,archived'], ]; } protected function prepareForValidation(): void { // Optional normalization example: if ($this->has('name')) { $this->merge([ 'name' => trim($this->input('name')), ]); } } }

UpdateProjectRequest (app/Http/Requests/UpdateProjectRequest.php):

namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class UpdateProjectRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'name' => ['sometimes', 'required', 'string', 'max:120'], 'description' => ['sometimes', 'nullable', 'string', 'max:2000'], 'status' => ['sometimes', 'required', 'in:active,archived'], ]; } }

Notice the update rules use sometimes. That enables PATCH-style partial updates without forcing every field.

4) Use API Resources to control the JSON output

Resources let you avoid returning raw Eloquent models (which can accidentally expose fields). Create one:

php artisan make:resource ProjectResource

Edit (app/Http/Resources/ProjectResource.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, 'createdAt' => $this->created_at?->toISOString(), 'updatedAt' => $this->updated_at?->toISOString(), ]; } }

5) Add a Policy for authorization

We want “users can only access their own projects”. Generate a policy:

php artisan make:policy ProjectPolicy --model=Project

Edit (app/Policies/ProjectPolicy.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; } }

Laravel can auto-discover policies in newer versions; if yours doesn’t, register it in AuthServiceProvider.

6) Build the controller: small, predictable, testable

Edit (app/Http/Controllers/Api/ProjectController.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) ->when($request->filled('status'), function ($q) use ($request) { $q->where('status', $request->string('status')); }) ->orderByDesc('id'); $projects = $query->paginate( perPage: min(100, max(1, (int) $request->input('perPage', 15))) ); return ProjectResource::collection($projects); } public function store(StoreProjectRequest $request) { $project = $request->user()->projects()->create( $request->validated() ); 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->fresh()); } public function destroy(Request $request, Project $project) { $this->authorize('delete', $project); $project->delete(); return response()->noContent(); } }

One more thing: the store() method uses $request->user()->projects(). That requires a relation on User. Add this to your User model:

use Illuminate\Database\Eloquent\Relations\HasMany; public function projects(): HasMany { return $this->hasMany(Project::class); }

7) Quick manual test with curl

Assuming you have a Sanctum token stored in $TOKEN:

# Create curl -X POST "http://localhost:8000/api/projects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"Docs revamp","description":"Rewrite onboarding docs","status":"active"}' # List (filter + pagination) curl "http://localhost:8000/api/projects?status=active&perPage=10" \ -H "Authorization: Bearer $TOKEN"

8) Feature tests: lock in behavior

Feature tests catch broken auth, validation, and response shapes. Create a test:

php artisan make:test ProjectApiTest

Example test (tests/Feature/ProjectApiTest.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_and_list_own_projects(): void { $user = User::factory()->create(); $this->actingAs($user); $create = $this->postJson('/api/projects', [ 'name' => 'My Project', 'description' => 'Hello', 'status' => 'active', ]); $create->assertCreated() ->assertJsonPath('data.name', 'My Project'); Project::factory()->create(['user_id' => $user->id, 'status' => 'archived']); Project::factory()->create(); // someone else $list = $this->getJson('/api/projects?status=archived'); $list->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.status', 'archived'); } public function test_user_cannot_view_someone_elses_project(): void { $owner = User::factory()->create(); $intruder = User::factory()->create(); $project = Project::factory()->create(['user_id' => $owner->id]); $this->actingAs($intruder); $this->getJson("/api/projects/{$project->id}") ->assertForbidden(); } }

Note: This uses model factories for User and Project. If you don’t have a Project factory yet:

php artisan make:factory ProjectFactory --model=Project

Then in (database/factories/ProjectFactory.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(), 'status' => $this->faker->randomElement(['active', 'archived']), ]; } }

9) Why this pattern scales

  • Controllers stay small: input comes from FormRequest, output goes through Resource, access is enforced by Policy.

  • Response shape is stable: frontends don’t break because you added a column to the table.

  • Authorization is centralized: you’re not sprinkling if ($user->id...) across methods.

  • Tests document behavior: validation + auth + pagination become “locked” and safe to refactor.

Next steps you can add in 30 minutes

  • Add search support in index() with ->where('name', 'like', "%...%")

  • Add soft deletes (use SoftDeletes) and expose a deletedAt field via the resource

  • Use Laravel’s RateLimiter for basic abuse protection on write endpoints

  • Introduce a service class only when logic genuinely grows beyond CRUD (don’t over-abstract early)

With this structure in place, adding new endpoints becomes straightforward: validate with a request, authorize with a policy, and return a resource. That’s the boring kind of consistency your future self will love.


Leave a Reply

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