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
Projectmodel 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=activeor?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