Hey everyone, Jamie here.
We've all been there: You're on the train, underground, or just dealing with spotty Wi-Fi, and your app becomes a digital paperweight the moment connectivity drops. Meanwhile, users are trying to capture that important note, complete a task, or continue working – but everything grinds to a halt because your app can't reach the server.
This is where offline-first design and data synchronization become game-changers. Building apps that work seamlessly offline and intelligently sync when connectivity returns isn't just a nice-to-have anymore – it's what users expect. Let's explore how to build robust offline capabilities with Laravel and Flutter that'll keep your users productive regardless of their connection status.
The Offline-First Mindset
Before diving into implementation, let's establish the core principles:
Offline-First: Your app should work without a network connection as the default state, not as an exception. Users should be able to read, create, and modify data locally.
Eventual Consistency: Accept that data might be temporarily out of sync between client and server. Focus on graceful conflict resolution rather than preventing conflicts entirely.
Optimistic Updates: Update the UI immediately when users make changes, then sync with the server in the background. If conflicts arise, handle them gracefully.
Smart Synchronization: Only sync what's necessary, when it's necessary. Respect users' data plans and battery life.
Flutter: Building the Local Database Foundation
The key to offline functionality is having a robust local database. Flutter offers several excellent options, but I'll focus on the most practical approaches.
Drift is a powerful, type-safe SQLite wrapper that makes complex queries and migrations straightforward:
dependencies:
drift: ^2.14.1
sqlite3_flutter_libs: ^0.5.0
path_provider: ^2.0.0
path: ^1.8.0
dev_dependencies:
drift_dev: ^2.14.1
build_runner: ^2.3.0
Setting up your local schema:
// database.dart
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
// Tables
class Tasks extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get title => text().withLength(min: 1, max: 200)();
TextColumn get description => text().nullable()();
BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
// Sync fields
IntColumn get serverId => integer().nullable()(); // Server-side ID
BoolColumn get needsSync => boolean().withDefault(const Constant(true))();
TextColumn get syncAction => text().nullable()(); // 'create', 'update', 'delete'
DateTimeColumn get lastSyncAt => dateTime().nullable()();
}
@DriftDatabase(tables: [Tasks])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
static LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'app_database.db'));
return NativeDatabase(file);
});
}
}
The Sync-Aware Repository Pattern:
class TaskRepository {
final AppDatabase _db;
TaskRepository(this._db);
// Get all tasks (works offline)
Stream<List<Task>> watchAllTasks() {
return _db.select(_db.tasks).watch();
}
// Create task (works offline)
Future<Task> createTask(String title, String? description) async {
final task = TasksCompanion(
title: Value(title),
description: Value(description),
needsSync: const Value(true),
syncAction: const Value('create'),
);
final id = await _db.into(_db.tasks).insert(task);
final createdTask = await (_db.select(_db.tasks)..where((t) => t.id.equals(id))).getSingle();
// Trigger background sync
_scheduleSync();
return createdTask;
}
// Update task (works offline)
Future<void> updateTask(Task task, {String? title, String? description, bool? isCompleted}) async {
final update = TasksCompanion(
id: Value(task.id),
title: title != null ? Value(title) : const Value.absent(),
description: description != null ? Value(description) : const Value.absent(),
isCompleted: isCompleted != null ? Value(isCompleted) : const Value.absent(),
updatedAt: Value(DateTime.now()),
needsSync: const Value(true),
syncAction: Value(task.serverId != null ? 'update' : 'create'),
);
await (_db.update(_db.tasks)..where((t) => t.id.equals(task.id))).write(update);
_scheduleSync();
}
// Soft delete (works offline)
Future<void> deleteTask(Task task) async {
if (task.serverId != null) {
// Mark for deletion sync
await (_db.update(_db.tasks)..where((t) => t.id.equals(task.id))).write(
const TasksCompanion(
needsSync: Value(true),
syncAction: Value('delete'),
),
);
} else {
// Local-only task, delete immediately
await (_db.delete(_db.tasks)..where((t) => t.id.equals(task.id))).go();
}
_scheduleSync();
}
void _scheduleSync() {
// Trigger your sync service
SyncService.instance.scheduleSync();
}
}
Laravel: Building Sync-Friendly APIs
Your Laravel backend needs to support efficient synchronization. This means designing APIs that can handle batch operations, conflict resolution, and incremental updates.
Sync-Aware Models
Start by adding sync metadata to your Eloquent models:
// app/Models/Task.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Task extends Model
{
use SoftDeletes;
protected $fillable = [
'title',
'description',
'is_completed',
'user_id',
];
protected $casts = [
'is_completed' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
];
// Add a sync version for conflict resolution
protected static function boot()
{
parent::boot();
static::updating(function ($task) {
$task->sync_version = ($task->sync_version ?? 0) + 1;
});
}
}
Migration for sync support:
// database/migrations/add_sync_fields_to_tasks_table.php
public function up()
{
Schema::table('tasks', function (Blueprint $table) {
$table->integer('sync_version')->default(1);
$table->timestamp('client_updated_at')->nullable();
});
}
Batch Sync Endpoints
Design your API to handle batch operations efficiently:
// app/Http/Controllers/SyncController.php
<?php
namespace App\Http\Controllers;
use App\Models\Task;
use Illuminate\Http\Request;
class SyncController extends Controller
{
public function pullChanges(Request $request)
{
$lastSyncAt = $request->input('last_sync_at');
$lastSyncAt = $lastSyncAt ? Carbon::parse($lastSyncAt) : null;
$query = auth()->user()->tasks();
if ($lastSyncAt) {
$query->where(function ($q) use ($lastSyncAt) {
$q->where('updated_at', '>', $lastSyncAt)
->orWhere('deleted_at', '>', $lastSyncAt);
});
}
$tasks = $query->withTrashed()->get();
return response()->json([
'tasks' => $tasks,
'server_time' => now()->toISOString(),
]);
}
public function pushChanges(Request $request)
{
$changes = $request->input('changes', []);
$results = [];
foreach ($changes as $change) {
$result = $this->processChange($change);
$results[] = $result;
}
return response()->json([
'results' => $results,
'server_time' => now()->toISOString(),
]);
}
private function processChange(array $change)
{
$action = $change['action']; // 'create', 'update', 'delete'
$clientId = $change['client_id'];
$data = $change['data'];
try {
switch ($action) {
case 'create':
$task = auth()->user()->tasks()->create($data);
return [
'client_id' => $clientId,
'status' => 'success',
'server_id' => $task->id,
'sync_version' => $task->sync_version,
];
case 'update':
$task = auth()->user()->tasks()->find($data['id']);
if (!$task) {
return [
'client_id' => $clientId,
'status' => 'not_found',
];
}
// Conflict detection
if (isset($data['sync_version']) && $task->sync_version > $data['sync_version']) {
return [
'client_id' => $clientId,
'status' => 'conflict',
'server_data' => $task->toArray(),
];
}
$task->update($data);
return [
'client_id' => $clientId,
'status' => 'success',
'sync_version' => $task->sync_version,
];
case 'delete':
$task = auth()->user()->tasks()->find($data['id']);
if ($task) {
$task->delete();
}
return [
'client_id' => $clientId,
'status' => 'success',
];
default:
return [
'client_id' => $clientId,
'status' => 'invalid_action',
];
}
} catch (\Exception $e) {
return [
'client_id' => $clientId,
'status' => 'error',
'message' => $e->getMessage(),
];
}
}
}
The Synchronization Engine
The heart of your offline-first app is the sync service that orchestrates data flow between local storage and your server.
// sync_service.dart
import 'dart:async';
import 'dart:convert';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:http/http.dart' as http;
class SyncService {
static final SyncService instance = SyncService._internal();
SyncService._internal();
final AppDatabase _db = AppDatabase();
Timer? _syncTimer;
bool _isSyncing = false;
DateTime? _lastSyncAt;
// Stream to notify UI about sync status
final _syncStatusController = StreamController<SyncStatus>.broadcast();
Stream<SyncStatus> get syncStatusStream => _syncStatusController.stream;
void initialize() {
// Listen for connectivity changes
Connectivity().onConnectivityChanged.listen((result) {
if (result != ConnectivityResult.none) {
scheduleSync();
}
});
// Periodic sync when online
_syncTimer = Timer.periodic(const Duration(minutes: 5), (_) {
scheduleSync();
});
}
void scheduleSync() {
if (_isSyncing) return;
// Check connectivity first
Connectivity().checkConnectivity().then((result) {
if (result != ConnectivityResult.none) {
_performSync();
}
});
}
Future<void> _performSync() async {
if (_isSyncing) return;
_isSyncing = true;
_syncStatusController.add(SyncStatus.syncing);
try {
// Step 1: Pull changes from server
await _pullFromServer();
// Step 2: Push local changes to server
await _pushToServer();
_lastSyncAt = DateTime.now();
_syncStatusController.add(SyncStatus.success);
} catch (e) {
print('Sync failed: $e');
_syncStatusController.add(SyncStatus.error);
} finally {
_isSyncing = false;
}
}
Future<void> _pullFromServer() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}/sync/pull'),
headers: {
'Authorization': 'Bearer ${await AuthService.getToken()}',
'Content-Type': 'application/json',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final serverTasks = data['tasks'] as List;
for (final taskData in serverTasks) {
await _mergeServerTask(taskData);
}
}
}
Future<void> _mergeServerTask(Map<String, dynamic> serverTask) async {
final serverId = serverTask['id'];
final isDeleted = serverTask['deleted_at'] != null;
// Find existing local task by server ID
final existingTask = await (_db.select(_db.tasks)
..where((t) => t.serverId.equals(serverId)))
.getSingleOrNull();
if (isDeleted) {
// Handle server deletion
if (existingTask != null) {
await (_db.delete(_db.tasks)..where((t) => t.id.equals(existingTask.id))).go();
}
return;
}
if (existingTask != null) {
// Update existing task (server wins for now - you could implement smarter conflict resolution)
await (_db.update(_db.tasks)..where((t) => t.id.equals(existingTask.id))).write(
TasksCompanion(
title: Value(serverTask['title']),
description: Value(serverTask['description']),
isCompleted: Value(serverTask['is_completed']),
updatedAt: Value(DateTime.parse(serverTask['updated_at'])),
needsSync: const Value(false),
lastSyncAt: Value(DateTime.now()),
),
);
} else {
// Create new task from server
await _db.into(_db.tasks).insert(TasksCompanion(
serverId: Value(serverId),
title: Value(serverTask['title']),
description: Value(serverTask['description']),
isCompleted: Value(serverTask['is_completed']),
createdAt: Value(DateTime.parse(serverTask['created_at'])),
updatedAt: Value(DateTime.parse(serverTask['updated_at'])),
needsSync: const Value(false),
lastSyncAt: Value(DateTime.now()),
));
}
}
Future<void> _pushToServer() async {
// Get all items that need syncing
final itemsToSync = await (_db.select(_db.tasks)
..where((t) => t.needsSync.equals(true)))
.get();
if (itemsToSync.isEmpty) return;
final changes = itemsToSync.map((task) => {
'client_id': task.id,
'action': task.syncAction ?? 'update',
'data': {
if (task.serverId != null) 'id': task.serverId,
'title': task.title,
'description': task.description,
'is_completed': task.isCompleted,
'client_updated_at': task.updatedAt.toIso8601String(),
},
}).toList();
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}/sync/push'),
headers: {
'Authorization': 'Bearer ${await AuthService.getToken()}',
'Content-Type': 'application/json',
},
body: jsonEncode({'changes': changes}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final results = data['results'] as List;
for (final result in results) {
await _handleSyncResult(result);
}
}
}
Future<void> _handleSyncResult(Map<String, dynamic> result) async {
final clientId = result['client_id'];
final status = result['status'];
final task = await (_db.select(_db.tasks)
..where((t) => t.id.equals(clientId)))
.getSingleOrNull();
if (task == null) return;
switch (status) {
case 'success':
// Update with server ID if it's a new item
final serverId = result['server_id'];
await (_db.update(_db.tasks)..where((t) => t.id.equals(clientId))).write(
TasksCompanion(
serverId: serverId != null ? Value(serverId) : const Value.absent(),
needsSync: const Value(false),
syncAction: const Value.absent(),
lastSyncAt: Value(DateTime.now()),
),
);
break;
case 'conflict':
// Handle conflict - for now, server wins, but you could present UI for user to resolve
final serverData = result['server_data'];
await _mergeServerTask(serverData);
break;
case 'not_found':
// Item doesn't exist on server, might have been deleted
await (_db.delete(_db.tasks)..where((t) => t.id.equals(clientId))).go();
break;
case 'error':
// Keep for retry - could implement exponential backoff
print('Sync error for item $clientId: ${result['message']}');
break;
}
}
void dispose() {
_syncTimer?.cancel();
_syncStatusController.close();
}
}
enum SyncStatus { idle, syncing, success, error }
```.fromJson(messageData)));
});
}
void _onMessageReceived(MessageReceived event, Emitter<ChatState> emit) {
// Update state with new message
}
}
Production Considerations
- Connection Management: Handle reconnections gracefully. Network conditions change, especially on mobile.
- Rate Limiting: Don't overwhelm your server or users with too many updates.
- Authentication: Secure your WebSocket connections. Use tokens that can expire and be refreshed.
- Scaling: Consider using Redis for horizontal scaling of WebSocket connections.
- Fallback Strategies: Have polling as a fallback for environments where WebSockets are blocked.
The Pragmatic Approach
Real-time features can make your app feel magical, but they also add complexity. Start simple:
- Identify Real Needs: Not every update needs to be real-time. Sometimes “eventual consistency” is perfectly fine.
- Start with SSE: If you primarily need server-to-client updates, SSE is simpler than full WebSockets.
- Use Hosted Solutions Initially: Pusher or Ably can get you moving quickly. You can always self-host later.
- Test Network Conditions: Real-time features behave differently on poor connections. Test accordingly.
Real-time communication transforms user experiences, making apps feel responsive and alive. Both Laravel and Flutter provide excellent tools to make this happen smoothly.
What real-time features are you planning to build? Any challenges you've faced with WebSockets or SSE? Let's chat about it!
Cheers,
Jamie C