Server

Server Setup

Install tether/server, register Syncable Eloquent models, expose push and pull endpoints, and reconcile offline client mutations on your Laravel server.

tether/server installs on your Laravel server application. It exposes authoritative sync HTTP endpoints, applies incoming offline client mutations to your Eloquent models, detects conflicts, and generates pull snapshots for client reconciliation.


Installation

composer require tether/server
php artisan tether:install-server
php artisan migrate

The installer publishes config/tether-server.php. The migration creates tether_server_mutations, which stores applied push mutations for idempotency and diagnostics.


Sync endpoints

Two routes are registered automatically:

MethodPathPurpose
POST/tether/pushReceive and apply a batch of client mutations
POST/tether/pullReturn server state since a given cursor

To register routes manually instead:

// config/tether-server.php
'register_routes' => false,
// routes/api.php
use Tether\Server\Http\Controllers\SyncController;

Route::middleware(['api', 'auth:sanctum'])
    ->prefix('tether')
    ->group(function () {
        Route::post('/push', [SyncController::class, 'push']);
        Route::post('/pull', [SyncController::class, 'pull']);
    });

Package doesn't require any middleware to operate, but you should add authentication middleware in production to protect the endpoints from unauthenticated access.


Registering models

The server must know which models participate in sync. Choose one of two approaches.

Option A - Syncable trait

Add the trait to any model you want clients to sync. It auto-registers with sensible defaults.

Every server-side Syncable model must also use standard Laravel timestamps. Tether relies on updated_at for pull cursors and default conflict checks.

use Tether\Server\Traits\Syncable;

class Task extends Model
{
    use Syncable;
}

Control which fields clients can write:

// Whitelist - only these fields are accepted from mutation payloads
protected array $syncable = ['title', 'status', 'due_date'];

// Blacklist - all fillable fields except these
protected array $syncableExcept = ['internal_notes', 'admin_flags'];

$syncable takes precedence if both are set. Setting $syncable = ['*'] disables filtering.

Define callbacks as static methods on the model:

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

class Task extends Model
{
    use Syncable;

    // Restrict which records are returned during pull
    public static function tetherScope(Builder $query, string $clientId, Request $request): Builder
    {
        return $query->where('user_id', $request->user()->id);
    }

    // Amend snapshot payload before sending to client
    public static function tetherPullSnapshotMapper(Snapshot $snapshot, Model $row): Snapshot
    {
        return $snapshot->withPayload(Arr::except($snapshot->payload, ['internal_notes']));
    }

    // Amend incoming mutation payload before applying
    public static function tetherPushMutationMapper(
        Mutation $mutation,
        Request $request,
    ): Mutation {
        return $mutation->withPayload(array_merge($mutation->getPayload(), ['user_id' => $request->user()->id]));
    }

    // Custom conflict resolution (client wins in this example)
    public static function tetherConflictResolver(Mutation $mutation, Model $record, Request $request): ConflictResolution
    {
        return ConflictResolution::apply($mutation->getPayload());
    }
}

Option B - SyncRegistry in a service provider

For more explicit control, register models via SyncRegistry in a service provider. This takes precedence over the trait. You don't need to use the trait if you register the model this way.

use Tether\Server\SyncRegistry;
use Tether\Core\Conflict\ConflictResolution;
use Tether\Core\Mutation\Mutation;
use Tether\Core\Sync\Snapshot;
use Illuminate\Support\Arr;

public function boot(): void
{
    app(SyncRegistry::class)->register(
        modelClass: Task::class,
        scope: fn ($query, $clientId, $request) => $query->where('user_id', $request->user()->id),
        pullSnapshotMapper: fn (Snapshot $snapshot, $row) => $snapshot->withPayload(Arr::except($snapshot->payload, ['internal_notes'])),
        pushMutationMapper: fn (Mutation $mutation, Request $request) => $mutation->withPayload(array_merge(
            $mutation->getPayload(),
            ['user_id' => $request->user()->id]
        )),
        conflictResolver: fn (Mutation $mutation, $record, $request) => ConflictResolution::reject(),
    );
}

SyncRegistry options

All parameters except modelClass are optional.

ParameterSignaturePurpose
scope(Builder $q, string $clientId, Request $r): BuilderFilter which rows are returned during pull (e.g. per-user)
pullSnapshotMapper(Snapshot $snapshot, Model $row): SnapshotTransform row attributes before sending to client
pushMutationMapper(Mutation $mutation, Request $request): MutationTransform incoming payload before applying (e.g. stamp user ID)
conflictResolver(Mutation $mutation, Model $record, Request $request): ConflictResolutionCustom per-model conflict resolution

Protecting the sync endpoints

Add authentication middleware that fits your app:

// config/tether-server.php
'middleware' => ['api', 'auth:sanctum'],