The split that works for me
Most of my product work looks the same at a high level: Laravel is the source of truth, Flutter is the client people hold in their hand. I do not put business rules only in the mobile app “because it feels faster.” That is how you end up with two truths and a painful migration later.
What lives where
Laravel
- Database schema, migrations, Eloquent models
- Auth (Sanctum tokens for mobile)
- Validation, policies, domain services
- Admin, reports, queues, mail, webhooks
- File storage and server-side jobs
Flutter
- Screens, navigation, local UI state
- Offline-friendly caching when the product needs it
- Camera, notifications, device APIs via plugins
- API client with clear error handling
Step 1 — Laravel API skeleton
composer create-project laravel/laravel api
cd api
composer require laravel/sanctum
php artisan install:api
php artisan migrate
Docs: Laravel Sanctum. Ensure your User model uses HasApiTokens.
Step 2 — Versioned routes
In routes/api.php (shape may vary slightly by Laravel version):
use App\Http\Controllers\Api\V1\AuthController;
use App\Http\Controllers\Api\V1\PostController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () {
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/me', [AuthController::class, 'me']);
Route::apiResource('posts', PostController::class);
});
});
Clients call /api/v1/.... Version early even if you only have v1 today.
Step 3 — Login issues a token
public function login(Request $request)
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (! Auth::attempt($credentials)) {
return response()->json(['message' => 'Invalid credentials'], 401);
}
$token = $request->user()->createToken('flutter')->plainTextToken;
return response()->json([
'token' => $token,
'user' => $request->user(),
]);
}
Step 4 — Form Requests + API Resources
Validate with Form Requests so Flutter gets predictable 422 JSON. Transform models with API Resources so Eloquent internals are not your public contract:
return new PostResource($post);
// or: return PostResource::collection($posts);
Step 5 — Flutter client pattern
final response = await dio.post(
'$baseUrl/api/v1/login',
data: {'email': email, 'password': password},
);
final token = response.data['token'] as String;
await storage.write(key: 'token', value: token);
dio.options.headers['Authorization'] = 'Bearer $token';
dio.options.headers['Accept'] = 'application/json';
On 401, clear the token and send the user to login. Store baseUrl per flavor (dev/staging/prod)—never hardcode production only.
Habits that save pain
- Consistent error JSON:
message+errorsmap for validation - Paginate list endpoints; do not return unbounded arrays
- Document auth expiry and required headers for the mobile team
- Use HTTPS everywhere outside local emulators
This split is why Laravel + Flutter is my default product stack: clear ownership, one mobile codebase, one backend source of truth.
Environments and flavors
I keep at least three API base URLs: local, staging, production. In Flutter that is flavors or dart-define; in Laravel that is separate .env files / hosts. Never point a production app build at a staging API “temporarily.”
Error mapping on the client
401→ clear token, navigate to login403→ show “not allowed”422→ maperrors.fieldto form fields5xx→ generic retry, log for support
File uploads
Upload to Laravel (validated, stored on a disk), return a URL or id. Do not invent a second storage story inside Flutter unless offline-first is a product requirement.
Push notifications
Device tokens live in Laravel; FCM/APNs sending is a queued job. Flutter registers the token after login and refreshes it when it rotates.
Definition of done for a feature
- Migration + model + policy on Laravel
- API Resource + tests or at least manual HTTP cases
- Flutter screen with loading/empty/error states
- Works on iOS and Android against staging
Offline-first caution
True offline sync is a product feature with conflict rules—not a weekend add-on. If you only need “read last cached list when the subway drops,” cache responses. If you need multi-device offline edits, budget real design time.