March 06, 2026

Architecting the Backend: Handling Offline-First Sync with Laravel

By Paresh Prajapati • Lead Architect

Architecting the Backend: Handling Offline-First Sync with Laravel

The Server-Side of Offline-First

In our previous guide, we explored how to build a lightning-fast, offline-first Flutter application using local databases like Isar. We established that the mobile device acts as the source of truth for the user, providing zero-latency interactions.

But eventually, that local data must reach the cloud. When the device regains connectivity, it needs to push its changes upstream and pull any new changes downstream. This requires a robust, scalable backend. In this tutorial, we will architect a synchronization endpoint using Laravel that can handle batched offline payloads efficiently.

The Challenge: Batched Requests and Conflicts

When an offline app reconnects, it doesn't send one request per action. If a user was offline for three days, they might have created 50 new records, updated 20, and deleted 5. The mobile app will send all of this in a single, batched JSON payload.

Your Laravel API must unpack this payload, process each record, handle potential data conflicts, and return a response containing any new data generated by the server or other devices.

Step 1: The Database Schema Requirements

To make synchronization work, your database tables need a few specific columns. Standard auto-incrementing integers (like id = 1, 2, 3) cause massive issues in offline environments because two offline devices might both generate id = 4.

  • UUIDs: Use Universally Unique Identifiers (UUIDs) as primary keys. The mobile app generates the UUID when it creates the record offline, ensuring there are no collisions when it finally syncs to Laravel.
  • Timestamps: Laravel's default created_at and updated_at are mandatory. The updated_at timestamp is critical for conflict resolution.
  • Soft Deletes: Never hard-delete records. Use Laravel's SoftDeletes trait. When a mobile app "deletes" a record offline, it syncs a deleted_at timestamp to the server. This allows other devices pulling data to know they should also remove that record locally.

Step 2: The Sync Payload Structure

Your Flutter app should send a POST request to your Laravel endpoint (e.g., /api/sync/tasks) with a payload that looks like this:


{
  "last_sync_at": "2026-03-05T10:00:00Z",
  "changes": [
    {
      "id": "123e4567-e89b-12d3-a456-426614174000",
      "title": "Fix the database indexing",
      "is_completed": true,
      "updated_at": "2026-03-06T12:30:00Z",
      "deleted_at": null
    }
  ]
}

Step 3: The Laravel Controller Logic

Now, let's write the controller to handle this. We will use a standard "Last Write Wins" conflict resolution strategy. If the mobile app's updated_at is newer than the server's, the server accepts the change.


namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Task;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;

class SyncController extends Controller
{
    public function syncTasks(Request $request)
    {
        $lastSyncAt = $request->input('last_sync_at');
        $clientChanges = $request->input('changes', []);
        $userId = $request->user()->id;

        DB::beginTransaction();

        try {
            // 1. Process Upstream Changes (From Mobile to Server)
            foreach ($clientChanges as $clientData) {
                $serverRecord = Task::withTrashed()
                    ->where('id', $clientData['id'])
                    ->where('user_id', $userId)
                    ->first();

                $clientUpdatedAt = Carbon::parse($clientData['updated_at']);

                // If record doesn't exist, or client has a newer version, update the server
                if (!$serverRecord || $clientUpdatedAt->gt($serverRecord->updated_at)) {
                    Task::withTrashed()->updateOrCreate(
                        ['id' => $clientData['id'], 'user_id' => $userId],
                        [
                            'title' => $clientData['title'],
                            'is_completed' => $clientData['is_completed'],
                            'updated_at' => $clientUpdatedAt,
                            'deleted_at' => $clientData['deleted_at'] ? Carbon::parse($clientData['deleted_at']) : null,
                        ]
                    );
                }
            }

            // 2. Fetch Downstream Changes (From Server to Mobile)
            // Get all records modified on the server since the client's last sync
            $serverChanges = Task::withTrashed()
                ->where('user_id', $userId)
                ->where('updated_at', '>', Carbon::parse($lastSyncAt))
                ->get();

            DB::commit();

            return response()->json([
                'status' => 'success',
                'server_changes' => $serverChanges,
                'sync_timestamp' => now()->toIso8601String(),
            ]);

        } catch (\Exception $e) {
            DB::rollBack();
            return response()->json(['status' => 'error', 'message' => 'Sync failed'], 500);
        }
    }
}

Breaking Down the Architecture

This controller handles everything seamlessly. We use DB::beginTransaction() to ensure data integrity; if one record fails to save, the entire batch is rolled back, preventing corrupted states.

By leveraging updateOrCreate and checking the updated_at timestamps, we ensure the server only accepts the freshest data. Finally, the server queries for any records that changed while the user was offline (perhaps from a web dashboard or a different device) and returns them in the server_changes array for the Flutter app to inject into its local Isar database.

Conclusion

Building offline-first synchronization requires careful orchestration between the client and the server. By utilizing UUIDs, strict timestamping, and batched API endpoints in Laravel, you can build highly resilient applications that offer your users a flawless experience, regardless of their network connection.

Paresh Prajapati
Lead Architect, Smart Tech Devs