Flutter app with a Laravel backend (2026 guide)
Short answer: install Laravel Sanctum, give the app a handful of JSON routes under /api, and have Flutter send Authorization: Bearer <token> on every call after sign-in. Keep the token in flutter_secure_storage. Firebase
Short answer: install Laravel Sanctum, give the app a handful of JSON routes under /api, and have Flutter send Authorization: Bearer <token> on every call after sign-in. Keep the token in flutter_secure_storage. Firebase stays only where Apple and Google make you use it: push delivery goes through FCM either way.
Most Flutter tutorials assume Firebase for everything. That is quick to start, but your users, their data and your bill end up in someone else's console. A small Laravel app gives you the same building blocks on a server you control, plus an admin panel. This guide covers the six things a typical app needs from it, in the order you usually build them, and then how to set the project up so a coding agent can keep building it with you.
Firebase or your own Laravel backend?
| Firebase | Your own Laravel backend | |
|---|---|---|
| Accounts | Firebase Auth | Sanctum tokens, your own users table |
| Data | Firestore rules | Routes and policies in PHP |
| Admin view of your users | Firebase console | An admin panel you build (Filament) |
| Pricing | Per read, write and sign-in | One server, from a few dollars a month |
| Push to phones | FCM | Still FCM, sent from your server |
| Leaving later | Export and rewrite | It is already yours |
Pick Firebase if you want zero servers. Pick Laravel if you already know PHP, want one place to see and edit your users, or need data to stay on infrastructure you choose.
The routes the app needs
A complete first version is about a dozen routes:
| Route | Auth | What it does |
|---|---|---|
POST /api/auth/register |
public | Sends a 6-digit code, creates nothing yet |
POST /api/auth/verify |
public | Code + password → account + token |
POST /api/auth/login |
public | Email + password → token |
POST /api/auth/social |
public | Google or Apple id token → token |
POST /api/auth/password/forgot |
public | Sends a reset code |
GET /api/auth/me |
token | The signed-in user |
POST /api/auth/logout |
token | Revokes this token |
DELETE /api/auth/me |
token | Deletes the account and its data |
PUT /api/backup |
token | Stores a snapshot of the app's data |
GET /api/backup |
token | Latest snapshot, for restore |
GET /api/version |
public | Latest and minimum app version |
Put the public auth routes behind a rate limiter (throttle:) so nobody can use them to send thousands of emails.
Step 1: Sanctum tokens
In a new Laravel app:
php artisan install:api
That installs Sanctum and creates routes/api.php. Add the HasApiTokens trait to User, then return a token from every route that signs someone in:
public function login(Request $req)
{
$data = $req->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' => ['Invalid credentials.']]);
}
return [
'token' => $user->createToken('mobile')->plainTextToken,
'user' => ['id' => $user->id, 'name' => $user->name, 'email' => $user->email],
];
}
Everything private goes in one group:
Route::middleware('auth:sanctum')->group(function () {
Route::get('auth/me', [AuthController::class, 'me']);
Route::post('auth/logout', [AuthController::class, 'logout']);
});
Sanctum tokens do not expire unless you set expiration in config/sanctum.php. For a phone app that is usually what you want: sign-out deletes the token with $req->user()->currentAccessToken()->delete().
Step 2: sign-up confirmed by an emailed code
A code typed into the app works better on a phone than a link in an email, which opens the browser instead of the app. Split sign-up into two calls:
-
registervalidates name, email and password, stores a hashed password next to a 6-digit code, emails the code and answers 202. No user row exists yet. -
verifytakes the email, the code and the same password, creates the user withemail_verified_atset and answers with a token.
Two details matter. Answer register the same way whether or not the address already has an account, and email the existing owner a note instead of a code, so the route cannot be used to find out who is signed up. And keep codes short-lived (10–15 minutes) with a limit on attempts.
Step 3: calling the API from Flutter
One small client handles the base URL, the headers and the token:
class Api {
Api(this.baseUrl);
final String baseUrl;
final _storage = const FlutterSecureStorage();
Future<Map<String, String>> _headers() async {
final token = await _storage.read(key: 'api_token');
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
}
Future<Map<String, dynamic>> post(String path, Map<String, dynamic> body) async {
final res = await http
.post(Uri.parse('$baseUrl/api/$path'), headers: await _headers(), body: jsonEncode(body))
.timeout(const Duration(seconds: 30));
final json = res.body.isEmpty ? <String, dynamic>{} : jsonDecode(res.body) as Map<String, dynamic>;
if (res.statusCode == 401) await _storage.delete(key: 'api_token');
if (res.statusCode >= 400) throw ApiException(json['message'] as String? ?? 'Error ${res.statusCode}');
return json;
}
Future<void> signIn(String email, String password) async {
final json = await post('auth/login', {'email': email, 'password': password});
await _storage.write(key: 'api_token', value: json['token'] as String);
}
}
Pass the server address at build time instead of hard-coding it:
flutter run --dart-define=API_BASE_URL=https://api.example.com
and read it with const String.fromEnvironment('API_BASE_URL'). When it is empty, the app can fall back to local-only mode, which keeps development and screenshots independent of a server.
The Accept: application/json header is not optional: without it, Laravel answers a validation error with a redirect instead of JSON.
Step 4: Google and Apple sign-in
The phone does the provider part and gets an id token. Send that token to POST /api/auth/social with the provider name. The server checks the signature against Google's or Apple's public keys, checks aud against your client ids, iss and exp, and then finds or creates the user.
Match users in this order:
- By provider and subject (
sub), stored on the user row. This never changes for a person. - By email, but only if the provider says the email is verified and your own row is verified too. Otherwise anyone could create a password account with someone else's address and be let into it when the real owner later signs in with Google.
- Otherwise create a new user.
Answer with the same { token, user } as a password login, so the rest of the app does not care how the person signed in. The phone side, including Apple's App Store rule and the Android SHA-1 keys, is in our guide to Google and Apple sign-in in Flutter.
Step 5: cloud backup on your server
For most apps "sync" really means "restore my data on a new phone". That is much simpler than real-time sync: the app exports its local data as one JSON document and uploads it.
Route::middleware('auth:sanctum')->group(function () {
Route::put('backup', [BackupController::class, 'store']);
Route::get('backup', [BackupController::class, 'latest']);
});
In the model, cast the payload with 'payload' => 'encrypted' so it is stored encrypted with your APP_KEY. Three rules keep it safe:
- Version every upload and keep the last few, so a bad backup does not replace the only good one.
- Limit the size and answer 413 above it.
-
Keep a copy of the app key. A new
APP_KEYmakes every stored backup unreadable. Answer that case with its own status (409) so the app can say "back up again" instead of "nothing found".
Step 6: update prompts and push
Update prompts. A public GET /api/version returns:
{ "latest": "1.0.1", "minimum": "1.0.0", "message": "A few fixes and a faster start.",
"android_url": "https://play.google.com/store/apps/details?id=…", "ios_url": "https://apps.apple.com/app/id…" }
On launch the app compares its own version (package_info_plus) with both numbers. Below minimum, block the app with a button to the store; below latest, show a dismissible prompt. Keep the numbers in a table the admin panel can edit, so releasing a forced update does not need a deploy.
Push. Phones only receive push through FCM (and APNs behind it on iOS), so Firebase stays for this one job. Your server sends through the FCM HTTP v1 API with a service-account access token:
Http::withToken($accessToken)->post(
"https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send",
['message' => ['topic' => 'all', 'notification' => ['title' => $title, 'body' => $body]]],
);
Subscribe every install to a topic such as all for announcements, and store device tokens on the user when you need to reach one person. Log every send, so the admin panel can show a history.
Step 7: make the project easy for a coding agent
Most of the screens you add after this will probably be written with Claude Code, Cursor, Codex or Copilot. A Flutter app plus a Laravel app is two projects in two languages, and an agent that has to guess how they fit together guesses wrong. Two kinds of file fix most of that.
Project notes. Put an AGENTS.md at the root of the project. Agents read it before they change anything, so keep it short and factual: what the app is, where things live, how the two sides talk, and the commands that must pass.
- Flutter app in `app/`, Laravel API and Filament panel in `backend/`.
- The app talks to the API only through `lib/data/api.dart`; every call
sends `Accept: application/json` and the Sanctum token.
- A new endpoint: route in `backend/routes/api.php`, a feature test in
`backend/tests/Feature/`, then the call in `api.dart`.
- Done means: `flutter analyze` clean, `flutter test` green,
`cd backend && composer test` green.
Claude Code reads CLAUDE.md instead; a one-line CLAUDE.md containing @AGENTS.md imports the same file, so there is only one to keep up to date.
Skills for the jobs you repeat. A skill is a folder with a SKILL.md: a name, one sentence saying when to use it, then the exact steps.
---
name: add-api-endpoint
description: Add a Laravel API route and the Flutter call that uses it. Use when a screen needs new data from the server.
---
1. Add the route inside the `auth:sanctum` group in `backend/routes/api.php`.
2. Write the controller method; validate input with `$req->validate()`.
3. Add a feature test that calls the route with and without a token.
4. Add the method to `lib/data/api.dart` and use it from the controller.
5. Run the three commands from AGENTS.md.
Keep skills in a skills/ folder and list them in AGENTS.md, so every agent finds them; Claude Code also picks them up from .claude/skills/. Good first skills: add an endpoint, add a screen, remove a feature you do not need, and ship a release (bump the version, then update minimum and latest from step 6).
The tests are what make this work. With a test suite on both sides, "the commands pass" is a definition of done the agent can check by itself, instead of you reading every diff.
The admin panel
Filament turns your models into an admin panel with very little code: a users list, the stored backups, a form that sends a push, and the version table from step 6. Restrict it to an admin role, and keep it on the same Laravel app so it shares the models and the database.
Checklist before release
- [ ] HTTPS on the API domain, and
APP_DEBUG=false - [ ]
APP_KEYstored somewhere outside the server - [ ] Rate limits on every public auth route
- [ ]
Accept: application/jsonsent by the app - [ ] Token deleted on sign-out and on 401
- [ ] Google and Apple id tokens verified on the server, never trusted from the phone
- [ ] A Delete account route that removes the user and their data (App Store guideline 5.1.1(v))
- [ ]
minimumversion set before you ship a breaking API change - [ ]
AGENTS.mdnames the commands that must pass on both sides
FAQ
Can Laravel replace Firebase for a Flutter app?
For accounts, data, backups, update checks and the admin side, yes. Push notifications still go through Firebase Cloud Messaging, because that is how Android and iOS deliver them, but your Laravel server can send them.
Should I use Sanctum or Passport for a Flutter app?
Sanctum. It issues simple API tokens for first-party apps. Passport is a full OAuth2 server, which you only need if other companies' apps will sign in to your API.
Why does my Laravel API return HTML or a redirect to Flutter?
The request has no Accept: application/json header, so Laravel treats it as a browser request. Send that header on every call, or add a middleware that forces JSON responses for /api.
Where should a Flutter app store the Laravel token?
In flutter_secure_storage, which uses the Keychain on iOS and encrypted storage on Android. Not in shared_preferences.
How do I get Claude Code or Cursor to work well on a Flutter and Laravel project?
Add an AGENTS.md that explains where the app and the API live, how they talk and which test commands must pass, and a skill for each job you repeat, such as adding an endpoint. Then ask for changes in terms of those skills.
Does the app have to be online to work?
No. Keep the data on the phone and treat the server as a backup and an account. The app works offline and uploads a snapshot when it can.
If you would rather start with all of this already wired up, Skelora is a Flutter app with this Laravel API and a Filament admin panel in the same download: email-code accounts, Google and Apple sign-in, encrypted backups, update prompts and push. It ships with an AGENTS.md and one skill per feature, so your coding agent can take out what you do not need.
Originally published at coderqh.com.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.