RESTful API with Laravel: Best Practices 2026
Learn how to design and build professional RESTful APIs with Laravel. We cover API Resources, Form Requests, versioning, rate limiting, pagination, filtering, and testing to create robust and scalable APIs.
Written by Francisco Zapata
Building professional-grade RESTful APIs is a core skill for any modern backend developer. Laravel provides a comprehensive ecosystem of tools that streamline the creation of robust, secure, and well-documented APIs. In this article, we will explore the best practices for designing APIs that are easy to maintain and scale.
API Project Structure
The first step toward a well-organized API is establishing a clear structure. Laravel allows us to separate API routes in the routes/api.php file, which automatically applies the /api prefix and the appropriate middleware.
// routes/api.php
use App\Http\Controllers\Api\V1\PostController;
use App\Http\Controllers\Api\V1\UserController;
Route::prefix('v1')->group(function () {
Route::apiResource('posts', PostController::class);
Route::apiResource('users', UserController::class);
Route::get('users/{user}/posts', [UserController::class, 'posts']);
});
The apiResource method automatically generates index, store, show, update, and destroy routes while excluding the create and edit routes that are unnecessary for an API.
API Controllers
API controllers should be concise and delegate business logic to dedicated services or actions. Use the --api flag when generating controllers to get only the relevant methods.
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Http\Resources\PostResource;
use App\Http\Resources\PostCollection;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
class PostController extends Controller
{
public function index(): PostCollection
{
$posts = Post::query()
->with(['author', 'category'])
->published()
->latest()
->paginate(15);
return new PostCollection($posts);
}
public function store(StorePostRequest $request): JsonResponse
{
$post = Post::create($request->validated());
return (new PostResource($post))
->response()
->setStatusCode(201);
}
public function show(Post $post): PostResource
{
$post->load(['author', 'comments.user', 'tags']);
return new PostResource($post);
}
public function update(UpdatePostRequest $request, Post $post): PostResource
{
$post->update($request->validated());
return new PostResource($post);
}
public function destroy(Post $post): JsonResponse
{
$post->delete();
return response()->json(null, 204);
}
}
API Resources: Shaping Your Responses
API Resources serve as the transformation layer between your Eloquent models and the JSON responses your clients receive. They give you fine-grained control over what data gets exposed and how it is formatted.
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'slug' => $this->slug,
'title' => $this->title,
'excerpt' => $this->excerpt,
'content' => $this->when(
$request->routeIs('posts.show'),
$this->content
),
'featured_image' => $this->featured_image,
'reading_time' => $this->reading_time,
'published_at' => $this->published_at?->toIso8601String(),
'author' => new UserResource($this->whenLoaded('author')),
'category' => new CategoryResource($this->whenLoaded('category')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
'comments_count' => $this->whenCounted('comments'),
];
}
}
For collections, you can attach additional metadata that gives context to the response.
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class PostCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total_posts' => Post::count(),
'api_version' => 'v1',
],
];
}
}
Form Requests: Bulletproof Validation
Form Requests encapsulate validation and authorization logic outside the controller, keeping your code clean and reusable.
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
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'],
'slug' => ['required', 'string', 'unique:posts,slug'],
'content' => ['required', 'string', 'min:100'],
'category_id' => ['required', 'exists:categories,id'],
'tags' => ['sometimes', 'array'],
'tags.*' => ['exists:tags,id'],
'featured_image' => ['nullable', 'url', 'max:500'],
'status' => ['required', Rule::in(['draft', 'published', 'archived'])],
];
}
public function messages(): array
{
return [
'title.required' => 'The post title is required.',
'content.min' => 'The content must be at least 100 characters.',
'category_id.exists' => 'The selected category does not exist.',
];
}
protected function prepareForValidation(): void
{
$this->merge([
'slug' => Str::slug($this->title),
'author_id' => $this->user()->id,
]);
}
}
API Versioning
URL prefix versioning is the most straightforward and widely adopted approach. Organize your controllers and resources by version.
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('posts', V1\PostController::class);
});
Route::prefix('v2')->group(function () {
Route::apiResource('posts', V2\PostController::class);
});
Rate Limiting
Laravel lets you define flexible rate limits to protect your API from abuse and ensure fair usage across all consumers.
// bootstrap/app.php or AppServiceProvider
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(120)->by($request->user()->id)
: Limit::perMinute(30)->by($request->ip());
});
// Dedicated limit for sensitive endpoints
RateLimiter::for('uploads', function (Request $request) {
return Limit::perMinute(5)->by($request->user()->id);
});
Apply these limits to the appropriate routes:
Route::middleware('throttle:uploads')->group(function () {
Route::post('posts/{post}/images', [PostImageController::class, 'store']);
});
Consistent Error Handling
Uniform error handling is essential for a good developer experience when consuming your API.
// bootstrap/app.php
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'error' => 'Resource not found.',
'code' => 'RESOURCE_NOT_FOUND',
], 404);
}
});
})
Filtering and Pagination
Implementing flexible filtering allows clients to fetch exactly the data they need without over-fetching.
// app/Models/Post.php
public function scopeFilter(Builder $query, array $filters): Builder
{
return $query
->when($filters['search'] ?? null, fn ($q, $search) =>
$q->where('title', 'like', "%{$search}%")
)
->when($filters['category'] ?? null, fn ($q, $category) =>
$q->where('category_id', $category)
)
->when($filters['status'] ?? null, fn ($q, $status) =>
$q->where('status', $status)
)
->when($filters['from'] ?? null, fn ($q, $from) =>
$q->where('published_at', '>=', $from)
);
}
// In the controller
public function index(Request $request): PostCollection
{
$posts = Post::query()
->filter($request->only(['search', 'category', 'status', 'from']))
->with(['author', 'category'])
->latest()
->paginate($request->input('per_page', 15));
return new PostCollection($posts);
}
API Testing
Automated tests ensure your API works correctly as the codebase evolves over time.
<?php
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use Tests\TestCase;
class PostApiTest extends TestCase
{
public function test_can_list_posts(): void
{
Post::factory()->count(5)->create();
$response = $this->getJson('/api/v1/posts');
$response->assertOk()
->assertJsonCount(5, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'slug', 'excerpt', 'author'],
],
'links',
'meta',
]);
}
public function test_can_create_post(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/v1/posts', [
'title' => 'My new post',
'content' => str_repeat('Sample content. ', 20),
'category_id' => Category::factory()->create()->id,
'status' => 'published',
]);
$response->assertCreated()
->assertJsonPath('data.title', 'My new post');
}
public function test_validates_required_fields(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/v1/posts', []);
$response->assertUnprocessable()
->assertJsonValidationErrors(['title', 'content']);
}
}
Conclusion
Building professional RESTful APIs with Laravel demands attention to detail at every layer: from route structure and controllers to data transformation with Resources, validation with Form Requests, and protection with rate limiting. By following these practices, your APIs will be consistent, secure, and easy for any frontend or mobile client to consume. The Laravel ecosystem greatly simplifies this process, letting you focus on business logic while the framework handles the repetitive patterns.
Comments (0)
Leave a comment
Be the first to comment