Laravel API Development: Building RESTful APIs That Scale
Complete guide to building production-ready APIs with Laravel. Cover API resources, versioning, authentication, rate limiting, documentation, and testing strategies.
Laravel API Development: Building RESTful APIs That Scale
Laravel excels at API development with built-in support for JSON responses, API resources, rate limiting, and authentication. This guide covers building production-ready APIs from the ground up.
Project Setup
API-Only Installation
bash
laravel new api-project --api
Or configure existing project:
php
// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
api: __DIR__.'/../routes/api.php',
apiPrefix: 'api/v1',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->api(prepend: [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
]);
})
->create();
API Resources
Basic Resource
php
php artisan make:resource UserResource
php
// app/Http/Resources/UserResource.php
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'avatar_url' => $this->avatar_url,
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}
Resource with Relationships
php
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'content' => $this->content,
'published_at' => $this->published_at?->toISOString(),
// Conditional relationships
'author' => new UserResource($this->whenLoaded('author')),
'category' => new CategoryResource($this->whenLoaded('category')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
'comments_count' => $this->whenCounted('comments'),
// Computed fields
'reading_time' => $this->reading_time,
'is_published' => $this->is_published,
// Links
'links' => [
'self' => route('api.posts.show', $this),
'author' => route('api.users.show', $this->author_id),
],
];
}
}
Resource Collection
php
class PostCollection 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(),
],
];
}
}
Controllers
API Controller Pattern
php
// app/Http/Controllers/Api/V1/PostController.php
class PostController extends Controller
{
public function __construct(
private PostService $postService
) {}
public function index(Request $request)
{
$posts = Post::query()
->with(['author', 'category', 'tags'])
->withCount('comments')
->when($request->category, fn($q, $cat) => $q->where('category_id', $cat))
->when($request->search, fn($q, $search) => $q->search($search))
->latest('published_at')
->paginate($request->per_page ?? 15);
return new PostCollection($posts);
}
public function store(StorePostRequest $request)
{
$post = $this->postService->create($request->validated());
return (new PostResource($post))
->response()
->setStatusCode(201);
}
public function show(Post $post)
{
$post->load(['author', 'category', 'tags', 'comments.author']);
return new PostResource($post);
}
public function update(UpdatePostRequest $request, Post $post)
{
$post = $this->postService->update($post, $request->validated());
return new PostResource($post);
}
public function destroy(Post $post)
{
$this->authorize('delete', $post);
$post->delete();
return response()->noContent();
}
}
Form Requests
php
// app/Http/Requests/StorePostRequest.php
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Post::class);
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
'category_id' => ['required', 'exists:categories,id'],
'tags' => ['array'],
'tags.*' => ['exists:tags,id'],
'published_at' => ['nullable', 'date', 'after:now'],
];
}
public function messages(): array
{
return [
'title.required' => 'A post title is required.',
'category_id.exists' => 'The selected category does not exist.',
];
}
}
Authentication
Sanctum Token Authentication
php
// routes/api.php
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', [AuthController::class, 'user']);
Route::post('/logout', [AuthController::class, 'logout']);
Route::apiResource('posts', PostController::class);
});
php
// app/Http/Controllers/Api/AuthController.php
class AuthController extends Controller
{
public function login(LoginRequest $request)
{
$credentials = $request->validated();
if (!Auth::attempt($credentials)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
$user = Auth::user();
$token = $user->createToken(
$request->device_name ?? 'api-token',
['*'], // abilities
now()->addDays(30) // expiration
);
return response()->json([
'user' => new UserResource($user),
'token' => $token->plainTextToken,
'expires_at' => $token->accessToken->expires_at,
]);
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->noContent();
}
public function user(Request $request)
{
return new UserResource($request->user());
}
}
Token Abilities (Scopes)
php
// Creating token with specific abilities
$token = $user->createToken('api', ['posts:read', 'posts:write']);
// Checking abilities
Route::get('/posts', [PostController::class, 'index'])
->middleware('ability:posts:read');
Route::post('/posts', [PostController::class, 'store'])
->middleware('ability:posts:write');
// In controller
if ($request->user()->tokenCan('posts:delete')) {
// Can delete
}
API Versioning
URL Versioning
php
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('posts', Api\V1\PostController::class);
});
Route::prefix('v2')->group(function () {
Route::apiResource('posts', Api\V2\PostController::class);
});
Header Versioning
php
// app/Http/Middleware/ApiVersion.php
class ApiVersion
{
public function handle(Request $request, Closure $next)
{
$version = $request->header('API-Version', 'v1');
config(['api.version' => $version]);
return $next($request);
}
}
Rate Limiting
Define Limiters
php
// app/Providers/AppServiceProvider.php
public function boot(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('uploads', function (Request $request) {
return Limit::perMinute(10)->by($request->user()->id);
});
// Tiered limits
RateLimiter::for('api', function (Request $request) {
$user = $request->user();
return match ($user?->plan) {
'enterprise' => Limit::none(),
'pro' => Limit::perMinute(1000)->by($user->id),
'basic' => Limit::perMinute(100)->by($user->id),
default => Limit::perMinute(20)->by($request->ip()),
};
});
}
Apply to Routes
php
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::apiResource('posts', PostController::class);
});
Route::post('/upload', [UploadController::class, 'store'])
->middleware('throttle:uploads');
Error Handling
Custom Exception Handler
php
// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'message' => 'Resource not found',
'error' => 'not_found',
], 404);
}
});
$exceptions->render(function (ValidationException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'message' => 'Validation failed',
'errors' => $e->errors(),
], 422);
}
});
$exceptions->render(function (AuthenticationException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'message' => 'Unauthenticated',
'error' => 'unauthenticated',
], 401);
}
});
})
Custom API Exceptions
php
// app/Exceptions/ApiException.php
class ApiException extends Exception
{
public function __construct(
string $message,
public string $errorCode,
public int $statusCode = 400,
public array $meta = []
) {
parent::__construct($message);
}
public function render(Request $request)
{
return response()->json([
'message' => $this->message,
'error' => $this->errorCode,
'meta' => $this->meta,
], $this->statusCode);
}
}
// Usage
throw new ApiException(
'Insufficient credits',
'insufficient_credits',
402,
['required' => 100, 'available' => 50]
);
Testing
Feature Tests
php
class PostApiTest extends TestCase
{
use RefreshDatabase;
public function test_can_list_posts(): void
{
$user = User::factory()->create();
Post::factory()->count(5)->create();
$response = $this->actingAs($user, 'sanctum')
->getJson('/api/v1/posts');
$response->assertOk()
->assertJsonCount(5, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'slug', 'excerpt'],
],
'meta' => ['total', 'per_page'],
'links' => ['first', 'last'],
]);
}
public function test_can_create_post(): void
{
$user = User::factory()->create();
$category = Category::factory()->create();
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/v1/posts', [
'title' => 'Test Post',
'content' => 'Post content here',
'category_id' => $category->id,
]);
$response->assertCreated()
->assertJsonPath('data.title', 'Test Post');
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'author_id' => $user->id,
]);
}
public function test_validates_post_creation(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/v1/posts', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['title', 'content', 'category_id']);
}
public function test_unauthenticated_user_cannot_access(): void
{
$response = $this->getJson('/api/v1/posts');
$response->assertUnauthorized();
}
}
Best Practices
1. Consistent Response Format
php
// app/Traits/ApiResponse.php
trait ApiResponse
{
protected function success($data, int $status = 200)
{
return response()->json(['data' => $data], $status);
}
protected function created($data)
{
return $this->success($data, 201);
}
protected function noContent()
{
return response()->noContent();
}
protected function error(string $message, string $code, int $status = 400)
{
return response()->json([
'message' => $message,
'error' => $code,
], $status);
}
}
2. Use Query Parameters Consistently
text
GET /api/v1/posts?page=1&per_page=15
GET /api/v1/posts?filter[status]=published&filter[category]=tech
GET /api/v1/posts?include=author,tags
GET /api/v1/posts?sort=-published_at
GET /api/v1/posts?fields[posts]=id,title,excerpt
3. Document Your API
Use tools like Scribe or OpenAPI/Swagger for automatic documentation.
bash
composer require knuckleswtf/scribe
php artisan scribe:generate
Conclusion
Building APIs with Laravel is straightforward with the right patterns. Focus on consistent responses, proper authentication, comprehensive testing, and good documentation.
The key is designing for the consumer—whether that's a mobile app, SPA, or third-party integration.