Packstub.

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 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:

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:

php artisan migrate

Config file alternative

Every plugin method has a config equivalent in config/packstub-tenancy.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/):

$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:

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:

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 accountsname/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:

    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 — push the listeners onto your queue instead:

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:

    '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:

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:

->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.