Task Manager
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
Taskmodel withtitle,description,status, anddue_datefields - 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
| Step | Server | Client |
|---|---|---|
| Install | composer require tether/server | composer require tether/client |
| Migrate | php artisan tether:install-server && migrate | php artisan tether:install && migrate |
| Model | Syncable + scope + push mutation mapper | Syncable + $syncable fields |
| Auth | 'middleware' => ['api', 'auth:sanctum'] | Token sent via SyncHttpClient |
| Sync | Passive - responds to push/pull requests | TetherClient::sync() or tether:sync |