DEV Community

arasosman
arasosman

Posted on • Originally published at mycuriosity.blog

Laravel API Development: Best Practices and Security

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}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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)
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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()));
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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']);
});
Enter fullscreen mode Exit fullscreen mode

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)
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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']);
});
Enter fullscreen mode Exit fullscreen mode

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');
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
];
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)