Conflict Resolution
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:
- Looks up the existing server record by
tether_id. - Compares the incoming mutation's
timestampagainst the server record'supdated_at. - 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:
- Applies the server state locally.
- Marks the mutation as
conflictintether_mutation_logs. - 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.
Service Container
Inject and extend Tether services through Laravel's container, including sync engines, registries, HTTP clients, snapshot applicators, and queues.
Payload Mapping
Transform Tether mutation and snapshot payloads between client and server using client mutation mappers, push mappers, and pull snapshot mappers.