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

  1. Always validate and authorize
  2. Use rate limiting
  3. Validate token abilities/scopes
  4. Sanitize input and output
  5. Use HTTPS in production

Performance

  1. Eager load relationships
  2. Use cursor pagination for large datasets
  3. Cache frequently accessed data
  4. Index database columns used in queries

Next Steps

/