Advanced Laravel

Advanced API Development

20 min Lesson 16 of 40

Advanced API Development

Building production-ready APIs requires advanced techniques beyond basic CRUD operations. In this lesson, we'll explore API versioning, HATEOAS principles, content negotiation, sophisticated rate limiting strategies, and comprehensive API documentation practices that make your APIs maintainable and developer-friendly.

API Versioning Strategies

API versioning allows you to introduce breaking changes without disrupting existing clients. Laravel provides flexible routing mechanisms for implementing various versioning strategies.

<?php

// routes/api.php - URI Versioning (most common)
Route::prefix('v1')->group(function () {
    Route::apiResource('users', App\Http\Controllers\Api\V1\UserController::class);
    Route::apiResource('posts', App\Http\Controllers\Api\V1\PostController::class);
});

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

// Header Versioning (Accept header)
// routes/api.php
Route::middleware(['api.version:v1'])->group(function () {
    Route::apiResource('users', App\Http\Controllers\Api\V1\UserController::class);
});

Route::middleware(['api.version:v2'])->group(function () {
    Route::apiResource('users', App\Http\Controllers\Api\V2\UserController::class);
});

// app/Http/Middleware/ApiVersion.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class ApiVersion
{
    public function handle(Request $request, Closure $next, string $version)
    {
        $acceptHeader = $request->header('Accept', '');

        if (str_contains($acceptHeader, "version={$version}")) {
            return $next($request);
        }

        return response()->json([
            'message' => "API version {$version} required in Accept header",
            'example' => "Accept: application/json;version={$version}"
        ], 406);
    }
}

// Query Parameter Versioning
// /api/users?version=v1
Route::middleware(['api.version.query'])->group(function () {
    Route::apiResource('users', function (Request $request) {
        $version = $request->query('version', 'v1');
        $controller = "App\Http\Controllers\Api\{$version}\UserController";
        return app($controller)->index($request);
    });
});
Best Practice: URI versioning (like /api/v1/users) is the most widely adopted approach because it's explicit, easy to understand, and works seamlessly with API documentation tools like Swagger/OpenAPI.

HATEOAS (Hypermedia as the Engine of Application State)

HATEOAS is a REST constraint that makes APIs self-documenting by including hypermedia links in responses, allowing clients to discover available actions dynamically.

<?php

// app/Http/Resources/UserResource.php
namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'created_at' => $this->created_at->toIso8601String(),
            'links' => [
                'self' => [
                    'href' => route('api.users.show', $this->id),
                    'method' => 'GET',
                ],
                'update' => [
                    'href' => route('api.users.update', $this->id),
                    'method' => 'PUT',
                ],
                'delete' => [
                    'href' => route('api.users.destroy', $this->id),
                    'method' => 'DELETE',
                ],
                'posts' => [
                    'href' => route('api.users.posts.index', $this->id),
                    'method' => 'GET',
                ],
            ],
            '_embedded' => [
                'latest_post' => new PostResource($this->whenLoaded('latestPost')),
            ],
        ];
    }
}

// app/Http/Resources/PostCollection.php
namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class PostCollection extends ResourceCollection
{
    public function toArray($request)
    {
        return [
            'data' => $this->collection,
            'links' => [
                'self' => [
                    'href' => $request->fullUrl(),
                ],
                'first' => [
                    'href' => $this->url(1),
                ],
                'last' => [
                    'href' => $this->url($this->lastPage()),
                ],
                'prev' => [
                    'href' => $this->previousPageUrl(),
                ],
                'next' => [
                    'href' => $this->nextPageUrl(),
                ],
            ],
            'meta' => [
                'current_page' => $this->currentPage(),
                'last_page' => $this->lastPage(),
                'per_page' => $this->perPage(),
                'total' => $this->total(),
            ],
        ];
    }
}

// Usage in Controller
public function show(User $user)
{
    $user->load('latestPost');
    return new UserResource($user);
}

// Example JSON Response:
{
    "id": 1,
    "name": "John Doe",
    "email": "john@example.com",
    "created_at": "2024-01-15T10:30:00Z",
    "links": {
        "self": {"href": "https://api.example.com/v1/users/1", "method": "GET"},
        "update": {"href": "https://api.example.com/v1/users/1", "method": "PUT"},
        "delete": {"href": "https://api.example.com/v1/users/1", "method": "DELETE"},
        "posts": {"href": "https://api.example.com/v1/users/1/posts", "method": "GET"}
    },
    "_embedded": {
        "latest_post": {
            "id": 42,
            "title": "My Latest Article",
            "links": {...}
        }
    }
}

Content Negotiation

Content negotiation allows your API to serve different response formats (JSON, XML, CSV) based on client preferences specified in the Accept header.

<?php

// app/Http/Middleware/ContentNegotiation.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Response;

class ContentNegotiation
{
    public function handle(Request $request, Closure $next)
    {
        $response = $next($request);

        $acceptHeader = $request->header('Accept', 'application/json');

        // Parse Accept header
        if (str_contains($acceptHeader, 'application/xml')) {
            return $this->convertToXml($response);
        } elseif (str_contains($acceptHeader, 'text/csv')) {
            return $this->convertToCsv($response);
        }

        // Default to JSON
        return $response;
    }

    protected function convertToXml($response)
    {
        $data = $response->getData(true);
        $xml = new \SimpleXMLElement('<?xml version="1.0"?><root></root>');

        $this->arrayToXml($data, $xml);

        return Response::make($xml->asXML(), $response->status())
            ->header('Content-Type', 'application/xml');
    }

    protected function arrayToXml($data, &$xml)
    {
        foreach ($data as $key => $value) {
            if (is_array($value)) {
                $subNode = $xml->addChild($key);
                $this->arrayToXml($value, $subNode);
            } else {
                $xml->addChild($key, htmlspecialchars($value));
            }
        }
    }

    protected function convertToCsv($response)
    {
        $data = $response->getData(true);

        // Flatten data if needed
        if (isset($data['data']) && is_array($data['data'])) {
            $data = $data['data'];
        }

        $csv = fopen('php://temp', 'r+');

        // Write headers
        if (!empty($data)) {
            fputcsv($csv, array_keys(reset($data)));

            // Write rows
            foreach ($data as $row) {
                fputcsv($csv, $row);
            }
        }

        rewind($csv);
        $output = stream_get_contents($csv);
        fclose($csv);

        return Response::make($output, $response->status())
            ->header('Content-Type', 'text/csv')
            ->header('Content-Disposition', 'attachment; filename="export.csv"');
    }
}

// Register middleware in app/Http/Kernel.php
protected $middlewareGroups = [
    'api' => [
        \App\Http\Middleware\ContentNegotiation::class,
        // ... other middleware
    ],
];
Tip: Always validate the Accept header and return a 406 Not Acceptable response if the requested format is not supported. This prevents unexpected behavior and provides clear feedback to API consumers.

Advanced Rate Limiting Strategies

Laravel's throttle middleware can be customized for sophisticated rate limiting based on user tiers, endpoint sensitivity, and dynamic limits.

<?php

// app/Providers/RouteServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

public function boot()
{
    // Tier-based rate limiting
    RateLimiter::for('api', function (Request $request) {
        $user = $request->user();

        if (!$user) {
            // Anonymous users: 60 requests per minute
            return Limit::perMinute(60)->by($request->ip());
        }

        // Rate limit based on user tier
        return match($user->tier) {
            'free' => Limit::perMinute(100)->by($user->id),
            'pro' => Limit::perMinute(1000)->by($user->id),
            'enterprise' => Limit::perMinute(10000)->by($user->id),
            default => Limit::perMinute(100)->by($user->id),
        };
    });

    // Endpoint-specific rate limiting
    RateLimiter::for('expensive-operations', function (Request $request) {
        return Limit::perMinute(10)->by($request->user()?->id ?: $request->ip())
            ->response(function (Request $request, array $headers) {
                return response()->json([
                    'message' => 'Too many expensive operations. Please try again later.',
                    'retry_after' => $headers['Retry-After'],
                ], 429, $headers);
            });
    });

    // Dynamic rate limiting with multiple limits
    RateLimiter::for('flexible', function (Request $request) {
        return [
            Limit::perMinute(60)->by($request->ip()),
            Limit::perDay(1000)->by($request->user()?->id ?: $request->ip()),
        ];
    });

    // Time-based rate limiting (business hours vs off-hours)
    RateLimiter::for('time-based', function (Request $request) {
        $hour = now()->hour;
        $isBusinessHours = $hour >= 9 && $hour < 17;

        $limit = $isBusinessHours ? 100 : 500; // More generous off-hours

        return Limit::perMinute($limit)->by($request->user()?->id ?: $request->ip());
    });
}

// routes/api.php - Apply rate limiting
Route::middleware(['throttle:api'])->group(function () {
    Route::get('/users', [UserController::class, 'index']);
    Route::get('/posts', [PostController::class, 'index']);
});

Route::middleware(['throttle:expensive-operations'])->group(function () {
    Route::post('/reports/generate', [ReportController::class, 'generate']);
    Route::post('/exports/large', [ExportController::class, 'exportLarge']);
});

// Custom throttle middleware with Redis
// app/Http/Middleware/ApiThrottle.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;

class ApiThrottle
{
    public function handle(Request $request, Closure $next, int $maxAttempts = 60)
    {
        $key = $this->resolveRequestSignature($request);
        $attempts = Redis::incr($key);

        if ($attempts === 1) {
            Redis::expire($key, 60); // 1 minute window
        }

        if ($attempts > $maxAttempts) {
            $retryAfter = Redis::ttl($key);

            return response()->json([
                'message' => 'Rate limit exceeded.',
                'retry_after' => $retryAfter,
            ], 429, [
                'X-RateLimit-Limit' => $maxAttempts,
                'X-RateLimit-Remaining' => 0,
                'Retry-After' => $retryAfter,
            ]);
        }

        $response = $next($request);

        return $response->withHeaders([
            'X-RateLimit-Limit' => $maxAttempts,
            'X-RateLimit-Remaining' => max(0, $maxAttempts - $attempts),
        ]);
    }

    protected function resolveRequestSignature(Request $request): string
    {
        $user = $request->user();
        $identifier = $user ? "user:{$user->id}" : "ip:{$request->ip()}";

        return "throttle:{$identifier}:{$request->path()}";
    }
}
Warning: When implementing custom rate limiting, always consider distributed systems. Use Redis or a centralized cache to ensure rate limits work correctly across multiple application servers.

API Documentation with OpenAPI/Swagger

Comprehensive API documentation is essential for developer experience. Laravel integrates seamlessly with OpenAPI specifications using packages like L5-Swagger.

# Install L5-Swagger
composer require darkaonline/l5-swagger

# Publish configuration
php artisan vendor:publish --provider "L5Swagger\L5SwaggerServiceProvider"

# Generate documentation
php artisan l5-swagger:generate

<?php
// app/Http/Controllers/Api/V1/UserController.php
namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Request;

/**
 * @OA\Info(
 *     title="My Application API",
 *     version="1.0.0",
 *     description="RESTful API for My Application",
 *     @OA\Contact(
 *         email="api@example.com"
 *     )
 * )
 * @OA\Server(
 *     url="https://api.example.com/v1",
 *     description="Production API Server"
 * )
 * @OA\SecurityScheme(
 *     securityScheme="bearerAuth",
 *     type="http",
 *     scheme="bearer",
 *     bearerFormat="JWT"
 * )
 */
class UserController extends Controller
{
    /**
     * @OA\Get(
     *     path="/users",
     *     summary="Get list of users",
     *     tags={"Users"},
     *     security={{"bearerAuth":{}}},
     *     @OA\Parameter(
     *         name="page",
     *         in="query",
     *         description="Page number",
     *         required=false,
     *         @OA\Schema(type="integer", example=1)
     *     ),
     *     @OA\Parameter(
     *         name="per_page",
     *         in="query",
     *         description="Items per page",
     *         required=false,
     *         @OA\Schema(type="integer", example=15)
     *     ),
     *     @OA\Response(
     *         response=200,
     *         description="Successful operation",
     *         @OA\JsonContent(
     *             @OA\Property(property="data", type="array",
     *                 @OA\Items(ref="#/components/schemas/User")
     *             ),
     *             @OA\Property(property="links", type="object"),
     *             @OA\Property(property="meta", type="object")
     *         )
     *     ),
     *     @OA\Response(
     *         response=401,
     *         description="Unauthenticated"
     *     ),
     *     @OA\Response(
     *         response=429,
     *         description="Too Many Requests"
     *     )
     * )
     */
    public function index(Request $request)
    {
        $users = User::paginate($request->input('per_page', 15));
        return UserResource::collection($users);
    }

    /**
     * @OA\Post(
     *     path="/users",
     *     summary="Create a new user",
     *     tags={"Users"},
     *     security={{"bearerAuth":{}}},
     *     @OA\RequestBody(
     *         required=true,
     *         @OA\JsonContent(
     *             required={"name","email","password"},
     *             @OA\Property(property="name", type="string", example="John Doe"),
     *             @OA\Property(property="email", type="string", format="email", example="john@example.com"),
     *             @OA\Property(property="password", type="string", format="password", example="secret123")
     *         )
     *     ),
     *     @OA\Response(
     *         response=201,
     *         description="User created successfully",
     *         @OA\JsonContent(ref="#/components/schemas/User")
     *     ),
     *     @OA\Response(
     *         response=422,
     *         description="Validation error"
     *     )
     * )
     */
    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users',
            'password' => 'required|min:8',
        ]);

        $user = User::create($validated);
        return new UserResource($user);
    }
}

/**
 * @OA\Schema(
 *     schema="User",
 *     type="object",
 *     title="User",
 *     required={"id", "name", "email"},
 *     @OA\Property(property="id", type="integer", example=1),
 *     @OA\Property(property="name", type="string", example="John Doe"),
 *     @OA\Property(property="email", type="string", format="email", example="john@example.com"),
 *     @OA\Property(property="created_at", type="string", format="date-time"),
 *     @OA\Property(property="links", type="object",
 *         @OA\Property(property="self", type="object",
 *             @OA\Property(property="href", type="string"),
 *             @OA\Property(property="method", type="string")
 *         )
 *     )
 * )
 */
Documentation Best Practices: Keep annotations close to the code they document. Use schemas for reusable models. Always document authentication requirements, rate limits, and error responses. Update documentation before deploying API changes.

Exercise 1: Implement API Versioning

Create a versioned API with two versions:

  1. Version 1: Returns user data with basic fields (id, name, email)
  2. Version 2: Returns user data with additional fields (profile_photo, bio, social_links)
  3. Implement URI-based versioning (/api/v1/users and /api/v2/users)
  4. Add proper deprecation warnings in v1 responses
  5. Test both versions return different data structures

Exercise 2: Build HATEOAS-Compliant Resources

Transform your existing API resources to be HATEOAS-compliant:

  1. Create a PostResource that includes hypermedia links (self, update, delete, author, comments)
  2. Add conditional links based on user permissions (only show delete link if user can delete)
  3. Implement a CommentCollection with pagination links
  4. Add embedded resources for related data
  5. Test that clients can navigate your API using only the links provided

Exercise 3: Advanced Rate Limiting

Implement a multi-tier rate limiting system:

  1. Create three user tiers: free (100 req/min), pro (1000 req/min), enterprise (unlimited)
  2. Implement endpoint-specific limits: expensive operations (10 req/min), standard operations (tier-based)
  3. Add rate limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After) to all responses
  4. Create a custom rate limiter that tracks daily quotas in addition to per-minute limits
  5. Test rate limiting across multiple requests and verify proper 429 responses

Summary

In this lesson, you've mastered advanced API development techniques including versioning strategies for backward compatibility, HATEOAS principles for self-documenting APIs, content negotiation for flexible response formats, sophisticated rate limiting for protecting resources, and comprehensive OpenAPI documentation for excellent developer experience. These patterns are essential for building enterprise-grade APIs that scale and evolve gracefully.