How It Works
Tether is built on an offline sync architecture for Laravel: mutation-log push, state-snapshot pull, client-generated ULID identity, idempotent retries, and server-side conflict handling. Client writes are captured locally first, pushed to the server as immutable mutations, and reconciled by pulling authoritative server snapshots back to the client.
Data flow
[Client local DB]
-> every write intercepted by Syncable trait
[Client mutation log]
-> push (client-initiated, batched)
[Server POST /tether/push]
-> PushSyncProcessor -> ConflictDetector -> MutationApplicator -> ServerMutationLog
[Server Eloquent models] (authoritative state)
-> StateSnapshotGenerator (updated_at / deleted_at cursor)
[Server POST /tether/pull response]
-> SnapshotApplicator (forceFill + save by tether_id, without events)
[Client local DB] (reconciled)
Push - sending mutations to the server
When the client calls TetherClient::push():
- Failed mutations rejected with reason
errorare retried while belowmax_retry_attempts. - Pending mutations are loaded and chunked into batches of
push_batch_size. - Each batch is sent as JSON to the configured push URL.
- The server processes each mutation:
- Checks duplicate mutation IDs for idempotent retry handling.
- Runs the registered server push mutation mapper, if any.
- Detects conflicts against the current server record.
- Applies the mutation via
MutationApplicator. - Records the applied mutation in
tether_server_mutations.
- The server responds with
applied,rejected, andconflictsarrays. - Applied mutations become
synced; rejected mutations becomefailed; conflicts becomeconflictand the current server state is applied locally.
Pull - receiving server state
When the client calls TetherClient::pull():
- The stored
last_sync_cursoris read fromtether_sync_state. - A pull request is sent with the integer microsecond cursor, or
nullfor a full first pull. - The server queries registered models using their scopes and filters rows by
updated_at/deleted_at. - The response contains
Snapshotentries,new_sync_cursor, andhas_more. - Each snapshot is applied locally by finding the model by
tether_id, then usingforceFill()andsave()insideModel::withoutEvents(). - If
has_moreistrue, the client repeats the pull using the updated cursor until all pages are consumed.
Pull is state-based, not mutation replay. This is why server-created records can reach clients even though they were never present in a client mutation log.
ULID identity
Every syncable record is assigned a client-generated ULID in its tether_id column at creation time. This is separate from the model's primary key unless you explicitly override the sync key.
- Records created offline sync later without ID remapping.
- The server matches incoming mutations by
tether_id, not by local auto-increment keys. - Client and server tables can keep their own primary-key strategies.
Sync cursor
The sync cursor is an integer microsecond timestamp derived from syncable models' updated_at / deleted_at values. The client stores the last seen cursor and sends it with every pull request.
This means:
- The first pull (
last_sync_cursor = null) fetches everything in scope. - Subsequent pulls fetch only rows changed after the last cursor.
- Cursors stay integers in requests, responses, logs, and client state.
- Standard Laravel timestamps are required on syncable client and server models.
Conflict detection
A conflict occurs when a client pushes a non-create mutation for a server record that has been updated more recently than the incoming mutation. The default detector compares the mutation timestamp with the server record's updated_at.
Default resolution: server wins. The server returns the current server state; the client applies it locally and marks the mutation as conflict.
Custom resolvers can be registered per model - see Conflict Resolution.
Idempotency
All push operations are safe to retry. The server deduplicates by mutation_id:
- Within a batch - duplicate mutation IDs in the same request are handled before they can be applied twice.
- Across requests - previously applied mutation IDs are recognised and returned as
appliedwithout re-applying.
Enable strict_duplicates mode to return duplicates as rejected with reason duplicate instead of applied.
Concurrency protection
The client uses Laravel's atomic cache lock (tether_sync_lock) to ensure only one sync cycle runs at a time. If a cycle is already running, sync(), push(), or pull() returns a SyncResult with skipped = true.
Introduction
Learn how Laravel Tether helps build offline-first apps with local writes, mutation logs, client-driven push/pull sync, and server reconciliation.
Client Setup
Install tether/client, configure a local-first Laravel client app, add Syncable models, and record offline mutations for later sync.