Bekzod Erkinov
Listen to Article
Loading...Table of contents · 10 sections
Building a REST API with Laravel 12 and Sanctum, Part 1: Versioning, JSON Errors and Token Abilities
A fresh Laravel 12 app with Sanctum fails in three places the Sanctum docs don't mention: a guest request without Accept: application/json returns a 500 (Route [login] not defined.) instead of a 401, a token missing an ability produces a generic http_error instead of your 403 handler, and a throttled login loses its Retry-After header. This post builds a versioned token API with /api/v1 routes, one JSON error shape, tokens with abilities and an expiry, and a test suite, and shows the small edit that triggers each failure.
Versions, from composer.lock: PHP 8.4.24, Laravel 12.69.3, Sanctum 4.3.3, PHPUnit 11.5.57. I ran everything on the host with SQLite. Docker/Sail was not used, and the suite is PHPUnit, with one extra Pest test run separately (see the testing section).
This is part 1 of 3. Part 2 covers resource controllers, validation and API transformers. Part 3 covers rate limiting, Redis caching, queues and webhooks. For what comes after that, see scaling a Laravel application to 100,000 users.
Setup: Laravel with Sanctum installed
Nothing here needs MySQL, Redis or a queue worker. PHPUnit is what the Laravel 12 skeleton ships, so the suite uses it.
composer create-project "laravel/laravel:^12.0" api
cd api && php artisan install:api --no-interaction
install:api is the documented Sanctum path (Sanctum docs). It publishes and runs the personal_access_tokens migration and creates routes/api.php. Its source also prints "Please add the [Laravel\Sanctum\HasApiTokens] trait to your User model." (ApiInstallCommand.php, v12.69.3, line 84), which is easy to skim past. Add it now:
// app/Models/User.php
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
Once the User edit and the route, controller, bootstrap/app.php and test files below are in place, this is the block I used for the curl session:
php artisan migrate --force
php artisan tinker --execute="App\Models\User::factory()->create(['email' => '[email protected]', 'password' => 'secret-pass']);"
php artisan test
php artisan serve &
Laravel REST API versioning with route files
Laravel already prefixes everything in routes/api.php with /api. I add v1 there and keep the real routes in their own file so a v2 is a sibling file, not a refactor. URL versioning shows up in logs, curl lines and browser bars, and you can't forget to send it.
<?php
// routes/api.php
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->name('v1.')->group(base_path('routes/api_v1.php'));
<?php
// routes/api_v1.php
use App\Http\Controllers\Api\V1\AuthController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/ping', fn () => ['status' => 'ok', 'version' => 'v1']);
// throttle:6,1 (6 attempts per minute) is a placeholder, tune it for your app
Route::post('/auth/token', [AuthController::class, 'store'])->middleware('throttle:6,1');
Route::middleware('auth:sanctum')->group(function () {
Route::get('/me', fn (Request $request) => $request->user());
Route::delete('/auth/token', [AuthController::class, 'destroy']);
Route::get('/reports', fn () => ['data' => [['id' => 1, 'name' => 'Q3']]])
->middleware('ability:reports:read,admin');
Route::post('/reports', fn () => response()->json(['data' => ['id' => 2]], 201))
->middleware('abilities:reports:write');
Route::get('/reports/export', fn () => ['data' => 'csv'])
->middleware('abilities:reports:read,reports:write');
});
php artisan route:list --path=api prints (the routes/api_v1.php:N numbers match the route file above):
POST api/v1/auth/token ..................................................... v1. › Api\V1\AuthController@store
DELETE api/v1/auth/token ................................................... v1. › Api\V1\AuthController@destroy
GET|HEAD api/v1/me .................................................................... v1. › routes/api_v1.php:14
GET|HEAD api/v1/ping ................................................................... v1. › routes/api_v1.php:8
GET|HEAD api/v1/reports ............................................................... v1. › routes/api_v1.php:17
POST api/v1/reports ............................................................... v1. › routes/api_v1.php:19
GET|HEAD api/v1/reports/export ........................................................ v1. › routes/api_v1.php:21
Showing [7] routes
To retire v1 later, announce the date with RFC 8594's Sunset header. A small middleware, app/Http/Middleware/SunsetV1.php:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SunsetV1
{
public function handle(Request $request, Closure $next)
{
return $next($request)
->header('Deprecation', 'true')
->header('Sunset', 'Wed, 31 Mar 2027 23:59:59 GMT')
->header('Link', '</api/v2>; rel="successor-version"');
}
}
Attach it with Route::prefix('v1')->name('v1.')->middleware(SunsetV1::class)->group(...) in routes/api.php. Passing a bare closure to ->middleware() there throws, so use a class. curl -i /api/v1/ping then returns:
HTTP/1.1 200 OK
Deprecation: true
Sunset: Wed, 31 Mar 2027 23:59:59 GMT
Link: </api/v2>; rel="successor-version"
{"status":"ok","version":"v1"}
The date and /api/v2 are placeholders. The middleware is not attached in the rest of this post and no test in the suite asserts its headers. I checked the output above by hand, and the full suite still passes with it attached.
Sanctum token abilities and expiry
Sanctum has two modes. For your own first-party SPA on the same top-level domain, use its cookie-based SPA auth. Tokens are for mobile apps, CLIs, server-to-server calls and third parties, and this post covers tokens only (Sanctum docs).
Two defaults to know. Tokens never expire unless you set expiration in config/sanctum.php or pass an expiry to createToken. And the signature of createToken in v4.3.3 is:
public function createToken(string $name, array $abilities = ['*'], ?DateTimeInterface $expiresAt = null)
So a token created without an abilities argument can do everything. The login endpoint below passes both explicitly and restricts what a client can ask for:
<?php
// 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\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'device_name' => ['required', 'string', 'max:100'],
'abilities' => ['sometimes', 'array'],
'abilities.*' => ['string', Rule::in(['reports:read', 'reports:write'])],
]);
$user = User::where('email', $data['email'])->first();
if (! $user || ! Hash::check($data['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
$token = $user->createToken(
$data['device_name'],
$data['abilities'] ?? ['reports:read'], // least privilege by default
now()->addDays(30),
);
return response()->json([
'token' => $token->plainTextToken,
'token_type' => 'Bearer',
'abilities' => $token->accessToken->abilities,
'expires_at' => $token->accessToken->expires_at,
], 201);
}
public function destroy(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json(null, 204);
}
}
Thirty days is my choice, not a Sanctum default. The docs (Token Expiration) point to the sanctum:prune-expired command for cleaning up expired rows. It exists in 4.3.3: php artisan list shows sanctum:prune-expired Prune tokens expired for more than specified number of hours, it takes --hours=24, and running it prints Tokens expired for more than [24 hours] pruned successfully. Schedule it as the docs show: Schedule::command('sanctum:prune-expired --hours=24')->daily();.
Bad credentials return 422 here (a ValidationException on email), the pattern in the Sanctum docs. Return 401 instead if your clients treat every 401 as "clear the stored token and log in again". An expired token also gets 401 unauthenticated (see the curl session), so such a client recovers from expiry for free.
Consistent JSON error responses in Laravel
Out of the box, an API client gets different shapes depending on what went wrong: a validation envelope, an HTML page, a redirect. I picked one envelope, {"error": {"code", "message", ...}}. RFC 9457 Problem Details is the standard alternative; the technique is identical. (Part 2 goes deeper on validation and API resources.)
Here is bootstrap/app.php:
<?php
// bootstrap/app.php
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// Sanctum doesn't register these aliases for you
$middleware->alias([
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class, // ALL listed
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class, // ANY listed
]);
// Gotcha 1: without this, the default redirect callback calls route('login') for guests hitting /api/*
$middleware->redirectGuestsTo(fn (Request $request) => $request->is('api/*') ? null : route('login'));
})
->withExceptions(function (Exceptions $exceptions): void {
// JSON under /api even when the client forgot Accept: application/json
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson()
);
$error = fn (string $code, string $message, int $status, array $extra = [], array $headers = []) =>
response()->json(['error' => ['code' => $code, 'message' => $message] + $extra], $status, $headers);
$exceptions->render(fn (AuthenticationException $e, Request $r) =>
$r->is('api/*') ? $error('unauthenticated', 'Missing or invalid token.', 401) : null);
$exceptions->render(fn (ValidationException $e, Request $r) =>
$r->is('api/*') ? $error('validation_failed', 'The given data was invalid.', 422, ['fields' => $e->errors()]) : null);
// Gotcha 2: match on HTTP status, not on Sanctum's exception class (also covers 404/405/429)
$codes = [403 => 'forbidden', 404 => 'not_found', 405 => 'method_not_allowed', 429 => 'too_many_requests'];
// Gotcha 3: pass $e->getHeaders() through, or the 429 loses its Retry-After header
$exceptions->render(fn (HttpExceptionInterface $e, Request $r) =>
$r->is('api/*')
? $error($codes[$e->getStatusCode()] ?? 'http_error', $e->getMessage() ?: 'HTTP error.', $e->getStatusCode(), [], $e->getHeaders())
: null);
})->create();
The Sanctum docs have you register the two aliases by hand in bootstrap/app.php (Token Ability Middleware); a fresh Laravel 12 app doesn't have them. abilities:a,b requires both, ability:a,b requires either. In the routes above, GET /reports accepts a token with reports:read or admin, and GET /reports/export needs both reports:read and reports:write. The tests below show both behaviours.
Gotcha 1: Route [login] not defined
My first version had only the render callbacks. To reproduce, delete the two redirectGuestsTo lines (the comment and the call) from bootstrap/app.php above. Then GET /api/v1/me with no token and no Accept header fails test_protected_route_without_token_is_401_json (Laravel 12.69.3, stack trace trimmed):
FAILED Tests\Feature\ApiV1Test > protected route without token is 401 json
Expected response status code [401] but received 500.
Failed asserting that 500 is identical to 401.
The following exception occurred during the last request:
Symfony\Component\Routing\Exception\RouteNotFoundException: Route [login] not defined.
#1 .../Foundation/Configuration/ApplicationBuilder.php(278): route('login')
The trace points at the cause. When authentication fails, the framework's Authenticate::unauthenticated() builds the exception with:
$request->expectsJson() ? null : $this->redirectTo($request),
With no Accept: application/json, expectsJson() is false, so redirectTo() runs. That method only calls the static $redirectToCallback. Unless you set one, withMiddleware() installs a default that does ->redirectGuestsTo(fn () => route('login')). A pure-API app has no login route, so the lookup throws before your AuthenticationException renderer runs. shouldRenderJsonWhen doesn't help because it only affects rendering. Overriding the callback to return null for api/* fixes it.
Gotcha 2: Sanctum's 403 isn't the exception you'd catch
My first version had a renderer for MissingAbilityException. It never fired. To reproduce, in bootstrap/app.php add use Laravel\Sanctum\Exceptions\MissingAbilityException;, delete 403 => 'forbidden', from $codes, and add this renderer before the $codes line:
$exceptions->render(fn (MissingAbilityException $e, Request $r) =>
$r->is('api/*') ? $error('forbidden', 'Missing ability.', 403) : null);
test_token_without_ability_gets_403 then fails (Laravel 12.69.3):
FAILED Tests\Feature\ApiV1Test > token without ability gets 403
Failed asserting that two strings are identical.
-'forbidden'
+'http_error'
MissingAbilityException extends AuthorizationException, and the framework's exception handler converts that into an HTTP exception before render callbacks run:
$e instanceof AuthorizationException && ! $e->hasStatus() => new AccessDeniedHttpException($e->getMessage(), $e),
So match on the HTTP status, as the $codes map does.
Gotcha 3: the 429 loses Retry-After
To reproduce, change $e->getStatusCode(), [], $e->getHeaders()) to $e->getStatusCode()) in the last renderer. The render callback then builds a fresh response and drops the headers the exception carried:
FAILED Tests\Feature\ApiV1Test > seventh login attempt in a minute is 429 in the error envelope
Header [Retry-After] not present on response.
$e->getHeaders() is passed through as the last argument above, which is what fixed it.
Testing a Sanctum API with PHPUnit
The test class, tests/Feature/ApiV1Test.php:
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class ApiV1Test extends TestCase
{
use RefreshDatabase;
public function test_ping_is_versioned(): void
{
$this->getJson('/api/v1/ping')->assertOk()->assertExactJson(['status' => 'ok', 'version' => 'v1']);
}
public function test_unknown_route_returns_json_404_without_accept_header(): void
{
$this->get('/api/v1/nope')->assertNotFound()->assertJsonPath('error.code', 'not_found');
}
public function test_protected_route_without_token_is_401_json(): void
{
$this->get('/api/v1/me')->assertUnauthorized()->assertJsonPath('error.code', 'unauthenticated');
}
public function test_login_issues_token_with_requested_abilities(): void
{
$user = User::factory()->create(['password' => 'secret-pass']);
$this->postJson('/api/v1/auth/token', [
'email' => $user->email,
'password' => 'secret-pass',
'device_name' => 'phone',
'abilities' => ['reports:write'],
])
->assertCreated()
->assertJsonPath('abilities', ['reports:write'])
->assertJsonStructure(['token', 'token_type', 'abilities', 'expires_at']);
}
public function test_bad_password_is_422_with_field_errors(): void
{
$user = User::factory()->create();
$this->postJson('/api/v1/auth/token', [
'email' => $user->email,
'password' => 'wrong',
'device_name' => 'phone',
])
->assertUnprocessable()
->assertJsonPath('error.code', 'validation_failed')
->assertJsonStructure(['error' => ['fields' => ['email']]]);
}
public function test_seventh_login_attempt_in_a_minute_is_429_in_the_error_envelope(): void
{
$user = User::factory()->create();
$attempt = fn () => $this->postJson('/api/v1/auth/token', [
'email' => $user->email,
'password' => 'wrong',
'device_name' => 'phone',
]);
foreach (range(1, 6) as $i) {
$attempt()->assertUnprocessable();
}
$attempt()
->assertStatus(429)
->assertJsonPath('error.code', 'too_many_requests')
->assertHeader('Retry-After');
}
public function test_token_without_ability_gets_403(): void
{
Sanctum::actingAs(User::factory()->create(), ['reports:read']);
$this->postJson('/api/v1/reports')->assertForbidden()->assertJsonPath('error.code', 'forbidden');
}
public function test_either_ability_passes_ability_middleware(): void
{
Sanctum::actingAs(User::factory()->create(), ['admin']);
$this->getJson('/api/v1/reports')->assertOk();
}
public function test_abilities_middleware_needs_all_listed_abilities(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['reports:read']);
$this->getJson('/api/v1/reports/export')->assertForbidden();
Sanctum::actingAs($user, ['reports:read', 'reports:write']);
$this->getJson('/api/v1/reports/export')->assertOk();
}
public function test_logout_deletes_the_token(): void
{
$plain = User::factory()->create()->createToken('t', ['reports:read'])->plainTextToken;
$this->assertDatabaseCount('personal_access_tokens', 1);
$this->withToken($plain)->deleteJson('/api/v1/auth/token')->assertNoContent();
$this->assertDatabaseCount('personal_access_tokens', 0);
}
public function test_expired_token_is_401_in_the_error_envelope(): void
{
$plain = User::factory()->create()->createToken('t', ['*'], now()->subMinute())->plainTextToken;
$this->withToken($plain)->getJson('/api/v1/me')
->assertUnauthorized()
->assertJsonPath('error.code', 'unauthenticated');
}
}
Pest is not part of the suite. I ran one test in a copy of the app with pestphp/pest v3.8.7 and pestphp/pest-plugin-laravel installed. It needs a tests/Pest.php that binds the Laravel TestCase and RefreshDatabase (without it the first run failed with A facade root has not been set.):
// tests/Pest.php
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');
// tests/Feature/AbilitiesPestTest.php
it('returns 403 in the error envelope when the token lacks the ability', function () {
Sanctum::actingAs(User::factory()->create(), ['reports:read']);
$this->postJson('/api/v1/reports')->assertForbidden()->assertJsonPath('error.code', 'forbidden');
});
Output: ✓ it returns 403 in the error envelope when the token lacks the ability, then Tests: 1 passed (2 assertions).
Sanctum::actingAs is the documented shortcut for faking a token with chosen abilities. The expired-token test goes through a real createToken plus a bearer header, so the expiry check runs against a real database row, and it asserts the envelope: an expired token is a 401 unauthenticated, the same as a missing one. In test_abilities_middleware_needs_all_listed_abilities, a token with only reports:read gets 403 on the abilities:reports:read,reports:write route and a token with both gets 200. The 429 test is the only place the throttle's response goes through the error envelope.
Output of php artisan test: 13 tests, which are the 11 in ApiV1Test plus the skeleton's two example tests.
PASS Tests\Unit\ExampleTest
✓ that true is true 0.02s
PASS Tests\Feature\ApiV1Test
✓ ping is versioned 1.19s
✓ unknown route returns json 404 without accept header 0.10s
✓ protected route without token is 401 json 0.08s
✓ login issues token with requested abilities 0.27s
✓ bad password is 422 with field errors 0.07s
✓ seventh login attempt in a minute is 429 in the error envelope 0.12s
✓ token without ability gets 403 0.12s
✓ either ability passes ability middleware 0.06s
✓ abilities middleware needs all listed abilities 0.06s
✓ logout deletes the token 0.08s
✓ expired token is 401 in the error envelope 0.07s
PASS Tests\Feature\ExampleTest
✓ the application returns a successful response 0.16s
Tests: 13 passed (39 assertions)
Duration: 3.11s
Test the Laravel API with curl
With the migrate / tinker / serve block from setup run, this is real output from Laravel 12.69.3 with noise headers trimmed (the Sunset middleware was not attached). No request sent an Accept header. $TOKEN is the token from the 201 response. The throttle counts every request to the login route, so I log in first and trigger the 429 last.
$ curl -i http://127.0.0.1:8000/api/v1/me
HTTP/1.1 401 Unauthorized
{"error":{"code":"unauthenticated","message":"Missing or invalid token."}}
$ curl -i -X POST http://127.0.0.1:8000/api/v1/auth/token -d [email protected] -d password=secret-pass -d device_name=laptop
HTTP/1.1 201 Created
{"token":"3|kqldJjdosNejhkbSKhLdIqXTuFxZZGhTxMbU9tuc08b3a1ab","token_type":"Bearer","abilities":["reports:read"],"expires_at":"2026-11-06T12:58:03.000000Z"}
$ curl -i http://127.0.0.1:8000/api/v1/reports/export -H "Authorization: Bearer $TOKEN"
HTTP/1.1 403 Forbidden
{"error":{"code":"forbidden","message":"Invalid ability provided."}}
$ curl -i http://127.0.0.1:8000/api/v1/me -H "Authorization: Bearer $TOKEN"
HTTP/1.1 200 OK
{"id":1,"name":"Prof. Verona Muller DVM","email":"[email protected]", ...}
$ curl -i -X DELETE http://127.0.0.1:8000/api/v1/auth/token -H "Authorization: Bearer $TOKEN"
HTTP/1.1 204 No Content
$ curl -i http://127.0.0.1:8000/api/v1/me -H "Authorization: Bearer $TOKEN"
HTTP/1.1 401 Unauthorized
{"error":{"code":"unauthenticated","message":"Missing or invalid token."}}
The default token got reports:read only, so the export route (which needs both abilities) is a 403 even though the user is valid. A token minted in tinker with expires_at one minute in the past behaves like a missing one:
$ curl -i http://127.0.0.1:8000/api/v1/me -H "Authorization: Bearer $EXPIRED"
HTTP/1.1 401 Unauthorized
{"error":{"code":"unauthenticated","message":"Missing or invalid token."}}
Now the throttle. The login above was attempt 1, five wrong passwords bring the count to 6, and the next request is the 429:
$ for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "login attempt $((i+1)): %{http_code}\n" -X POST http://127.0.0.1:8000/api/v1/auth/token -d [email protected] -d password=wrong -d device_name=laptop; done
login attempt 2: 422
login attempt 3: 422
login attempt 4: 422
login attempt 5: 422
login attempt 6: 422
login attempt 7: 429
$ curl -i -X POST http://127.0.0.1:8000/api/v1/auth/token -d [email protected] -d password=wrong -d device_name=laptop
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 6
X-RateLimit-Remaining: 0
Retry-After: 44
{"error":{"code":"too_many_requests","message":"Too Many Attempts."}}
While that window is open, even a correct password gets a 429, so clients must honour Retry-After. The tokens are from a throwaway database that no longer exists.
Why logout revokes a Sanctum token immediately
The last request above shows it: after the DELETE, the same string is a 401. Sanctum's guard looks the token up in personal_access_tokens on each request (Guard.php line 43 calls findToken) and then writes last_used_at back to that row, so revocation is a row delete and takes effect on the next request. That is the practical difference from a stateless JWT. I haven't measured the cost of that lookup and write, so I'm not putting a number on it. After one authenticated GET /me, a fresh token's last_used_at went from null to 2026-10-07 12:35:12.
Laravel 12 or 13?
Per the release table, Laravel 12's bug-fix window ended on 13 August 2026 and it gets security fixes until 24 February 2027. Laravel 13 (released 17 March 2026, PHP 8.3+) gets bug fixes until Q3 2027, so start new projects on 13. I only ran this code on 12.69.3.
Decision rules
- Tokens vs SPA cookie auth: your own first-party SPA on the same top-level domain → Sanctum's cookie mode. Mobile app, CLI, server-to-server or third-party → tokens, as in this post.
- Abilities vs policies: abilities say what a token may do; they don't know who owns a record. The docs' own example combines both: the user owns the server and
tokenCan('server:update'). Use abilities for coarse gates and policies for row-level checks. - Token lifetime: my rule of thumb, not a Sanctum default. It rests on one fact: a leaked bearer token works until it expires or you delete its row. Mobile app tokens: 30 days, then re-login. Scripts and CI: 1 day or less, minted per run. Write-capable tokens shorter than read-only ones. A user can always mint another, so err short.
- Default abilities: never rely on
['*']. Pass an explicit list on everycreateTokencall, and make the default the least powerful one.
Pitfalls not covered
- HTTPS and token storage. The
Authorization: Bearerheader is plain text on HTTP, so serve the API over HTTPS only. The plain-text token is returned once and the server keeps only a hash, so leaks happen on the client: use the platform keystore on mobile, a secret manager for scripts, and never put the token in a URL, log line or git repo. - Rate limiting.
throttle:6,1is a placeholder. Per-user and per-token limits belong to part 3.
What to do next
- Run
install:api, addHasApiTokens, and confirmroute:list --path=apishows yourv1routes. - Paste in the full
bootstrap/app.php, thencurla protected route with no headers. You should get JSON 401, notRoute [login] not defined. - Decide your ability list, and pick 422 or 401 for bad credentials.
- Set an expiry (
expirationinconfig/sanctum.phpor per token) and schedulesanctum:prune-expired. - Move on to part 2 for resource controllers and API transformers, then part 3 for rate limiting and caching.
Keep reading
Building a Modern SaaS Application with Laravel - Part 1: Multi-Tenancy Architecture & Database Foundations
58 min · 1,076 views
Operating SystemsAdvanced Laravel Tutorial: Queues, Events and Pest Tests
22 min · 248 views
PerformanceComplete Solution: Building a Secure E-commerce Website with Stripe and Laravel
28 min · 231 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log InRelated Articles
ChatGPT vs Claude: Real Performance Benchmarks From Production Testing
Apr 22, 2026
Building a Production-Grade E-Commerce Platform with Laravel 12 - Part 2: Product Catalog & Cart with Database Design
May 12, 2026
Building a Modern SaaS Application with Laravel - Part 3: Production Scaling, Queues & Observability
Apr 25, 2026