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:
FormRequestclasses for validationJsonResourceclasses 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
FormRequestearly. 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
201for created resources and204for 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