API Standards Guide
Last Updated: 2026-01-23 Status: Active Audience: Developers
This guide documents API design patterns and standards for the Client Portal application.
Table of Contents
- Overview
- API Structure
- Authentication
- Request Handling
- Response Formats
- Error Handling
- Versioning
- Rate Limiting
- Testing
- Best Practices
- API Explorer
- SDK Generation
- API Analytics
- API Debug Logging
- API Versioning
Overview
The Client Portal API follows RESTful conventions with JSON responses.
Design Principles
- Consistent: Same patterns across all endpoints
- Predictable: Standard HTTP methods and status codes
- Secure: Authentication required, proper authorization
- Documented: Clear endpoint documentation
API Structure
Route Organization
// routes/api.php
<?php
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () {
// Public routes
Route::post('login', [AuthController::class, 'login']);
Route::post('register', [AuthController::class, 'register']);
// Authenticated routes
Route::middleware('auth:sanctum')->group(function () {
// User endpoints
Route::get('user', [UserController::class, 'show']);
Route::put('user', [UserController::class, 'update']);
Route::post('logout', [AuthController::class, 'logout']);
// Resource routes
Route::apiResource('clients', ClientController::class);
Route::apiResource('projects', ProjectController::class);
Route::apiResource('invoices', InvoiceController::class);
// Nested resources
Route::apiResource('clients.projects', ClientProjectController::class)
->shallow();
// Custom actions
Route::post('invoices/{invoice}/send', [InvoiceController::class, 'send']);
Route::post('invoices/{invoice}/pay', [InvoiceController::class, 'pay']);
});
});
URL Conventions
| Convention | Example |
|---|---|
| Plural nouns | /api/v1/clients |
| Lowercase | /api/v1/project-files |
| Hyphens for multi-word | /api/v1/invoice-items |
| Nested resources | /api/v1/clients/{client}/projects |
| Actions as verbs | /api/v1/invoices/{invoice}/send |
HTTP Methods
| Method | Purpose | Example |
|---|---|---|
| GET | Retrieve resource(s) | GET /clients |
| POST | Create resource | POST /clients |
| PUT/PATCH | Update resource | PUT /clients/1 |
| DELETE | Delete resource | DELETE /clients/1 |
Authentication
Sanctum Token Authentication
// Install Sanctum
php artisan install:api
// AuthController
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\LoginRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function login(LoginRequest $request): JsonResponse
{
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
$token = $user->createToken(
$request->device_name ?? 'api',
['*'], // abilities
now()->addDays(30) // expiration
);
return response()->json([
'success' => true,
'data' => [
'user' => new UserResource($user),
'token' => $token->plainTextToken,
'expires_at' => $token->accessToken->expires_at,
],
]);
}
public function logout(): JsonResponse
{
auth()->user()->currentAccessToken()->delete();
return response()->json([
'success' => true,
'message' => 'Successfully logged out.',
]);
}
public function logoutAll(): JsonResponse
{
auth()->user()->tokens()->delete();
return response()->json([
'success' => true,
'message' => 'Logged out from all devices.',
]);
}
}
Using Authentication
# Login to get token
curl -X POST https://api.example.com/v1/login \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "secret"}'
# Use token in requests
curl https://api.example.com/v1/clients \
-H "Authorization: Bearer {token}" \
-H "Accept: application/json"
Token Abilities (Scopes)
// Create token with limited abilities
$token = $user->createToken('read-only', ['clients:read', 'invoices:read']);
// Check abilities in controller
public function destroy(Client $client): JsonResponse
{
if (!auth()->user()->tokenCan('clients:delete')) {
abort(403, 'Token does not have required permissions.');
}
$client->delete();
return response()->json(['success' => true]);
}
Request Handling
API Form Requests
<?php
namespace App\Http\Requests\Api;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
abstract class ApiFormRequest extends FormRequest
{
/**
* Handle a failed validation attempt.
*/
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(
response()->json([
'success' => false,
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422)
);
}
/**
* Handle a failed authorization attempt.
*/
protected function failedAuthorization(): void
{
throw new HttpResponseException(
response()->json([
'success' => false,
'message' => 'You are not authorized to perform this action.',
], 403)
);
}
}
<?php
namespace App\Http\Requests\Api;
class StoreClientRequest extends ApiFormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Client::class);
}
public function rules(): array
{
return [
'company_name' => ['required', 'string', 'max:255'],
'contact_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:clients,email'],
'phone' => ['nullable', 'string', 'max:20'],
'address' => ['nullable', 'string', 'max:500'],
];
}
}
Query Parameters
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ClientResource;
use App\Models\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class ClientController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$query = Client::query();
// Search
if ($request->has('search')) {
$search = $request->input('search');
$query->where(function ($q) use ($search) {
$q->where('company_name', 'like', "%{$search}%")
->orWhere('contact_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// Filter by status
if ($request->has('status')) {
$query->where('status', $request->input('status'));
}
// Sort
$sortField = $request->input('sort', 'created_at');
$sortDirection = $request->input('direction', 'desc');
$allowedSorts = ['company_name', 'created_at', 'updated_at'];
if (in_array($sortField, $allowedSorts)) {
$query->orderBy($sortField, $sortDirection === 'asc' ? 'asc' : 'desc');
}
// Include relationships
if ($request->has('include')) {
$includes = explode(',', $request->input('include'));
$allowed = ['projects', 'invoices', 'user'];
$query->with(array_intersect($includes, $allowed));
}
// Paginate
$perPage = min($request->input('per_page', 15), 100);
return ClientResource::collection($query->paginate($perPage));
}
}
Response Formats
API Resources
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ClientResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'company_name' => $this->company_name,
'contact_name' => $this->contact_name,
'email' => $this->email,
'phone' => $this->phone,
'status' => $this->status,
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
// Conditional relationships
'projects' => ProjectResource::collection($this->whenLoaded('projects')),
'invoices' => InvoiceResource::collection($this->whenLoaded('invoices')),
// Conditional attributes
'projects_count' => $this->when(
isset($this->projects_count),
$this->projects_count
),
// Links
'links' => [
'self' => route('api.v1.clients.show', $this->id),
'projects' => route('api.v1.clients.projects.index', $this->id),
],
];
}
}
Resource Collections
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ClientCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total' => $this->total(),
'per_page' => $this->perPage(),
'current_page' => $this->currentPage(),
'last_page' => $this->lastPage(),
],
'links' => [
'first' => $this->url(1),
'last' => $this->url($this->lastPage()),
'prev' => $this->previousPageUrl(),
'next' => $this->nextPageUrl(),
],
];
}
}
Standard Response Structure
// Success response
{
"success": true,
"data": { ... },
"message": "Optional success message"
}
// Collection response
{
"success": true,
"data": [ ... ],
"meta": {
"total": 100,
"per_page": 15,
"current_page": 1,
"last_page": 7
},
"links": {
"first": "...",
"last": "...",
"prev": null,
"next": "..."
}
}
// Error response
{
"success": false,
"message": "Error description",
"errors": {
"field": ["Error message"]
}
}
Base API Controller
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class ApiController extends Controller
{
/**
* Return a success response.
*/
protected function success(
mixed $data = null,
string $message = null,
int $code = 200
): JsonResponse {
$response = ['success' => true];
if ($message !== null) {
$response['message'] = $message;
}
if ($data !== null) {
$response['data'] = $data;
}
return response()->json($response, $code);
}
/**
* Return a created response.
*/
protected function created(mixed $data, string $message = 'Resource created.'): JsonResponse
{
return $this->success($data, $message, 201);
}
/**
* Return a no content response.
*/
protected function noContent(): JsonResponse
{
return response()->json(null, 204);
}
/**
* Return an error response.
*/
protected function error(
string $message,
int $code = 400,
array $errors = []
): JsonResponse {
$response = [
'success' => false,
'message' => $message,
];
if (!empty($errors)) {
$response['errors'] = $errors;
}
return response()->json($response, $code);
}
}
Error Handling
HTTP Status Codes
| Code | Meaning | Use Case |
|---|---|---|
| 200 | OK | Successful GET/PUT |
| 201 | Created | Successful POST |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Invalid request format |
| 401 | Unauthorized | Missing/invalid auth |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource doesn't exist |
| 422 | Unprocessable Entity | Validation failed |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Server error |
Global Exception Handler
// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
return Application::configure(basePath: dirname(__DIR__))
->withExceptions(function (Exceptions $exceptions) {
// Handle API exceptions
$exceptions->render(function (Throwable $e, Request $request) {
if ($request->is('api/*') || $request->expectsJson()) {
return $this->handleApiException($e);
}
});
})->create();
private function handleApiException(Throwable $e): JsonResponse
{
if ($e instanceof ModelNotFoundException) {
return response()->json([
'success' => false,
'message' => 'Resource not found.',
], 404);
}
if ($e instanceof AuthenticationException) {
return response()->json([
'success' => false,
'message' => 'Unauthenticated.',
], 401);
}
if ($e instanceof AuthorizationException) {
return response()->json([
'success' => false,
'message' => $e->getMessage() ?: 'Forbidden.',
], 403);
}
if ($e instanceof ValidationException) {
return response()->json([
'success' => false,
'message' => 'Validation failed.',
'errors' => $e->errors(),
], 422);
}
if ($e instanceof ThrottleRequestsException) {
return response()->json([
'success' => false,
'message' => 'Too many requests. Please try again later.',
], 429);
}
// Log unexpected errors
Log::error('API Error', [
'exception' => get_class($e),
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'message' => app()->environment('production')
? 'An unexpected error occurred.'
: $e->getMessage(),
], 500);
}
Versioning
URL Versioning
// routes/api.php
Route::prefix('v1')->name('api.v1.')->group(function () {
Route::apiResource('clients', Api\V1\ClientController::class);
});
Route::prefix('v2')->name('api.v2.')->group(function () {
Route::apiResource('clients', Api\V2\ClientController::class);
});
Controller Organization
app/Http/Controllers/Api/
├── V1/
│ ├── ClientController.php
│ └── InvoiceController.php
└── V2/
├── ClientController.php
└── InvoiceController.php
Resource Versioning
// app/Http/Resources/V1/ClientResource.php
namespace App\Http\Resources\V1;
class ClientResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->company_name, // V1 uses 'name'
// ...
];
}
}
// app/Http/Resources/V2/ClientResource.php
namespace App\Http\Resources\V2;
class ClientResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'company_name' => $this->company_name, // V2 uses 'company_name'
'company' => [
'name' => $this->company_name,
'address' => $this->address,
],
// ...
];
}
}
Rate Limiting
Configure Rate Limits
// bootstrap/app.php or AppServiceProvider
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('auth', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
// routes/api.php
Route::middleware('throttle:auth')->group(function () {
Route::post('login', [AuthController::class, 'login']);
});
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
// Protected routes
});
Rate Limit Headers
The response includes:
X-RateLimit-Limit: Max requests allowedX-RateLimit-Remaining: Remaining requestsRetry-After: Seconds until limit resets (when exceeded)
Testing
API Test Setup
<?php
namespace Tests\Feature\Api;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
abstract class ApiTestCase extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
}
protected function actingAsApiUser(?User $user = null): static
{
Sanctum::actingAs($user ?? $this->user);
return $this;
}
}
Testing Endpoints
<?php
namespace Tests\Feature\Api;
use App\Models\Client;
class ClientApiTest extends ApiTestCase
{
public function test_can_list_clients(): void
{
Client::factory()->count(5)->create();
$response = $this->actingAsApiUser()
->getJson('/api/v1/clients');
$response->assertOk()
->assertJsonStructure([
'success',
'data' => [
'*' => ['id', 'company_name', 'email'],
],
'meta' => ['total', 'per_page'],
]);
}
public function test_can_create_client(): void
{
$data = [
'company_name' => 'Acme Inc',
'contact_name' => 'John Doe',
'email' => 'john@acme.com',
];
$response = $this->actingAsApiUser()
->postJson('/api/v1/clients', $data);
$response->assertCreated()
->assertJsonPath('success', true)
->assertJsonPath('data.company_name', 'Acme Inc');
$this->assertDatabaseHas('clients', ['email' => 'john@acme.com']);
}
public function test_validation_errors_returned_correctly(): void
{
$response = $this->actingAsApiUser()
->postJson('/api/v1/clients', []);
$response->assertUnprocessable()
->assertJsonPath('success', false)
->assertJsonValidationErrors(['company_name', 'email']);
}
public function test_unauthenticated_request_returns_401(): void
{
$response = $this->getJson('/api/v1/clients');
$response->assertUnauthorized()
->assertJsonPath('success', false);
}
public function test_unauthorized_request_returns_403(): void
{
$client = Client::factory()->create();
$response = $this->actingAsApiUser()
->deleteJson("/api/v1/clients/{$client->id}");
$response->assertForbidden();
}
}
Testing Rate Limits
public function test_rate_limit_applied(): void
{
for ($i = 0; $i < 60; $i++) {
$this->actingAsApiUser()->getJson('/api/v1/clients');
}
$response = $this->actingAsApiUser()->getJson('/api/v1/clients');
$response->assertStatus(429)
->assertJsonPath('message', 'Too many requests. Please try again later.');
}
Best Practices
Do
- Use proper HTTP status codes
- Return consistent response structure
- Validate all inputs
- Use API Resources for responses
- Version your API
- Implement rate limiting
- Document all endpoints
- Test thoroughly
Don't
- Return HTML from API endpoints
- Use 200 for all responses
- Expose internal errors in production
- Skip authentication/authorization
- Allow unlimited request rates
- Break backwards compatibility
API Explorer
The API includes interactive tools for exploring and testing endpoints.
Swagger UI
# Web UI
https://your-domain.com/api/docs
# OpenAPI Spec (JSON)
https://your-domain.com/api/docs/spec
API Playground
https://admin.your-domain.com/api-playground
The API Playground provides an enhanced testing experience with:
Endpoint Information:
- Authentication type badges (API Key, Session, Either, Public)
- Role requirement badges (Admin, Client, Any Authenticated)
- Grouped by category (Profile, Projects, Invoices, etc.)
Request Building:
- Method and URL builder
- API key selection from available keys
- Custom headers
- JSON body editor with "Load Sample" button
- Query parameters
Sample Data:
- Pre-defined sample request bodies for POST/PUT/PATCH endpoints
- Expected response format preview
- Code examples in cURL, JavaScript, PHP, and Python
Response Viewing:
- Response body with JSON formatting
- Response headers
- Status code and timing information
Endpoint Configuration
Endpoint metadata is defined in config/api-endpoints.php:
[
'path' => '/api/v1/projects',
'method' => 'POST',
'summary' => 'Create a new project',
'auth_type' => 'api_key', // api_key, session, either, none
'required_roles' => ['admin'], // or [], ['client'], ['admin', 'client']
'sample_request' => [
'client_id' => 1,
'name' => 'New Project',
],
'sample_response' => [
'data' => ['id' => 1, 'name' => 'New Project'],
],
'tags' => ['Projects'],
]
SDK Generation
Generate client SDKs for multiple programming languages.
Command Line Usage
# Generate TypeScript SDK
php artisan api:generate-sdk typescript
# Generate Python SDK
php artisan api:generate-sdk python
# Supported languages
php artisan api:generate-sdk typescript # TypeScript with axios
php artisan api:generate-sdk javascript # JavaScript
php artisan api:generate-sdk php # PHP with Guzzle
php artisan api:generate-sdk python # Python
php artisan api:generate-sdk ruby # Ruby
php artisan api:generate-sdk go # Go
php artisan api:generate-sdk java # Java
php artisan api:generate-sdk csharp # C#/.NET
# Options
php artisan api:generate-sdk typescript --spec-only # Only generate OpenAPI spec
php artisan api:generate-sdk typescript --output=/custom/path # Custom output directory
php artisan api:generate-sdk typescript --package-name=my-sdk # Custom package name
Web UI
Admins can access the SDK generator at:
https://your-domain.com/admin/api-sdk
Features:
- Generate SDKs for any supported language
- Download as ZIP archive
- Regenerate OpenAPI spec
- View generation status
Requirements
SDK generation requires the OpenAPI Generator CLI:
npm install -g @openapitools/openapi-generator-cli
API Analytics
Monitor API usage with the built-in analytics dashboard.
Accessing Analytics
https://your-domain.com/admin/api-analytics
Metrics Tracked
- Total requests
- Success/failure rates
- Response times (average, max)
- Requests by endpoint
- Requests by HTTP method
- Error rates by status code
- Top API consumers
- Daily/hourly trends
Middleware
API requests are automatically logged via the LogApiUsage middleware:
// routes/api.php
Route::prefix('v1')->middleware(['api.version', 'api.log'])->group(function () {
// All routes in this group are logged
});
Data Retention
Configure log retention in your scheduled tasks:
// app/Console/Kernel.php
$schedule->command('model:prune', [
'--model' => ApiUsageLog::class,
])->daily();
// app/Models/ApiUsageLog.php
protected function prunable(): Builder
{
return static::where('created_at', '<', now()->subDays(90));
}
API Debug Logging
For detailed request/response debugging during development and troubleshooting.
Enabling Debug Logging
Debug logging can be enabled/disabled by admins via:
- Admin UI:
/admin/api-debug-logs- Toggle button - System Setting:
api_debug_logging_enabled
What Gets Logged
When enabled, the debug logger captures:
- Full request URL and method
- Request headers (sanitized)
- Request body (with sensitive fields redacted)
- Query parameters
- Response status and headers
- Response body (truncated to 100KB)
- Response time
- User/API key information
- Error details for failed requests
Viewing Debug Logs
Access debug logs at:
https://admin.your-domain.com/api-debug-logs
Features:
- Filter by status (success/failed)
- Filter by HTTP method
- Filter by endpoint pattern
- Filter by date range
- Filter by user or API key
- View full request/response details
- Bulk delete old logs
- Clear all logs
Sensitive Data Handling
The middleware automatically redacts sensitive data:
Headers removed:
- Authorization
- Cookie
- X-API-KEY
Body fields redacted:
- password, password_confirmation
- token, access_token, refresh_token
- secret, api_secret, client_secret
- credit_card, card_number, cvv
- ssn, social_security
Configuration
// config/api-endpoints.php
// Contains endpoint metadata used by API Playground
// Key system settings:
// api_debug_logging_enabled (boolean) - Enable/disable debug logging
Middleware
The debug logging middleware (api.debug.log) is automatically applied to API v1 routes:
// routes/api.php
Route::prefix('v1')
->middleware(['api.version', 'api.log', 'api.debug.log'])
->group(function () {
// Routes here are debug logged when enabled
});
API Versioning
Version Header
The API automatically adds version headers to responses:
X-API-Version: Current API versionX-API-Deprecated: Whether version is deprecated
Requesting Specific Versions
# Via URL prefix
GET /api/v1/clients
# Via header
GET /api/clients
X-API-Version: v1
# Via Accept header
GET /api/clients
Accept: application/vnd.clientportal.v1+json
Query Convention Extensions (Plan 266+)
The /api/v1/admin/audit-* namespace introduced a stricter query convention via the AppliesQueryParams trait. These conventions are documented here for reuse by future namespaces:
CSV filter normalization
When a filter accepts comma-separated values (e.g. filter[status]=draft,active):
- Whitespace around segments is trimmed.
- Duplicate values are deduplicated.
- Empty segments from trailing commas are dropped silently.
- Maximum 50 values per csv filter — over that →
422.
meta.truncated_includes
When a nested include (e.g. ?include=sections.items) is capped at 50 rows per parent, the response payload includes meta.truncated_includes: [...] containing the truncated include paths. Consumers needing the full collection should hit the dedicated child endpoint instead.
Date filter timezone behaviour
For <field>_after and <field>_before filters:
- ISO 8601 inputs with timezone are honoured as-is.
- Bare dates (
2026-05-18) resolve toconfig('app.timezone')midnight start-of-day — NOT UTC midnight. - DB columns store UTC; the trait normalizes via
CarbonImmutable::parse($input, config('app.timezone'))->utc(). - Bounds are inclusive on both sides (
>=for_after,<=for_before).
trashed query parameter
For endpoints whose model uses SoftDeletes:
trashed=without(default) —WHERE deleted_at IS NULL.trashed=with— interleaved with soft-deleted rows.trashed=only— soft-deleted rows only.
Every trashed=with|only request writes one AdminAuditLog entry (action: api.audit.trashed_query).
Related Documentation
- Laravel Sanctum
- Laravel API Resources
- Admin Audit Read API — reference implementation of the query convention.
- VALIDATION_GUIDE.md
- ERROR_HANDLING.md