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);
}