Laravel PHP in Practice: Build a Small JSON API the “Laravel Way” (Validation, Resources, and Feature Tests)
If you’re a junior/mid developer, Laravel can feel “magical” at first—until you learn the conventions that make it predictable. In this hands-on guide, you’ll build a small JSON API for a Project entity using Laravel’s strongest tools: migrations, Eloquent, Form Requests (validation), API Resources (response shaping), and feature tests (confidence).
You’ll end with endpoints that look like:
GET /api/projects(list with pagination + simple filtering)POST /api/projects(create with validation)GET /api/projects/{project}(show)PATCH /api/projects/{project}(update)DELETE /api/projects/{project}(delete)
1) Create the project (and install Laravel)
Start a new Laravel app (Laravel 10/11 are both fine):
composer create-project laravel/laravel projects-api cd projects-api # Create an app key and basic config php artisan key:generate
Configure your database in .env (example for MySQL):
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=projects_api DB_USERNAME=root DB_PASSWORD=
2) Model + migration: define the data
Generate a model with a migration:
php artisan make:model Project -m
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->string('status')->default('draft'); // draft|active|archived $table->date('due_date')->nullable(); $table->timestamps(); $table->index(['status', 'due_date']); }); } public function down(): void { Schema::dropIfExists('projects'); } };
Run the migration:
php artisan migrate
Now set up the Eloquent model (app/Models/Project.php) to allow mass assignment safely:
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class Project extends Model { protected $fillable = [ 'name', 'description', 'status', 'due_date', ]; protected $casts = [ 'due_date' => 'date', ]; }
3) Build the controller: clean endpoints
Create an API controller:
php artisan make:controller Api/ProjectController --api
We’ll also create:
- a Form Request for validation
- an API Resource to control JSON output
php artisan make:request ProjectStoreRequest php artisan make:request ProjectUpdateRequest php artisan make:resource ProjectResource
4) Validation with Form Requests
Put your validation rules in one place. This keeps controllers readable and reusable.
app/Http/Requests/ProjectStoreRequest.php:
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class ProjectStoreRequest extends FormRequest { public function authorize(): bool { return true; // later: restrict by auth/policies } public function rules(): array { return [ 'name' => ['required', 'string', 'min:3', 'max:120'], 'description' => ['nullable', 'string', 'max:5000'], 'status' => ['sometimes', 'string', Rule::in(['draft', 'active', 'archived'])], 'due_date' => ['nullable', 'date', 'after_or_equal:today'], ]; } }
app/Http/Requests/ProjectUpdateRequest.php:
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class ProjectUpdateRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'name' => ['sometimes', 'string', 'min:3', 'max:120'], 'description' => ['sometimes', 'nullable', 'string', 'max:5000'], 'status' => ['sometimes', 'string', Rule::in(['draft', 'active', 'archived'])], 'due_date' => ['sometimes', 'nullable', 'date'], ]; } }
5) Shape responses with API Resources
Resources prevent “accidental API changes” (like exposing internal fields) and keep responses consistent.
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, 'dueDate' => optional($this->due_date)->toDateString(), 'createdAt' => $this->created_at->toISOString(), 'updatedAt' => $this->updated_at->toISOString(), ]; } }
6) Controller implementation (pagination + filtering)
Now wire everything together.
app/Http/Controllers/Api/ProjectController.php:
<?php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Requests\ProjectStoreRequest; use App\Http\Requests\ProjectUpdateRequest; use App\Http\Resources\ProjectResource; use App\Models\Project; use Illuminate\Http\Request; class ProjectController extends Controller { public function index(Request $request) { $query = Project::query(); // Simple filters: ?status=active&search=dashboard if ($request->filled('status')) { $query->where('status', $request->string('status')); } if ($request->filled('search')) { $search = '%' . $request->string('search') . '%'; $query->where(function ($q) use ($search) { $q->where('name', 'like', $search) ->orWhere('description', 'like', $search); }); } // Sort newest first $projects = $query->orderByDesc('id')->paginate(10); return ProjectResource::collection($projects); } public function store(ProjectStoreRequest $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(ProjectUpdateRequest $request, Project $project) { $project->update($request->validated()); return new ProjectResource($project); } public function destroy(Project $project) { $project->delete(); return response()->json(['deleted' => true]); } }
7) Routes: keep API routes separate
Edit routes/api.php:
<?php use App\Http\Controllers\Api\ProjectController; use Illuminate\Support\Facades\Route; Route::apiResource('projects', ProjectController::class);
Run the server and try it:
php artisan serve
Create a project using curl:
curl -X POST "http://127.0.0.1:8000/api/projects" \ -H "Content-Type: application/json" \ -d '{ "name": "Internal Dashboard", "description": "Rebuild the admin dashboard", "status": "active", "due_date": "2030-12-31" }'
List projects (with filters):
curl "http://127.0.0.1:8000/api/projects?status=active&search=dashboard"
8) Add feature tests (the fastest way to avoid regressions)
Laravel’s feature tests hit real routes and verify JSON. Create a test:
php artisan make:test ProjectApiTest
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' => 'API Launch', 'description' => 'Ship v1 of the API', 'status' => 'active', 'due_date' => '2030-01-01', ]; $res = $this->postJson('/api/projects', $payload); $res->assertCreated() ->assertJsonPath('data.name', 'API Launch') ->assertJsonPath('data.status', 'active'); $this->assertDatabaseHas('projects', ['name' => 'API Launch']); } public function test_it_validates_input(): void { $res = $this->postJson('/api/projects', [ 'name' => 'A', // too short 'status' => 'not-valid', ]); $res->assertStatus(422) ->assertJsonValidationErrors(['name', 'status']); } public function test_it_lists_projects_with_pagination(): void { Project::factory()->count(15)->create(['status' => 'active']); $res = $this->getJson('/api/projects?status=active'); $res->assertOk() ->assertJsonStructure([ 'data', 'links' => ['first', 'last', 'prev', 'next'], 'meta' => ['current_page', 'last_page', 'per_page', 'total'], ]); } }
This test references a factory, so generate one:
php artisan make:factory ProjectFactory --model=Project
database/factories/ProjectFactory.php:
<?php namespace Database\Factories; use App\Models\Project; use Illuminate\Database\Eloquent\Factories\Factory; class ProjectFactory extends Factory { protected $model = Project::class; public function definition(): array { return [ 'name' => $this->faker->sentence(3), 'description' => $this->faker->optional()->paragraph(), 'status' => $this->faker->randomElement(['draft', 'active', 'archived']), 'due_date' => $this->faker->optional()->date(), ]; } }
Run the tests:
php artisan test
9) Practical tips to keep it maintainable
-
Keep validation out of controllers. Form Requests make your controller methods short and readable.
-
Use API Resources for stable contracts. If you rename a DB column later, the API output doesn’t have to break.
-
Prefer route model binding. Accepting
Project $projectis cleaner and avoids manualfindOrFaillogic. -
Test the “happy path” and one failure case. That usually catches most regressions without over-testing.
-
Paginate by default. Returning thousands of rows is a common early API mistake.
Wrap-up
You now have a small Laravel JSON API built with conventions that scale: migrations for schema, Eloquent for data access, Form Requests for validation, Resources for response shaping, and feature tests for confidence. From here, the next natural steps are authentication (Sanctum), authorization (policies), and more advanced filtering (scopes or query builders)—but this foundation is already “real project” quality.
Leave a Reply