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:
FormRequestclasses for validation + input normalizationAPI Resourcesto control your response shape (and avoid leaking fields)Policiesfor 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 throughResource, access is enforced byPolicy. -
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
searchsupport inindex()with->where('name', 'like', "%...%") -
Add soft deletes (
use SoftDeletes) and expose adeletedAtfield via the resource -
Use Laravel’s
RateLimiterfor 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