Laravel PHP in Practice: Build a Clean JSON API with Form Requests and API Resources

Laravel PHP in Practice: Build a Clean JSON API with Form Requests and API Resources

Laravel makes it easy to return JSON from controllers, but a clean API needs more than return response()->json(). You need request validation, predictable response shapes, proper HTTP status codes, and code that stays readable as the project grows.

In this hands-on guide, we will build a small Laravel API for managing projects. The focus is not on a huge architecture. The goal is a practical pattern junior and mid-level developers can reuse in real applications.

What We Will Build

We will create endpoints for:

  • Listing projects
  • Creating a project
  • Showing a single project
  • Updating a project
  • Deleting a project

The API will use:

  • FormRequest classes for validation
  • JsonResource classes for clean output formatting
  • Route model binding
  • Consistent HTTP responses

Step 1: Create the Model and Migration

Start by creating a Project model with a migration:

php artisan make:model Project -m

Open the migration file in database/migrations and define the table:

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(); }); }

Run the migration:

php artisan migrate

Now update the model in app/Models/Project.php:

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

The $fillable array tells Laravel which fields can be mass assigned. This matters when you use methods like Project::create($data) or $project->update($data).

Step 2: Add API Routes

Open routes/api.php and add a resource route:

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

apiResource creates routes for common API actions without adding unnecessary create and edit routes, which are usually only needed for server-rendered HTML forms.

You can inspect the generated routes with:

php artisan route:list --path=api/projects

Step 3: Create the Controller

Create an API controller:

php artisan make:controller Api/ProjectController --api

This creates a controller with methods like index, store, show, update, and destroy.

Before filling it in, we will create two supporting classes: a request validator and a resource formatter.

Step 4: Validate Input with Form Requests

Validation inside controllers works, but it quickly becomes noisy. A better approach is to move validation into a dedicated request class.

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

Open 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' => ['required', Rule::in(['draft', 'active', 'completed'])], 'deadline' => ['nullable', 'date', 'after_or_equal:today'], ]; } }

Now open app/Http/Requests/UpdateProjectRequest.php:

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

The difference is important. For creating a project, name and status are required. For updating, the client may send only one field, so we use sometimes.

Step 5: Format Output with an API Resource

Returning the raw model works, but it couples your database structure to your API response. API resources give you a stable response format.

php artisan make:resource ProjectResource

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

One small improvement: cast deadline as a date in the model so Laravel handles it properly.

class Project extends Model { protected $fillable = [ 'name', 'description', 'status', 'deadline', ]; protected $casts = [ 'deadline' => 'date', ]; }

Step 6: Implement the Controller

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

<?php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Requests\StoreProjectRequest; use App\Http\Requests\UpdateProjectRequest; use App\Http\Resources\ProjectResource; use App\Models\Project; use Illuminate\Http\JsonResponse; 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): JsonResponse { $project = Project::create($request->validated()); return (new ProjectResource($project)) ->response() ->setStatusCode(201); } public function show(Project $project): ProjectResource { return new ProjectResource($project); } public function update( UpdateProjectRequest $request, Project $project ): ProjectResource { $project->update($request->validated()); return new ProjectResource($project->refresh()); } public function destroy(Project $project): JsonResponse { $project->delete(); return response()->json(null, 204); } }

This controller stays small because each class has a clear job:

  • The controller coordinates the request and response.
  • The request classes validate incoming data.
  • The resource class controls the JSON structure.
  • The model handles database interaction.

Step 7: Test the API Manually

You can test the create 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 the marketing website and improve page speed.", "status": "active", "deadline": "2026-07-30" }'

A successful response should return HTTP 201 Created with a JSON body similar to this:

{ "data": { "id": 1, "name": "Website redesign", "description": "Refresh the marketing website and improve page speed.", "status": "active", "deadline": "2026-07-30", "created_at": "2026-05-13T10:15:30.000000Z", "updated_at": "2026-05-13T10:15:30.000000Z" } }

Now try sending invalid data:

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

Laravel will automatically return a 422 Unprocessable Entity response with validation errors. That means you do not need to manually write error response logic for common validation failures.

Step 8: Add Simple Filtering

Most real APIs need basic filtering. Let’s allow clients to filter projects by status using a query string like ?status=active.

Update the index method:

public function index(): AnonymousResourceCollection { $projects = Project::query() ->when(request('status'), function ($query, string $status) { $query->where('status', $status); }) ->latest() ->paginate(10); return ProjectResource::collection($projects); }

Now this request returns only active projects:

curl http://localhost:8000/api/projects?status=active \ -H "Accept: application/json"

For a production API, you should validate filter values too. A simple approach is to create a dedicated request class for listing projects, but for small internal APIs this lightweight version is often acceptable.

Practical Tips for Cleaner Laravel APIs

  • Use FormRequest early. It keeps controllers readable and makes validation reusable.
  • Use API resources instead of returning models directly. This protects your API from accidental database changes.
  • Return the right status code. Use 201 for created resources and 204 for successful deletes with no response body.
  • Keep controllers thin. If business logic grows, move it into service classes or actions.
  • Prefer pagination for list endpoints. Never return thousands of records by default.

Conclusion

A good Laravel API does not need to be complicated. By combining route model binding, form requests, API resources, and clear status codes, you can build endpoints that are easy to read, test, and extend.

The main habit to build is separation of concerns. Validation belongs in request classes. Response formatting belongs in resources. Controllers should stay focused on application flow. Once your API follows that structure, adding authentication, policies, tests, and more complex filters becomes much easier.


Leave a Reply

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