Laravel 13 REST API Best Practices (2026) — Complete Guide
I inherited a Laravel API once where the previous developer mixed response formats across endpoints, skipped versioning entirely, and hardcoded auth logic into controllers.
We spent three weeks just standardizing responses before we could add a single new feature. Three weeks of real money and real frustration that was completely avoidable.
This guide covers the exact Laravel 13 REST API patterns I now use in production across every project — from structure and authentication to performance and testing.
1. Version Your API from Day One
Skipping versioning is one of the easiest ways to trap yourself later. Even if your first release is tiny, your API is still a contract. The moment a mobile app, a frontend, or another team depends on it, breaking changes get expensive.
Starting with /api/v1/ costs nothing upfront. Changing /api/products to /api/v2/products later breaks every client that already depends on it.
php
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('posts', PostController::class);
Route::apiResource('users', UserController::class);
});
File structure to match:
app/Http/Controllers/
Api/
V1/
PostController.php
UserController.php
V2/
PostController.php
When to create V2: Only when you need breaking changes — changed response shape, removed fields, changed auth requirements. Adding new fields to an existing response is non-breaking and does not require a new version.
2. Always Use API Resources — Never Return Raw Models
php
// Wrong — exposes internal fields, passwords, timestamps return response()->json($user); // Right — full control over what the client receives return new UserResource($user);
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,
'role' => $this->role->name,
'created_at' => $this->created_at->toISOString(),
// Conditionally include sensitive data
'api_key' => $this->when(
$request->user()->isAdmin(),
$this->api_key
),
// Include relationships only when loaded
'posts' => PostResource::collection(
$this->whenLoaded('posts')
),
];
}
}
For collections with pagination:
php
return UserResource::collection(User::paginate(20)); // Returns data array + pagination links automatically
3. Sanctum vs Passport — Choose the Right Tool
Use Sanctum when:
- Building auth for your own SPA or mobile app
- Issuing simple API tokens to users
- You do not need third-party OAuth2 flows
- This covers 90% of projects
Use Passport when:
- External developers need to integrate against your API
- You need multiple OAuth2 grant types
- Building a public API platform
php
// config/auth.php
'guards' => [
'api' => [
'driver' => 'sanctum',
'provider' => 'users',
],
],
// Protect routes
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('posts', PostController::class);
});
// Issue token on login
$token = $user->createToken('mobile-app', ['posts:read', 'posts:write'])
->plainTextToken;
// Fine-grained permissions
Route::middleware(['auth:sanctum', 'abilities:posts:write'])->group(function () {
Route::post('/posts', [PostController::class, 'store']);
});
4. Form Requests for All Validation
php
// Wrong — validation in controller
public function store(Request $request): JsonResponse
{
$request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
'status' => 'required|in:draft,published',
]);
// ...
}
// Right — dedicated Form Request
public function store(StorePostRequest $request): JsonResponse
{
$post = Post::create($request->validated());
return new PostResource($post);
}
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'],
'body' => ['required', 'string'],
'status' => ['required', Rule::in(['draft', 'published'])],
'tags' => ['array'],
'tags.*' => ['exists:tags,id'],
];
}
}
5. UUID v7 for Public-Facing IDs
Auto-incrementing IDs expose your record count and creation order. UUIDv7 is time-ordered, index-friendly, and does not leak internal data.
php
// Migration
Schema::create('posts', function (Blueprint $table) {
$table->uuid('id')->primary()->default(DB::raw('uuidv7()'));
$table->string('title');
$table->timestamps();
});
// Model
use Illuminate\Database\Eloquent\Concerns\HasUuids;
class Post extends Model
{
use HasUuids;
}
6. Consistent Error Responses
php
// Standard error shape — use everywhere
{
"message": "The given data was invalid.",
"errors": {
"title": ["The title field is required."]
},
"status": 422
}
// Standard success shape
{
"data": { ... },
"message": "Post created successfully."
}
php
// app/Exceptions/Handler.php
public function register(): void
{
$this->renderable(function (ModelNotFoundException $e) {
return response()->json([
'message' => 'Resource not found.',
'status' => 404,
], 404);
});
$this->renderable(function (AuthorizationException $e) {
return response()->json([
'message' => 'You are not authorized to perform this action.',
'status' => 403,
], 403);
});
}
Important: Set APP_DEBUG=false in production. Stack traces expose your file paths, database structure, and dependency versions.
7. Rate Limiting on Everything Public
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('api-login', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
}
php
// routes/api.php
Route::middleware(['throttle:api-login'])->group(function () {
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
});
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::apiResource('posts', PostController::class);
});
8. Correct HTTP Status Codes
CodeWhen to useExample200Successful GET, PUT, PATCHReturn updated resource201Successful POSTNew post created204Successful DELETEPost deleted, no body401UnauthenticatedMissing or invalid token403UnauthorizedNot owner of resource404Resource not foundPost ID does not exist422Validation failedRequired field missing429Too many requestsRate limit exceeded500Server errorUnexpected exception
php
public function store(StorePostRequest $request): JsonResponse
{
$post = Post::create($request->validated());
return (new PostResource($post))
->response()
->setStatusCode(201); // 201 Created — not 200
}
public function destroy(Post $post): JsonResponse
{
$this->authorize('delete', $post);
$post->delete();
return response()->json(null, 204); // 204 No Content
}
9. Eliminate N+1 Queries
php
// Wrong — N+1 problem
$posts = Post::paginate(20);
// Each post fires separate query for author and tags
// = 1 + 20 + 20 = 41 queries
// Right — eager loading
$posts = Post::with(['author', 'tags', 'category'])
->withCount('comments')
->paginate(20);
// = 4 queries total. Always.
Add this to AppServiceProvider to catch N+1 in development:
php
Model::preventLazyLoading(!app()->isProduction());
10. Test the Contract — Not the Implementation
php
// tests/Feature/Api/V1/PostTest.php
test('guests cannot create posts', function () {
$this->postJson('/api/v1/posts', [
'title' => 'Hello world',
'body' => 'Post content',
])->assertUnauthorized(); // 401
});
test('authenticated users can create posts', function () {
$user = User::factory()->create();
$this->actingAs($user, 'sanctum')
->postJson('/api/v1/posts', [
'title' => 'Hello world',
'body' => 'Post content.',
'status' => 'published',
])
->assertCreated() // 201
->assertJsonStructure([
'data' => ['id', 'title', 'body', 'status', 'created_at'],
]);
});
test('users cannot delete posts they do not own', function () {
$owner = User::factory()->create();
$other = User::factory()->create();
$post = Post::factory()->for($owner)->create();
$this->actingAs($other, 'sanctum')
->deleteJson("/api/v1/posts/{$post->id}")
->assertForbidden(); // 403
});
Assert against literal URLs — not named routes. Your clients depend on the actual URL.
11. Laravel 13 — PHP Attributes and Typed Config
php
// Before — Laravel 12
class Post extends Model
{
protected $fillable = ['title', 'body', 'status', 'user_id'];
protected $hidden = ['internal_notes'];
protected $casts = ['published_at' => 'datetime'];
}
// After — Laravel 13 PHP Attributes
#[Fillable(['title', 'body', 'status', 'user_id'])]
#[Hidden(['internal_notes'])]
#[Cast(['published_at' => 'datetime'])]
class Post extends Model {}
php
// Typed config — catch env mistakes at boot
$limit = config()->integer('api.rate_limit');
$debug = config()->boolean('app.debug');
$prefix = config()->string('api.prefix');
// Wrong type throws ConfigTypeMismatchException at boot
12. Auto Documentation
bash
composer require dedoc/scramble # Generates OpenAPI docs automatically at /docs/api
Complete Checklist
Structure:
-
/api/v1/versioning from day one - All responses through API Resources
- All validation through Form Requests
- Business logic in Service classes
Auth:
- Sanctum for SPA/mobile
- Passport only for third-party OAuth2
- Token abilities for permissions
Security:
- Rate limiting on all public endpoints
-
APP_DEBUG=falsein production - UUID v7 for public IDs
- Policies for authorization
Performance:
- Eager loading on all list endpoints
- Pagination on all collections
- Indexes on filter columns
Testing:
- Feature test for every endpoint
- Assert against literal URLs
- Test unauthorized and forbidden cases
Laravel 13:
- PHP Attributes on models
- Typed config —
config()->string(),config()->integer() - JSON:API resources for third-party consumers
Wrapping Up
Good Laravel APIs are not just about returning JSON from a controller. They are about giving clients a contract they can trust — stable URLs, predictable payloads, useful status codes, clear authentication, and changes that do not break clients six months later.
The patterns above are production-tested. Apply them from the start and your API will survive growth far better than one held together by "we'll clean it up later."
Tushar Modi — Full Stack Developer, Jaipur tusharmodi.in