I've built and maintained several Laravel APIs over the years, and I've learned the hard way that the decisions you make early in API design have long-lasting consequences. A well-structured API is a joy to work with and extend. A poorly structured one becomes a maintenance nightmare that slows down every feature request. Here are the patterns and approaches I've found that lead to APIs you'll actually want to work with months (or years) later.
Good API design isn't just about following REST conventions - it's about creating predictable, consistent interfaces that make developers (including future you) productive and happy.
API Structure That Makes Sense
The foundation of a maintainable API is a clear, logical structure. I organize Laravel APIs around resources and actions, with consistent patterns that developers can predict.
Controller Organization
Rather than cramming everything into a few large controllers, I create focused controllers that handle specific resources:
app/Http/Controllers/Api/V1/
├── Auth/
│ ├── LoginController.php
│ ├── RegisterController.php
│ └── PasswordResetController.php
├── Users/
│ ├── UserController.php
│ ├── UserProfileController.php
│ └── UserPreferencesController.php
├── Posts/
│ ├── PostController.php
│ ├── PostCommentController.php
│ └── PostLikeController.php
└── Admin/
├── AdminUserController.php
└── AdminReportController.php
Controller Naming Conventions I Follow
- • Resource controllers: PostController for main CRUD operations
- • Nested resources: PostCommentController for comments on posts
- • Actions on resources: PostLikeController for liking posts
- • Scoped resources: AdminUserController for admin-specific user operations
Route Organization
// routes/api.php
Route::prefix('v1')->group(function () {
// Public routes
Route::post('auth/login', [LoginController::class, 'login']);
Route::post('auth/register', [RegisterController::class, 'register']);
Route::post('auth/password/reset', [PasswordResetController::class, 'reset']);
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
// User routes
Route::apiResource('users', UserController::class)->except(['store']);
Route::get('users/{user}/profile', [UserProfileController::class, 'show']);
Route::put('users/{user}/profile', [UserProfileController::class, 'update']);
// Post routes
Route::apiResource('posts', PostController::class);
Route::apiResource('posts.comments', PostCommentController::class);
Route::post('posts/{post}/like', [PostLikeController::class, 'store']);
Route::delete('posts/{post}/like', [PostLikeController::class, 'destroy']);
// Admin routes
Route::middleware('admin')->prefix('admin')->group(function () {
Route::apiResource('users', AdminUserController::class);
Route::get('reports/users', [AdminReportController::class, 'users']);
});
});
});
Request Validation That Scales
Form Request classes are one of Laravel's best features for API development. They keep your controllers clean and make validation logic reusable and testable.
Form Request Structure
class CreatePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Post::class);
}
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'content' => 'required|string|min:10',
'status' => 'required|in:draft,published,scheduled',
'published_at' => 'nullable|date|after:now',
'tags' => 'nullable|array',
'tags.*' => 'string|max:50',
'featured_image' => 'nullable|image|max:2048'
];
}
public function messages(): array
{
return [
'content.min' => 'Post content must be at least 10 characters long.',
'published_at.after' => 'Scheduled posts must be set for a future date.',
'tags.*.max' => 'Each tag must not exceed 50 characters.'
];
}
public function prepareForValidation(): void
{
$this->merge([
'status' => $this->status ?? 'draft',
'published_at' => $this->status === 'scheduled' ? $this->published_at : null
]);
}
public function validated($key = null, $default = null)
{
$validated = parent::validated();
// Add computed fields
$validated['slug'] = Str::slug($validated['title']);
$validated['reading_time'] = $this->calculateReadingTime($validated['content']);
return $key ? data_get($validated, $key, $default) : $validated;
}
private function calculateReadingTime(string $content): int
{
$wordCount = str_word_count(strip_tags($content));
return max(1, ceil($wordCount / 200)); // 200 words per minute
}
}
Conditional Validation
class UpdateUserRequest extends FormRequest
{
public function rules(): array
{
$userId = $this->route('user')->id ?? $this->user()->id;
return [
'name' => 'sometimes|required|string|max:255',
'email' => "sometimes|required|email|unique:users,email,{$userId}",
'password' => 'sometimes|required|string|min:8|confirmed',
'avatar' => 'sometimes|nullable|image|max:1024',
'preferences' => 'sometimes|array',
'preferences.notifications' => 'boolean',
'preferences.theme' => 'in:light,dark,auto'
];
}
public function authorize(): bool
{
$targetUser = $this->route('user');
// Users can update themselves, admins can update anyone
return $this->user()->id === $targetUser->id ||
$this->user()->hasRole('admin');
}
}
API Resources for Consistent Responses
API Resources are crucial for maintaining consistent response formats and hiding internal implementation details from your API consumers.
Basic Resource Structure
class PostResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'content' => $this->when($this->shouldShowFullContent($request), $this->content),
'status' => $this->status,
'reading_time' => $this->reading_time,
'published_at' => $this->published_at?->toISOString(),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
// Relationships
'author' => new UserResource($this->whenLoaded('author')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
'comments_count' => $this->when(isset($this->comments_count), $this->comments_count),
'likes_count' => $this->when(isset($this->likes_count), $this->likes_count),
// User-specific data
'is_liked' => $this->when($request->user(), function () use ($request) {
return $this->likes()->where('user_id', $request->user()->id)->exists();
}),
// Admin-only fields
'internal_notes' => $this->when($request->user()?->hasRole('admin'), $this->internal_notes),
];
}
private function shouldShowFullContent($request): bool
{
// Show full content on single post view or if user is author
return $request->route()->getName() === 'posts.show' ||
$request->user()?->id === $this->user_id;
}
}
Flexible Resource Collections
class PostCollection extends ResourceCollection
{
public function toArray($request): array
{
return [
'data' => $this->collection,
'meta' => [
'total' => $this->total(),
'count' => $this->count(),
'per_page' => $this->perPage(),
'current_page' => $this->currentPage(),
'total_pages' => $this->lastPage(),
'has_more' => $this->hasMorePages(),
],
'links' => [
'first' => $this->url(1),
'last' => $this->url($this->lastPage()),
'prev' => $this->previousPageUrl(),
'next' => $this->nextPageUrl(),
],
'filters' => $this->getAppliedFilters($request),
];
}
private function getAppliedFilters($request): array
{
return [
'status' => $request->get('status'),
'author' => $request->get('author'),
'tag' => $request->get('tag'),
'search' => $request->get('search'),
];
}
}
Error Handling That Helps Developers
Good error handling is crucial for API usability. Your errors should be informative, consistent, and actionable.
Custom Exception Handler
class Handler extends ExceptionHandler
{
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): JsonResponse
{
if ($e instanceof ValidationException) {
return response()->json([
'message' => 'Validation failed',
'errors' => $e->errors(),
'error_code' => 'VALIDATION_ERROR'
], 422);
}
if ($e instanceof ModelNotFoundException) {
return response()->json([
'message' => 'Resource not found',
'error_code' => 'RESOURCE_NOT_FOUND'
], 404);
}
if ($e instanceof AuthenticationException) {
return response()->json([
'message' => 'Authentication required',
'error_code' => 'AUTHENTICATION_REQUIRED'
], 401);
}
if ($e instanceof AuthorizationException) {
return response()->json([
'message' => 'Insufficient permissions',
'error_code' => 'INSUFFICIENT_PERMISSIONS'
], 403);
}
if ($e instanceof ThrottleRequestsException) {
return response()->json([
'message' => 'Too many requests',
'error_code' => 'RATE_LIMIT_EXCEEDED',
'retry_after' => $e->getHeaders()['Retry-After'] ?? null
], 429);
}
// Log unexpected errors
Log::error('API Error', [
'exception' => get_class($e),
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'request' => $request->all()
]);
return response()->json([
'message' => app()->environment('production')
? 'An error occurred while processing your request'
: $e->getMessage(),
'error_code' => 'INTERNAL_SERVER_ERROR'
], 500);
}
}
Custom Business Logic Exceptions
class PostNotFoundException extends Exception
{
public static function forId(int $id): self
{
return new self("Post with ID {$id} was not found.");
}
public static function forSlug(string $slug): self
{
return new self("Post with slug '{$slug}' was not found.");
}
}
class PostPublishingException extends Exception
{
public static function alreadyPublished(): self
{
return new self('This post is already published.');
}
public static function missingRequiredFields(array $fields): self
{
$fieldsList = implode(', ', $fields);
return new self("Cannot publish post: missing required fields: {$fieldsList}");
}
}
// Usage in controller
public function publish(Request $request, int $postId)
{
try {
$post = Post::findOrFail($postId);
if ($post->status === 'published') {
throw PostPublishingException::alreadyPublished();
}
$this->postService->publish($post);
return new PostResource($post->fresh());
} catch (PostPublishingException $e) {
return response()->json([
'message' => $e->getMessage(),
'error_code' => 'POST_PUBLISHING_ERROR'
], 422);
}
}
Authentication and Authorization Patterns
API Token Management
class LoginController extends Controller
{
public function login(LoginRequest $request)
{
$credentials = $request->validated();
if (!Auth::attempt($credentials)) {
return response()->json([
'message' => 'Invalid credentials',
'error_code' => 'INVALID_CREDENTIALS'
], 401);
}
$user = Auth::user();
// Revoke existing tokens for this device
$user->tokens()->where('name', $request->device_name ?? 'api-token')->delete();
$token = $user->createToken($request->device_name ?? 'api-token', [
'posts:read',
'posts:write',
'profile:read',
'profile:write'
]);
return response()->json([
'user' => new UserResource($user),
'token' => $token->plainTextToken,
'abilities' => $token->accessToken->abilities,
'expires_at' => $token->accessToken->expires_at
]);
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json([
'message' => 'Successfully logged out'
]);
}
}
Policy-Based Authorization
class PostPolicy
{
public function viewAny(User $user): bool
{
return true; // Anyone can view posts list
}
public function view(?User $user, Post $post): bool
{
if ($post->status === 'published') {
return true;
}
// Only author can view unpublished posts
return $user && $user->id === $post->user_id;
}
public function create(User $user): bool
{
return $user->hasVerifiedEmail();
}
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id || $user->hasRole('admin');
}
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id || $user->hasRole('admin');
}
public function publish(User $user, Post $post): bool
{
return $user->id === $post->user_id &&
$user->hasVerifiedEmail() &&
!$user->is_suspended;
}
}
// Usage in controller
class PostController extends Controller
{
public function update(UpdatePostRequest $request, Post $post)
{
$this->authorize('update', $post);
$post->update($request->validated());
return new PostResource($post);
}
}
Query Optimization and Performance
Efficient Resource Loading
class PostController extends Controller
{
public function index(Request $request)
{
$posts = Post::query()
->with([
'author:id,name,avatar',
'tags:id,name,slug'
])
->withCount(['comments', 'likes'])
->when($request->user(), function ($query) use ($request) {
return $query->withExists([
'likes as is_liked' => function ($query) use ($request) {
$query->where('user_id', $request->user()->id);
}
]);
})
->published()
->latest('published_at')
->paginate($request->get('per_page', 15));
return new PostCollection($posts);
}
public function show(Request $request, string $slug)
{
$post = Post::query()
->with([
'author:id,name,avatar,bio',
'tags:id,name,slug',
'comments.author:id,name,avatar'
])
->withCount(['comments', 'likes', 'shares'])
->where('slug', $slug)
->firstOrFail();
$this->authorize('view', $post);
// Track view
$post->increment('views');
return new PostResource($post);
}
}
Smart Filtering and Searching
class PostFilter
{
public function __construct(
private Builder $query,
private Request $request
) {}
public function apply(): Builder
{
return $this->query
->when($this->request->get('search'), [$this, 'search'])
->when($this->request->get('status'), [$this, 'status'])
->when($this->request->get('author'), [$this, 'author'])
->when($this->request->get('tag'), [$this, 'tag'])
->when($this->request->get('date_from'), [$this, 'dateFrom'])
->when($this->request->get('date_to'), [$this, 'dateTo']);
}
public function search(Builder $query, string $search): Builder
{
return $query->where(function ($query) use ($search) {
$query->where('title', 'like', "%{$search}%")
->orWhere('content', 'like', "%{$search}%")
->orWhere('excerpt', 'like', "%{$search}%");
});
}
public function status(Builder $query, string $status): Builder
{
return $query->where('status', $status);
}
public function author(Builder $query, string $authorSlug): Builder
{
return $query->whereHas('author', function ($query) use ($authorSlug) {
$query->where('slug', $authorSlug);
});
}
public function tag(Builder $query, string $tagSlug): Builder
{
return $query->whereHas('tags', function ($query) use ($tagSlug) {
$query->where('slug', $tagSlug);
});
}
public function dateFrom(Builder $query, string $date): Builder
{
return $query->where('published_at', '>=', $date);
}
public function dateTo(Builder $query, string $date): Builder
{
return $query->where('published_at', '<=', $date);
}
}
// Usage in controller
public function index(Request $request)
{
$query = Post::query();
$posts = (new PostFilter($query, $request))
->apply()
->with(['author:id,name', 'tags:id,name'])
->paginate($request->get('per_page', 15));
return new PostCollection($posts);
}
API Versioning Strategy
Planning for API evolution from the start saves you headaches later. Here's how I approach versioning:
Versioning Approaches
- • URL versioning: `/api/v1/posts` (what I usually use)
- • Header versioning: `Accept: application/vnd.api+json;version=1`
- • Parameter versioning: `/api/posts?version=1` (not recommended)
Version Management Structure
app/Http/Controllers/Api/
├── V1/
│ ├── PostController.php
│ ├── UserController.php
│ └── Resources/
│ ├── PostResource.php
│ └── UserResource.php
└── V2/
├── PostController.php
├── UserController.php
└── Resources/
├── PostResource.php
└── UserResource.php
// routes/api.php
Route::prefix('v1')->namespace('Api\V1')->group(function () {
Route::apiResource('posts', PostController::class);
});
Route::prefix('v2')->namespace('Api\V2')->group(function () {
Route::apiResource('posts', PostController::class);
});
Backward Compatibility Helpers
// V2 controller extending V1 for compatibility
class PostController extends \App\Http\Controllers\Api\V1\PostController
{
public function index(Request $request)
{
// New V2 logic
$posts = $this->getPostsWithNewFeatures($request);
return new PostCollection($posts);
}
// Inherit other methods from V1 unless overridden
}
// Middleware to handle version deprecation warnings
class ApiVersionMiddleware
{
public function handle(Request $request, Closure $next, string $version)
{
$response = $next($request);
if ($version === 'v1' && $this->shouldWarnAboutDeprecation()) {
$response->headers->set('X-API-Deprecation-Warning',
'API v1 will be deprecated on 2024-12-31. Please migrate to v2.'
);
}
return $response;
}
}
Documentation and Testing
API Documentation with OpenAPI
I use Laravel's built-in tools combined with annotations to generate API documentation:
/**
* @OA\Get(
* path="/api/v1/posts",
* summary="Get list of posts",
* tags={"Posts"},
* @OA\Parameter(
* name="page",
* in="query",
* description="Page number",
* @OA\Schema(type="integer", minimum=1, default=1)
* ),
* @OA\Parameter(
* name="per_page",
* in="query",
* description="Items per page",
* @OA\Schema(type="integer", minimum=1, maximum=100, default=15)
* ),
* @OA\Parameter(
* name="search",
* in="query",
* description="Search term",
* @OA\Schema(type="string")
* ),
* @OA\Response(
* response=200,
* description="Successful response",
* @OA\JsonContent(
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Post")),
* @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta"),
* @OA\Property(property="links", ref="#/components/schemas/PaginationLinks")
* )
* )
* )
*/
public function index(Request $request)
{
// Implementation...
}
Comprehensive API Testing
class PostApiTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function can_get_posts_list()
{
$posts = Post::factory()->published()->count(5)->create();
$response = $this->getJson('/api/v1/posts');
$response->assertStatus(200)
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'slug', 'excerpt', 'author', 'published_at']
],
'meta' => ['total', 'per_page', 'current_page'],
'links' => ['first', 'last', 'prev', 'next']
]);
}
/** @test */
public function can_filter_posts_by_author()
{
$author = User::factory()->create();
$authorPosts = Post::factory()->count(3)->create(['user_id' => $author->id]);
$otherPosts = Post::factory()->count(2)->create();
$response = $this->getJson("/api/v1/posts?author={$author->slug}");
$response->assertStatus(200)
->assertJsonCount(3, 'data');
}
/** @test */
public function authenticated_user_can_create_post()
{
$user = User::factory()->create();
$postData = [
'title' => 'Test Post',
'content' => 'This is test content for the post.',
'status' => 'published'
];
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/v1/posts', $postData);
$response->assertStatus(201)
->assertJsonFragment([
'title' => 'Test Post',
'slug' => 'test-post'
]);
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'user_id' => $user->id
]);
}
}
Performance and Monitoring
Rate Limiting
// In RouteServiceProvider
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(100)->by($request->user()->id)
: Limit::perMinute(20)->by($request->ip());
});
RateLimiter::for('uploads', function (Request $request) {
return $request->user()
? Limit::perMinute(10)->by($request->user()->id)
: Limit::perMinute(2)->by($request->ip());
});
// In routes
Route::middleware(['throttle:api'])->group(function () {
Route::apiResource('posts', PostController::class);
});
Route::middleware(['throttle:uploads'])->group(function () {
Route::post('posts/{post}/upload', [PostUploadController::class, 'store']);
});
Real-World Considerations
Do This
Avoid This
Building APIs That Last
The best APIs are those that grow with your application without breaking existing integrations. This means thinking about extensibility, maintaining backward compatibility when possible, and being thoughtful about the contracts you create.
Every API design decision is a trade-off between simplicity, flexibility, and performance. The key is making these trade-offs consciously and documenting the reasoning for future reference.
🎯 Key Principles
Design for your consumers first, optimize for performance second, and plan for change from the beginning. A well-designed API is an asset that enables rapid development and integration.
Remember: your API is a product. Treat it with the same care you'd give to any user-facing feature, because to developers consuming your API, it is the product.
Working on a Laravel API or want to discuss API design patterns that have worked well for your team? I'd love to chat about the challenges you're facing and approaches that have served you well. API design is one of those areas where shared experience is incredibly valuable.

Chris Page
Fractional CTO and Software Engineer with 25+ years of experience. I help startups scale from 0 to 7 figures using AI-assisted development and proven frameworks.