# Building REST APIs This guide covers how to build production-ready REST APIs using the core-api package. You'll learn to create resources, implement pagination, add filtering and sorting, and secure endpoints with authentication. ## Quick Start Register API routes by listening to the `ApiRoutesRegistering` event: ```php 'onApiRoutes', ]; public function onApiRoutes(ApiRoutesRegistering $event): void { $event->routes(function () { Route::apiResource('posts', Api\PostController::class); }); } } ``` ## Creating Resources ### API Resources Transform Eloquent models into consistent JSON responses using Laravel's API Resources: ```php $this->id, 'type' => 'post', 'attributes' => [ 'title' => $this->title, 'slug' => $this->slug, 'excerpt' => $this->excerpt, 'content' => $this->when( $request->user()?->tokenCan('posts:read-content'), $this->content ), 'status' => $this->status, 'published_at' => $this->published_at?->toIso8601String(), ], 'relationships' => [ 'author' => $this->whenLoaded('author', fn () => [ 'id' => $this->author->id, 'name' => $this->author->name, ]), 'categories' => $this->whenLoaded('categories', fn () => $this->categories->map(fn ($cat) => [ 'id' => $cat->id, 'name' => $cat->name, ]) ), ], 'meta' => [ 'created_at' => $this->created_at->toIso8601String(), 'updated_at' => $this->updated_at->toIso8601String(), ], ]; } } ``` ### Resource Controllers Build controllers that use the `HasApiResponses` trait for consistent error handling: ```php with(['author', 'categories']) ->paginate($request->input('per_page', 25)); return new PaginatedCollection($posts, PostResource::class); } public function show(Post $post) { $post->load(['author', 'categories']); return new PostResource($post); } public function store(Request $request) { $validated = $request->validate([ 'title' => 'required|string|max:255', 'content' => 'required|string', 'status' => 'in:draft,published', ]); $post = Post::create($validated); return $this->createdResponse( new PostResource($post), 'Post created successfully.' ); } public function update(Request $request, Post $post) { $validated = $request->validate([ 'title' => 'string|max:255', 'content' => 'string', 'status' => 'in:draft,published', ]); $post->update($validated); return new PostResource($post); } public function destroy(Post $post) { $post->delete(); return response()->json(null, 204); } } ``` ## Pagination ### Using PaginatedCollection The `PaginatedCollection` class provides standardized pagination metadata: ```php use Core\Mod\Api\Resources\PaginatedCollection; public function index(Request $request) { $posts = Post::paginate( $request->input('per_page', config('api.pagination.default_per_page', 25)) ); return new PaginatedCollection($posts, PostResource::class); } ``` ### Response Format Paginated responses include comprehensive metadata: ```json { "data": [ {"id": 1, "type": "post", "attributes": {...}}, {"id": 2, "type": "post", "attributes": {...}} ], "meta": { "current_page": 1, "from": 1, "last_page": 10, "per_page": 25, "to": 25, "total": 250 }, "links": { "first": "https://api.example.com/v1/posts?page=1", "last": "https://api.example.com/v1/posts?page=10", "prev": null, "next": "https://api.example.com/v1/posts?page=2" } } ``` ### Pagination Best Practices **1. Limit Maximum Page Size** ```php public function index(Request $request) { $perPage = min( $request->input('per_page', 25), config('api.pagination.max_per_page', 100) ); return new PaginatedCollection( Post::paginate($perPage), PostResource::class ); } ``` **2. Use Cursor Pagination for Large Datasets** ```php public function index(Request $request) { $posts = Post::orderBy('id') ->cursorPaginate($request->input('per_page', 25)); return PostResource::collection($posts); } ``` **3. Include Total Count Conditionally** For very large tables, counting can be expensive: ```php public function index(Request $request) { $query = Post::query(); // Only count if explicitly requested if ($request->boolean('include_total')) { return new PaginatedCollection( $query->paginate($request->input('per_page', 25)), PostResource::class ); } // Use simple pagination (no total count) return PostResource::collection( $query->simplePaginate($request->input('per_page', 25)) ); } ``` ## Filtering ### Query Parameter Filters Implement flexible filtering with query parameters: ```php public function index(Request $request) { $query = Post::query(); // Status filter if ($status = $request->input('status')) { $query->where('status', $status); } // Date range filters if ($after = $request->input('created_after')) { $query->where('created_at', '>=', $after); } if ($before = $request->input('created_before')) { $query->where('created_at', '<=', $before); } // Author filter if ($authorId = $request->input('author_id')) { $query->where('author_id', $authorId); } // Full-text search if ($search = $request->input('search')) { $query->where(function ($q) use ($search) { $q->where('title', 'like', "%{$search}%") ->orWhere('content', 'like', "%{$search}%"); }); } return new PaginatedCollection( $query->paginate($request->input('per_page', 25)), PostResource::class ); } ``` ### Filter Validation Validate filter parameters to prevent errors: ```php public function index(Request $request) { $request->validate([ 'status' => 'in:draft,published,archived', 'created_after' => 'date|before_or_equal:created_before', 'created_before' => 'date', 'author_id' => 'integer|exists:users,id', 'per_page' => 'integer|min:1|max:100', ]); // Apply filters... } ``` ### Reusable Filter Traits Create a trait for common filtering patterns: ```php input('created_after')) { $query->where('created_at', '>=', $after); } if ($before = $request->input('created_before')) { $query->where('created_at', '<=', $before); } if ($updatedAfter = $request->input('updated_after')) { $query->where('updated_at', '>=', $updatedAfter); } // Status filter (if model has status) if ($status = $request->input('status')) { $query->where('status', $status); } return $query; } } ``` ## Sorting ### Sort Parameter Implement sorting with a `sort` query parameter: ```php public function index(Request $request) { $query = Post::query(); // Parse sort parameter: -created_at,title $sortFields = $this->parseSortFields( $request->input('sort', '-created_at') ); foreach ($sortFields as $field => $direction) { $query->orderBy($field, $direction); } return new PaginatedCollection( $query->paginate($request->input('per_page', 25)), PostResource::class ); } protected function parseSortFields(string $sort): array { $allowedFields = ['id', 'title', 'created_at', 'updated_at', 'published_at']; $fields = []; foreach (explode(',', $sort) as $field) { $direction = 'asc'; if (str_starts_with($field, '-')) { $direction = 'desc'; $field = substr($field, 1); } if (in_array($field, $allowedFields)) { $fields[$field] = $direction; } } return $fields ?: ['created_at' => 'desc']; } ``` ### Sort Validation Validate sort fields against an allowlist: ```php public function index(Request $request) { $request->validate([ 'sort' => [ 'string', 'regex:/^-?(id|title|created_at|updated_at)(,-?(id|title|created_at|updated_at))*$/', ], ]); // Apply sorting... } ``` ## Authentication ### Protecting Routes Use the `auth:api` middleware to protect endpoints: ```php // In your Boot class $event->routes(function () { // Public routes (no authentication) Route::get('/posts', [PostController::class, 'index']); Route::get('/posts/{post}', [PostController::class, 'show']); // Protected routes (require authentication) Route::middleware('auth:api')->group(function () { Route::post('/posts', [PostController::class, 'store']); Route::put('/posts/{post}', [PostController::class, 'update']); Route::delete('/posts/{post}', [PostController::class, 'destroy']); }); }); ``` ### Scope-Based Authorization Enforce API key scopes on routes: ```php Route::middleware(['auth:api', 'scope:posts:write']) ->post('/posts', [PostController::class, 'store']); Route::middleware(['auth:api', 'scope:posts:delete']) ->delete('/posts/{post}', [PostController::class, 'destroy']); ``` ### Checking Scopes in Controllers Verify scopes programmatically for fine-grained control: ```php public function update(Request $request, Post $post) { // Check if user can update posts if (!$request->user()->tokenCan('posts:write')) { return $this->accessDeniedResponse('Insufficient permissions to update posts.'); } // Check if user can publish (requires elevated scope) if ($request->input('status') === 'published') { if (!$request->user()->tokenCan('posts:publish')) { return $this->accessDeniedResponse('Insufficient permissions to publish posts.'); } } $post->update($request->validated()); return new PostResource($post); } ``` ### API Key Authentication Examples **PHP with Guzzle:** ```php use GuzzleHttp\Client; $client = new Client([ 'base_uri' => 'https://api.example.com/v1/', 'headers' => [ 'Authorization' => 'Bearer ' . $apiKey, 'Accept' => 'application/json', 'Content-Type' => 'application/json', ], ]); // List posts $response = $client->get('posts', [ 'query' => [ 'status' => 'published', 'per_page' => 50, 'sort' => '-published_at', ], ]); $posts = json_decode($response->getBody(), true); // Create a post $response = $client->post('posts', [ 'json' => [ 'title' => 'New Post', 'content' => 'Post content here...', 'status' => 'draft', ], ]); $newPost = json_decode($response->getBody(), true); ``` **JavaScript with Fetch:** ```javascript const API_KEY = 'sk_live_abc123...'; const BASE_URL = 'https://api.example.com/v1'; async function listPosts(params = {}) { const query = new URLSearchParams(params).toString(); const response = await fetch(`${BASE_URL}/posts?${query}`, { headers: { 'Authorization': `Bearer ${API_KEY}`, 'Accept': 'application/json', }, }); if (!response.ok) { throw new Error(`API error: ${response.status}`); } return response.json(); } async function createPost(data) { const response = await fetch(`${BASE_URL}/posts`, { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Accept': 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify(data), }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Failed to create post'); } return response.json(); } // Usage const posts = await listPosts({ status: 'published', per_page: 25 }); const newPost = await createPost({ title: 'Hello', content: 'World' }); ``` **Python with Requests:** ```python import requests API_KEY = 'sk_live_abc123...' BASE_URL = 'https://api.example.com/v1' headers = { 'Authorization': f'Bearer {API_KEY}', 'Accept': 'application/json', 'Content-Type': 'application/json', } # List posts response = requests.get( f'{BASE_URL}/posts', headers=headers, params={ 'status': 'published', 'per_page': 50, 'sort': '-published_at', } ) response.raise_for_status() posts = response.json() # Create a post response = requests.post( f'{BASE_URL}/posts', headers=headers, json={ 'title': 'New Post', 'content': 'Post content here...', 'status': 'draft', } ) response.raise_for_status() new_post = response.json() ``` ## OpenAPI Documentation ### Document Endpoints Use attributes to auto-generate OpenAPI documentation: ```php use Core\Mod\Api\Documentation\Attributes\ApiTag; use Core\Mod\Api\Documentation\Attributes\ApiParameter; use Core\Mod\Api\Documentation\Attributes\ApiResponse; use Core\Mod\Api\Documentation\Attributes\ApiSecurity; #[ApiTag('Posts', 'Blog post management')] #[ApiSecurity('api_key')] class PostController extends Controller { #[ApiParameter('page', 'query', 'integer', 'Page number', example: 1)] #[ApiParameter('per_page', 'query', 'integer', 'Items per page', example: 25)] #[ApiParameter('status', 'query', 'string', 'Filter by status', enum: ['draft', 'published'])] #[ApiParameter('sort', 'query', 'string', 'Sort fields (prefix with - for desc)', example: '-created_at')] #[ApiResponse(200, PostResource::class, 'List of posts', paginated: true)] public function index(Request $request) { // ... } #[ApiParameter('id', 'path', 'integer', 'Post ID', required: true)] #[ApiResponse(200, PostResource::class, 'Post details')] #[ApiResponse(404, null, 'Post not found')] public function show(Post $post) { // ... } #[ApiResponse(201, PostResource::class, 'Post created')] #[ApiResponse(422, null, 'Validation error')] public function store(Request $request) { // ... } } ``` ## Error Handling ### Consistent Error Responses Use the `HasApiResponses` trait for consistent errors: ```php use Core\Mod\Api\Concerns\HasApiResponses; class PostController extends Controller { use HasApiResponses; public function show($id) { $post = Post::find($id); if (!$post) { return $this->notFoundResponse('Post'); } return new PostResource($post); } public function store(Request $request) { // Check entitlement limits if (!$this->canCreatePost($request->user())) { return $this->limitReachedResponse( 'posts', 'You have reached your post limit. Please upgrade your plan.' ); } // Validation errors are handled automatically by Laravel $validated = $request->validate([...]); // ... } } ``` ### Error Response Format All errors follow a consistent format: ```json { "error": "not_found", "message": "Post not found." } ``` ```json { "error": "validation_failed", "message": "The given data was invalid.", "errors": { "title": ["The title field is required."], "content": ["The content must be at least 100 characters."] } } ``` ```json { "error": "feature_limit_reached", "message": "You have reached your post limit.", "feature": "posts", "upgrade_url": "https://example.com/upgrade" } ``` ## Best Practices ### 1. Use API Resources Always transform models through resources: ```php // Good - consistent response format return new PostResource($post); // Bad - exposes database schema return response()->json($post); ``` ### 2. Validate All Input ```php public function store(Request $request) { $validated = $request->validate([ 'title' => 'required|string|max:255', 'content' => 'required|string|min:100', 'status' => 'in:draft,published', 'published_at' => 'nullable|date|after:now', ]); // Use validated data only $post = Post::create($validated); } ``` ### 3. Eager Load Relationships ```php // Good - single query with eager loading $posts = Post::with(['author', 'categories'])->paginate(); // Bad - N+1 queries $posts = Post::paginate(); foreach ($posts as $post) { echo $post->author->name; // Additional query per post } ``` ### 4. Use Route Model Binding ```php // Good - automatic 404 if not found public function show(Post $post) { return new PostResource($post); } // Unnecessary - route model binding handles this public function show($id) { $post = Post::findOrFail($id); return new PostResource($post); } ``` ### 5. Scope Data by Workspace ```php public function index(Request $request) { $workspaceId = $request->user()->currentWorkspaceId(); $posts = Post::where('workspace_id', $workspaceId) ->paginate(); return new PaginatedCollection($posts, PostResource::class); } ``` ## Testing ### Feature Tests ```php create([ 'scopes' => ['posts:read'], ]); Post::factory()->count(5)->create(); $response = $this->withHeaders([ 'Authorization' => "Bearer {$apiKey->plaintext_key}", ])->getJson('/api/v1/posts'); $response->assertOk() ->assertJsonStructure([ 'data' => [ '*' => ['id', 'type', 'attributes'], ], 'meta' => ['current_page', 'total'], 'links', ]); } public function test_filters_posts_by_status(): void { $apiKey = ApiKey::factory()->create(['scopes' => ['posts:read']]); Post::factory()->create(['status' => 'draft']); Post::factory()->create(['status' => 'published']); $response = $this->withHeaders([ 'Authorization' => "Bearer {$apiKey->plaintext_key}", ])->getJson('/api/v1/posts?status=published'); $response->assertOk() ->assertJsonCount(1, 'data'); } public function test_creates_post_with_valid_scope(): void { $apiKey = ApiKey::factory()->create([ 'scopes' => ['posts:write'], ]); $response = $this->withHeaders([ 'Authorization' => "Bearer {$apiKey->plaintext_key}", ])->postJson('/api/v1/posts', [ 'title' => 'Test Post', 'content' => 'Test content...', ]); $response->assertCreated() ->assertJsonPath('data.attributes.title', 'Test Post'); } public function test_rejects_create_without_scope(): void { $apiKey = ApiKey::factory()->create([ 'scopes' => ['posts:read'], // No write scope ]); $response = $this->withHeaders([ 'Authorization' => "Bearer {$apiKey->plaintext_key}", ])->postJson('/api/v1/posts', [ 'title' => 'Test Post', 'content' => 'Test content...', ]); $response->assertForbidden(); } } ``` ## Learn More - [Authentication](/packages/api/authentication) - API key management - [Rate Limiting](/packages/api/rate-limiting) - Tier-based rate limits - [Scopes](/packages/api/scopes) - Permission system - [Webhooks](/packages/api/webhooks) - Event notifications - [OpenAPI Documentation](/packages/api/documentation) - Auto-generated docs