API-first from day one
When Flutter (or any mobile client) is in the plan, I treat the Laravel API as a product surface—not a leftover from a Blade app. Responses are JSON, auth is token-based with Sanctum, and validation errors are predictable.
Step 1 — Project + Sanctum
composer create-project laravel/laravel api-app
cd api-app
composer require laravel/sanctum
php artisan install:api
php artisan migrate
Confirm the User model uses Laravel\Sanctum\HasApiTokens.
Step 2 — Routes
Route::prefix('v1')->group(function () {
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::apiResource('tasks', TaskController::class);
});
});
Step 3 — Form Request validation
php artisan make:request StoreTaskRequest
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'done' => ['sometimes', 'boolean'],
];
}
Failed validation returns 422 with an errors object—exactly what mobile clients should parse.
Step 4 — API Resource
php artisan make:resource TaskResource
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'done' => (bool) $this->done,
'created_at' => $this->created_at?->toIso8601String(),
];
}
Eloquent models are not your public contract. Resources keep the mobile shape stable while the schema evolves.
Step 5 — Controller pattern
public function store(StoreTaskRequest $request)
{
$task = $request->user()->tasks()->create($request->validated());
return (new TaskResource($task))
->response()
->setStatusCode(201);
}
public function index(Request $request)
{
$tasks = $request->user()->tasks()->latest()->paginate(20);
return TaskResource::collection($tasks);
}
Step 6 — Authorization with policies
php artisan make:policy TaskPolicy --model=Task
// Gate::authorize('update', $task); or $this->authorize('update', $task);
Return clear 403 JSON rather than leaking whether a row exists when that matters for your threat model.
Step 7 — Consistent errors clients can handle
401— missing/invalid token → Flutter forces re-login403— authenticated but not allowed422— field errors429— rate limited (add throttle middleware on auth routes)
Versioning and pagination
Prefix v1 from day one. Paginate lists. Document Authorization: Bearer … and Accept: application/json. That is how I ship Laravel APIs that Flutter can consume without drama.
Logout and token hygiene
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out']);
}
For mobile, decide whether one device shares one token or each install gets its own named token (createToken('android-pixel')). On password change, revoke other tokens.
Rate limiting auth routes
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:5,1');
Brute force against /login is not theoretical. Throttle early.
CORS for local Flutter web / separate domains
If the client origin differs from the API host, configure Laravel’s CORS middleware carefully. For native mobile talking HTTPS to your API, CORS is usually irrelevant; for Flutter web it matters.
Testing the API without the app
- Register/login via HTTP client (Insomnia/Postman/curl).
- Copy the Bearer token.
- Hit a protected route with
Authorization: Bearer …andAccept: application/json. - Assert 422 by omitting a required field before you write Flutter forms.
curl -X POST https://api.example.test/api/v1/login \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"email":"a@b.com","password":"secret"}'
Common mistakes
- Returning Eloquent models directly and leaking attributes later
- Forgetting
Accept: application/jsonand getting HTML error pages in the client - No pagination on list endpoints
- Authorizing in the UI only (never skip policies on the server)
Idempotency and destructive actions
DELETE and PATCH should be safe to retry where possible. For payments or one-time actions, use idempotency keys or server-side guards so mobile retries do not double-charge.