// back_to_blog Laravel

Building a Laravel + Flutter Product: How I Split the Stack

Jul 18, 2026 · 3 min read · 4 tags
Building a Laravel + Flutter Product: How I Split the Stack

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 + errors map 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 login
  • 403 → show “not allowed”
  • 422 → map errors.field to form fields
  • 5xx → 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

  1. Migration + model + policy on Laravel
  2. API Resource + tests or at least manual HTTP cases
  3. Flutter screen with loading/empty/error states
  4. 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.

Enjoyed this article?

Explore more posts or get in touch about a project.