Development
Jun 7, 2025
13 min read
9 views

Building Laravel APIs That Don't Hate You Later: Design Patterns That Scale

Learn practical approaches to designing Laravel APIs that are maintainable, scalable, and actually pleasant to work with. From resource organization to error handling, here are patterns that work in real applications.

Building Laravel APIs That Don't Hate You Later: Design Patterns That Scale

Share this post

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

Consistent response formats: Same structure for all endpoints
Meaningful HTTP status codes: Use standard codes appropriately
Comprehensive error messages: Help developers debug issues
Version your API: Plan for future changes
Document everything: Keep docs up to date

Avoid This

Exposing internal IDs: Use UUIDs or obfuscated IDs
N+1 query problems: Always eager load relationships
Inconsistent naming: Stick to camelCase or snake_case
Missing validation: Validate all inputs thoroughly
Ignoring security: Implement proper authentication/authorization

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

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.

Related Posts

Continue reading

Ready to Scale Your Startup?

Let's discuss how I can help you build your MVP or optimize your existing technology stack.