API

API Standards

Versioning

APIs should be versioned. This is to ensure that changes to the API do not break existing clients.
The version should be included in the URL.

Route::prefix('api/v1')->group(function () {
    //...
});

Naming

  • Use plural nouns for resource names
  • Use descriptive names that clearly communicate the resource's purpose
// Good
/api/v1/tickets
/api/v1/users
/api/v1/categories

// Bad
/api/v1/ticket          // Singular
/api/v1/getTickets      // Verb in URL
/api/v1/ticket-list     // Redundant suffix

HTTP Methods

Method Purpose Example
GET Retrieve resource(s) GET /tickets
POST Create a resource POST /tickets
PUT Replace entire resource PUT /tickets/{id}
PATCH Partial update PATCH /tickets/{id}
DELETE Remove a resource DELETE /tickets/{id}
<?php
// routes/api.php

declare(strict_types=1);

use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')->group(function (): void {
    Route::apiResource('tickets', TicketController::class);

    // Custom actions use POST
    Route::post('/tickets/{ticket}/close', CloseTicketController::class);
    Route::post('/tickets/{ticket}/reopen', ReopenTicketController::class);
});

Status Codes

Success Codes

Code Meaning When to Use
200 OK Successful GET, PUT, PATCH
201 Created Successful POST that creates a resource
204 No Content Successful DELETE or action with no response body

Client Error Codes

Code Meaning When to Use
400 Bad Request Malformed request syntax
401 Unauthorized Missing or invalid authentication
403 Forbidden Authenticated but not authorized
404 Not Found Resource doesn't exist
422 Unprocessable Entity Validation errors
429 Too Many Requests Rate limit exceeded

Server Error Codes

Code Meaning When to Use
500 Internal Server Error Unexpected server error
503 Service Unavailable Maintenance or overload
// In controller
public function store(StoreTicketRequest $request): JsonResponse
{
    $ticket = Ticket::create($request->validated());

    return response()->json(
        new TicketResource($ticket),
        Response::HTTP_CREATED
    );
}

public function destroy(Ticket $ticket): JsonResponse
{
    $ticket->delete();

    return response()->json(null, Response::HTTP_NO_CONTENT);
}

Response Format

Success Responses

Use Laravel API Resources for consistent formatting:

// app/Http/Resources/TicketResource.php
<?php

declare(strict_types=1);

namespace App\Http\Resources;

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

final class TicketResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'        => $this->hash_id,
            'title'     => $this->title,
            'body'      => $this->body,
            'status' 		=> $this->status->value,
            'user'      => new UserResource($this->whenLoaded('user')),
            'created_at' => $this->created_at->toIso8601String(),
            'updated_at' => $this->updated_at->toIso8601String(),
        ];
    }
}

Collection Responses

// Single resource
return new TicketResource($ticket);
// {"data": {"id": "abc123", "title": "..."}}

// Collection
return TicketResource::collection($tickets);
// {"data": [{"id": "abc123", ...}, {"id": "def456", ...}]}

Error Responses

// Consistent error structure
{
    "message": "The given data was invalid.",
    "errors": {
        "title": ["The title field is required."],
        "body": ["The body must be at least 10 characters."]
    }
}

Error Handling

API Exception Handler

// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->render(function (Throwable $e, Request $request) {
        if ($request->expectsJson()) {
            return match (true) {
                $e instanceof ModelNotFoundException => response()->json([
                    'message' => 'Resource not found.',
                ], Response::HTTP_NOT_FOUND),

                $e instanceof AuthorizationException => response()->json([
                    'message' => 'You are not authorized to perform this action.',
                ], Response::HTTP_FORBIDDEN),

                default => null,
            };
        }
    });
})

Authentication

Use Laravel Sanctum

// routes/api.php
Route::post('/login', LoginController::class);
Route::post('/register', RegisterController::class);

Route::middleware('auth:sanctum')->group(function (): void {
    Route::get('/user', fn (Request $request) => new UserResource($request->user()));
    Route::post('/logout', LogoutController::class);

    Route::apiResource('tickets', TicketController::class);
});

Token Response

// LoginController
public function __invoke(LoginRequest $request): JsonResponse
{
    $user = User::where('email', $request->email)->first();

    if (!$user || !Hash::check($request->password, $user->password)) {
        return response()->json([
            'message' => 'Invalid credentials.',
        ], Response::HTTP_UNAUTHORIZED);
    }

    $token = $user->createToken('api-token')->plainTextToken;

    return response()->json([
        'user' => new UserResource($user),
        'token' => $token,
    ]);
}

Request Validation

Use Form Requests

// app/Http/Requests/Api/StoreTicketRequest.php
<?php

declare(strict_types=1);

namespace App\Http\Requests\Api;

use Illuminate\Foundation\Http\FormRequest;

final class StoreTicketRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'title'       => ['required', 'string', 'max:255'],
            'body'        => ['required', 'string', 'min:10'],
            'category_id' => ['nullable', 'exists:categories,id'],
        ];
    }
}

Pagination

Default Pagination

// Controller
public function index(): AnonymousResourceCollection
{
    $tickets = Ticket::query()
        ->with(['user', 'category'])
        ->latest()
        ->paginate(15);

    return TicketResource::collection($tickets);
}

// Response format
{
    "data": [...],
    "links": {
        "first": "http://example.com/api/v1/tickets?page=1",
        "last": "http://example.com/api/v1/tickets?page=10",
        "prev": null,
        "next": "http://example.com/api/v1/tickets?page=2"
    },
    "meta": {
        "current_page": 1,
        "from": 1,
        "last_page": 10,
        "per_page": 15,
        "to": 15,
        "total": 150
    }
}

Cursor Pagination

For large datasets or infinite scroll:

$tickets = Ticket::query()
    ->orderBy('id')
    ->cursorPaginate(15);

Filtering and Sorting

Query Parameters

// GET /api/v1/tickets?filter[status]=open&filter[category_id]=5&sort=-created_at

public function index(Request $request): AnonymousResourceCollection
{
    $query = Ticket::query()->with(['user', 'category']);

    // Filtering
    if ($request->has('filter.status')) {
        $query->where('status', $request->input('filter.status'));
    }

    if ($request->has('filter.category_id')) {
        $query->where('category_id', $request->input('filter.category_id'));
    }

    // Sorting (prefix with - for descending)
    if ($sort = $request->input('sort')) {
        $direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
        $column = ltrim($sort, '-');
        $query->orderBy($column, $direction);
    }

    return TicketResource::collection($query->paginate(15));
}

Using Spatie Query Builder

use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;

public function index(): AnonymousResourceCollection
{
    $tickets = QueryBuilder::for(Ticket::class)
        ->allowedFilters([
            'status',
            AllowedFilter::exact('category_id'),
            AllowedFilter::scope('active'),
        ])
        ->allowedSorts(['created_at', 'title'])
        ->allowedIncludes(['user', 'category'])
        ->paginate(15);

    return TicketResource::collection($tickets);
}

Rate Limiting

Rate limiting should be applied liberally across API endpoints. It protects against abuse, brute force attacks, and runaway clients. When in doubt, add a rate limit.

When to Apply Rate Limits

  • Always - Authentication endpoints (login, register, password reset)
  • Always - Any endpoint that sends emails or SMS
  • Always - Endpoints that create resources (POST requests)
  • Always - Endpoints that trigger expensive operations (reports, exports)
  • Recommended - All public/unauthenticated endpoints
  • Recommended - Endpoints that query external services

Configure in RouteServiceProvider or bootstrap

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->throttleApi('60:1'); // 60 requests per minute
})

// Or custom rate limiters in AppServiceProvider
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});

// Stricter limits for sensitive endpoints
RateLimiter::for('login', function (Request $request) {
    return Limit::perMinute(5)->by($request->ip());
});

RateLimiter::for('email', function (Request $request) {
    return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip());
});

RateLimiter::for('exports', function (Request $request) {
    return Limit::perHour(10)->by($request->user()->id);
});

Apply to Routes

// Auth endpoints - strict limits
Route::post('/login', LoginController::class)->middleware('throttle:login');
Route::post('/register', RegisterController::class)->middleware('throttle:login');
Route::post('/forgot-password', ForgotPasswordController::class)->middleware('throttle:email');

// Endpoints that send notifications
Route::post('/tickets/{ticket}/notify', NotifyController::class)->middleware('throttle:email');

// Expensive operations
Route::get('/reports/export', ExportReportController::class)->middleware('throttle:exports');

Best Practices

Always Return JSON

// Good
return response()->json(['message' => 'Success']);

// Bad
return 'Success';
return response('Success');

Use Resource IDs in URLs, Not Internal IDs

// Good - Uses hash_id
/api/v1/tickets/abc123

// Configure route model binding
public function getRouteKeyName(): string
{
    return 'hash_id';
}

Include Related Data Optionally

// GET /api/v1/tickets?include=user,category

public function toArray(Request $request): array
{
    return [
        'id' => $this->hash_id,
        'title' => $this->title,
        'user' => new UserResource($this->whenLoaded('user')),
        'category' => new CategoryResource($this->whenLoaded('category')),
    ];
}

Never Expose Sensitive Data

// Good - Explicitly define what to expose
public function toArray(Request $request): array
{
    return [
        'id' => $this->hash_id,
        'name' => $this->name,
        'email' => $this->email,
        // password, remember_token, etc. are NOT included
    ];
}

// Bad - Exposing everything
public function toArray(Request $request): array
{
    return parent::toArray($request);
}