Laravel PHP in Practice: Build a Clean JSON Endpoint with Validation, Resources, and Tests

Laravel PHP in Practice: Build a Clean JSON Endpoint with Validation, Resources, and Tests

Laravel is a good framework for junior and mid-level developers because it gives you strong defaults without forcing you to write a lot of boilerplate. In this hands-on guide, we will build a small JSON API endpoint for managing projects. The goal is not to create a full application, but to show a practical Laravel structure you can reuse in real projects.

We will create:

  • a Project model and migration,
  • a controller for listing and creating projects,
  • a form request for validation,
  • a resource class for consistent JSON output,
  • and a feature test to prove the endpoint works.

1. Create the Model and Migration

Start by creating a model with a migration:

php artisan make:model Project -m

Open the generated migration file in database/migrations and define the 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->string('name'); $table->text('description')->nullable(); $table->string('status')->default('draft'); $table->date('deadline')->nullable(); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('projects'); } };

Run the migration:

php artisan migrate

Now update the Project model so Laravel knows which fields can be mass-assigned:

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

2. Define API Routes

Open routes/api.php and add two routes: one for listing projects and one for creating a project.

<?php use Illuminate\Support\Facades\Route; use App\Http\Controllers\Api\ProjectController; Route::get('/projects', [ProjectController::class, 'index']); Route::post('/projects', [ProjectController::class, 'store']);

Keeping API routes in api.php makes the intent clear. These routes are stateless and usually return JSON responses.

3. Create the Controller

Create an API controller:

php artisan make:controller Api/ProjectController

Then implement the index and store methods:

<?php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Requests\StoreProjectRequest; use App\Http\Resources\ProjectResource; use App\Models\Project; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; class ProjectController extends Controller { public function index(): AnonymousResourceCollection { $projects = Project::query() ->latest() ->paginate(10); return ProjectResource::collection($projects); } public function store(StoreProjectRequest $request): ProjectResource { $project = Project::create($request->validated()); return new ProjectResource($project); } }

There are two useful decisions here. First, the controller does not validate the request directly. Second, it does not manually build the JSON response. Validation is handled by a form request, and output formatting is handled by a resource.

4. Add Request Validation

Create a form request:

php artisan make:request StoreProjectRequest

Update app/Http/Requests/StoreProjectRequest.php:

<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class StoreProjectRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'name' => ['required', 'string', 'max:120'], 'description' => ['nullable', 'string', 'max:2000'], 'status' => [ 'nullable', 'string', Rule::in(['draft', 'active', 'paused', 'completed']), ], 'deadline' => ['nullable', 'date', 'after_or_equal:today'], ]; } }

This keeps validation rules close to the request they belong to. It also makes the controller easier to read. When validation fails, Laravel automatically returns a structured error response with a proper HTTP status code.

5. Format Responses with an API Resource

Create a resource class:

php artisan make:resource ProjectResource

Update 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, 'deadline' => optional($this->deadline)->toDateString(), 'created_at' => $this->created_at->toISOString(), ]; } }

A resource gives you control over the public shape of your API. Your database table can change later, but your API response does not have to expose every internal field.

6. Test the Endpoint Manually

You can test the POST /api/projects endpoint with curl:

curl -X POST http://localhost:8000/api/projects \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "name": "Website redesign", "description": "Refresh landing pages and improve performance.", "status": "active", "deadline": "2026-09-30" }'

A successful response should look similar to this:

{ "data": { "id": 1, "name": "Website redesign", "description": "Refresh landing pages and improve performance.", "status": "active", "deadline": "2026-09-30", "created_at": "2026-05-02T10:30:00.000000Z" } }

Now try an invalid request:

curl -X POST http://localhost:8000/api/projects \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "name": "", "status": "unknown" }'

Laravel should return validation errors for name and status. This is useful because clients receive predictable feedback instead of a generic server error.

7. Add a Feature Test

Manual testing is helpful, but automated tests protect your endpoint as the project grows. Create a feature test:

php artisan make:test ProjectApiTest

Update 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_user_can_create_project(): void { $response = $this->postJson('/api/projects', [ 'name' => 'Internal dashboard', 'description' => 'Build a dashboard for support metrics.', 'status' => 'active', 'deadline' => now()->addMonth()->toDateString(), ]); $response->assertCreated() ->assertJsonPath('data.name', 'Internal dashboard') ->assertJsonPath('data.status', 'active'); $this->assertDatabaseHas('projects', [ 'name' => 'Internal dashboard', 'status' => 'active', ]); } public function test_project_name_is_required(): void { $response = $this->postJson('/api/projects', [ 'status' => 'active', ]); $response->assertStatus(422) ->assertJsonValidationErrors(['name']); } public function test_user_can_list_projects(): void { Project::factory()->count(3)->create(); $response = $this->getJson('/api/projects'); $response->assertOk() ->assertJsonCount(3, 'data'); } }

The third test uses a factory. If your model does not have one yet, create it:

php artisan make:factory ProjectFactory --model=Project

Then define fake data in database/factories/ProjectFactory.php:

<?php namespace Database\Factories; use Illuminate\Database\Eloquent\Factories\Factory; class ProjectFactory extends Factory { public function definition(): array { return [ 'name' => fake()->sentence(3), 'description' => fake()->paragraph(), 'status' => fake()->randomElement(['draft', 'active', 'paused', 'completed']), 'deadline' => fake()->dateTimeBetween('now', '+6 months')->format('Y-m-d'), ]; } }

Also make sure your Project model uses factories:

<?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', 'deadline', ]; }

Run the tests:

php artisan test

8. Practical Improvements for Real Projects

This example is intentionally small, but the same structure scales well. For production APIs, consider these improvements:

  • Authentication: protect routes with Laravel Sanctum or another authentication layer.
  • Authorization: use policies so users can only access projects they own.
  • Filtering: allow query parameters like ?status=active or ?deadline_before=2026-12-31.
  • Service classes: move business rules out of controllers when creation logic becomes complex.
  • Consistent errors: define a clear error response format for frontend developers.

Conclusion

A clean Laravel API does not require complicated architecture. Start with a model, migration, controller, form request, resource, and feature tests. This gives you validation, predictable JSON responses, and confidence that future changes will not break basic behavior. For junior and mid-level developers, this pattern is a strong default: controllers stay small, validation is explicit, responses are consistent, and tests describe what the endpoint is supposed to do.


Leave a Reply

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