Examples

Task Manager

Follow a practical Laravel Tether example that builds a task manager with local offline writes, server sync endpoints, scoped pull, and conflicts.

This example builds an offline-first Laravel task manager: a server app that holds the canonical list of tasks, and a client app that can create, edit, and delete tasks while offline, then sync and reconcile with the server when appropriate.


What we're building

  • A Task model with title, description, status, and due_date fields
  • Server exposes the sync endpoint, scoped to tasks owned by the authenticated client
  • Client creates and updates tasks locally with no network requirement
  • Conflicts fall back to the server's version

Part 1 - Server

1.1 Install tether/server

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

1.2 Create the Task model and migration

php artisan make:model Task -m

Migration:

Schema::create('tasks', function (Blueprint $table) {
    $table->id();
    $table->tetherUlid();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('title');
    $table->text('description')->nullable();
    $table->string('status')->default('pending');
    $table->date('due_date')->nullable();
    $table->timestamps();
    $table->softDeletes();
});

The timestamps() call is required for Syncable server models because Tether uses updated_at for pull cursors and conflict checks.

1.3 Apply Syncable and define the scope

The scope ensures each client only receives its own tasks. The push mutation mapper stamps the authenticated user's ID onto every create, so clients can't write tasks for other users.

app/Models/Task.php:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Tether\Core\Mutation\Mutation;
use Tether\Server\Traits\Syncable;

class Task extends Model
{
    use SoftDeletes;
    use Syncable;

    protected $fillable = ['tether_id', 'user_id', 'title', 'description', 'status', 'due_date'];

    public static function tetherScope(Builder $query, string $clientId, Request $request): Builder
    {
        return $query->where('user_id', $request->user()->id);
    }

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

1.4 Protect the sync endpoints

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

Part 2 - Client

2.1 Install tether/client

composer require tether/client
php artisan tether:install
php artisan migrate

2.2 Configure the server URL and client credentials

TETHER_SERVER_PUSH_URL=https://your-server.example.com/tether/push
TETHER_SERVER_PULL_URL=https://your-server.example.com/tether/pull
TETHER_CLIENT_ID=device-abc-123

2.3 Create the Task model and migration

php artisan make:model Task -m

Migration:

Schema::create('tasks', function (Blueprint $table) {
    $table->id();
    $table->tetherUlid();           // tether_id char(26) unique nullable
    $table->string('title');
    $table->text('description')->nullable();
    $table->string('status')->default('pending');
    $table->date('due_date')->nullable();
    $table->timestamps();
    $table->softDeletes();
});

The client table also keeps timestamps() so local writes maintain standard updated_at values before sync.

app/Models/Task.php:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Tether\Client\Traits\Syncable;

class Task extends Model
{
    use SoftDeletes;
    use Syncable;

    protected $fillable = ['title', 'description', 'status', 'due_date'];

    protected array $syncable = ['title', 'description', 'status', 'due_date'];
}

2.4 Configure the HTTP client to send an auth token

// AppServiceProvider::boot()
use GuzzleHttp\Middleware;
use Tether\Client\SyncHttpClient;

app(SyncHttpClient::class)->withMiddleware(
    Middleware::mapRequest(
        fn ($request) => $request->withHeader('Authorization', 'Bearer '.config('services.tether.token'))
    )
);

SyncHttpClient can also be used for request signing, tenant headers, or transport-level logging. See the Service Container page for the full container service overview.

TETHER_SERVER_TOKEN=your-sanctum-token

Part 3 - Using it

Creating tasks (works offline)

use App\Models\Task;

$task = Task::create([
    'title'   => 'Write tests',
    'status'  => 'pending',
    'due_date' => '2026-06-01',
]);

// $task->tether_id is now set - no network required
// A 'pending' mutation log entry is recorded automatically

Updating and deleting

$task->update(['status' => 'in_progress']);
// New mutation log entry: operation = 'update'

$task->delete();
// Mutation log entry: operation = 'delete'

Sync it manually

use Tether\Client\Facades\TetherClient;

$result = TetherClient::sync();

// $result->pushed    - mutations sent
// $result->pulled    - records applied locally
// $result->conflicts - conflicts encountered
// $result->failed    - rejected mutations

or manually via artisan command:

php artisan tether:sync

Scheduling automatic sync

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('tether:sync')->everyFiveMinutes();

Checking sync status

php artisan tether:status

Part 4 - Handling conflicts

Listen for the TetherConflictDetected event to notify users when their change was overridden:

use Tether\Client\Events\TetherConflictDetected;
use Illuminate\Support\Facades\Log;

Event::listen(TetherConflictDetected::class, function (TetherConflictDetected $event) {
    Log::warning('Tether conflict', [
        'model'  => $event->model,
        'id'     => $event->entityId,
        'server' => $event->serverState,
    ]);
});

Summary

StepServerClient
Installcomposer require tether/servercomposer require tether/client
Migratephp artisan tether:install-server && migratephp artisan tether:install && migrate
ModelSyncable + scope + push mutation mapperSyncable + $syncable fields
Auth'middleware' => ['api', 'auth:sanctum']Token sent via SyncHttpClient
SyncPassive - responds to push/pull requestsTetherClient::sync() or tether:sync