Client Setup
tether/client is the Laravel package you install in your client application - the local-first app that writes data while offline, records each mutation, and syncs those changes to your Laravel server later.
Installation
composer require tether/client
php artisan tether:install
php artisan migrate
The installer publishes the config file and creates two database tables:
| Table | Purpose |
|---|---|
tether_mutation_logs | Records every local create / update / delete mutation |
tether_sync_state | Stores the last sync cursor, last sync time, client ID |
Database migration
Every syncable model needs a sync identity column and standard Laravel timestamps:
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->tetherUlid(); // tether_id char(26) unique nullable
$table->string('title');
$table->timestamps();
$table->softDeletes();
});
The tetherUlid() and dropTetherUlid() Blueprint macros are registered by tether/core, which is installed automatically as a dependency.
Tether relies on updated_at during pull reconciliation, so keep $table->timestamps() on every syncable model.
Making a model syncable
Add the client Syncable trait to any Eloquent model you want to synchronise:
use Tether\Client\Traits\Syncable;
class Task extends Model
{
use Syncable;
protected $fillable = ['title', 'status', 'due_date'];
protected array $syncable = ['title', 'status', 'due_date'];
}
On create, the trait assigns a ULID to tether_id column we've just added in the migration. On create, update, and delete, trait also records a pending mutation in tether_mutation_logs. Your model's primary key is not affected and can be used in foreign key relationships as usual.
Controlling which fields sync
The client payload is built from getSyncableFields() when sending create/update requests to the server.
// Explicit whitelist
protected array $syncable = ['title', 'status', 'due_date'];
// All fillable fields
protected array $syncable = ['*'];
// All fillable fields except these
protected array $syncable = ['*'];
protected array $syncableExcept = ['internal_notes'];
Deletes send an empty payload; the tether_id is enough to identify the record.
Custom sync key column
By default, Tether uses tether_id. To use a different column per model:
class Task extends Model
{
use Syncable;
protected string $tetherKeyName = 'sync_id';
}
Update the migration and make sure the server uses the same sync key for that model.
Soft deletes
If your model uses SoftDeletes, Tether captures delete events as delete mutations. On pull, server soft-deleted rows arrive as delete snapshots so clients can mirror the deletion.
Hard-deleted server rows cannot be discovered by state-based pull, so use soft deletes for records that need deletion propagation on client side.
Client identity
Every sync request includes a client_id. Tether uses it to identify the client making the request. Tether resolves it in this order:
- Callable registered with
TetherClientServiceProvider::resolveClientIdUsing() - Invokable class configured in
client_id_resolver TETHER_CLIENT_ID/client_id- Auto-generated ULID persisted in
tether_sync_state
For a config-cache-safe dynamic resolver:
// config/tether-client.php
'client_id_resolver' => \App\Tether\DeviceIdResolver::class,
For runtime resolution:
use Tether\Client\TetherClientServiceProvider;
public function boot(): void
{
TetherClientServiceProvider::resolveClientIdUsing(
fn () => auth()->user()?->device_id ?? config('tether-client.client_id')
);
}
Server URL configuration
Set the push and pull endpoint URLs in .env:
TETHER_SERVER_PUSH_URL=https://your-server.example.com/tether/push
TETHER_SERVER_PULL_URL=https://your-server.example.com/tether/pull
Push and pull can point to different hosts if needed.
Mutation log statuses
Every entry in tether_mutation_logs uses one of these statuses:
| Status | Meaning |
|---|---|
pending | Waiting to be pushed to the server |
synced | Successfully applied on the server |
failed | Rejected by the server or exhausted retries |
conflict | Server detected a conflict; server state was applied locally |
Pull page size
By default, a pull fetches all changed snapshots in one request. For large datasets, set a page size so the client issues multiple requests automatically:
// config/tether-client.php
'pull_page_size' => 500,
When null (the default), the server returns all changed records in scope in a single response.
How It Works
Understand Tether's offline sync architecture for Laravel: mutation-log push, state-snapshot pull, ULID identity, idempotency, and conflict handling.
Syncing
Run Laravel Tether sync manually, from queues, or on a schedule, and understand push/pull results, conflicts, retries, and pull pagination.