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:
- You have the tenant resource you want to sync, such as TenantUser, and a corresponding central resource.
- 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.
- If an update occurs in the tenant context, it is “bubbled up” into the central context and synced with all other attached tenants.
- 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.
Quick start
Section titled “Quick start”Database
Section titled “Database”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'); }};Models
Section titled “Models”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:
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', ]; }}The tenant resource model requires the same methods as the central one,
except for getTenantModelName().
use Stancl\Tenancy\ResourceSyncing\Syncable;
class TenantUser extends Model implements Syncable{ use ResourceSyncing;
/* * The table name can differ from the central resource * as they're separate models in separate databases. */ protected $table = 'users';
/** * The class name returned by this method is used to find * the corresponding central resource. * * Here, in the tenant resource, this method has to return * the central resource model class (CentralUser in our case). */ public function getCentralModelName(): string { return CentralUser::class; }
public function getSyncedAttributeNames(): array { return [ 'global_id', 'name', 'password', 'email', ]; }}The added users() method isn’t necessary for resource syncing,
however it’s generally helpful to be able to access a tenant’s users as
$tenant->users() just like you can access $centralUser->tenant.
class Tenant extends BaseTenant implements TenantWithDatabase{ use HasDatabase, HasDomains;
public function users(): MorphToMany { return $this->morphedByMany( CentralUser::class, 'tenant_resources', 'tenant_resources', 'tenant_id', 'resource_global_id', 'id', 'global_id' )->using(TenantMorphPivot::class); }}Basic usage example
Section titled “Basic usage example”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.
use App\Models\CentralUser;use App\Models\TenantUser;use App\Models\Tenant;
$centralUser = CentralUser::create([ 'name' => 'John Doe', 'global_id' => 'foo', 'password' => 'password',]);
tenancy()->initialize(Tenant::create());
$tenantUser = TenantUser::create([ 'name' => 'John Doe', 'global_id' => 'foo', 'password' => 'password',]);
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.
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.
Core concepts
Section titled “Core concepts”This section goes over the main concepts in resource syncing.
Synced attributes
Section titled “Synced attributes”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:
use App\Models\CentralUser;use App\Models\Tenant;use App\Models\TenantUser;
$centralUser = CentralUser::create([ 'name' => 'John Doe', 'global_id' => 'foo', 'password' => 'password', 'role' => 'admin',]);
tenancy()->initialize(Tenant::create());$tenantUser = TenantUser::create([ 'name' => 'John Doe', '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
Section titled “Creation attributes”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.
use App\Models\Tenant;use App\Models\TenantUser;use App\Models\CentralUser;
$tenant = Tenant::create();tenancy()->initialize($tenant);
$tenantUser = TenantUser::create([ 'name' => 'John Doe', '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.
use App\Models\Tenant;use App\Models\CentralUser;use App\Models\TenantUser;
$centralUser = CentralUser::create([ 'name' => 'John Doe', '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:
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',]);
tenancy()->end();
$centralUser = CentralUser::firstWhere('global_id', 'user');
dump($centralUser->name); // 'User'dump($centralUser->password); // '1234'
dump($centralUser->role); // 'user'Cascade on deleting a resource
Section titled “Cascade on deleting a resource”When a central resource is deleted, all tenant versions of that resource are deleted automatically by default.
use App\Models\Tenant;use App\Models\CentralUser;use App\Models\TenantUser;
$tenant1 = Tenant::create();$tenant2 = Tenant::create();
$centralUser = CentralUser::create([ 'name' => 'John Doe', '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')),]);Conditional sync
Section titled “Conditional sync”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.
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. }}Soft deletes with resource syncing
Section titled “Soft deletes with resource syncing”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:
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(); } };
// ...}Configuration
Section titled “Configuration”This section goes over some customizations you can make.
Events
Section titled “Events”By default, the resource syncing events and listeners are registered in
the TenancyServiceProvider like this:
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.
Queueing event listeners
Section titled “Queueing event listeners”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:
public function boot(){ ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::$shouldQueue = true;
// ...}Global identifier
Section titled “Global identifier”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), );}Pivot tables
Section titled “Pivot tables”The ResourceSyncing trait provides a default tenants() relationship method that uses
a polymorphic (morphToMany) relationship with the tenant_resources pivot table:
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.
Polymorphic pivot
Section titled “Polymorphic pivot”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.
Basic pivot
Section titled “Basic pivot”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:
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');});Custom pivot models
Section titled “Custom pivot models”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:
use Stancl\Tenancy\ResourceSyncing\PivotWithCentralResource;use Stancl\Tenancy\ResourceSyncing\TenantPivot;
class CustomTenantPivot extends TenantPivot implements PivotWithCentralResource{ public function getCentralResourceClass(): class { return CentralUser::class; }}class Tenant extends BaseTenant{ public function users(): BelongsToMany { return $this->belongsToMany( CentralUser::class, 'tenant_users', 'tenant_id', 'global_user_id', 'id', 'global_id', 'users' )->using(CustomTenantPivot::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.
How it works
Section titled “How it works”Database schema
Section titled “Database schema”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.
Default event flow
Section titled “Default event flow”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.
Saving synced resources
Section titled “Saving synced resources”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.
Deleting synced resources
Section titled “Deleting synced resources”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
Section titled “UpdateOrCreateSyncedResource”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.
DeleteResourcesInTenants
Section titled “DeleteResourcesInTenants”When a central resource gets deleted, the DeleteResourcesInTenants listener deletes related
tenant resources from the database of each tenant returned by $centralResource->tenants.
DeleteResourceMapping
Section titled “DeleteResourceMapping”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').
DeleteAllTenantMappings
Section titled “DeleteAllTenantMappings”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.
/** * 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:
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.
Attaching and detaching
Section titled “Attaching and detaching”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:
$centralUser->tenants()->attach($tenant)or$tenant->users()->attach($centralUser)CentralResourceAttachedToTenantis firedCreateTenantResourcelistens to that event, creating aTenantUser
Detaching:
$centralUser->tenants()->detach($tenant)or$tenant->users()->detach($centralUser)CentralResourceDetachedFromTenantis firedDeleteResourceInTenantlistens to that event, deleting theTenantUser
PivotWithCentralResource
Section titled “PivotWithCentralResource”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.
New features in v4
Section titled “New features in v4”This section covers the most significant resource syncing features that are new in version 4.
Creation attributes
Section titled “Creation attributes”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.
shouldSync method
Section titled “shouldSync method”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.
Cascading deletes
Section titled “Cascading deletes”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).
Soft deletes
Section titled “Soft deletes”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
Section titled “Attaching and detaching”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 pivots are now the default
Section titled “Polymorphic pivots are now the default”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.
Global identifier defaults
Section titled “Global identifier defaults”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.
Footnotes
Section titled “Footnotes”-
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. ↩
-
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. ↩