REST API Design with Laravel
Laravel provides comprehensive tools for building robust, secure, and scalable RESTful APIs. In this lesson, we'll explore API design conventions, authentication, versioning, rate limiting, and best practices for creating production-ready APIs.
RESTful Conventions
REST (Representational State Transfer) follows specific conventions for API design. Laravel makes it easy to implement these standards:
<!-- HTTP Methods and Their Purposes -->
GET /api/users # List all users (index)
GET /api/users/{id} # Show single user (show)
POST /api/users # Create new user (store)
PUT /api/users/{id} # Update entire user (update)
PATCH /api/users/{id} # Partially update user (update)
DELETE /api/users/{id} # Delete user (destroy)
<!-- Nested Resources -->
GET /api/users/{id}/posts # Get user's posts
POST /api/users/{id}/posts # Create post for user
DELETE /api/posts/{id} # Delete specific post
REST Best Practices:
- Use nouns for resources (not verbs)
- Use plural names for collections (/users, not /user)
- Use HTTP methods to define actions
- Return appropriate HTTP status codes
- Version your API from the start
Setting Up API Routes
Laravel provides a dedicated routes/api.php file for API routes. These routes automatically have the /api prefix and are stateless:
// routes/api.php
use App\Http\Controllers\Api\UserController;
use App\Http\Controllers\Api\PostController;
use Illuminate\Support\Facades\Route;
// Public API routes
Route::get('/status', function () {
return response()->json([
'status' => 'online',
'version' => '1.0.0',
'timestamp' => now()->toIso8601String()
]);
});
// Protected API routes
Route::middleware(['auth:sanctum'])->group(function () {
// User resources
Route::apiResource('users', UserController::class);
// Nested resources
Route::apiResource('users.posts', PostController::class)
->shallow(); // Use shallow routing for nested resources
// Custom endpoints
Route::post('/users/{user}/follow', [UserController::class, 'follow']);
Route::delete('/users/{user}/unfollow', [UserController::class, 'unfollow']);
});
apiResource vs resource: The apiResource() method creates routes without create and edit methods, as APIs typically don't serve HTML forms. It only includes: index, store, show, update, destroy.
API Controllers
Create API-specific controllers with proper response formatting and error handling:
// app/Http/Controllers/Api/UserController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class UserController extends Controller
{
/**
* Display a listing of users.
*/
public function index(Request $request)
{
$users = User::query()
->when($request->search, function ($query, $search) {
$query->where('name', 'like', "%{$search}%");
})
->paginate($request->per_page ?? 15);
return UserResource::collection($users);
}
/**
* Store a newly created user.
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8|confirmed'
]);
$validated['password'] = bcrypt($validated['password']);
$user = User::create($validated);
return new UserResource($user);
}
/**
* Display the specified user.
*/
public function show(User $user)
{
return new UserResource($user->load(['posts', 'followers']));
}
/**
* Update the specified user.
*/
public function update(Request $request, User $user)
{
$this->authorize('update', $user);
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'email' => 'sometimes|email|unique:users,email,' . $user->id,
'bio' => 'nullable|string|max:1000'
]);
$user->update($validated);
return new UserResource($user);
}
/**
* Remove the specified user.
*/
public function destroy(User $user)
{
$this->authorize('delete', $user);
$user->delete();
return response()->json([
'message' => 'User deleted successfully'
], Response::HTTP_OK);
}
}
API Resources
API Resources provide a transformation layer between your Eloquent models and JSON responses:
// Generate resource: php artisan make:resource UserResource
// app/Http/Resources/UserResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'bio' => $this->bio,
'avatar_url' => $this->avatar_url,
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
// Conditional attributes
'email_verified' => $this->when($this->email_verified_at, true),
'phone' => $this->when($request->user()?->isAdmin(), $this->phone),
// Relationships
'posts' => PostResource::collection($this->whenLoaded('posts')),
'followers_count' => $this->when($this->relationLoaded('followers'),
fn() => $this->followers->count()
),
// Computed attributes
'is_following' => $this->when(
$request->user(),
fn() => $request->user()->isFollowing($this->resource)
),
// Links
'links' => [
'self' => route('api.users.show', $this->id),
'posts' => route('api.users.posts.index', $this->id)
]
];
}
/**
* Get additional data for resource response.
*/
public function with(Request $request): array
{
return [
'meta' => [
'version' => '1.0.0',
'timestamp' => now()->toIso8601String()
]
];
}
}
API Versioning
Implement API versioning to allow backward compatibility and smooth transitions:
// Method 1: URL Path Versioning (Recommended)
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('users', Api\V1\UserController::class);
Route::apiResource('posts', Api\V1\PostController::class);
});
Route::prefix('v2')->group(function () {
Route::apiResource('users', Api\V2\UserController::class);
Route::apiResource('posts', Api\V2\PostController::class);
});
// URLs: /api/v1/users, /api/v2/users
// Method 2: Header Versioning
// app/Http/Middleware/ApiVersion.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class ApiVersion
{
public function handle(Request $request, Closure $next, string $version)
{
$acceptedVersion = $request->header('Accept-Version', '1.0');
if ($acceptedVersion !== $version) {
return response()->json([
'error' => 'API version mismatch',
'requested' => $acceptedVersion,
'supported' => ['1.0', '2.0']
], 406);
}
return $next($request);
}
}
// Register middleware
Route::middleware(['api.version:2.0'])->group(function () {
// V2 routes
});
// Method 3: Content Negotiation
// Accept: application/vnd.myapp.v2+json
Versioning Best Practices:
- Use URL path versioning for simplicity and clarity
- Only increment versions for breaking changes
- Maintain old versions for a deprecation period
- Document version differences clearly
- Use semantic versioning (major.minor.patch)
Rate Limiting
Protect your API from abuse with Laravel's built-in rate limiting:
// app/Providers/RouteServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
// Configure rate limiters
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// Authenticated user limits
RateLimiter::for('api-authenticated', function (Request $request) {
return $request->user()
? Limit::perMinute(120)->by($request->user()->id)
: Limit::perMinute(20)->by($request->ip());
});
// Premium tier limits
RateLimiter::for('api-premium', function (Request $request) {
if ($request->user()?->isPremium()) {
return Limit::perMinute(1000)->by($request->user()->id);
}
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// Multiple limits
RateLimiter::for('api-strict', function (Request $request) {
return [
Limit::perMinute(10), // 10 per minute
Limit::perHour(100), // 100 per hour
Limit::perDay(1000), // 1000 per day
];
});
// Custom response when limit exceeded
RateLimiter::for('api-custom', function (Request $request) {
return Limit::perMinute(60)
->by($request->user()?->id ?: $request->ip())
->response(function (Request $request, array $headers) {
return response()->json([
'error' => 'Rate limit exceeded',
'message' => 'Too many requests. Please slow down.',
'retry_after' => $headers['Retry-After']
], 429, $headers);
});
});
}
// Apply to routes
// routes/api.php
Route::middleware(['throttle:api-premium'])->group(function () {
Route::apiResource('users', UserController::class);
});
// Apply to specific endpoints
Route::post('/expensive-operation', [ApiController::class, 'process'])
->middleware('throttle:5,1'); // 5 requests per minute
Rate Limit Headers: Laravel automatically adds X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After headers to rate-limited responses, making it easy for API clients to handle limits gracefully.
Response Formatting
Create consistent, standardized API responses across your application:
// app/Traits/ApiResponse.php
namespace App\Traits;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
trait ApiResponse
{
/**
* Success response
*/
protected function success($data = null, string $message = null, int $code = Response::HTTP_OK): JsonResponse
{
$response = [
'success' => true,
'message' => $message,
'data' => $data,
];
return response()->json(array_filter($response), $code);
}
/**
* Error response
*/
protected function error(string $message, int $code = Response::HTTP_BAD_REQUEST, $errors = null): JsonResponse
{
$response = [
'success' => false,
'message' => $message,
'errors' => $errors,
];
return response()->json(array_filter($response), $code);
}
/**
* Validation error response
*/
protected function validationError($errors, string $message = 'Validation failed'): JsonResponse
{
return response()->json([
'success' => false,
'message' => $message,
'errors' => $errors
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
/**
* Not found response
*/
protected function notFound(string $message = 'Resource not found'): JsonResponse
{
return $this->error($message, Response::HTTP_NOT_FOUND);
}
/**
* Unauthorized response
*/
protected function unauthorized(string $message = 'Unauthorized'): JsonResponse
{
return $this->error($message, Response::HTTP_UNAUTHORIZED);
}
/**
* Paginated response
*/
protected function paginated($data, string $message = null): JsonResponse
{
return response()->json([
'success' => true,
'message' => $message,
'data' => $data->items(),
'meta' => [
'current_page' => $data->currentPage(),
'last_page' => $data->lastPage(),
'per_page' => $data->perPage(),
'total' => $data->total(),
'from' => $data->firstItem(),
'to' => $data->lastItem(),
],
'links' => [
'first' => $data->url(1),
'last' => $data->url($data->lastPage()),
'prev' => $data->previousPageUrl(),
'next' => $data->nextPageUrl(),
]
]);
}
}
// Use in controller
class UserController extends Controller
{
use ApiResponse;
public function store(Request $request)
{
$validated = $request->validate([...]);
$user = User::create($validated);
return $this->success(
new UserResource($user),
'User created successfully',
Response::HTTP_CREATED
);
}
public function index()
{
$users = User::paginate(15);
return $this->paginated($users, 'Users retrieved successfully');
}
}
CORS Configuration
Configure Cross-Origin Resource Sharing to allow your API to be accessed from different domains:
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => [
'https://example.com',
'https://app.example.com',
],
// For development only
'allowed_origins_patterns' => [
'/^https?:\/\/localhost(:\d+)?$/',
],
'allowed_headers' => ['*'],
'exposed_headers' => [
'X-RateLimit-Limit',
'X-RateLimit-Remaining',
],
'max_age' => 0,
'supports_credentials' => true,
];
// Add CORS middleware to api routes
// app/Http/Kernel.php
protected $middlewareGroups = [
'api' => [
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\HandleCors::class, // Add this
],
];
// Or use Laravel's built-in CORS handling
// Ensure \Fruitcake\Cors\HandleCors is in $middleware
CORS Security: Never use 'allowed_origins' => ['*'] in production! Always specify exact domains. Use 'supports_credentials' => true only when necessary, as it requires specific origins (not wildcards).
API Documentation
Document your API using Laravel's built-in tools or popular packages:
// Install Scribe for API documentation
composer require --dev knuckleswtf/scribe
// Generate documentation
php artisan scribe:generate
// Add docblocks to controllers
/**
* List all users
*
* Returns a paginated list of users with optional search filtering.
*
* @group User Management
*
* @queryParam search string Filter users by name. Example: john
* @queryParam per_page int Number of items per page. Example: 20
*
* @response {
* "data": [
* {
* "id": 1,
* "name": "John Doe",
* "email": "john@example.com"
* }
* ],
* "links": {...},
* "meta": {...}
* }
*/
public function index(Request $request)
{
// ...
}
// Alternative: OpenAPI/Swagger
composer require darkaonline/l5-swagger
// Generate Swagger documentation
php artisan l5-swagger:generate
// Add Swagger annotations
/**
* @OA\Get(
* path="/api/users",
* summary="Get list of users",
* tags={"Users"},
* @OA\Parameter(
* name="page",
* in="query",
* description="Page number",
* required=false,
* @OA\Schema(type="integer")
* ),
* @OA\Response(
* response=200,
* description="Successful operation"
* )
* )
*/
Exercise 1: Build a Complete API Resource
Create a Post API with the following requirements:
- Create PostController with all CRUD operations
- Create PostResource with proper data transformation
- Implement filtering by category and author
- Add rate limiting (60 requests per minute)
- Include proper validation for store/update
- Return standardized JSON responses
- Add pagination with meta information
Exercise 2: Implement API Versioning
Create two versions of a User API:
- V1: Returns user with id, name, email
- V2: Returns user with uuid (instead of id), full_name (instead of name), email, created_at
- Set up routes for both versions
- Create separate controllers or use version detection
- Document the differences between versions
Exercise 3: Advanced Rate Limiting
Implement tiered rate limiting:
- Free users: 30 requests per minute
- Premium users: 120 requests per minute
- Admin users: No limits
- Anonymous users: 10 requests per minute by IP
- Return custom error messages with upgrade information
- Add X-RateLimit-Tier custom header
Summary
In this lesson, you learned how to design and build RESTful APIs with Laravel. You explored API routing conventions, resource controllers and transformations, versioning strategies, rate limiting, CORS configuration, and API documentation. These skills enable you to create professional, scalable APIs that follow industry best practices.
In the next lesson, we'll dive into Laravel Policies & Gates for fine-grained authorization control.