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:
apiResourceroutes- 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=20POST /api/projectsGET /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