Advanced

Conflict Resolution

Learn how Laravel Tether detects offline sync conflicts, applies server-wins defaults, returns server state, and supports custom conflict resolvers.

Offline sync conflict resolution matters when more than one client can change the same Laravel record before synchronisation. In Tether, a conflict occurs when a client pushes a non-create mutation for a record that has been modified on the server more recently than the incoming mutation. The server detects this automatically and resolves it with the default server-wins policy or with your custom resolver.


How conflicts are detected

When the server receives a push request, for each non-create mutation it:

  1. Looks up the existing server record by tether_id.
  2. Compares the incoming mutation's timestamp against the server record's updated_at.
  3. If the mutation is older than the server record, the conflict resolver is called.

If no conflict is detected, the mutation is applied normally.


Default behaviour - server wins

Without a custom resolver, the server rejects the incoming mutation and returns the current server state to the client. The client then:

  1. Applies the server state locally.
  2. Marks the mutation as conflict in tether_mutation_logs.
  3. Fires TetherConflictDetected.

ConflictResolution

Custom resolvers return a ConflictResolution instance:

use Tether\Core\Conflict\ConflictResolution;

ConflictResolution::apply($payload); // accept client or merged payload
ConflictResolution::reject();        // keep server state

ConflictResolution::apply() accepts the payload that will be written to the database.


Registering a custom resolver

On the model

use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Tether\Core\Conflict\ConflictResolution;
use Tether\Core\Mutation\Mutation;
use Tether\Server\Traits\Syncable;

class Task extends Model
{
    use Syncable;

    public static function tetherConflictResolver(
        Mutation $incoming,
        Model $serverRecord,
        Request $request,
    ): ConflictResolution {
        return ConflictResolution::apply($incoming->getPayload()); // client wins
    }
}

Via SyncRegistry

use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Tether\Core\Conflict\ConflictResolution;
use Tether\Core\Mutation\Mutation;
use Tether\Server\SyncRegistry;

public function boot(): void
{
    app(SyncRegistry::class)->register(
        modelClass: Task::class,
        conflictResolver: fn (
            Mutation $incoming,
            Model $record,
            Request $request,
        ) => ConflictResolution::apply($incoming->getPayload()),
    );
}

Examples

Client always wins

public static function tetherConflictResolver(
    Mutation $incoming,
    Model $serverRecord,
    Request $request,
): ConflictResolution {
    return ConflictResolution::apply($incoming->getPayload());
}

Use when the client is always authoritative, such as local settings or user preferences.


Server always wins

public static function tetherConflictResolver(
    Mutation $incoming,
    Model $serverRecord,
    Request $request,
): ConflictResolution {
    return ConflictResolution::reject();
}

This is equivalent to the default behaviour, but makes the decision explicit.


Field-level merge

public static function tetherConflictResolver(
    Mutation $incoming,
    Model $serverRecord,
    Request $request,
): ConflictResolution {
    $incomingPayload = $incoming->getPayload();

    $merged = array_merge(
        $serverRecord->toArray(),
        ['status' => $incomingPayload['status'] ?? $serverRecord->status],
    );

    return ConflictResolution::apply($merged);
}

Per-user ownership check

public static function tetherConflictResolver(
    Mutation $incoming,
    Model $serverRecord,
    Request $request,
): ConflictResolution {
    if ($serverRecord->user_id !== $request->user()->id) {
        return ConflictResolution::reject();
    }

    return ConflictResolution::apply($incoming->getPayload());
}

What the client receives

The push response contains an entry in conflicts:

{
  "mutation_id": "01HWZMX3KB7T2PA31HV2WDKGF4",
  "reason": "conflict",
  "data": {
    "server_state": { "title": "Server version", "status": "done" },
    "server_updated_at": "2026-05-02T09:00:00+00:00"
  }
}

The client applies server_state, marks the mutation as conflict, and fires TetherConflictDetected.