Laravel API Done Right: Build a Clean JSON CRUD with Form Requests, Resources, and Feature Tests

Laravel API Done Right: Build a Clean JSON CRUD with Form Requests, Resources, and Feature Tests

Laravel makes it easy to ship an API fast—but “easy” can turn into messy if you skip structure. In this hands-on walkthrough, you’ll build a small but production-friendly JSON API for a Project resource using:

  • apiResource routes
  • Form Request validation (keeps controllers clean)
  • API Resources (consistent response shape)
  • Pagination + filtering
  • Feature tests (so refactors don’t break you)

The result is a pattern you can reuse for most CRUD endpoints on real projects.

1) Create the Project model + migration

Start a Laravel app (any recent version is fine). Then generate a model, migration, factory, and controller:

php artisan make:model Project -mfc

Edit the migration in database/migrations/xxxx_xx_xx_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->string('name'); $table->text('description')->nullable(); $table->enum('status', ['draft', 'active', 'archived'])->default('draft'); $table->timestamp('due_at')->nullable(); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('projects'); } };

Run migrations:

php artisan migrate

Now define mass-assignable fields in app/Models/Project.php:

<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Project extends Model { use HasFactory; protected $fillable = [ 'name', 'description', 'status', 'due_at', ]; protected $casts = [ 'due_at' => 'datetime', ]; }

2) Add API routes (RESTful in one line)

In routes/api.php:

<?php use App\Http\Controllers\ProjectController; use Illuminate\Support\Facades\Route; Route::apiResource('projects', ProjectController::class);

This creates routes for index, show, store, update, and destroy automatically.

3) Use Form Requests to validate input

Keeping validation out of controllers makes endpoints easier to read and test. Generate requests:

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

In app/Http/Requests/StoreProjectRequest.php:

<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class StoreProjectRequest extends FormRequest { public function authorize(): bool { return true; // wire up policies later if needed } public function rules(): array { return [ 'name' => ['required', 'string', 'min:3', 'max:120'], 'description' => ['nullable', 'string', 'max:2000'], 'status' => ['nullable', 'in:draft,active,archived'], 'due_at' => ['nullable', 'date'], ]; } }

And in app/Http/Requests/UpdateProjectRequest.php (same rules, but typically sometimes):

<?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', 'min:3', 'max:120'], 'description' => ['sometimes', 'nullable', 'string', 'max:2000'], 'status' => ['sometimes', 'nullable', 'in:draft,active,archived'], 'due_at' => ['sometimes', 'nullable', 'date'], ]; } }

4) Shape output with an API Resource

Without a resource, responses tend to vary from endpoint to endpoint. Resources enforce a consistent JSON format.

Create one:

php artisan make:resource ProjectResource

Edit 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, 'due_at' => optional($this->due_at)->toISOString(), 'created_at' => $this->created_at->toISOString(), 'updated_at' => $this->updated_at->toISOString(), ]; } }

Note: optional(...) prevents errors when due_at is null.

5) Build the controller with pagination + filtering

Now wire everything together in app/Http/Controllers/ProjectController.php:

<?php namespace App\Http\Controllers; 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) { $perPage = min((int) $request->query('per_page', 10), 50); $query = Project::query() ->when($request->query('status'), fn ($q, $status) => $q->where('status', $status) ) ->when($request->query('search'), function ($q, $search) { $q->where('name', 'like', "%{$search}%"); }) ->orderByDesc('created_at'); return ProjectResource::collection($query->paginate($perPage)); } public function store(StoreProjectRequest $request) { $project = Project::create($request->validated()); 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(['deleted' => true]); } }

You now have:

  • GET /api/projects?status=active&search=client&per_page=20
  • POST /api/projects
  • GET /api/projects/{project}
  • PUT /api/projects/{project}
  • DELETE /api/projects/{project}

6) Try the API with curl

Create a project:

curl -X POST http://localhost:8000/api/projects \ -H "Content-Type: application/json" \ -d '{ "name": "Website Redesign", "description": "Refresh landing page and checkout UX", "status": "active", "due_at": "2026-05-01" }'

List projects with filters:

curl "http://localhost:8000/api/projects?status=active&search=Website&per_page=5"

Update a project (partial update works because we used sometimes):

curl -X PUT http://localhost:8000/api/projects/1 \ -H "Content-Type: application/json" \ -d '{ "status": "archived" }'

7) Add a factory + feature tests (confidence boost)

APIs break most often during “small refactors.” Feature tests are your safety net.

Edit database/factories/ProjectFactory.php:

<?php namespace Database\Factories; use Illuminate\Database\Eloquent\Factories\Factory; class ProjectFactory extends Factory { public function definition(): array { return [ 'name' => $this->faker->sentence(3), 'description' => $this->faker->optional()->paragraph(), 'status' => $this->faker->randomElement(['draft', 'active', 'archived']), 'due_at' => $this->faker->optional()->dateTimeBetween('now', '+2 months'), ]; } }

Create a feature test:

php artisan make:test ProjectApiTest

Edit tests/Feature/ProjectApiTest.php:

<?php namespace Tests\Feature; use App\Models\Project; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class ProjectApiTest extends TestCase { use RefreshDatabase; public function test_it_creates_a_project(): void { $payload = [ 'name' => 'Internal Tools', 'status' => 'draft', ]; $res = $this->postJson('/api/projects', $payload); $res->assertStatus(201) ->assertJsonPath('data.name', 'Internal Tools'); $this->assertDatabaseHas('projects', ['name' => 'Internal Tools']); } public function test_it_validates_input(): void { $res = $this->postJson('/api/projects', ['name' => 'aa']); // too short $res->assertStatus(422) ->assertJsonValidationErrors(['name']); } public function test_it_lists_projects_with_filters(): void { Project::factory()->count(3)->create(['status' => 'active']); Project::factory()->count(2)->create(['status' => 'draft']); $res = $this->getJson('/api/projects?status=active&per_page=2'); $res->assertOk() ->assertJsonCount(2, 'data'); // page size } }

Run tests:

php artisan test

8) Practical tips for real-world APIs

  • Keep controllers thin: validation in Form Requests, formatting in Resources, business logic in services if it grows.

  • Cap pagination: we used min(per_page, 50) to avoid “give me 10k rows” requests.

  • Standardize response shapes: Resources make it easy to add/change fields without breaking clients unexpectedly.

  • Prefer query params for filtering: ?status=active&search=... is discoverable and cache-friendly.

  • Add auth next: once CRUD is clean, adding Sanctum/JWT is much easier than retrofitting it into messy endpoints.

Wrap-up

You built a solid Laravel JSON API with clean boundaries: requests validate input, resources shape output, controllers orchestrate, and tests keep you safe. For your next iteration, consider adding authentication, policies for authorization, and more advanced filtering (date ranges, sorting, and includes). The structure you set up here will scale with you.


Leave a Reply

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