Laravel API Development Best Practices: A Complete Guide
Master REST API development with Laravel. Learn authentication, versioning, error handling, rate limiting, and performance optimization from 14 years of PHP experience.
Building APIs that scale, perform, and maintain backward compatibility requires more than knowing Laravel's routing system. After 14 years of PHP development and dozens of production APIs serving millions of requests, I have learned that the difference between a good API and a great one lies in the details: consistent error handling, thoughtful versioning, robust authentication, and performance optimization that anticipates growth.
This guide distills practical API development best practices I have implemented across projects ranging from startup MVPs to enterprise platforms. Whether you are building your first Laravel API or refining an existing one, these patterns will help you create APIs that developers love to consume.
Why API Design Matters More Than Ever
Modern applications are API-first. Your mobile app, SPA frontend, third-party integrations, and internal microservices all communicate through APIs. Poor API design creates cascading problems: frustrated developers, increased support burden, security vulnerabilities, and technical debt that compounds with every release.
Well-designed APIs, conversely, become competitive advantages. They reduce integration time, minimize support requests, enable partner ecosystems, and scale gracefully as your application grows.
RESTful Design Principles
REST (Representational State Transfer) provides a proven architectural style for building APIs. While you do not need to implement REST dogmatically, understanding its principles leads to more intuitive, predictable APIs.
Resource-Oriented URLs
Design URLs around resources (nouns), not actions (verbs). Let HTTP methods communicate intent.
<?php
// routes/api.php
use App\Http\Controllers\Api\V1\InvoiceController;
use App\Http\Controllers\Api\V1\ClientController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () {
// Resource-oriented routes
Route::apiResource('clients', ClientController::class);
Route::apiResource('clients.invoices', InvoiceController::class)->shallow();
// BAD: Action-oriented routes (avoid these patterns)
// Route::post('/createInvoice', 'InvoiceController@create');
// Route::get('/getClientById/{id}', 'ClientController@get');
// Route::post('/deleteInvoice/{id}', 'InvoiceController@delete');
});
Use plural nouns for collections and hierarchical paths for relationships:
GET /api/v1/clients # List all clients
POST /api/v1/clients # Create a client
GET /api/v1/clients/{id} # Get a specific client
PUT /api/v1/clients/{id} # Update a client
DELETE /api/v1/clients/{id} # Delete a client
GET /api/v1/clients/{id}/invoices # List client's invoices
HTTP Methods and Status Codes
Use HTTP methods semantically and return appropriate status codes. This consistency makes your API predictable and self-documenting.
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreInvoiceRequest;
use App\Http\Requests\UpdateInvoiceRequest;
use App\Http\Resources\InvoiceResource;
use App\Http\Resources\InvoiceCollection;
use App\Models\Invoice;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class InvoiceController extends Controller
{
/**
* List invoices with pagination
*
* @return InvoiceCollection
*/
public function index(): InvoiceCollection
{
$invoices = Invoice::query()
->with(['client', 'lineItems'])
->paginate(25);
return new InvoiceCollection($invoices); // 200 OK
}
/**
* Create a new invoice
*
* @param StoreInvoiceRequest $request
* @return JsonResponse
*/
public function store(StoreInvoiceRequest $request): JsonResponse
{
$invoice = Invoice::create($request->validated());
return (new InvoiceResource($invoice))
->response()
->setStatusCode(Response::HTTP_CREATED); // 201 Created
}
/**
* Display a specific invoice
*
* @param Invoice $invoice
* @return InvoiceResource
*/
public function show(Invoice $invoice): InvoiceResource
{
return new InvoiceResource(
$invoice->load(['client', 'lineItems', 'payments'])
); // 200 OK
}
/**
* Update an existing invoice
*
* @param UpdateInvoiceRequest $request
* @param Invoice $invoice
* @return InvoiceResource
*/
public function update(UpdateInvoiceRequest $request, Invoice $invoice): InvoiceResource
{
$invoice->update($request->validated());
return new InvoiceResource($invoice->fresh()); // 200 OK
}
/**
* Delete an invoice
*
* @param Invoice $invoice
* @return Response
*/
public function destroy(Invoice $invoice): Response
{
$invoice->delete();
return response()->noContent(); // 204 No Content
}
}
Essential HTTP Status Codes for APIs:
| Code | Meaning | Use Case |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST creating a resource |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Malformed request syntax |
| 401 | Unauthorized | Authentication required |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource does not exist |
| 422 | Unprocessable Entity | Validation errors |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server error |
API Authentication: Choosing the Right Strategy
Authentication is the foundation of API security. Laravel provides several options, each suited to different use cases.
Laravel Sanctum for First-Party Applications
Sanctum is the recommended choice for SPAs, mobile apps, and simple token-based APIs. It provides cookie-based authentication for SPAs and personal access tokens for mobile and third-party clients.
<?php
// Install Sanctum
// composer require laravel/sanctum
// php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
// php artisan migrate
// app/Http/Controllers/Api/AuthController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\LoginRequest;
use App\Http\Requests\RegisterRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
/**
* Register a new user and issue token
*/
public function register(RegisterRequest $request): JsonResponse
{
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
$token = $user->createToken(
$request->device_name ?? 'api',
['*'], // Token abilities
now()->addDays(30) // Token expiration
)->plainTextToken;
return response()->json([
'user' => $user,
'token' => $token,
'token_type' => 'Bearer',
], 201);
}
/**
* Authenticate and issue token
*/
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.'],
]);
}
// Revoke existing tokens for this device (optional)
$user->tokens()
->where('name', $request->device_name ?? 'api')
->delete();
$token = $user->createToken(
$request->device_name ?? 'api',
['*'],
now()->addDays(30)
)->plainTextToken;
return response()->json([
'user' => $user,
'token' => $token,
'token_type' => 'Bearer',
]);
}
/**
* Revoke current token
*/
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json([
'message' => 'Successfully logged out',
]);
}
/**
* Revoke all tokens
*/
public function logoutAll(Request $request): JsonResponse
{
$request->user()->tokens()->delete();
return response()->json([
'message' => 'Successfully logged out from all devices',
]);
}
}
Token Abilities for Fine-Grained Permissions
Implement token abilities (scopes) to restrict what each token can do:
<?php
// Issue tokens with specific abilities
$token = $user->createToken('mobile-app', [
'invoices:read',
'invoices:create',
'clients:read',
// Notably missing: 'invoices:delete', 'clients:write'
])->plainTextToken;
// Check abilities in controllers or middleware
public function destroy(Invoice $invoice): Response
{
// Check if token has required ability
if (!auth()->user()->tokenCan('invoices:delete')) {
abort(403, 'Token does not have permission to delete invoices');
}
$invoice->delete();
return response()->noContent();
}
// Or use middleware
Route::delete('/invoices/{invoice}', [InvoiceController::class, 'destroy'])
->middleware('ability:invoices:delete');
When to Use Passport (OAuth2)
Choose Laravel Passport when you need full OAuth2 compliance, particularly for third-party integrations:
- Building a platform where external developers create applications
- Implementing SSO across multiple applications
- Enterprise requirements mandating OAuth2
- Need for authorization codes, refresh tokens, or implicit grants
For most first-party applications, Sanctum's simplicity outweighs Passport's additional features.
API Versioning Strategies
APIs evolve, but breaking changes alienate consumers. Versioning provides a contract between your API and its consumers, enabling evolution without disruption.
URL Path Versioning (Recommended)
URL versioning is explicit, cache-friendly, and widely adopted by major APIs including Stripe, GitHub, and Facebook.
<?php
// routes/api.php
use Illuminate\Support\Facades\Route;
// Version 1 routes
Route::prefix('v1')->group(function () {
Route::apiResource('invoices', App\Http\Controllers\Api\V1\InvoiceController::class);
Route::apiResource('clients', App\Http\Controllers\Api\V1\ClientController::class);
});
// Version 2 routes (with breaking changes)
Route::prefix('v2')->group(function () {
Route::apiResource('invoices', App\Http\Controllers\Api\V2\InvoiceController::class);
Route::apiResource('clients', App\Http\Controllers\Api\V2\ClientController::class);
});
Organize controllers by version to maintain separation:
app/
├── Http/
│ ├── Controllers/
│ │ ├── Api/
│ │ │ ├── V1/
│ │ │ │ ├── InvoiceController.php
│ │ │ │ └── ClientController.php
│ │ │ └── V2/
│ │ │ ├── InvoiceController.php
│ │ │ └── ClientController.php
│ │ └── Controller.php
│ └── Resources/
│ ├── V1/
│ │ ├── InvoiceResource.php
│ │ └── ClientResource.php
│ └── V2/
│ ├── InvoiceResource.php
│ └── ClientResource.php
Semantic Versioning for APIs
Apply semantic versioning principles to communicate the scope of changes:
- Major version (v1 → v2): Breaking changes requiring client updates
- Minor version: New features, backward compatible
- Patch version: Bug fixes, backward compatible
Only increment the major version for breaking changes. Add new endpoints and optional fields without versioning.
Deprecation Strategy
Communicate deprecation clearly and provide migration timelines:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class ApiDeprecationWarning
{
public function handle(Request $request, Closure $next, string $deprecationDate): mixed
{
$response = $next($request);
$response->headers->set('Deprecation', $deprecationDate);
$response->headers->set('Sunset', $deprecationDate);
$response->headers->set(
'Link',
'</api/v2>; rel="successor-version"'
);
return $response;
}
}
// routes/api.php
Route::prefix('v1')
->middleware('deprecation:2026-06-01')
->group(function () {
// V1 routes
});
Consistent Error Handling
Inconsistent error responses frustrate API consumers. Establish a standard error format and apply it consistently across your entire API.
Standardized Error Response Format
Create a consistent structure for all error responses:
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* Register the exception handling callbacks for the application.
*/
public function register(): void
{
$this->renderable(function (Throwable $e, $request) {
if ($request->expectsJson() || $request->is('api/*')) {
return $this->handleApiException($e);
}
});
}
/**
* Convert exception to standardized API response
*/
protected function handleApiException(Throwable $e): JsonResponse
{
$error = [
'error' => [
'code' => $this->getErrorCode($e),
'message' => $this->getErrorMessage($e),
],
];
// Add validation errors if applicable
if ($e instanceof ValidationException) {
$error['error']['details'] = $e->errors();
}
// Add debug information in non-production environments
if (config('app.debug') && !$e instanceof ValidationException) {
$error['error']['debug'] = [
'exception' => get_class($e),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => collect($e->getTrace())->take(5)->toArray(),
];
}
return response()->json($error, $this->getStatusCode($e));
}
/**
* Get appropriate HTTP status code for exception
*/
protected function getStatusCode(Throwable $e): int
{
return match (true) {
$e instanceof ValidationException => 422,
$e instanceof AuthenticationException => 401,
$e instanceof AuthorizationException => 403,
$e instanceof ModelNotFoundException => 404,
$e instanceof NotFoundHttpException => 404,
$e instanceof HttpException => $e->getStatusCode(),
default => 500,
};
}
/**
* Get error code for exception type
*/
protected function getErrorCode(Throwable $e): string
{
return match (true) {
$e instanceof ValidationException => 'VALIDATION_ERROR',
$e instanceof AuthenticationException => 'UNAUTHENTICATED',
$e instanceof AuthorizationException => 'FORBIDDEN',
$e instanceof ModelNotFoundException => 'RESOURCE_NOT_FOUND',
$e instanceof NotFoundHttpException => 'NOT_FOUND',
default => 'INTERNAL_ERROR',
};
}
/**
* Get user-friendly error message
*/
protected function getErrorMessage(Throwable $e): string
{
return match (true) {
$e instanceof ValidationException => 'The given data was invalid.',
$e instanceof AuthenticationException => 'Authentication required.',
$e instanceof AuthorizationException => 'You are not authorized to perform this action.',
$e instanceof ModelNotFoundException => 'The requested resource was not found.',
$e instanceof NotFoundHttpException => 'The requested endpoint was not found.',
$e instanceof HttpException => $e->getMessage() ?: 'An error occurred.',
default => config('app.debug')
? $e->getMessage()
: 'An unexpected error occurred. Please try again later.',
};
}
}
This produces consistent responses:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The given data was invalid.",
"details": {
"email": ["The email field is required."],
"amount": ["The amount must be a positive number."]
}
}
}
Rate Limiting and Throttling
Rate limiting protects your API from abuse, ensures fair resource allocation, and maintains performance under load. Laravel provides built-in throttling that you can customize for different endpoints and user tiers.
Configuring Rate Limits
Define rate limiters in your AppServiceProvider or a dedicated provider:
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->configureRateLimiting();
}
protected function configureRateLimiting(): void
{
// Default API rate limit
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(
$request->user()?->id ?: $request->ip()
);
});
// Tiered rate limits based on subscription
RateLimiter::for('api-tiered', function (Request $request) {
$user = $request->user();
if (!$user) {
return Limit::perMinute(10)->by($request->ip());
}
return match ($user->subscription_tier) {
'enterprise' => Limit::perMinute(1000)->by($user->id),
'professional' => Limit::perMinute(300)->by($user->id),
'starter' => Limit::perMinute(100)->by($user->id),
default => Limit::perMinute(30)->by($user->id),
};
});
// Strict limits for expensive operations
RateLimiter::for('expensive', function (Request $request) {
return Limit::perHour(10)->by(
$request->user()?->id ?: $request->ip()
)->response(function () {
return response()->json([
'error' => [
'code' => 'RATE_LIMIT_EXCEEDED',
'message' => 'You have exceeded the rate limit for this operation.',
'retry_after' => 3600,
],
], 429);
});
});
// Separate limits for authentication attempts
RateLimiter::for('auth', function (Request $request) {
return [
Limit::perMinute(5)->by($request->ip()),
Limit::perHour(20)->by($request->ip()),
];
});
}
}
Rate Limit Headers
Include rate limit information in response headers so clients can manage their request rates:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
class RateLimitHeaders
{
public function handle(Request $request, Closure $next): mixed
{
$response = $next($request);
$key = $request->user()?->id ?: $request->ip();
$limiter = 'api';
$remaining = RateLimiter::remaining($limiter . ':' . $key, 60);
$retryAfter = RateLimiter::availableIn($limiter . ':' . $key);
$response->headers->set('X-RateLimit-Limit', 60);
$response->headers->set('X-RateLimit-Remaining', max(0, $remaining));
if ($remaining <= 0) {
$response->headers->set('X-RateLimit-Reset', time() + $retryAfter);
$response->headers->set('Retry-After', $retryAfter);
}
return $response;
}
}
API Documentation with Laravel Scribe
Comprehensive documentation is essential for API adoption. Laravel Scribe generates beautiful documentation automatically from your code and annotations.
# Install Scribe
composer require --dev knuckleswtf/scribe
# Publish configuration
php artisan vendor:publish --tag=scribe-config
# Generate documentation
php artisan scribe:generate
Document your endpoints with annotations:
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreInvoiceRequest;
use App\Http\Resources\InvoiceResource;
use App\Models\Invoice;
/**
* @group Invoice Management
*
* APIs for managing invoices
*/
class InvoiceController extends Controller
{
/**
* List all invoices
*
* Get a paginated list of invoices for the authenticated user.
*
* @queryParam page integer Page number for pagination. Example: 1
* @queryParam per_page integer Number of items per page (max: 100). Example: 25
* @queryParam status string Filter by status (draft, sent, paid, overdue). Example: sent
* @queryParam client_id integer Filter by client ID. Example: 42
*
* @response 200 scenario="Success" {
* "data": [
* {
* "id": 1,
* "invoice_number": "INV-2026-0001",
* "client": {"id": 42, "name": "Acme Corp"},
* "amount": 1500.00,
* "status": "sent",
* "due_date": "2026-02-01",
* "created_at": "2026-01-09T10:00:00Z"
* }
* ],
* "links": {...},
* "meta": {"current_page": 1, "total": 50}
* }
*
* @authenticated
*/
public function index(): InvoiceCollection
{
// Implementation
}
/**
* Create an invoice
*
* Create a new invoice for a client.
*
* @bodyParam client_id integer required The client ID. Example: 42
* @bodyParam line_items array required Array of line items.
* @bodyParam line_items[].description string required Item description. Example: Web development services
* @bodyParam line_items[].quantity integer required Quantity. Example: 10
* @bodyParam line_items[].unit_price number required Unit price. Example: 150.00
* @bodyParam due_date date required Invoice due date. Example: 2026-02-01
* @bodyParam notes string Optional notes. Example: Thank you for your business!
*
* @response 201 scenario="Created" {
* "data": {
* "id": 101,
* "invoice_number": "INV-2026-0101",
* "status": "draft",
* "amount": 1500.00
* }
* }
*
* @response 422 scenario="Validation Error" {
* "error": {
* "code": "VALIDATION_ERROR",
* "message": "The given data was invalid.",
* "details": {
* "client_id": ["The client_id field is required."]
* }
* }
* }
*
* @authenticated
*/
public function store(StoreInvoiceRequest $request): JsonResponse
{
// Implementation
}
}
Performance Optimization
API performance directly impacts user experience and infrastructure costs. Implement these optimizations from the start.
Eager Loading to Prevent N+1 Queries
Laravel's Eloquent makes it easy to introduce N+1 query problems. Always eager load relationships:
<?php
namespace App\Http\Controllers\Api\V1;
use App\Models\Invoice;
use App\Http\Resources\InvoiceCollection;
class InvoiceController extends Controller
{
public function index(): InvoiceCollection
{
// BAD: N+1 queries when accessing client and lineItems
// $invoices = Invoice::paginate(25);
// GOOD: Eager load relationships
$invoices = Invoice::query()
->with([
'client:id,name,email',
'lineItems:id,invoice_id,description,amount',
'payments:id,invoice_id,amount,paid_at',
])
->select(['id', 'client_id', 'invoice_number', 'status', 'amount', 'due_date', 'created_at'])
->latest()
->paginate(25);
return new InvoiceCollection($invoices);
}
}
Response Caching
Cache expensive queries and computations:
<?php
namespace App\Http\Controllers\Api\V1;
use App\Models\Invoice;
use Illuminate\Support\Facades\Cache;
class DashboardController extends Controller
{
public function statistics(): JsonResponse
{
$userId = auth()->id();
$cacheKey = "user:{$userId}:dashboard:stats";
$stats = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($userId) {
return [
'total_revenue' => Invoice::where('user_id', $userId)
->where('status', 'paid')
->sum('amount'),
'outstanding' => Invoice::where('user_id', $userId)
->whereIn('status', ['sent', 'overdue'])
->sum('amount'),
'invoices_this_month' => Invoice::where('user_id', $userId)
->whereMonth('created_at', now()->month)
->count(),
];
});
return response()->json(['data' => $stats]);
}
}
Database Query Optimization
Enable query logging during development to identify slow queries:
<?php
// In a service provider or middleware (development only)
if (app()->environment('local')) {
DB::listen(function ($query) {
if ($query->time > 100) { // Log queries over 100ms
Log::warning('Slow query detected', [
'sql' => $query->sql,
'bindings' => $query->bindings,
'time' => $query->time . 'ms',
]);
}
});
}
Add appropriate indexes for common query patterns:
<?php
// database/migrations/xxxx_add_indexes_to_invoices_table.php
return new class extends Migration
{
public function up(): void
{
Schema::table('invoices', function (Blueprint $table) {
// Composite index for common filter + sort queries
$table->index(['user_id', 'status', 'created_at']);
$table->index(['client_id', 'status']);
$table->index(['due_date', 'status']); // For overdue invoice queries
});
}
};
Pagination Best Practices
Always paginate collection endpoints. For large datasets, consider cursor pagination for better performance:
<?php
public function index(): JsonResponse
{
// Standard pagination for smaller datasets
$invoices = Invoice::paginate(25);
// Cursor pagination for large datasets (more efficient)
$invoices = Invoice::orderBy('id')->cursorPaginate(25);
return new InvoiceCollection($invoices);
}
Security Best Practices
API security extends beyond authentication. Implement defense in depth with multiple layers of protection.
Input Validation
Use Form Requests for comprehensive validation:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreInvoiceRequest extends FormRequest
{
public function authorize(): bool
{
// Check if user can create invoices for this client
return $this->user()->clients()
->where('id', $this->input('client_id'))
->exists();
}
public function rules(): array
{
return [
'client_id' => [
'required',
'integer',
Rule::exists('clients', 'id')
->where('user_id', $this->user()->id),
],
'due_date' => ['required', 'date', 'after:today'],
'line_items' => ['required', 'array', 'min:1'],
'line_items.*.description' => ['required', 'string', 'max:255'],
'line_items.*.quantity' => ['required', 'integer', 'min:1', 'max:9999'],
'line_items.*.unit_price' => ['required', 'numeric', 'min:0.01', 'max:999999.99'],
'notes' => ['nullable', 'string', 'max:1000'],
'tax_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
}
public function messages(): array
{
return [
'client_id.exists' => 'The selected client does not exist or does not belong to you.',
'due_date.after' => 'The due date must be a future date.',
'line_items.min' => 'At least one line item is required.',
];
}
}
Authorization with Policies
Use policies to centralize authorization logic:
<?php
namespace App\Policies;
use App\Models\Invoice;
use App\Models\User;
class InvoicePolicy
{
/**
* Determine if user can view any invoices
*/
public function viewAny(User $user): bool
{
return true; // All authenticated users can list their invoices
}
/**
* Determine if user can view the invoice
*/
public function view(User $user, Invoice $invoice): bool
{
return $user->id === $invoice->user_id
|| $user->hasRole('admin');
}
/**
* Determine if user can update the invoice
*/
public function update(User $user, Invoice $invoice): bool
{
// Cannot update paid or deleted invoices
if (in_array($invoice->status, ['paid', 'cancelled'])) {
return false;
}
return $user->id === $invoice->user_id;
}
/**
* Determine if user can delete the invoice
*/
public function delete(User $user, Invoice $invoice): bool
{
// Cannot delete sent or paid invoices
if (in_array($invoice->status, ['sent', 'paid'])) {
return false;
}
return $user->id === $invoice->user_id;
}
}
CORS Configuration
Configure CORS appropriately for your API consumers:
<?php
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', '')),
// IMPORTANT: Never use '*' with credentials in production
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [
'X-RateLimit-Limit',
'X-RateLimit-Remaining',
'X-RateLimit-Reset',
],
'max_age' => 86400,
'supports_credentials' => true,
];
Testing Your API
Comprehensive API tests ensure reliability and prevent regressions. Laravel provides excellent testing utilities for APIs.
<?php
namespace Tests\Feature\Api\V1;
use App\Models\User;
use App\Models\Client;
use App\Models\Invoice;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class InvoiceApiTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Client $client;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->client = Client::factory()->create(['user_id' => $this->user->id]);
}
/** @test */
public function guests_cannot_access_invoice_endpoints(): void
{
$response = $this->getJson('/api/v1/invoices');
$response->assertUnauthorized();
}
/** @test */
public function users_can_list_their_invoices(): void
{
Sanctum::actingAs($this->user);
Invoice::factory()
->count(3)
->for($this->user)
->for($this->client)
->create();
// Create invoices for another user (should not appear)
Invoice::factory()
->count(2)
->create();
$response = $this->getJson('/api/v1/invoices');
$response
->assertOk()
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => [
'id',
'invoice_number',
'client',
'amount',
'status',
'due_date',
],
],
'links',
'meta',
]);
}
/** @test */
public function users_can_create_invoices(): void
{
Sanctum::actingAs($this->user);
$invoiceData = [
'client_id' => $this->client->id,
'due_date' => now()->addDays(30)->format('Y-m-d'),
'line_items' => [
[
'description' => 'Web development services',
'quantity' => 10,
'unit_price' => 150.00,
],
],
'notes' => 'Thank you for your business!',
];
$response = $this->postJson('/api/v1/invoices', $invoiceData);
$response
->assertCreated()
->assertJsonPath('data.status', 'draft')
->assertJsonPath('data.client.id', $this->client->id);
$this->assertDatabaseHas('invoices', [
'user_id' => $this->user->id,
'client_id' => $this->client->id,
'status' => 'draft',
]);
}
/** @test */
public function invoice_creation_validates_input(): void
{
Sanctum::actingAs($this->user);
$response = $this->postJson('/api/v1/invoices', [
'client_id' => 999999, // Non-existent client
'due_date' => '2020-01-01', // Past date
'line_items' => [], // Empty
]);
$response
->assertUnprocessable()
->assertJsonValidationErrors([
'client_id',
'due_date',
'line_items',
]);
}
/** @test */
public function users_cannot_access_other_users_invoices(): void
{
Sanctum::actingAs($this->user);
$otherUser = User::factory()->create();
$otherInvoice = Invoice::factory()->for($otherUser)->create();
$response = $this->getJson("/api/v1/invoices/{$otherInvoice->id}");
$response->assertForbidden();
}
/** @test */
public function rate_limiting_works_correctly(): void
{
Sanctum::actingAs($this->user);
// Make requests up to the limit
for ($i = 0; $i < 60; $i++) {
$this->getJson('/api/v1/invoices');
}
// Next request should be rate limited
$response = $this->getJson('/api/v1/invoices');
$response->assertStatus(429);
}
}
Key Takeaways
Building production-ready Laravel APIs requires attention to multiple interconnected concerns. The practices that differentiate professional APIs:
- Resource-oriented design with consistent URL patterns and HTTP method semantics
- Appropriate authentication using Sanctum for first-party apps or Passport for OAuth2 requirements
- URL path versioning with clear deprecation timelines and migration documentation
- Standardized error responses that help developers debug issues quickly
- Tiered rate limiting to protect resources while serving different customer segments
- Comprehensive documentation generated automatically from code annotations
- Performance optimization through eager loading, caching, and database indexing
- Defense in depth security with validation, authorization policies, and proper CORS configuration
- Thorough testing covering authentication, authorization, validation, and edge cases
Start with these fundamentals, then iterate based on your specific requirements and consumer feedback. A well-designed API becomes an asset that accelerates development and enables integrations you have not yet imagined.
Need help building a robust API for your application? I specialize in Laravel API development with over 14 years of PHP experience building scalable, secure APIs for startups and enterprises. My API Development service includes architecture design, authentication implementation, documentation, and performance optimization. Schedule a free consultation to discuss your API requirements.
Related Reading:
- Contact Form Security: Best Practices for Spam Protection
- Migrating Legacy Laravel Apps: Lessons from 14 Years of PHP Development
- AWS Cost Optimization for PHP Apps: A Complete Guide
External Resources:
- Laravel Sanctum Documentation - Official Laravel Authentication
- Laravel API Resources - Transforming Models to JSON
- Laravel Scribe - API Documentation Generator
- REST API Design Best Practices - RESTful API Guidelines

Richard Joseph Porter
Full-stack developer with expertise in modern web technologies. Passionate about building scalable applications and sharing knowledge through technical writing.
Need Help Upgrading Your Laravel App?
I specialize in modernizing legacy Laravel applications with zero downtime. Get a free codebase audit and upgrade roadmap.
Related Articles
Migrating Legacy Laravel Apps: Lessons from 14 Years of PHP Development
A practical guide to upgrading Laravel 4.x-8.x applications to modern versions, with real-world strategies from enterprise migration projects.
AWS Cost Optimization for PHP Apps: A Complete Guide
Reduce your AWS bill by 40-70% with proven cost optimization strategies for PHP and Laravel applications. Covers EC2, RDS, Lambda, S3, and more.
Building a Modern Portfolio with Next.js 15
A comprehensive guide to creating a high-performance portfolio website using Next.js 15, TypeScript, and Tailwind CSS with advanced features like dark mode and scroll animations.