Laravel API Development: Best Practices and Security



This content originally appeared on DEV Community and was authored by arasosman

This article was originally published on My Curiosity Blog.

Introduction

Building robust APIs is crucial in today’s interconnected world. During my decade of Laravel development, I’ve built APIs serving millions of requests daily in San Francisco’s competitive tech environment. This guide shares the essential practices that ensure your APIs are secure, performant, and maintainable.

API Architecture Fundamentals

1. RESTful Design Principles

Resource-Based URLs:

// Good: Resource-based routes
Route::apiResource('users', UserController::class);
Route::apiResource('posts', PostController::class);
Route::apiResource('users.posts', UserPostController::class);

// Generated routes:
// GET    /api/users
// POST   /api/users
// GET    /api/users/{user}
// PUT    /api/users/{user}
// DELETE /api/users/{user}

HTTP Status Codes:

class ApiController extends Controller
{
    protected function successResponse($data = null, $message = 'Success', $code = 200)
    {
        return response()->json([
            'success' => true,
            'message' => $message,
            'data' => $data
        ], $code);
    }

    protected function errorResponse($message = 'Error', $code = 400, $errors = null)
    {
        return response()->json([
            'success' => false,
            'message' => $message,
            'errors' => $errors
        ], $code);
    }
}

class UserController extends ApiController
{
    public function store(StoreUserRequest $request)
    {
        $user = User::create($request->validated());

        return $this->successResponse(
            new UserResource($user),
            'User created successfully',
            201
        );
    }

    public function show(User $user)
    {
        return $this->successResponse(new UserResource($user));
    }
}

2. API Versioning Strategy

URI Versioning:

// routes/api.php
Route::prefix('v1')->group(function () {
    Route::apiResource('users', V1\UserController::class);
    Route::apiResource('posts', V1\PostController::class);
});

Route::prefix('v2')->group(function () {
    Route::apiResource('users', V2\UserController::class);
    Route::apiResource('posts', V2\PostController::class);
});

Header-Based Versioning:

class ApiVersionMiddleware
{
    public function handle($request, Closure $next)
    {
        $version = $request->header('Accept-Version', 'v1');

        $request->merge(['api_version' => $version]);

        return $next($request);
    }
}

// Controller handling multiple versions
class UserController extends Controller
{
    public function index(Request $request)
    {
        $users = User::paginate(15);

        return match($request->api_version) {
            'v1' => UserV1Resource::collection($users),
            'v2' => UserV2Resource::collection($users),
            default => UserV1Resource::collection($users)
        };
    }
}

Authentication and Authorization

1. Laravel Sanctum Implementation

Setup and Configuration:

// Install Sanctum
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate

// Model setup
class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;

    public function createApiToken($name = 'api-token', $abilities = ['*'])
    {
        return $this->createToken($name, $abilities)->plainTextToken;
    }
}

Authentication Controller:

class AuthController extends ApiController
{
    public function login(LoginRequest $request)
    {
        if (!Auth::attempt($request->validated())) {
            return $this->errorResponse('Invalid credentials', 401);
        }

        $user = Auth::user();
        $token = $user->createApiToken('login-token');

        return $this->successResponse([
            'user' => new UserResource($user),
            'token' => $token,
            'token_type' => 'Bearer'
        ], 'Login successful');
    }

    public function logout(Request $request)
    {
        $request->user()->currentAccessToken()->delete();

        return $this->successResponse(null, 'Logged out successfully');
    }

    public function me(Request $request)
    {
        return $this->successResponse(new UserResource($request->user()));
    }
}

2. Advanced Authorization with Gates and Policies

Policy-Based Authorization:

class PostPolicy
{
    public function viewAny(User $user)
    {
        return true;
    }

    public function view(User $user, Post $post)
    {
        return $post->published || $user->id === $post->user_id;
    }

    public function create(User $user)
    {
        return $user->can('create-posts');
    }

    public function update(User $user, Post $post)
    {
        return $user->id === $post->user_id || $user->hasRole('admin');
    }

    public function delete(User $user, Post $post)
    {
        return $user->id === $post->user_id || $user->hasRole('admin');
    }
}

class PostController extends ApiController
{
    public function __construct()
    {
        $this->authorizeResource(Post::class, 'post');
    }

    public function index()
    {
        $posts = Post::where('published', true)
            ->with(['user:id,name', 'category:id,name'])
            ->paginate(15);

        return $this->successResponse(PostResource::collection($posts));
    }

    public function store(StorePostRequest $request)
    {
        $post = $request->user()->posts()->create($request->validated());

        return $this->successResponse(
            new PostResource($post->load('user', 'category')),
            'Post created successfully',
            201
        );
    }
}

3. Role-Based Access Control

// Custom middleware for role checking
class RoleMiddleware
{
    public function handle($request, Closure $next, ...$roles)
    {
        if (!$request->user() || !$request->user()->hasAnyRole($roles)) {
            return response()->json([
                'success' => false,
                'message' => 'Insufficient permissions'
            ], 403);
        }

        return $next($request);
    }
}

// Route protection
Route::middleware(['auth:sanctum', 'role:admin,moderator'])->group(function () {
    Route::get('/admin/users', [AdminController::class, 'users']);
    Route::delete('/admin/posts/{post}', [AdminController::class, 'deletePost']);
});

Request Validation and Data Transformation

1. Advanced Form Requests

class StorePostRequest extends FormRequest
{
    public function authorize()
    {
        return $this->user()->can('create', Post::class);
    }

    public function rules()
    {
        return [
            'title' => 'required|string|max:255|unique:posts,title',
            'content' => 'required|string|min:100',
            'category_id' => 'required|exists:categories,id',
            'tags' => 'array|max:5',
            'tags.*' => 'exists:tags,id',
            'featured_image' => 'sometimes|image|mimes:jpeg,png,jpg|max:2048',
            'published' => 'boolean',
            'publish_at' => 'sometimes|date|after:now'
        ];
    }

    public function messages()
    {
        return [
            'title.unique' => 'A post with this title already exists.',
            'content.min' => 'Post content must be at least 100 characters.',
            'tags.max' => 'You can select maximum 5 tags.',
        ];
    }

    protected function prepareForValidation()
    {
        $this->merge([
            'published' => $this->boolean('published'),
            'slug' => Str::slug($this->title)
        ]);
    }
}

2. API Resources for Data Transformation

class UserResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->when($this->isOwner($request), $this->email),
            'avatar' => $this->avatar_url,
            'created_at' => $this->created_at->toISOString(),
            'updated_at' => $this->updated_at->toISOString(),
            'posts_count' => $this->when($this->relationLoaded('posts'), $this->posts_count),
            'latest_post' => new PostResource($this->whenLoaded('latestPost')),
        ];
    }

    private function isOwner($request)
    {
        return $request->user() && $request->user()->id === $this->id;
    }
}

class PostResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'slug' => $this->slug,
            'excerpt' => $this->excerpt,
            'content' => $this->when($this->shouldShowFullContent($request), $this->content),
            'featured_image' => $this->featured_image_url,
            'published' => $this->published,
            'created_at' => $this->created_at->toISOString(),
            'author' => new UserResource($this->whenLoaded('user')),
            'category' => new CategoryResource($this->whenLoaded('category')),
            'tags' => TagResource::collection($this->whenLoaded('tags')),
            'comments_count' => $this->when($this->relationLoaded('comments'), $this->comments_count),
        ];
    }

    private function shouldShowFullContent($request)
    {
        return $request->routeIs('posts.show') || 
               ($request->user() && $request->user()->id === $this->user_id);
    }
}

Security Best Practices

1. Rate Limiting and Throttling

// config/sanctum.php
'expiration' => 60 * 24, // 24 hours

// Custom rate limiting
class ApiRateLimitMiddleware
{
    public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1)
    {
        $key = $this->resolveRequestSignature($request);

        if (RateLimiter::tooManyAttempts($key, $maxAttempts)) {
            return response()->json([
                'success' => false,
                'message' => 'Too many requests. Please try again later.',
                'retry_after' => RateLimiter::availableIn($key)
            ], 429);
        }

        RateLimiter::hit($key, $decayMinutes * 60);

        return $next($request);
    }

    protected function resolveRequestSignature($request)
    {
        if ($user = $request->user()) {
            return 'api_rate_limit:' . $user->id;
        }

        return 'api_rate_limit:' . $request->ip();
    }
}

// Apply different limits for different endpoints
Route::middleware(['throttle:api'])->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
});

Route::middleware(['throttle:10,1'])->group(function () {
    Route::post('/posts', [PostController::class, 'store']);
    Route::put('/posts/{post}', [PostController::class, 'update']);
});

2. Input Sanitization and Validation

class SecurityMiddleware
{
    public function handle($request, Closure $next)
    {
        // Sanitize input data
        $this->sanitizeInput($request);

        // Check for SQL injection patterns
        $this->checkForSQLInjection($request);

        // Validate content type
        $this->validateContentType($request);

        return $next($request);
    }

    private function sanitizeInput($request)
    {
        $input = $request->all();

        array_walk_recursive($input, function (&$value) {
            if (is_string($value)) {
                $value = strip_tags($value);
                $value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
            }
        });

        $request->merge($input);
    }

    private function checkForSQLInjection($request)
    {
        $suspiciousPatterns = [
            '/(\bUNION\b|\bSELECT\b|\bINSERT\b|\bDELETE\b|\bUPDATE\b|\bDROP\b)/i',
            '/(\bOR\b\s+\d+\s*=\s*\d+|\bAND\b\s+\d+\s*=\s*\d+)/i',
        ];

        foreach ($request->all() as $value) {
            if (is_string($value)) {
                foreach ($suspiciousPatterns as $pattern) {
                    if (preg_match($pattern, $value)) {
                        abort(400, 'Suspicious input detected');
                    }
                }
            }
        }
    }
}

3. CORS Configuration

// config/cors.php
return [
    'paths' => ['api/*'],
    'allowed_methods' => ['*'],
    'allowed_origins' => ['https://yourdomain.com', 'https://app.yourdomain.com'],
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['*'],
    'exposed_headers' => ['X-Total-Count', 'X-Per-Page'],
    'max_age' => 0,
    'supports_credentials' => true,
];

Error Handling and Logging

1. Global Exception Handling

class Handler extends ExceptionHandler
{
    public function render($request, Throwable $exception)
    {
        if ($request->is('api/*')) {
            return $this->handleApiException($exception);
        }

        return parent::render($request, $exception);
    }

    private function handleApiException(Throwable $exception)
    {
        if ($exception instanceof ValidationException) {
            return response()->json([
                'success' => false,
                'message' => 'Validation failed',
                'errors' => $exception->errors()
            ], 422);
        }

        if ($exception instanceof ModelNotFoundException) {
            return response()->json([
                'success' => false,
                'message' => 'Resource not found'
            ], 404);
        }

        if ($exception instanceof AuthenticationException) {
            return response()->json([
                'success' => false,
                'message' => 'Unauthenticated'
            ], 401);
        }

        if ($exception instanceof AuthorizationException) {
            return response()->json([
                'success' => false,
                'message' => 'Forbidden'
            ], 403);
        }

        // Log unexpected errors
        Log::error('API Exception: ' . $exception->getMessage(), [
            'exception' => $exception,
            'request' => request()->all(),
            'user_id' => auth()->id()
        ]);

        return response()->json([
            'success' => false,
            'message' => 'Internal server error'
        ], 500);
    }
}

2. API Logging and Monitoring

class ApiLoggingMiddleware
{
    public function handle($request, Closure $next)
    {
        $startTime = microtime(true);

        $response = $next($request);

        $duration = microtime(true) - $startTime;

        Log::info('API Request', [
            'method' => $request->method(),
            'url' => $request->fullUrl(),
            'ip' => $request->ip(),
            'user_agent' => $request->userAgent(),
            'user_id' => auth()->id(),
            'duration' => round($duration * 1000, 2) . 'ms',
            'status_code' => $response->getStatusCode(),
            'request_size' => strlen(json_encode($request->all())),
            'response_size' => strlen($response->getContent())
        ]);

        return $response;
    }
}

Performance Optimization

1. Database Query Optimization

class PostController extends ApiController
{
    public function index(Request $request)
    {
        $posts = Post::query()
            ->select(['id', 'title', 'slug', 'excerpt', 'featured_image', 'created_at', 'user_id', 'category_id'])
            ->with([
                'user:id,name,avatar',
                'category:id,name,slug'
            ])
            ->withCount(['comments', 'likes'])
            ->when($request->category, function ($query, $category) {
                $query->whereHas('category', function ($q) use ($category) {
                    $q->where('slug', $category);
                });
            })
            ->when($request->search, function ($query, $search) {
                $query->where(function ($q) use ($search) {
                    $q->where('title', 'like', "%{$search}%")
                      ->orWhere('excerpt', 'like', "%{$search}%");
                });
            })
            ->published()
            ->latest()
            ->paginate(15);

        return $this->successResponse(PostResource::collection($posts));
    }
}

2. Response Caching

class CacheableController extends ApiController
{
    protected function cacheResponse($key, $callback, $ttl = 3600)
    {
        return Cache::remember($key, $ttl, function () use ($callback) {
            return $callback();
        });
    }

    public function popularPosts()
    {
        $posts = $this->cacheResponse('popular_posts', function () {
            return Post::with(['user:id,name', 'category:id,name'])
                ->withCount(['views', 'likes', 'comments'])
                ->orderBy('views_count', 'desc')
                ->limit(10)
                ->get();
        }, 1800); // Cache for 30 minutes

        return $this->successResponse(PostResource::collection($posts));
    }
}

Testing Your API

1. Feature Tests

class PostApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_user_can_create_post()
    {
        $user = User::factory()->create();
        $category = Category::factory()->create();

        $postData = [
            'title' => 'Test Post',
            'content' => 'This is a test post content that is long enough to pass validation.',
            'category_id' => $category->id,
            'published' => true
        ];

        $response = $this->actingAs($user, 'sanctum')
            ->postJson('/api/posts', $postData);

        $response->assertCreated()
            ->assertJson([
                'success' => true,
                'message' => 'Post created successfully'
            ])
            ->assertJsonStructure([
                'data' => ['id', 'title', 'slug', 'content', 'author']
            ]);

        $this->assertDatabaseHas('posts', [
            'title' => 'Test Post',
            'user_id' => $user->id
        ]);
    }

    public function test_unauthorized_user_cannot_create_post()
    {
        $response = $this->postJson('/api/posts', [
            'title' => 'Test Post'
        ]);

        $response->assertUnauthorized();
    }
}

Documentation and API Design

1. API Documentation with Laravel

/**
 * @OA\Post(
 *     path="/api/posts",
 *     summary="Create a new post",
 *     tags={"Posts"},
 *     security={{ "sanctum": {} }},
 *     @OA\RequestBody(
 *         required=true,
 *         @OA\JsonContent(
 *             required={"title","content","category_id"},
 *             @OA\Property(property="title", type="string", maxLength=255),
 *             @OA\Property(property="content", type="string", minLength=100),
 *             @OA\Property(property="category_id", type="integer"),
 *             @OA\Property(property="published", type="boolean", default=false)
 *         )
 *     ),
 *     @OA\Response(
 *         response=201,
 *         description="Post created successfully",
 *         @OA\JsonContent(ref="#/components/schemas/PostResource")
 *     )
 * )
 */
public function store(StorePostRequest $request)
{
    // Implementation
}

Conclusion

Building secure, scalable APIs with Laravel requires attention to authentication, authorization, validation, error handling, and performance optimization. These practices, refined through years of production experience, will help you create APIs that can handle real-world demands while maintaining security and reliability.

Remember: security is not a feature you add later—it must be built into your API from the ground up. Always validate input, authenticate users properly, authorize actions, and monitor your API’s performance and security in production.


This content originally appeared on DEV Community and was authored by arasosman