Client

Client Setup

Install tether/client, configure a local-first Laravel client app, add Syncable models, and record offline mutations for later sync.

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:

TablePurpose
tether_mutation_logsRecords every local create / update / delete mutation
tether_sync_stateStores 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:

  1. Callable registered with TetherClientServiceProvider::resolveClientIdUsing()
  2. Invokable class configured in client_id_resolver
  3. TETHER_CLIENT_ID / client_id
  4. 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:

StatusMeaning
pendingWaiting to be pushed to the server
syncedSuccessfully applied on the server
failedRejected by the server or exhausted retries
conflictServer 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.