# Resource syncing

Multi-database tenancy isolates per-tenant data completely — but some records should exist in both worlds. The user logged into your SaaS is the same person no matter which tenant they act in; the product catalog you sell is the same catalog no matter which tenant installed it. Packstub Tenancy bridges [stancl/tenancy's Resource Syncing](https://v4.tenancyforlaravel.com/resource-syncing/) into Filament with a one-line plugin call: opted-in models keep a chosen set of attributes mirrored between the central database and every attached tenant database, bidirectionally, through ordinary Eloquent events — so your existing Filament resources keep working unchanged.

## How it works

Resource syncing pairs a **central model** (lives in the central database) with a **tenant model** (lives inside each tenant database). The two sides are correlated by a shared **global identifier** column — `global_id` by default — and connected through the `tenant_resources` polymorphic pivot in the central database, which records which tenants each central record is attached to.

Whenever a synced attribute changes on either side, Eloquent model events fire and stancl/tenancy's listeners propagate the change:

- A change on the **central** side fans out to every attached tenant database.
- A change on the **tenant** side bubbles up to the central record, then fans back out to the *other* attached tenants.

Attributes you don't list as synced stay local to whichever database they live in — which is exactly what you want for tenant-only columns like `role` or `installed_at`.

## Enable it

Declare your model pairs on the plugin:

```php
use Packstub\Tenancy\TenancyPlugin;

TenancyPlugin::make()
    ->syncResources([
        \App\Models\Central\User::class   => \App\Models\Tenant\User::class,
        \App\Models\Central\Plugin::class => \App\Models\Tenant\Plugin::class,
    ])
    ->cleanupOrphanedResourceMappings()  // wipe pivot rows when a tenant is deleted
    ->queueResourceSync();               // optional: fan out via your queue
```

The installer publishes a `tenant_resources` migration (stancl/tenancy's polymorphic pivot: `tenant_id`, `resource_global_id`, `tenant_resources_type`). Run it against the central database:

```bash
php artisan migrate
```

### Config file alternative

Every plugin method has a config equivalent in `config/packstub-tenancy.php`:

```php
'resource_syncing' => [
    'enabled' => true,
    'pairs' => [
        \App\Models\Central\User::class => \App\Models\Tenant\User::class,
    ],
    'queue'   => false,
    'cleanup' => true,  // bool | ['table' => 'tenant_id_column', ...]
],
```

Prefer the config file when sync events can originate outside a Filament request — queue workers and Artisan commands never boot a panel, so plugin-only wiring would leave the sync listeners unregistered in those processes. The service provider reads the config on every boot; calling both is safe, the listeners are registered at most once.

## Author the models

Both sides need a `global_id` column. Add it to the central table's migration and to the tenant migration (in `database/migrations/tenant/`):

```php
$table->string('global_id')->unique();
```

You don't need to fill it yourself — stancl/tenancy auto-generates it on `creating` using the generator configured in `tenancy.models.id_generator` (a UUID by default).

### Central side

Implement `SyncMaster`, use the `SyncsToTenants` trait, and point at the tenant model:

```php
namespace App\Models\Central;

use Illuminate\Database\Eloquent\Model;
use Packstub\Tenancy\Concerns\SyncsToTenants;
use Stancl\Tenancy\ResourceSyncing\SyncMaster;

class User extends Model implements SyncMaster
{
    use SyncsToTenants;

    public string $tenantModel = \App\Models\Tenant\User::class;

    protected $fillable = ['global_id', 'name', 'email', 'password'];

    public function syncedAttributes(): array
    {
        return ['global_id', 'name', 'email', 'password'];
    }

    /**
     * Optional: attributes applied when this central row first materializes
     * inside a tenant database. Tenant-only columns with defaults belong here.
     */
    public function getCreationAttributes(): array
    {
        return [
            'global_id', 'name', 'email', 'password',
            'role' => 'member', // tenant-only column
        ];
    }
}
```

The `SyncsToTenants` trait pins the model to the central connection (via stancl/tenancy's `CentralConnection` concern), so Filament resources running inside a tenant panel still read and write the central database correctly. It also boots the resource-syncing event hooks and provides sensible defaults for the `SyncMaster` contract methods.

### Tenant side

Implement `Syncable`, use the `IsTenantResource` trait, and point back at the central model:

```php
namespace App\Models\Tenant;

use Illuminate\Database\Eloquent\Model;
use Packstub\Tenancy\Concerns\IsTenantResource;
use Stancl\Tenancy\ResourceSyncing\Syncable;

class User extends Model implements Syncable
{
    use IsTenantResource;

    public string $centralModel = \App\Models\Central\User::class;

    protected $fillable = ['global_id', 'name', 'email', 'password', 'role'];

    public function syncedAttributes(): array
    {
        return ['global_id', 'name', 'email', 'password'];
    }
}
```

Tenant-side models stay on the active tenant connection — the tenancy bootstrapper rebinds the default connection inside tenant context, so no extra wiring is needed.

### Declaring synced attributes

Both traits resolve the synced attribute list the same way, in order of precedence:

1. A `syncedAttributes(): array` method (shown above — the explicit, recommended form).
2. A `public array $syncedAttributes` property.
3. Fallback: the global identifier key merged with the model's `$fillable`.

If you rely on the fallback and `$fillable` is empty, the trait throws a `LogicException` at runtime telling you to define `syncedAttributes()` — a sync that would only mirror `global_id` is almost certainly a mistake. Similarly, forgetting the `$tenantModel` or `$centralModel` property throws a `LogicException` naming the missing property.

## What gets synced

| Trigger | Effect |
|---|---|
| Update an attribute listed in `syncedAttributes()` on the central row | Cascades to every attached tenant database |
| Update the same attribute inside tenant context | Bubbles up to central, then re-fans to the *other* attached tenants |
| `$central->tenants()->attach($tenant)` | Creates the tenant-side row (using `getCreationAttributes()`) |
| `$central->tenants()->detach($tenant)` | Deletes the tenant-side row |
| Delete a central row | Cascades a delete to every attached tenant database |
| Restore a soft-deleted central row (models using `SoftDeletes`) | Restores the row in attached tenant databases |
| Create a tenant-side row with no matching central record | Auto-creates the central record |
| Delete a tenant | With `cleanupOrphanedResourceMappings()`: wipes the tenant's pivot rows |

Attributes **not** in `syncedAttributes()` never cross a database boundary. Updates to them fire no sync at all — only changes touching a synced attribute (or a newly created row) trigger propagation.

## Real-world use cases

1. **Shared user accounts** — `name`/`email`/`password` in `syncedAttributes()`, `role` per-tenant. One identity across all tenants, tenant-specific permissions. A password change in any tenant panel propagates everywhere.
2. **Product or plugin catalog** — a central `Plugin` defines `name`/`version`/`description`; the tenant copy adds `installed_at`, so each tenant manages installation state independently while the catalog stays authoritative.
3. **License keys** — the central `License` is the source of truth; the tenant copy syncs `key`/`expires_at` down and keeps tenant-local `notes` out of the synced set.
4. **Subscription plan templates** — central marketing tiers; each tenant caches the subset it is entitled to.

## Filament integration

Syncing happens at the model layer through Eloquent events, so your existing Filament resources keep working with no changes:

- A `UserResource` in the **tenant panel** edits a `Tenant\User` row — the save bubbles up to central and out to the other attached tenants.
- A `UserResource` in the **central admin panel** edits a `Central\User` row — the save cascades to every attached tenant.
- The `tenants()` relationship on the central model is a `MorphToMany` — Eloquent's `BelongsToMany` subclass, going through the polymorphic pivot — so managing attachments from a Filament form Just Works:

  ```php
  use Filament\Forms\Components\Select;

  Select::make('tenants')
      ->relationship('tenants', 'name')
      ->multiple()
      ->preload();
  ```

  Attaching creates the tenant-side row; detaching deletes it.

One subtlety the plugin handles for you: when a shared **central** record is edited from *inside a tenant panel*, stancl/tenancy's default behavior would classify the save as tenant-originated (the ambient tenant is set) and silently attach the acting tenant to the record. Packstub Tenancy's `SyncsToTenants` trait pins central saves to a central origin, so editing a central record from a tenant panel fans out to its attached tenants and never auto-attaches the tenant you happen to be browsing.

## Queued syncing

By default the sync listeners run inline, in the same request that triggered the change. If one central change fans out to many tenant databases — hundreds of tenants, or tenants spread across [multiple database servers](https://packstub.dev/docs/filament-tenancy/horizontal-scaling) — push the listeners onto your queue instead:

```php
TenancyPlugin::make()
    ->syncResources([...])
    ->queueResourceSync();
```

Two requirements:

1. **A queue worker must be running.** Queued sync jobs sit in the queue until a worker picks them up — the same worker requirement as tenant provisioning.
2. **`QueueTenancyBootstrapper` must be enabled** in `config/tenancy.php`:

   ```php
   'bootstrappers' => [
       // ...
       Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
   ],
   ```

The bootstrapper is what restores the originating tenant (or central) database context inside the worker. Without it, a queued sync job runs against whatever connection the worker happens to be on and silently reads or writes the wrong database. The plugin refuses to let that happen — enabling `queueResourceSync()` without the bootstrapper throws at boot:

```
RuntimeException: Resource syncing is configured to queue (queueResourceSync), but
Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper is not enabled in
tenancy.bootstrappers. Without it, queued sync jobs run in the wrong database
context and silently corrupt data. Add it to config/tenancy.php, or run the sync
listeners inline with queueResourceSync(false).
```

Add the bootstrapper, or drop back to inline syncing with `queueResourceSync(false)`.

## Cleanup when a tenant is deleted

When a tenant is deleted, its rows in the `tenant_resources` pivot would otherwise linger — mappings pointing at a database that no longer exists. Enable cleanup and the plugin listens for stancl/tenancy's `TenantDeleted` event and wipes the deleted tenant's pivot rows:

```php
TenancyPlugin::make()
    ->syncResources([...])
    ->cleanupOrphanedResourceMappings();
```

The default targets the `tenant_resources` pivot the plugin ships. If you have additional custom pivot tables keyed by tenant, pass a map of `table => tenant-ID column`:

```php
->cleanupOrphanedResourceMappings([
    'tenant_resources' => 'tenant_id',
    'tenant_licenses'  => 'tenant_id',
])
```

The config equivalent is `resource_syncing.cleanup`, accepting the same `bool | array` shape. Passing an empty array is treated as "off" — no `TenantDeleted` listener is registered.

## Pair validation

The pairs you declare in `syncResources()` are cross-checked at boot against the models' own wiring, so a typo fails loudly instead of silently doing nothing. Each of these throws an `InvalidArgumentException`:

| Mistake | Error |
|---|---|
| Central class doesn't implement `SyncMaster` | `Central model [App\Models\Central\User] must implement Stancl\Tenancy\ResourceSyncing\SyncMaster` |
| Tenant class doesn't implement `Syncable` | `Tenant model [App\Models\Tenant\User] must implement Stancl\Tenancy\ResourceSyncing\Syncable` |
| Pair contradicts the central model's `$tenantModel` | `Declared sync pair [A => B] contradicts A::$tenantModel = C.` |
| Pair contradicts the tenant model's `$centralModel` | `Declared sync pair [A => B] contradicts B::$centralModel = C.` |

The declared pairs are informational at registration time — stancl/tenancy's listeners react to the `SyncMaster`/`Syncable` contracts on the models themselves. Declaring pairs buys you this boot-time integrity check and makes the wiring explicit, which pays off in audits.
