Laravel API in Practice: Sanctum Auth, Versioned Routes, Rate Limiting, and Clean JSON Responses
If you’re building a modern web app, you’ll probably ship a JSON API—whether it’s consumed by a SPA, a mobile app, or another service. Laravel makes this straightforward, but “it works” isn’t the same as “it’s maintainable.” In this hands-on guide, you’ll build a small, production-friendly API slice with:
v1versioned routes- token authentication with
laravel/sanctum - request validation via
FormRequest - consistent JSON responses with
Resources - rate limiting and basic authorization
We’ll implement a simple Task API (CRUD + pagination) that’s ready to evolve.
1) Setup: Sanctum + API-first defaults
Install Sanctum and publish its config/migrations:
composer require laravel/sanctum php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" php artisan migrate
Ensure your User model uses Sanctum tokens:
// app/Models/User.php namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { use HasApiTokens; // ... }
For token-based API auth, you’ll typically send an Authorization: Bearer ... header. In Laravel 10/11+, Sanctum is already quite ergonomic for APIs.
2) Create a Task model + migration
Generate model, migration, and controller:
php artisan make:model Task -m php artisan make:controller Api/V1/TaskController --api
Edit the migration:
// database/migrations/xxxx_xx_xx_create_tasks_table.php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('tasks', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->string('title'); $table->text('notes')->nullable(); $table->boolean('done')->default(false); $table->timestamps(); $table->index(['user_id', 'done']); }); } public function down(): void { Schema::dropIfExists('tasks'); } };
Run migrations:
php artisan migrate
Set mass-assignable fields:
// app/Models/Task.php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Task extends Model { protected $fillable = ['title', 'notes', 'done']; public function user(): BelongsTo { return $this->belongsTo(User::class); } }
3) Versioned routes + auth endpoints
Create versioned routes in routes/api.php. We’ll add register/login endpoints, then protect task routes with Sanctum.
// routes/api.php use App\Http\Controllers\Api\V1\AuthController; use App\Http\Controllers\Api\V1\TaskController; use Illuminate\Support\Facades\Route; Route::prefix('v1')->group(function () { Route::post('/auth/register', [AuthController::class, 'register']); Route::post('/auth/login', [AuthController::class, 'login']); Route::middleware('auth:sanctum')->group(function () { Route::post('/auth/logout', [AuthController::class, 'logout']); Route::apiResource('tasks', TaskController::class); }); });
Create the auth controller:
php artisan make:controller Api/V1/AuthController
// app/Http/Controllers/Api/V1/AuthController.php namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\ValidationException; class AuthController extends Controller { public function register(Request $request) { $data = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'password' => ['required', 'string', 'min:8'], ]); $user = User::create([ 'name' => $data['name'], 'email' => $data['email'], 'password' => Hash::make($data['password']), ]); $token = $user->createToken('api')->plainTextToken; return response()->json([ 'data' => [ 'user' => ['id' => $user->id, 'name' => $user->name, 'email' => $user->email], 'token' => $token, ] ], 201); } public function login(Request $request) { $data = $request->validate([ 'email' => ['required', 'email'], 'password' => ['required', 'string'], ]); $user = User::where('email', $data['email'])->first(); if (! $user || ! Hash::check($data['password'], $user->password)) { throw ValidationException::withMessages([ 'email' => ['The provided credentials are incorrect.'], ]); } // Optional: revoke old tokens for "one device" behavior // $user->tokens()->delete(); $token = $user->createToken('api')->plainTextToken; return response()->json([ 'data' => [ 'user' => ['id' => $user->id, 'name' => $user->name, 'email' => $user->email], 'token' => $token, ] ]); } public function logout(Request $request) { $request->user()->currentAccessToken()->delete(); return response()->json(['data' => ['message' => 'Logged out']]); } }
4) Validation with Form Requests
Keeping validation out of controllers makes your API easier to read and reuse. Create requests:
php artisan make:request StoreTaskRequest php artisan make:request UpdateTaskRequest
// app/Http/Requests/StoreTaskRequest.php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class StoreTaskRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'title' => ['required', 'string', 'max:200'], 'notes' => ['nullable', 'string', 'max:2000'], 'done' => ['sometimes', 'boolean'], ]; } }
// app/Http/Requests/UpdateTaskRequest.php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class UpdateTaskRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'title' => ['sometimes', 'string', 'max:200'], 'notes' => ['sometimes', 'nullable', 'string', 'max:2000'], 'done' => ['sometimes', 'boolean'], ]; } }
5) Consistent JSON with Resources (and pagination)
Resources give you a stable response shape—even if your database changes. Create a resource:
php artisan make:resource TaskResource
// app/Http/Resources/TaskResource.php namespace App\Http\Resources; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; class TaskResource extends JsonResource { public function toArray(Request $request): array { return [ 'id' => $this->id, 'title' => $this->title, 'notes' => $this->notes, 'done' => (bool) $this->done, 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), ]; } }
6) TaskController: scoped queries, safe writes
Key rule: never allow users to access other users’ tasks. The simplest approach is to always scope through $request->user().
// app/Http/Controllers/Api/V1/TaskController.php namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Http\Requests\StoreTaskRequest; use App\Http\Requests\UpdateTaskRequest; use App\Http\Resources\TaskResource; use App\Models\Task; use Illuminate\Http\Request; class TaskController extends Controller { public function index(Request $request) { $query = Task::query()->where('user_id', $request->user()->id); // Simple filters for real-world UX if ($request->has('done')) { $query->where('done', filter_var($request->query('done'), FILTER_VALIDATE_BOOLEAN)); } $tasks = $query ->orderByDesc('id') ->paginate((int) $request->query('per_page', 10)); return TaskResource::collection($tasks); } public function store(StoreTaskRequest $request) { $task = $request->user()->tasks()->create($request->validated()); return (new TaskResource($task)) ->response() ->setStatusCode(201); } public function show(Request $request, Task $task) { $this->abortIfNotOwner($request, $task); return new TaskResource($task); } public function update(UpdateTaskRequest $request, Task $task) { $this->abortIfNotOwner($request, $task); $task->update($request->validated()); return new TaskResource($task); } public function destroy(Request $request, Task $task) { $this->abortIfNotOwner($request, $task); $task->delete(); return response()->json(['data' => ['message' => 'Deleted']]); } private function abortIfNotOwner(Request $request, Task $task): void { abort_if($task->user_id !== $request->user()->id, 403, 'Forbidden'); } }
Add the inverse relationship on User:
// app/Models/User.php use Illuminate\Database\Eloquent\Relations\HasMany; public function tasks(): HasMany { return $this->hasMany(\App\Models\Task::class); }
7) Rate limiting: stop noisy clients early
Laravel lets you throttle routes. Add a limiter in App\Providers\RouteServiceProvider (or AppServiceProvider, depending on your version), then apply middleware.
// app/Providers/RouteServiceProvider.php (boot method) use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; RateLimiter::for('api-tasks', function (Request $request) { // 60 requests/min per user (or fallback to IP) $key = optional($request->user())->id ?: $request->ip(); return Limit::perMinute(60)->by($key); });
Apply it to task routes:
// routes/api.php Route::middleware(['auth:sanctum', 'throttle:api-tasks'])->group(function () { Route::apiResource('tasks', TaskController::class); });
8) Try it: curl examples you can copy/paste
Register, capture the token, then call the API:
# Register curl -s -X POST http://localhost:8000/api/v1/auth/register \ -H "Content-Type: application/json" \ -d '{"name":"Ava","email":"[email protected]","password":"password123"}'
# Create a task (replace TOKEN) curl -s -X POST http://localhost:8000/api/v1/tasks \ -H "Authorization: Bearer TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":"Ship Laravel API","notes":"Add auth + resources","done":false}'
# List tasks with pagination + filter curl -s "http://localhost:8000/api/v1/tasks?per_page=5&done=false" \ -H "Authorization: Bearer TOKEN"
9) Practical tips juniors miss
-
Always scope by user. Even if you add policies later, “query through the authenticated user” prevents a whole class of mistakes.
-
Use Resources early. Your frontend will thank you when you rename DB columns without breaking responses.
-
Prefer
FormRequestfor validation. It keeps controllers short and makes rules easy to find. -
Throttle important endpoints. Even internal apps get hammered by retries, loops, and misconfigured clients.
-
Version from day one. You don’t need multiple versions now—just the namespace and URL prefix so you can evolve safely.
What you have now
You’ve built a clean, secure baseline for a Laravel JSON API: token auth, versioned routing, consistent responses, guarded access, and rate limiting. From here, you can add:
Policiesinstead of the inline owner check- sorting (e.g.,
?sort=created_at) with a whitelist - search (e.g.,
?q=...) with indexes as your data grows - API error format standardization (e.g., a small response helper)
This is the kind of structure that stays pleasant to work in when your “simple API” becomes a real product.
Leave a Reply