laravel-api

Auto-invoked skill

Build REST APIs with versioning and OpenAPI documentation

Trigger Keywords

This skill automatically activates when Claude detects these keywords:

api rest endpoint openapi json resource

Overview

The laravel-api skill provides expertise for building production-ready REST APIs. When activated, Claude understands API versioning strategies, JSON resource transformations, rate limiting, authentication, and OpenAPI documentation.

What This Skill Provides

  • API Versioning - URL-based (/api/v1/) or header-based versioning
  • JSON Resources - Proper response transformation with relationships
  • Request Validation - Form requests with API-specific error formatting
  • Rate Limiting - Per-user and per-endpoint throttling
  • Authentication - Sanctum, Passport, or API key strategies
  • Documentation - OpenAPI/Swagger spec generation

Example Conversations

# Building a new API
"I need to create a REST API for managing products with filtering and pagination"

# Adding versioning
"Help me version my API - I need to support v1 and v2 simultaneously"

# Response formatting
"How should I structure my API responses for a collection with pagination?"

# Authentication
"Set up Sanctum authentication for my API with token abilities"

JSON Resource Implementation

Transform your models into consistent API responses:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ProductResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'slug' => $this->slug,
            'price' => [
                'amount' => $this->price,
                'formatted' => number_format($this->price, 2),
                'currency' => 'USD',
            ],
            'in_stock' => $this->stock_quantity > 0,

            // Conditional relationships - only included if loaded
            'category' => new CategoryResource($this->whenLoaded('category')),
            'reviews' => ReviewResource::collection($this->whenLoaded('reviews')),

            // Conditional counts
            'reviews_count' => $this->whenCounted('reviews'),

            // Conditional fields based on user
            'cost' => $this->when($request->user()?->isAdmin(), $this->cost),

            'created_at' => $this->created_at->toISOString(),
        ];
    }

    // Wrap single resources
    public function with(Request $request): array
    {
        return [
            'meta' => [
                'api_version' => 'v1',
            ],
        ];
    }
}

Resource Collection with Custom Pagination

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;

class ProductCollection extends ResourceCollection
{
    public $collects = ProductResource::class;

    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'total' => $this->total(),
                'per_page' => $this->perPage(),
                'current_page' => $this->currentPage(),
                'last_page' => $this->lastPage(),
                'has_more' => $this->hasMorePages(),
            ],
        ];
    }
}

// Usage in controller
public function index(Request $request)
{
    $products = Product::query()
        ->with(['category', 'reviews'])
        ->withCount('reviews')
        ->when($request->category, fn ($q, $cat) => $q->where('category_id', $cat))
        ->when($request->search, fn ($q, $s) => $q->where('name', 'like', "%{$s}%"))
        ->paginate($request->per_page ?? 15);

    return new ProductCollection($products);
}

API Response Patterns

The skill ensures consistent response structures:

// Collection response with pagination
{
    "data": [
        {"id": 1, "name": "Product 1", "price": {"amount": 29.99}},
        {"id": 2, "name": "Product 2", "price": {"amount": 49.99}}
    ],
    "meta": {
        "total": 100,
        "per_page": 15,
        "current_page": 1,
        "last_page": 7,
        "has_more": true
    },
    "links": {
        "first": "/api/v1/products?page=1",
        "last": "/api/v1/products?page=7",
        "next": "/api/v1/products?page=2",
        "prev": null
    }
}

// Error response (422 Validation)
{
    "message": "The given data was invalid.",
    "errors": {
        "email": ["The email field is required."],
        "password": ["The password must be at least 8 characters."]
    }
}

API Versioning Setup

<?php

// routes/api.php - URL-based versioning (recommended)
use Illuminate\Support\Facades\Route;

Route::prefix('v1')->group(function () {
    Route::apiResource('products', App\Http\Controllers\Api\V1\ProductController::class);
    Route::apiResource('orders', App\Http\Controllers\Api\V1\OrderController::class);
});

Route::prefix('v2')->group(function () {
    Route::apiResource('products', App\Http\Controllers\Api\V2\ProductController::class);
});

// app/Http/Controllers/Api/V1/ProductController.php
namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\V1\ProductResource;
use App\Models\Product;

class ProductController extends Controller
{
    public function index()
    {
        return ProductResource::collection(
            Product::with('category')->paginate()
        );
    }

    public function show(Product $product)
    {
        return new ProductResource($product->load('category', 'reviews'));
    }
}

Rate Limiting Configuration

<?php

// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    // Default API rate limit
    RateLimiter::for('api', function ($request) {
        return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
    });

    // Stricter limit for expensive operations
    RateLimiter::for('exports', function ($request) {
        return Limit::perHour(10)->by($request->user()->id);
    });

    // Different limits by subscription tier
    RateLimiter::for('tiered', function ($request) {
        $user = $request->user();

        return match ($user?->subscription_tier) {
            'enterprise' => Limit::none(),
            'pro' => Limit::perMinute(1000)->by($user->id),
            default => Limit::perMinute(100)->by($user?->id ?: $request->ip()),
        };
    });
}

// Apply in routes
Route::middleware(['throttle:api'])->group(function () {
    Route::apiResource('products', ProductController::class);
});

Route::middleware(['throttle:exports'])->group(function () {
    Route::get('/export/orders', [ExportController::class, 'orders']);
});

Sanctum API Authentication

<?php

// routes/api.php
Route::post('/auth/token', function (Request $request) {
    $request->validate([
        'email' => 'required|email',
        'password' => 'required',
        'device_name' => 'required',
    ]);

    $user = User::where('email', $request->email)->first();

    if (!$user || !Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages([
            'email' => ['The provided credentials are incorrect.'],
        ]);
    }

    // Create token with abilities (permissions)
    $token = $user->createToken($request->device_name, [
        'products:read',
        'products:write',
        'orders:read',
    ]);

    return response()->json([
        'token' => $token->plainTextToken,
        'expires_at' => now()->addDays(30)->toISOString(),
    ]);
});

// Protected routes
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/user', fn (Request $request) => $request->user());

    // Check token abilities
    Route::middleware('ability:products:write')->group(function () {
        Route::post('/products', [ProductController::class, 'store']);
        Route::put('/products/{product}', [ProductController::class, 'update']);
    });
});

Testing API Endpoints

<?php

use App\Models\Product;
use App\Models\User;

it('lists products with pagination', function () {
    Product::factory()->count(20)->create();

    $response = $this->getJson('/api/v1/products?per_page=10');

    $response
        ->assertOk()
        ->assertJsonCount(10, 'data')
        ->assertJsonStructure([
            'data' => [['id', 'name', 'price']],
            'meta' => ['total', 'per_page', 'current_page'],
        ]);
});

it('requires authentication for protected routes', function () {
    $this->postJson('/api/v1/products', ['name' => 'Test'])
        ->assertUnauthorized();
});

it('creates product with valid token', function () {
    $user = User::factory()->create();
    $token = $user->createToken('test', ['products:write'])->plainTextToken;

    $response = $this->withToken($token)
        ->postJson('/api/v1/products', [
            'name' => 'New Product',
            'price' => 29.99,
        ]);

    $response->assertCreated()
        ->assertJsonPath('data.name', 'New Product');
});

it('respects rate limits', function () {
    $user = User::factory()->create();

    // Exhaust rate limit
    for ($i = 0; $i < 60; $i++) {
        $this->actingAs($user)->getJson('/api/v1/products');
    }

    // Next request should be rate limited
    $this->actingAs($user)
        ->getJson('/api/v1/products')
        ->assertStatus(429);
});

Common Patterns

Pattern When to Use
JsonResource Single model transformation
ResourceCollection Collections with custom pagination
whenLoaded() Conditional relationship inclusion
QueryBuilder Filtering, sorting, including via query params

Common Pitfalls

  • N+1 in resources - Always eager load relationships before transforming
  • Exposing sensitive data - Explicitly define what to include, don't use toArray()
  • Inconsistent errors - Use exception handlers for uniform error responses
  • Missing rate limits - Always protect public endpoints

Related Commands

Related Agent