Every time I start a new Laravel API project, I used to spend half a day on the same setup:
- Sanctum authentication
- Spatie roles & permissions
- Consistent JSON error responses
- API versioning
- Rate limiting
After doing this on project after project, I finally settled on a structure that works every time. Here's exactly how I do it.
1. Consistent JSON Response Format
The first thing I set up is a trait that forces every endpoint to return the same JSON structure. Nothing is more annoying than an API that returns different formats for success vs errors.
<?php
namespace App\Traits;
trait ApiResponseTrait
{
public function successResponse($data, $message = 'Success', $statusCode = 200)
{
return response()->json([
'success' => true,
'message' => $message,
'data' => $data,
'meta' => [
'version' => 'v1',
'timestamp' => now()->toISOString(),
],
], $statusCode);
}
public function errorResponse($message = 'Error', $statusCode = 400, $errors = [])
{
return response()->json([
'success' => false,
'message' => $message,
'errors' => $errors,
'meta' => [
'version' => 'v1',
'timestamp' => now()->toISOString(),
],
], $statusCode);
}
public function notFoundResponse($message = 'Resource not found')
{
return $this->errorResponse($message, 404);
}
public function unauthorizedResponse($message = 'Unauthorized')
{
return $this->errorResponse($message, 401);
}
public function validationErrorResponse($errors, $message = 'Validation failed')
{
return $this->errorResponse($message, 422, $errors);
}
}
Every controller uses this trait. No more inconsistent responses.
2. Global Exception Handler
Instead of handling exceptions in every controller, I catch everything in one place.
<?php
namespace App\Exceptions;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
class Handler extends ExceptionHandler
{
public function render($request, Throwable $exception)
{
if ($request->expectsJson()) {
if ($exception instanceof ModelNotFoundException) {
return response()->json([
'success' => false,
'message' => 'Resource not found',
], 404);
}
if ($exception instanceof AuthenticationException) {
return response()->json([
'success' => false,
'message' => 'Unauthenticated',
], 401);
}
if ($exception instanceof ValidationException) {
return response()->json([
'success' => false,
'message' => 'Validation failed',
'errors' => $exception->errors(),
], 422);
}
if ($exception instanceof NotFoundHttpException) {
return response()->json([
'success' => false,
'message' => 'Route not found',
], 404);
}
}
return parent::render($request, $exception);
}
}
Now every exception returns clean JSON automatically — no more ugly HTML error pages in your API.
3. API Versioning Structure
I always separate routes and controllers by version. This way future changes don't break existing clients.
app/Http/Controllers/Api/
├── V1/
│ ├── AuthController.php
│ └── UserController.php
└── V2/ ← ready for the future
In routes/api.php:
use App\Http\Controllers\Api\V1\AuthController;
use App\Http\Controllers\Api\V1\UserController;
Route::prefix('v1')->group(function () {
// Public auth routes
Route::prefix('auth')->middleware('throttle:auth')->group(function () {
Route::post('register', [AuthController::class, 'register']);
Route::post('login', [AuthController::class, 'login']);
});
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
Route::post('auth/logout', [AuthController::class, 'logout']);
Route::get('auth/me', [AuthController::class, 'me']);
Route::get('users', [UserController::class, 'index']);
});
});
Clean, versioned, easy to extend.
4. Sanctum Authentication
I use Laravel Sanctum for all API authentication. Here's a clean AuthController:
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\LoginRequest;
use App\Http\Requests\Api\RegisterRequest;
use App\Models\User;
use App\Traits\ApiResponseTrait;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
class AuthController extends Controller
{
use ApiResponseTrait;
public function register(RegisterRequest $request)
{
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
$user->assignRole('user');
$token = $user->createToken('api-token')->plainTextToken;
return $this->successResponse([
'user' => $user,
'token' => $token,
], 'Registration successful', 201);
}
public function login(LoginRequest $request)
{
if (!Auth::attempt($request->only('email', 'password'))) {
return $this->unauthorizedResponse('Invalid credentials');
}
$user = Auth::user();
$token = $user->createToken('api-token')->plainTextToken;
return $this->successResponse([
'user' => $user,
'token' => $token,
], 'Login successful');
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return $this->successResponse(null, 'Logged out successfully');
}
public function me(Request $request)
{
return $this->successResponse(
$request->user()->load('roles', 'permissions')
);
}
}
5. Spatie Roles & Permissions
I always use spatie/laravel-permission for roles. Here's my seeder:
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class RolePermissionSeeder extends Seeder
{
public function run(): void
{
// Create permissions
$permissions = [
'view users',
'edit users',
'delete users',
'manage roles',
];
foreach ($permissions as $permission) {
Permission::firstOrCreate(['name' => $permission]);
}
// Create roles and assign permissions
$admin = Role::firstOrCreate(['name' => 'admin']);
$admin->syncPermissions($permissions);
$editor = Role::firstOrCreate(['name' => 'editor']);
$editor->syncPermissions(['view users', 'edit users']);
Role::firstOrCreate(['name' => 'user']);
}
}
Run php artisan db:seed --class=RolePermissionSeeder and you have a full role system ready.
6. Rate Limiting
In app/Providers/RouteServiceProvider.php:
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('auth', function (Request $request) {
return Limit::perMinute(10)->by($request->ip());
});
Stricter limits on auth routes to prevent brute force. General API routes get 60 requests per minute.
The Result
Every response from my API looks like this:
{
"success": true,
"data": {
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"roles": ["admin"],
"permissions": ["view users", "edit users"]
},
"meta": {
"version": "v1",
"timestamp": "2026-03-05T10:00:00Z"
}
}
Clean, consistent, predictable. Every endpoint. Every time.
Wrapping Up
This structure took me several projects to settle on. The key principles are:
- One response format — use a trait, enforce it everywhere
- One place for exceptions — global handler, not per-controller
- Version from day one — you'll thank yourself later
- Roles from day one — adding them later is painful
If you don't want to set all this up manually, I packaged everything into a ready-to-use starter kit — migrations, seeders, controllers, Postman collection, and README all included.
What does your Laravel API structure look like? I'd love to hear how others approach this in the comments.
Top comments (0)