API Development
Guide to developing REST APIs for LaraDashboard modules including authentication, resource controllers, and best practices.
API Development
This guide covers developing REST APIs for LaraDashboard, including authentication, controllers, resources, and best practices for building robust APIs.
API Architecture
Overview
LaraDashboard's API follows RESTful principles:
- Resource-based URLs -
/api/v1/posts,/api/v1/users - HTTP Verbs - GET, POST, PUT/PATCH, DELETE
- JSON Responses - Consistent response format
- Authentication - Laravel Sanctum tokens
API Versioning
APIs are versioned via URL prefix:
/api/v1/users # Version 1
/api/v2/users # Version 2 (future)
Authentication
Laravel Sanctum
LaraDashboard uses Sanctum for API authentication.
Getting a Token
POST /api/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "password"
}
Response:
{
"access_token": "1|abc123...",
"token_type": "Bearer",
"expires_at": "2024-02-15T10:30:00Z"
}
Using the Token
Include in all subsequent requests:
GET /api/v1/users
Authorization: Bearer 1|abc123...
Revoking Tokens
POST /api/auth/logout
Authorization: Bearer 1|abc123...
Token Abilities (Scopes)
Create tokens with specific abilities:
$token = $user->createToken('api-token', ['read', 'write']);
// In middleware or controller
if ($user->tokenCan('write')) {
// Allow write operation
}
Creating API Endpoints
Route Definition
// routes/api.php
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
// User routes
Route::apiResource('users', UserController::class);
// Custom routes
Route::post('users/{user}/avatar', [UserController::class, 'uploadAvatar']);
// Nested resources
Route::apiResource('posts.comments', CommentController::class);
});
Module API Routes
// modules/YourModule/routes/api.php
Route::prefix('v1/yourmodule')
->middleware(['api', 'auth:sanctum'])
->group(function () {
Route::apiResource('items', ItemController::class);
Route::post('items/bulk-delete', [ItemController::class, 'bulkDelete']);
});
API Controllers
Basic Structure
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Http\Resources\PostResource;
use App\Http\Requests\Api\StorePostRequest;
use App\Http\Requests\Api\UpdatePostRequest;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
class PostController extends Controller
{
/**
* Display a listing of posts.
*/
public function index(): AnonymousResourceCollection
{
$this->authorize('viewAny', Post::class);
$posts = Post::query()
->with(['author', 'categories'])
->when(request('search'), fn($q, $s) => $q->search($s))
->when(request('status'), fn($q, $s) => $q->where('status', $s))
->when(request('category'), fn($q, $c) => $q->whereHas('categories', fn($q) => $q->where('id', $c)))
->latest()
->paginate(request('per_page', 15));
return PostResource::collection($posts);
}
/**
* Store a newly created post.
*/
public function store(StorePostRequest $request): PostResource
{
$this->authorize('create', Post::class);
$post = Post::create($request->validated());
if ($request->has('categories')) {
$post->categories()->sync($request->categories);
}
return new PostResource($post->load(['author', 'categories']));
}
/**
* Display the specified post.
*/
public function show(Post $post): PostResource
{
$this->authorize('view', $post);
return new PostResource($post->load(['author', 'categories', 'meta']));
}
/**
* Update the specified post.
*/
public function update(UpdatePostRequest $request, Post $post): PostResource
{
$this->authorize('update', $post);
$post->update($request->validated());
if ($request->has('categories')) {
$post->categories()->sync($request->categories);
}
return new PostResource($post->fresh(['author', 'categories']));
}
/**
* Remove the specified post.
*/
public function destroy(Post $post): Response
{
$this->authorize('delete', $post);
$post->delete();
return response()->noContent();
}
}
Custom Actions
/**
* Bulk delete posts.
*/
public function bulkDelete(BulkDeleteRequest $request): Response
{
$this->authorize('delete', Post::class);
Post::whereIn('id', $request->ids)->delete();
return response()->noContent();
}
/**
* Publish a post.
*/
public function publish(Post $post): PostResource
{
$this->authorize('publish', $post);
$post->update([
'status' => 'published',
'published_at' => now(),
]);
return new PostResource($post);
}
API Resources
Basic Resource
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'content' => $this->content,
'status' => $this->status,
'featured_image' => $this->featured_image_url,
'published_at' => $this->published_at?->toISOString(),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
// Relationships (conditional loading)
'author' => new UserResource($this->whenLoaded('author')),
'categories' => TermResource::collection($this->whenLoaded('categories')),
// Computed fields
'reading_time' => $this->calculateReadingTime(),
// Links
'links' => [
'self' => route('api.posts.show', $this->id),
'web' => route('posts.show', $this->slug),
],
];
}
private function calculateReadingTime(): int
{
$wordCount = str_word_count(strip_tags($this->content));
return max(1, ceil($wordCount / 200));
}
}
Conditional Attributes
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
// Only include if user has permission
$this->mergeWhen($request->user()?->can('view-analytics'), [
'view_count' => $this->view_count,
'engagement_rate' => $this->engagement_rate,
]),
// Include based on request parameter
'content' => $this->when(
$request->has('include_content'),
$this->content
),
];
}
Resource Collections
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class PostCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total_posts' => Post::count(),
'published_posts' => Post::where('status', 'published')->count(),
],
];
}
}
Form Requests
API Validation
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Post::class);
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
'status' => ['sometimes', Rule::in(['draft', 'published'])],
'categories' => ['sometimes', 'array'],
'categories.*' => ['exists:terms,id'],
'published_at' => ['sometimes', 'date', 'after:now'],
];
}
public function messages(): array
{
return [
'title.required' => 'A post title is required.',
'content.required' => 'Post content cannot be empty.',
];
}
}
Validation Error Response
Validation errors return 422 status:
{
"message": "The given data was invalid.",
"errors": {
"title": ["A post title is required."],
"content": ["Post content cannot be empty."]
}
}
Error Handling
Standard Error Responses
// 404 Not Found
return response()->json([
'message' => 'Post not found',
'error' => 'not_found'
], 404);
// 403 Forbidden
return response()->json([
'message' => 'You do not have permission to perform this action',
'error' => 'forbidden'
], 403);
// 500 Internal Error
return response()->json([
'message' => 'An unexpected error occurred',
'error' => 'server_error'
], 500);
Custom Exception Handler
// app/Exceptions/Handler.php
public function render($request, Throwable $e)
{
if ($request->expectsJson()) {
if ($e instanceof ModelNotFoundException) {
return response()->json([
'message' => 'Resource not found',
'error' => 'not_found'
], 404);
}
if ($e instanceof AuthorizationException) {
return response()->json([
'message' => 'Unauthorized action',
'error' => 'forbidden'
], 403);
}
}
return parent::render($request, $e);
}
Rate Limiting
Configure Rate Limits
// app/Providers/RouteServiceProvider.php
protected function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(
$request->user()?->id ?: $request->ip()
);
});
// Higher limit for authenticated users
RateLimiter::for('api-authenticated', function (Request $request) {
return $request->user()
? Limit::perMinute(120)->by($request->user()->id)
: Limit::perMinute(30)->by($request->ip());
});
}
Apply to Routes
Route::middleware(['auth:sanctum', 'throttle:api-authenticated'])
->prefix('v1')
->group(function () {
// Routes
});
Rate Limit Headers
Responses include rate limit information:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
X-RateLimit-Reset: 1705312800
Pagination
Standard Pagination
$posts = Post::paginate(15);
return PostResource::collection($posts);
Response:
{
"data": [...],
"links": {
"first": "http://example.com/api/v1/posts?page=1",
"last": "http://example.com/api/v1/posts?page=10",
"prev": null,
"next": "http://example.com/api/v1/posts?page=2"
},
"meta": {
"current_page": 1,
"from": 1,
"last_page": 10,
"per_page": 15,
"to": 15,
"total": 150
}
}
Cursor Pagination
For large datasets:
$posts = Post::cursorPaginate(15);
return PostResource::collection($posts);
Filtering & Sorting
Query Parameters
GET /api/v1/posts?status=published&category=5&sort=-created_at&per_page=20
Implementation
public function index(Request $request): AnonymousResourceCollection
{
$query = Post::query();
// Filtering
if ($request->has('status')) {
$query->where('status', $request->status);
}
if ($request->has('category')) {
$query->whereHas('categories', fn($q) => $q->where('id', $request->category));
}
if ($request->has('search')) {
$query->where(function ($q) use ($request) {
$q->where('title', 'like', "%{$request->search}%")
->orWhere('content', 'like', "%{$request->search}%");
});
}
// Date filtering
if ($request->has('from')) {
$query->whereDate('created_at', '>=', $request->from);
}
if ($request->has('to')) {
$query->whereDate('created_at', '<=', $request->to);
}
// Sorting
$sortField = ltrim($request->get('sort', '-created_at'), '-');
$sortDirection = str_starts_with($request->get('sort', ''), '-') ? 'desc' : 'asc';
$allowedSorts = ['created_at', 'updated_at', 'title', 'published_at'];
if (in_array($sortField, $allowedSorts)) {
$query->orderBy($sortField, $sortDirection);
}
return PostResource::collection(
$query->paginate($request->get('per_page', 15))
);
}
Including Relations
Eager Loading
GET /api/v1/posts?include=author,categories,comments
public function index(Request $request): AnonymousResourceCollection
{
$query = Post::query();
// Handle includes
$allowedIncludes = ['author', 'categories', 'comments', 'meta'];
$includes = array_intersect(
explode(',', $request->get('include', '')),
$allowedIncludes
);
if (!empty($includes)) {
$query->with($includes);
}
return PostResource::collection($query->paginate());
}
API Testing
Feature Tests
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use App\Models\User;
use App\Models\Post;
use Laravel\Sanctum\Sanctum;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostApiTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->user->givePermissionTo(['posts.view', 'posts.create', 'posts.edit', 'posts.delete']);
}
public function test_can_list_posts(): void
{
Sanctum::actingAs($this->user);
Post::factory()->count(3)->create();
$response = $this->getJson('/api/v1/posts');
$response
->assertOk()
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'slug', 'status']
],
'meta' => ['current_page', 'total']
]);
}
public function test_can_create_post(): void
{
Sanctum::actingAs($this->user);
$response = $this->postJson('/api/v1/posts', [
'title' => 'Test Post',
'content' => 'Test content',
'status' => 'draft',
]);
$response
->assertCreated()
->assertJsonPath('data.title', 'Test Post');
$this->assertDatabaseHas('posts', ['title' => 'Test Post']);
}
public function test_validation_errors_return_422(): void
{
Sanctum::actingAs($this->user);
$response = $this->postJson('/api/v1/posts', []);
$response
->assertStatus(422)
->assertJsonValidationErrors(['title', 'content']);
}
public function test_unauthenticated_request_returns_401(): void
{
$response = $this->getJson('/api/v1/posts');
$response->assertUnauthorized();
}
public function test_unauthorized_action_returns_403(): void
{
$userWithoutPermission = User::factory()->create();
Sanctum::actingAs($userWithoutPermission);
$response = $this->postJson('/api/v1/posts', [
'title' => 'Test',
'content' => 'Test',
]);
$response->assertForbidden();
}
}
API Documentation
Using Scramble
LaraDashboard uses Scramble for auto-generated API documentation.
Access documentation at:
GET /api/docs # Interactive documentation
GET /api.json # OpenAPI spec
Documenting Endpoints
/**
* List all posts.
*
* Returns a paginated list of posts with optional filtering and sorting.
*
* @queryParam status string Filter by status (draft, published). Example: published
* @queryParam category integer Filter by category ID. Example: 5
* @queryParam search string Search in title and content. Example: laravel
* @queryParam sort string Sort field with direction (-created_at). Example: -created_at
* @queryParam per_page integer Items per page (default 15). Example: 20
*
* @response 200 scenario="Success" {
* "data": [{"id": 1, "title": "Post Title"}],
* "meta": {"total": 100}
* }
*/
public function index(Request $request): AnonymousResourceCollection
{
// ...
}
Best Practices
Response Format Consistency
Always return consistent response structures:
// Success
{
"data": { ... },
"meta": { ... }
}
// Error
{
"message": "Error description",
"error": "error_code",
"errors": { ... }
}
Security
- Always validate and authorize
- Use rate limiting
- Validate token abilities/scopes
- Sanitize input and output
- Use HTTPS in production
Performance
- Eager load relationships
- Use cursor pagination for large datasets
- Cache frequently accessed data
- Index database columns used in queries
Next Steps
- Module Development - Build module APIs
- Hooks Reference - API hooks
- API Reference - Complete API documentation