After building dozens of Laravel APIs—from simple CRUD backends to complex multi-tenant systems—I've settled on patterns that work well for me. This isn't about following trends; it's about what makes codebases maintainable over time.
The Foundation: API Resources
Laravel's API Resources are underrated. They provide a clean separation between your database structure and your API response format.
// app/Http/Resources/UserResource.php
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'avatar_url' => $this->avatar_url,
'created_at' => $this->created_at->toISOString(),
// Conditional relationships
'team' => new TeamResource($this->whenLoaded('team')),
'roles' => RoleResource::collection($this->whenLoaded('roles')),
];
}
}
The key insight: your database columns will change, your relationships will evolve, but your API contract should remain stable. Resources give you that buffer.
Form Requests for Validation
Every API endpoint that accepts input gets a Form Request. No exceptions.
// app/Http/Requests/CreateProjectRequest.php
class CreateProjectRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Project::class);
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:1000'],
'team_id' => ['required', 'exists:teams,id'],
'deadline' => ['nullable', 'date', 'after:today'],
];
}
public function messages(): array
{
return [
'deadline.after' => 'The deadline must be a future date.',
];
}
}
This keeps controllers thin and makes validation logic testable in isolation.
Service Classes for Business Logic
Controllers should be boring. They receive a request, call a service, and return a response. All the interesting work happens in service classes.
// app/Services/ProjectService.php
class ProjectService
{
public function __construct(
private NotificationService $notifications
) {}
public function create(User $user, array $data): Project
{
$project = DB::transaction(function () use ($user, $data) {
$project = Project::create([
'name' => $data['name'],
'description' => $data['description'] ?? null,
'team_id' => $data['team_id'],
'created_by' => $user->id,
]);
// Set up default project structure
$project->boards()->create(['name' => 'Default Board']);
return $project;
});
// Notify team members
$this->notifications->notifyTeam(
$project->team,
new ProjectCreatedNotification($project)
);
return $project;
}
}
The controller becomes trivial:
// app/Http/Controllers/Api/ProjectController.php
public function store(CreateProjectRequest $request, ProjectService $service)
{
$project = $service->create(
$request->user(),
$request->validated()
);
return new ProjectResource($project);
}
Consistent Error Responses
APIs need consistent error formatting. I use a custom exception handler that formats all errors uniformly:
// app/Exceptions/Handler.php
public function render($request, Throwable $e)
{
if ($request->expectsJson()) {
return $this->handleApiException($request, $e);
}
return parent::render($request, $e);
}
private function handleApiException($request, Throwable $e)
{
if ($e instanceof ValidationException) {
return response()->json([
'message' => 'Validation failed',
'errors' => $e->errors(),
], 422);
}
if ($e instanceof ModelNotFoundException) {
return response()->json([
'message' => 'Resource not found',
], 404);
}
if ($e instanceof AuthorizationException) {
return response()->json([
'message' => 'Unauthorized',
], 403);
}
// Log unexpected errors
Log::error($e->getMessage(), ['exception' => $e]);
return response()->json([
'message' => app()->isProduction()
? 'An error occurred'
: $e->getMessage(),
], 500);
}
API Versioning
For APIs that will evolve, I version from day one:
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('projects', Api\V1\ProjectController::class);
});
Route::prefix('v2')->group(function () {
Route::apiResource('projects', Api\V2\ProjectController::class);
});
This isn't about predicting the future—it's about making the future possible without breaking existing clients.
Testing Strategy
Every API endpoint gets at least these tests:
public function test_can_create_project()
{
$user = User::factory()->create();
$team = Team::factory()->create();
$team->users()->attach($user);
$response = $this->actingAs($user)->postJson('/api/v1/projects', [
'name' => 'New Project',
'team_id' => $team->id,
]);
$response->assertCreated()
->assertJsonStructure(['data' => ['id', 'name', 'created_at']]);
}
public function test_cannot_create_project_without_required_fields()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/v1/projects', []);
$response->assertUnprocessable()
->assertJsonValidationErrors(['name', 'team_id']);
}
public function test_cannot_create_project_for_other_team()
{
$user = User::factory()->create();
$otherTeam = Team::factory()->create();
$response = $this->actingAs($user)->postJson('/api/v1/projects', [
'name' => 'New Project',
'team_id' => $otherTeam->id,
]);
$response->assertForbidden();
}
Conclusion
These patterns aren't revolutionary. They're the result of maintaining APIs over time and learning what makes that maintenance bearable. The common thread: separation of concerns and explicit contracts at every boundary.