Server Setup
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:
| Method | Path | Purpose |
|---|---|---|
POST | /tether/push | Receive and apply a batch of client mutations |
POST | /tether/pull | Return 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.
| Parameter | Signature | Purpose |
|---|---|---|
scope | (Builder $q, string $clientId, Request $r): Builder | Filter which rows are returned during pull (e.g. per-user) |
pullSnapshotMapper | (Snapshot $snapshot, Model $row): Snapshot | Transform row attributes before sending to client |
pushMutationMapper | (Mutation $mutation, Request $request): Mutation | Transform incoming payload before applying (e.g. stamp user ID) |
conflictResolver | (Mutation $mutation, Model $record, Request $request): ConflictResolution | Custom per-model conflict resolution |
Protecting the sync endpoints
Add authentication middleware that fits your app:
// config/tether-server.php
'middleware' => ['api', 'auth:sanctum'],
Configuration
Reference every tether/client config key for Laravel offline sync, including client identity, HTTP endpoints, queue sync, batching, retries, and cursors.
Configuration
Configure tether/server routes, middleware, sync keys, registered models, mutation storage, duplicate handling, and Laravel sync endpoint behaviour.