Skip to content

Resource syncing

If you’d like to keep some resources (Eloquent models) in sync across tenant databases, you can use the resource syncing feature. The most common use case is syncing users, such that “the same user” keeps the same email address, name, and importantly password, across all tenants the user exists in.

The feature works like this:

  1. You have the tenant resource you want to sync, such as TenantUser, and a corresponding central resource.
  2. The central resource, such as CentralUser, is attached to tenants, indicating which tenants the resource exists in — which tenants the resource should be synced to.
  3. If an update occurs in the tenant context, it is “bubbled up” into the central context and synced with all other attached tenants.
  4. If an update occurs in the central context, the change will be “cascaded down” to individual tenant databases.

The feature is heavily configurable to support all sorts of scenarios.

The central resource will exist in the central database, for instance in the users table. The tenant resource will exist in tenant databases.

In this example, we’ll assume you have the same migration for the users table in database/migrations, and in database/migrations/tenant:

return new class extends Migration
{
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('global_id')->unique();
$table->string('name');
$table->string('role')->nullable();
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('users');
}
};

For resource syncing to work, the tables require a global identifier column (in this example, global_id). This is what tells the feature which resources “are the same” — it looks at the attached tenants, and then updates models whose global_id matches the updated model.

You’ll also need a pivot table in the central database that will map the resources to tenants. You can publish the tenant_resources migration using the php artisan vendor:publish --tag=resource-syncing-migrations command.

return new class extends Migration
{
public function up(): void
{
Schema::create('tenant_resources', function (Blueprint $table) {
$table->increments('id');
$table->string('tenant_id');
$table->string('resource_global_id');
$table->string('tenant_resources_type');
});
}
public function down(): void
{
Schema::dropIfExists('tenant_resources');
}
};

You’ll need two models for the resource. One for the central resource, implementing the SyncMaster interface, and one for the tenant resource, implementing the Syncable interface.

Make sure that both models use the ResourceSyncing trait. The trait provides the majority of the methods required by the mentioned interfaces. It’s also responsible for hooking into the saved(), deleted() and creating() model events (also forceDeleting() and restored() if your model is using SoftDeletes), and triggering resource syncing events like SyncedResourceSaved, SyncMasterDeleted, etc.

In addition to using the ResourceSyncing trait, you’ll need to add a few methods to the models — a key one being getSyncedAttributeNames() which specifies which columns should be synced.

Here’s what that code looks like, following our example of syncing users:

app/Models/CentralUser.php
use Stancl\Tenancy\ResourceSyncing\SyncMaster;
class CentralUser extends Model implements SyncMaster
{
use ResourceSyncing, CentralConnection;
public $table = 'users';
/**
* Class name of the tenant resource model.
*
* In our example, the tenant resource is TenantUser.
*/
public function getTenantModelName(): string
{
return TenantUser::class;
}
/**
* Class name of the related central resource model.
*
* Since the class we're in is actually the central
* resource, we can just return static::class.
*/
public function getCentralModelName(): string
{
return static::class;
}
/**
* List of all attributes to keep in sync with the other resource
* (meaning the tenant model described in the other tab above).
*
* When this resource gets updated, the other resource's columns
* with the same names will automatically be updated too.
*/
public function getSyncedAttributeNames(): array
{
return [
'global_id',
'name',
'password',
'email',
];
}
}

Before diving deeper into all the available configuration and underlying mechanisms, let’s test that our syncing setup works.

We’ll do this by creating a model in the central database and a model in a tenant database with the same global ID. In most contexts, the package generates the IDs for you but to keep things simple we’ll do this by hand.

After creating our models, we’ll try to make a change in one of them and see if the change gets synced to the other resource.

php artisan tinker
use App\Models\CentralUser;
use App\Models\TenantUser;
use App\Models\Tenant;
$centralUser = CentralUser::create([
'name' => 'John Doe',
'email' => '[email protected]',
'global_id' => 'foo',
'password' => 'password',
]);
tenancy()->initialize(Tenant::create());
$tenantUser = TenantUser::create([
'name' => 'John Doe',
'email' => '[email protected]',
'global_id' => 'foo',
'password' => 'password',
]);
$tenantUser->update(['email' => '[email protected]']);
tenancy()->end();
dump($centralUser->refresh()->email);

Let’s also take a look at attaching and detaching. Tenants can be attached to/detached from central resources and vice versa. In many apps that approach will be more common than creating two resources with the same global ID.

php artisan tinker
use App\Models\CentralUser;
use App\Models\TenantUser;
$centralUser = CentralUser::first();
$tenant = $centralUser->tenants()->first();
dump($tenant->run(fn () => count(TenantUser::all())));
$centralUser->tenants()->detach($tenant);
dump($tenant->run(fn () => count(TenantUser::all())));
$centralUser->tenants()->attach($tenant);
dump($tenant->run(fn () => count(TenantUser::all())));

When a model is attached, rather than created manually, Tenancy will automatically create it. That process can be customized by defining creation attributes.

This section goes over the main concepts in resource syncing.

An important requirement of the Syncable interface is the getSyncedAttributeNames() method. The method should return an array of the attributes you want to keep in sync with the other resource. When a resource gets updated, only the synced attributes specified by this method will get updated on the other resource 1.

For instance, when CentralUser has name in the synced attributes, updating a CentralUser record’s name will also update the name of all associated TenantUser records.

class TenantUser extends Model implements Syncable
{
use ResourceSyncing;
// ...
public function getSyncedAttributeNames(): array
{
return [
'global_id',
'name',
'password',
'email',
// There's also a 'role' attribute that we don't want to
// sync so we do NOT include it here.
];
}
}

To test this out:

php artisan tinker
use App\Models\CentralUser;
use App\Models\Tenant;
use App\Models\TenantUser;
$centralUser = CentralUser::create([
'name' => 'John Doe',
'email' => '[email protected]',
'global_id' => 'foo',
'password' => 'password',
'role' => 'admin',
]);
tenancy()->initialize(Tenant::create());
$tenantUser = TenantUser::create([
'name' => 'John Doe',
'email' => '[email protected]',
'global_id' => 'foo',
'password' => 'password',
'role' => 'user',
]);
$tenantUser->update(['name' => 'User', 'role' => 'moderator']);
tenancy()->end();
$centralUser->refresh();
dump($centralUser->role);
dump($centralUser->name);

Creation attributes are the attributes which the other resource will be created with. By default, the getCreationAttributes() method provided by the ResourceSyncing trait uses the synced attributes as the creation attributes.

The main purpose of creation attributes is to provide values for columns that need to be filled, but aren’t necessarily synced. For instance, you may want to only sync the email and password of a user, but not the name. However when the resource is created for the first time in the other context, you still need to set a value for that column, so you’d still copy the name initially even if it won’t be synced after that.

Speaking of creation, if you create a resource in the tenant context, and the central resource — with the same global ID — doesn’t exist yet, it will be created automatically. All tenant resources will be accompanied by a central resource unless syncing is disabled.

php artisan tinker
use App\Models\Tenant;
use App\Models\TenantUser;
use App\Models\CentralUser;
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$tenantUser = TenantUser::create([
'name' => 'John Doe',
'email' => '[email protected]',
'global_id' => 'foo',
'password' => 'password',
]);
tenancy()->end();
// Assuming no central user with a 'foo' global_id exists yet,
// the central user got created with the same name, email,
// global_id and password as the tenant user.
dump(CentralUser::firstWhere('global_id', 'foo'));

The same applies in the other direction. If we want to create a tenant resource for a central resource, we can just attach a tenant to the central resource, rather than creating the tenant resource by hand. Creation attributes will also be used here — this time the creation attributes of the central resource, rather than the tenant resource as above.

php artisan tinker
use App\Models\Tenant;
use App\Models\CentralUser;
use App\Models\TenantUser;
$centralUser = CentralUser::create([
'name' => 'John Doe',
'email' => '[email protected]',
'global_id' => 'foo',
'password' => 'password',
]);
$tenant = Tenant::create();
$centralUser->tenants()->attach($tenant);
tenancy()->initialize($tenant);
dump(TenantUser::firstWhere('global_id', 'foo')->toArray());

To customize creation attributes, override the getCreationAttributes() method.

Unlike getSyncedAttributesNames(), the returned array can include default values rather than just a list of columns to keep in sync. This is useful if some column doesn’t exist in the other resource, but we want a reasonable default value: for instance if we use roles in the central context — user for a regular user and admin for a central admin (staff of your application) — we could include a default value for that column in the creation attributes of TenantUser. That way, when a TenantUser is created, and the central resource is automatically created if there isn’t an existing one with the same global ID, the created user will have limited permissions (if any) in the central app. 2

class TenantUser implements Syncable
{
public function getSyncedAttributeNames(): array
{
return [
'global_id',
'name',
'password',
'email',
];
}
public function getCreationAttributes(): array
{
return [
'global_id',
'name',
'password',
'email',
'role' => 'user',
];
}
}

Then, in tinker:

php artisan tinker
use App\Models\Tenant;
use App\Models\CentralUser;
use App\Models\TenantUser;
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$tenantUser = TenantUser::create([
'global_id' => 'user',
'name' => 'User',
'password' => '1234',
'email' => '[email protected]',
]);
tenancy()->end();
$centralUser = CentralUser::firstWhere('global_id', 'user');
dump($centralUser->name); // 'User'
dump($centralUser->password); // '1234'
dump($centralUser->email); // '[email protected]'
dump($centralUser->role); // 'user'

When a central resource is deleted, all tenant versions of that resource are deleted automatically by default.

php artisan tinker
use App\Models\Tenant;
use App\Models\CentralUser;
use App\Models\TenantUser;
$tenant1 = Tenant::create();
$tenant2 = Tenant::create();
$centralUser = CentralUser::create([
'name' => 'John Doe',
'email' => '[email protected]',
'global_id' => 'user',
'password' => 'password'
]);
$centralUser->tenants()->attach($tenant1);
$centralUser->tenants()->attach($tenant2);
dump([
$tenant1->run(fn () => TenantUser::firstWhere('global_id', 'user')),
$tenant2->run(fn () => TenantUser::firstWhere('global_id', 'user')),
]);
$centralUser->delete();
dump([
$tenant1->run(fn () => TenantUser::firstWhere('global_id', 'user')),
$tenant2->run(fn () => TenantUser::firstWhere('global_id', 'user')),
]);

Syncing can be conditionally disabled by overriding the shouldSync(): bool method.

If shouldSync() returns false, the model will not be synced. If it returns true, it will be synced. Or more precisely, syncing events will (not) be fired depending on the value returned by this method.

This means that if for a specific user, the central model returns false from shouldSync(), changes from the central context will not be synced to the tenant context. However, if the tenant user model’s shouldSync() returns true, changes from the tenant context will still be synced to the central context.

If you do override this method, presumably its output will be dynamic — it will return true in some cases and false in others. A simple example could read a synced column to determine whether a model is synced. More realistic examples would determine whether a model is synced using some existing fields, for instance to reuse the roles example from above, you could sync only users that have certain roles: managers could exist in multiple tenants, while regular users do not.

An interesting way to define this method is to use the global_id column. If a model has a global ID, it will be synced. If it doesn’t a global ID, it will not be synced. Importantly, if synced resources are created without a global ID, they by default have a global ID automatically generated. We can prevent this by overriding the generateGlobalIdentifierKey() to do nothing. It should be noted that using this setup requires extra care. If you have some models that will never be synced, it’s fine to create them without a global ID. If however the models aren’t synced now but might be synced later — a global ID will be added later — extra care should be taken to ensure there aren’t multiple independent versions of that model, such as users with the same email in separate tenants but no global IDs. If global IDs are added later in such a case, you should ensure that all of those users get the same global ID.

Therefore, if you’d like to conditionally sync some models, there are many ways to implement this and different implementations will make sense for different applications. You should however always think the implementation you pick through to make sure your models always stay consistent. For this reason, syncing is enabled by default and all tenant models will automatically have a global resource created. Tenancy doesn’t take a look at other fields — if you have a UNIQUE constraint on the email column of synced user tables, you create a tenant user record with email [email protected], it will create a central resource; if you then also create a new user with email [email protected] in a different tenant, instead of searching for the user in the central database and attaching it to the tenant, you will get a unique constraint violation in the central database, as Tenancy tries to create a new central resource with the same email. The right solution to an edge case like that, including all the security considerations of authentication, depends on how your application is structured.

CentralUser.php
class CentralUser extends Model
{
use ResourceSyncing;
// ...
public function shouldSync(): bool
{
return $this->global_id !== null;
}
protected function generateGlobalIdentifierKey(): void
{
// Do nothing. This can also depend on other fields.
}
}

By default, trashed (soft deleted) resources aren’t synced because the queries that resource syncing uses to find models don’t use the withTrashed() query builder macro.

To make resource syncing work with soft deletes, as in keep syncing from and to trashed records, you can define the $scopeGetModelQuery property on the listener that handles attribute syncing:

TenancyServiceProvider.php
use Stancl\Tenancy\ResourceSyncing\Listeners\UpdateOrCreateSyncedResource;
use Illuminate\Database\Eloquent\Builder;
public function boot()
{
UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) {
if ($query->hasMacro('withTrashed')) {
$query->withTrashed();
}
};
// ...
}

This section goes over some customizations you can make.

By default, the resource syncing events and listeners are registered in the TenancyServiceProvider like this:

TenancyServiceProvider.php
public function events()
{
return [
Events\TenantDeleted::class => [
// ...
// ResourceSyncing\Listeners\DeleteAllTenantMappings::class,
],
// ...
// Resource syncing
ResourceSyncing\Events\SyncedResourceSaved::class => [
ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::class,
],
ResourceSyncing\Events\SyncedResourceDeleted::class => [
ResourceSyncing\Listeners\DeleteResourceMapping::class,
],
ResourceSyncing\Events\SyncMasterDeleted::class => [
ResourceSyncing\Listeners\DeleteResourcesInTenants::class,
],
ResourceSyncing\Events\SyncMasterRestored::class => [
ResourceSyncing\Listeners\RestoreResourcesInTenants::class,
],
ResourceSyncing\Events\CentralResourceAttachedToTenant::class => [
ResourceSyncing\Listeners\CreateTenantResource::class,
],
ResourceSyncing\Events\CentralResourceDetachedFromTenant::class => [
ResourceSyncing\Listeners\DeleteResourceInTenant::class,
],
// Fired only when a synced resource is changed (as a result of syncing)
// in a different DB than DB from which the change originates (to avoid infinite loops)
ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase::class => [],
// ...
];
}

Feel free to disable some of the listeners, add additional ones, or even replace the default ones if you’d like to customize some aspects of how resource syncing works. This should be done with care and requires a good understanding of the event flow since the implementation is fairly complicated.

Most of the queue listeners listed above are queuable. Since resource syncing could be somewhat expensive if your resources are attached to many tenants, you may want to queue some of the listeners.

This would mainly be done with the UpdateOrCreateSyncedResource listener as it does most of the heavy work.

To queue any of these listeners, simply set the static $shouldQueue property on them to true:

TenancyServiceProvider.php
public function boot()
{
ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::$shouldQueue = true;
// ...
}

To customize the global identifier column name (global_id by default), override the getGlobalIdentifierKeyName() method:

public function getGlobalIdentifierKeyName(): string
{
return 'global_id';
return 'syncing_id';
}

When a synced resource is created, and no global ID is provided, the generateGlobalIdentifierKey() method is executed. By default, it uses the configured UniqueIdentifierGenerator. To use custom logic instead of the default generator, you can override the method:

protected function generateGlobalIdentifierKey(): void
{
$this->setAttribute(
$this->getGlobalIdentifierKeyName(),
generate_global_identifier($this),
);
}

The ResourceSyncing trait provides a default tenants() relationship method that uses a polymorphic (morphToMany) relationship with the tenant_resources pivot table:

ResourceSyncing.php
public function tenants(): BelongsToMany
{
return $this->morphToMany(
config('tenancy.models.tenant'),
'tenant_resources',
'tenant_resources',
'resource_global_id',
'tenant_id',
$this->getGlobalIdentifierKeyName()
)->using(TenantMorphPivot::class);
}

This default is suitable for most cases.

The default polymorphic approach lets you use a single tenant_resources table for all synced resources (users, products, etc.). This is the simplest option — just publish the migration (php artisan vendor:publish --tag=resource-syncing-migrations) and you’re good to go.

You can override the default tenants() method to customize things, e.g. use a different pivot model. Just make sure the relationship is either using the TenantMorphPivot model, or a correct, custom pivot model.

If you prefer a dedicated pivot table for each resource type (e.g. tenant_users, tenant_products, …), override the tenants() method to return belongsToMany instead of morphToMany:

CentralUser.php
public function tenants(): BelongsToMany
{
return $this->belongsToMany(
Tenant::class,
'tenant_users',
'global_user_id',
'tenant_id',
'global_id'
)->using(TenantPivot::class);
}

And your tenant_users migration could look like this:

Schema::create('tenant_users', function (Blueprint $table) {
$table->increments('id');
$table->string('tenant_id');
$table->unsignedBigInteger('global_user_id');
$table->unique(['tenant_id', 'global_user_id']);
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});

The package provides two pivot models you can use or extend: TenantMorphPivot for polymorphic relationships (the default), and TenantPivot for basic belongsToMany relationships. Both use the TriggerSyncingEvents trait.

When using a custom pivot model, it must use the TriggerSyncingEvents trait. The trait hooks into the pivot model’s events and fires resource syncing events when you attach/detach resources.

Attaching and detaching with a basic pivot

Section titled “Attaching and detaching with a basic pivot”

With the belongsToMany relationship, only $centralUser->tenants()->attach($tenant) works correctly. To make $tenant->users()->attach($centralUser) work too, your pivot must implement PivotWithCentralResource:

app/Models/CustomTenantPivot.php
use Stancl\Tenancy\ResourceSyncing\PivotWithCentralResource;
use Stancl\Tenancy\ResourceSyncing\TenantPivot;
class CustomTenantPivot extends TenantPivot implements PivotWithCentralResource
{
public function getCentralResourceClass(): class
{
return CentralUser::class;
}
}

Note that having attach/detach work in both directions isn’t required. If you syntactically only use the $centralUser->tenants()->attach($tenant) direction, you don’t need PivotWithCentralResource.

Central and tenant resources are associated using a pivot table and the global identifier. By default, the ResourceSyncing trait provides a polymorphic tenants() relationship using the tenant_resources pivot table.

Resource syncing is built on events that are triggered when you save, delete, attach, or detach resources. The syncing happens in listeners to these events.

On $centralResource->create/update(...), triggerSyncEvent() is called in your resource, which triggers the SyncedResourceSaved event handled by the UpdateOrCreateSyncedResource listener.

The same thing happens if a tenant resource is updated or created.

The SyncedResourceSavedInForeignDatabase event is dispatched after a resource in another database is saved as a result of syncing. This is mostly an internal detail and only used in our tests at this time — resource syncing doesn’t depend on this.

On $resource->delete(), triggerDeleteEvent() is called in your resource, which triggers the SyncedResourceDeleted event handled by the DeleteResourceMapping listener.

If the deleted resource is SyncMaster, the SyncMasterDeleted event is also triggered, and then handled by the DeleteResourcesInTenants listener.

Attaching tenants to central resources (and vice versa)

Section titled “Attaching tenants to central resources (and vice versa)”

On $centralResource->tenants()->attach($tenant), triggerAttachEvent() is called in your central resource, which triggers the CentralResourceAttachedToTenant event handled by the CreateTenantResource listener.

The same thing happens when $tenant->users()->attach($centralResource) is called, assuming properly configured pivots

As with saving synced resources, the SyncedResourceSavedInForeignDatabase event is dispatched after the resource in the other database (in this case the tenant database) is saved as a result of syncing (in this case the attach call).

Detaching tenants from central resources (and vice versa)

Section titled “Detaching tenants from central resources (and vice versa)”

On $centralResource->tenants()->detach($tenant), triggerDetachEvent() is called in your central resource, which triggers the CentralResourceDetachedFromTenant event handled by the DeleteResourceInTenant listener.

As above, the same thing happens in the opposite direction assuming properly configured pivots.

UpdateOrCreateSyncedResource is the most important listener of resource syncing.

When a synced resource gets saved, the UpdateOrCreateSyncedResource listener ensures the changes are propagated to the other resource.

Namely, if either a central resource or a tenant resource is changed, this listener makes sure those changes are reflected in the central resource as well as all (other) tenant resources.

If a tenant resource is created without an existing central resource with the same global ID, the listener creates the central resource.

When a central resource gets deleted, the DeleteResourcesInTenants listener deletes related tenant resources from the database of each tenant returned by $centralResource->tenants.

When any synced resource gets deleted, the DeleteResourceMapping listener deletes the related mappings from the pivot table.

When a central resource gets deleted, all related mappings get deleted from the pivot table.

When a tenant resource gets deleted, only the mapping for the tenant to which the resource belonged to gets deleted. This is essentially a equivalent to foreign key constraints with onDelete('cascade').

When a tenant gets deleted, the DeleteAllTenantMappings listener deletes all mappings in configured pivot tables for the deleted tenant.

This is essentially a replacement for onDelete('cascade'), in case you prefer not using foreign key constraints. Since most applications use database-level cascade deletes, this listener is commented out by default.

Unlike other listeners, this listener isn’t always fully “automatic” and may require some configuration.

DeleteAllTenantMappings.php
/**
* Pivot tables to clean up after a tenant is deleted, in the
* ['table_name' => 'tenant_key_column'] format.
*
* Since we cannot automatically detect which pivot tables
* are being used, they have to be specified here manually.
*
* The default value follows the polymorphic table used by default.
*/
public static array $pivotTables = ['tenant_resources' => 'tenant_id'];

To change the default configuration (which corresponds to the default polymorphic migration), simply set the static property to a different value in a service provider:

TenancyServiceProvider.php
public function boot()
{
ResourceSyncing\Listeners\DeleteAllTenantMappings::$pivotTables = [
'tenant_users' => 'tenant_id',
];
// ...
}

CreateTenantResource and DeleteResourceInTenant

Section titled “CreateTenantResource and DeleteResourceInTenant”

When a central resource gets attached to a tenant, the CreateTenantResource listener creates a resource using the central resource’s creation attributes in the tenant’s database.

When a central resource gets detached from a tenant, the DeleteResourceInTenant listener deletes the resource from the tenant database.

The pivot models we provide (TenantMorphPivot and TenantPivot) use the TriggerSyncingEvents trait, which hooks into the pivot model’s events and fires dedicated ones that allow for the following behavior.

Attaching:

  1. $centralUser->tenants()->attach($tenant) or $tenant->users()->attach($centralUser)
  2. CentralResourceAttachedToTenant is fired
  3. CreateTenantResource listens to that event, creating a TenantUser

Detaching:

  1. $centralUser->tenants()->detach($tenant) or $tenant->users()->detach($centralUser)
  2. CentralResourceDetachedFromTenant is fired
  3. DeleteResourceInTenant listens to that event, deleting the TenantUser

Polymorphic pivot models (extending MorphPivot) can access both models (the Tenant model and the central resource) while attaching/detaching. Basic pivot models (extending Pivot) can only access the tenant, not the central resource.

Therefore, when using basic pivots only one direction of attaching/detaching works: $centralUser->tenants(), not $tenant->users().

To solve this, you can use a custom pivot model that implements our PivotWithCentralResource interface which specifies the getCentralResourceClass() method, returning the central resource class. Then the resource syncing logic can query that model to find the relevant central resource record.

The PivotWithCentralResource interface is used in the TriggerSyncingEvents trait which is used by TenantPivot and TenantMorphPivot — which is why we recommend extending those pivot models when using custom pivot models. If you use this trait manually, you don’t need to extend those classes.

This section covers the most significant resource syncing features that are new in version 4.

You can now specify creation attributes in addition to synced attributes. This can be used to copy additional columns or provide default values, primarily for columns that should be filled with some value (so copying on the initial creation is fine) even if those columns shouldn’t be synced after the initial model creation.

Resource syncing can now be controlled (enabled or disabled) at the model level using the shouldSync(): bool method that specifies whether the model currently being saved should be synced.

When a central resource (SyncMaster) gets deleted, all tenant resources with the same global identifier are also deleted. The pivot records are deleted too.

When a tenant resource (Syncable) gets deleted, the related pivot record is also deleted.

There’s also a new listener - DeleteAllTenantMappings. You can use it to delete orphaned mappings after deleting a tenant (as an alternative to the DB-level onDelete('cascade') — only relevant if your tenant column in the pivot table doesn’t have a foreign key constraint).

The UpdateOrCreateSyncedResource::$scopeGetModelQuery property lets you scope the queries performed while syncing changes. Primarily, this allows soft deletes to be used with resource syncing. Specifically, syncing between trashed resources can be enabled through this. Cascaded deletes also work with soft deletes.

Attaching and detaching is now supported in both directions. Detaching now also deletes the tenant resource.

Syncable records are now created/deleted when attaching/detaching central resources.

$centralResource->tenants()->attach($tenant) now creates a tenant resource synced to $centralResource (same for $tenant->resources()->attach($centralResource) when using PivotWithCentralResource or MorphPivot).

Detaching tenants from central resources also deletes the synced tenant resources ($centralResource->tenants()->detach($tenant) deletes the tenant resource, same for $tenant->resources()->detach($centralResource) when using PivotWithCentralResource or MorphPivot).

Polymorphic many-to-many is now the default for the tenants() relationship method provided by the ResourceSyncing trait (using TenantMorphPivot). This allows you to have a single pivot table for all resources.

This approach doesn’t have any disadvantages compared to the non-polymorphic pivot. It also supports bidirectional attaching/detaching with no changes required.

The ResourceSyncing trait now provides a default implementation for getGlobalIdentifierKeyName() (returning global_id). This means you don’t need to implement this method yourself if you’re fine with the default global_id name.

We’ve also extracted global identifier key generation to generateGlobalKeyIdentifier() which can be overridden with custom logic, typically if you’d like to use different UniqueIdentifierGenerators for tenants and synced resources.

  1. For brevity, we use the term “other resource” a lot on this page. It refers to the other model class, but may be multiple models. When we say “other resource” in the context of a tenant resource, we talk about the central resource only. In the context of a central resource, the “other resource” means all the tenant models that are associated with the central resource. A simple way to look at this is that when we talk about the “other resource”, we’re just talking about classes — what attributes will be synced in one direction or the other, from one class to the other — rather than the details of how many records will end up affected.

  2. While the described approach is fine for many apps, if you have different types of users, like central admins vs regular users in tenant apps, you may prefer using separate models and separate auth guards. That way, the tenant users would still be synced to the central app just for “bookkeeping” purposes — resource syncing always needs to interact with the central database — but you could be using entirely separate models for central admins. Our book talks about considerations like these in depth.