Advanced

Service Container

Inject and extend Tether services through Laravel's container, including sync engines, registries, HTTP clients, snapshot applicators, and queues.

Tether registers its offline sync runtime services in Laravel's service container. Most applications only need the model traits, config files, facade, and artisan commands, but the container services are useful when you want to wire the sync engine into your own application services, controllers, auth layer, or diagnostics UI.

As a rule of thumb, registries and high-level services are the intended extension points. Processors and applicators are lower-level implementation services; use them when building a custom sync surface, and only replace them when you intentionally want to change package behaviour.


Client services

These services are registered by tether/client.

ServicePurposeCommon use
SyncEngineRuns sync(), push(), pull(), and syncStatus()Inject into your own services or controllers
SyncHttpClientSends push and pull HTTP requests to the serverAdd auth headers, request signing, or HTTP middleware
ClientSyncRegistryRegisters client-side payload and mutation mappersTransform inbound snapshots or outbound mutations
ClientIdResolverResolves the client_id sent with every sync requestRuntime device/user identity
SyncStateStorePersists local sync state such as last_sync_cursorStatus screens and diagnostics
PendingSyncQueueReads and updates local mutation log statePending/failed/conflict counters

Injecting the sync engine

SyncEngine is the service behind the TetherClient facade. Inject it when your own application code needs a concrete dependency.

namespace App\Services;

use Tether\Client\SyncEngine;
use Tether\Client\SyncResult;

class ManualSyncService
{
    public function __construct(
        private readonly SyncEngine $sync,
    ) {}

    public function run(): SyncResult
    {
        return $this->sync->sync();
    }
}

Use push() when you only want to send local mutations, and pull() when you only want to reconcile from the server.


SyncHttpClient

SyncHttpClient is the client-side HTTP transport used by SyncEngine. It is constructed from:

  • tether-client.server_routes.push
  • tether-client.server_routes.pull
  • the resolved client ID

It exposes:

$http->push($mutations);      // returns PushResult
$http->pull($cursor, $limit); // returns PullResult

Most applications should not call those methods directly. The main developer-facing hook is withMiddleware(), which applies Guzzle-compatible middleware to every Tether push and pull request.

// 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'),
        ),
    ),
);

Use this for bearer tokens, request signing, tenant headers, request logging, or any transport-level concern that should apply to both sync endpoints.


Client identity

The ClientIdResolver decides the client_id included in push and pull requests. Tether resolves it in this order:

  1. A runtime callable registered with TetherClientServiceProvider::resolveClientIdUsing().
  2. tether-client.client_id.
  3. An auto-generated ULID persisted in tether_sync_state.
// AppServiceProvider::boot()
use Tether\Client\TetherClientServiceProvider;

TetherClientServiceProvider::resolveClientIdUsing(
    fn () => 'user-'.auth()->id(),
);

For config-cached applications, prefer an invokable class string in tether-client.client_id_resolver.


Client mappers

Use ClientSyncRegistry to transform data at the client boundary.

// AppServiceProvider::boot()
use App\Models\Task;
use Tether\Client\ClientSyncRegistry;
use Tether\Core\Mutation\Mutation;
use Tether\Core\Sync\Snapshot;

app(ClientSyncRegistry::class)->register(
    modelClass: Task::class,
    payloadMapper: fn (Snapshot $snapshot) => $snapshot,
    mutationMapper: fn (Mutation $mutation) => $mutation,
);

Use payloadMapper for inbound pull snapshots before they are written locally. Use mutationMapper for outbound push mutations before they are sent to the server.

For more examples, see the Payload Mapping page.


Status and diagnostics

SyncStateStore and PendingSyncQueue are useful when building a status panel or debug page.

use Tether\Client\PendingSyncQueue;
use Tether\Client\SyncStateStore;

$pending = app(PendingSyncQueue::class)->count();
$failed = app(PendingSyncQueue::class)->failedCount();
$conflicts = app(PendingSyncQueue::class)->conflictCount();
$cursor = app(SyncStateStore::class)->get('last_sync_cursor');

For most UI cases, SyncEngine::syncStatus() is the simpler API because it returns the same information as a single DTO.


Server services

These services are registered by tether/server.

ServicePurposeCommon use
SyncRegistryRegisters syncable server models and callbacksMain server extension point
StateSnapshotGeneratorBuilds pull snapshots from registered modelsCustom pull controllers
PushSyncProcessorApplies push requests and records resultsCustom push controllers
ConflictDetectorEvaluates timestamp conflicts and resolversInternal unless replacing conflict behaviour
MutationApplicatorApplies create/update/delete mutations to EloquentInternal unless replacing mutation application

Registering server models

SyncRegistry is the primary server-side extension point. Use it when you want explicit registration instead of the server Syncable trait, or when you want to centralise model sync rules in a service provider.

// AppServiceProvider::boot()
use App\Models\Task;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Tether\Core\Conflict\ConflictResolution;
use Tether\Core\Mutation\Mutation;
use Tether\Core\Sync\Snapshot;
use Tether\Server\SyncRegistry;

app(SyncRegistry::class)->register(
    modelClass: Task::class,
    scope: fn (Builder $query, string $clientId, Request $request) => $query
        ->where('user_id', $request->user()->id),
    pullSnapshotMapper: fn (Snapshot $snapshot, Task $row) => $snapshot,
    pushMutationMapper: fn (Mutation $mutation, Request $request) => $mutation,
    conflictResolver: fn (Mutation $mutation, Task $record, Request $request) => ConflictResolution::reject(),
);

If a model also uses the server Syncable trait, explicit SyncRegistry::register() calls take precedence over trait auto-registration.


Custom sync controllers

The default server package registers POST /tether/push and POST /tether/pull. If you disable route registration, you can build your own controller and inject the same services the package controller uses.

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Tether\Core\Sync\PullRequest;
use Tether\Core\Sync\PushRequest;
use Tether\Server\PushSyncProcessor;
use Tether\Server\StateSnapshotGenerator;

class CustomTetherController
{
    public function push(Request $request, PushSyncProcessor $processor)
    {
        return response()->json(
            $processor->process(PushRequest::fromArray($request->all()), $request)->toArray(),
        );
    }

    public function pull(Request $request, StateSnapshotGenerator $snapshots)
    {
        return response()->json(
            $snapshots->since(PullRequest::fromArray($request->all()), $request)->toArray(),
        );
    }
}

This is useful when you need a different route shape, additional authorization, tenant setup, or custom request logging while keeping Tether's core push and pull behaviour.


Replacing services

Because Tether uses Laravel's container, you can replace services in your application service provider. Do this sparingly: replacing processors, applicators, or detectors can change sync semantics.

// AppServiceProvider::register()
use Tether\Client\SyncHttpClient;

$this->app->extend(SyncHttpClient::class, function (SyncHttpClient $client) {
    // Prefer withMiddleware() for transport changes.
    return $client;
});

Prefer the built-in extension points first:

  • Use SyncHttpClient::withMiddleware() for transport concerns.
  • Use ClientSyncRegistry and SyncRegistry for payload transformation.
  • Use SyncRegistry conflict resolvers for per-model conflict behaviour.
  • Use custom controllers with PushSyncProcessor and StateSnapshotGenerator for route-level changes.