Skip to content

Resource Syncing (draft)

The resource syncing feature allows you to sync specific columns between the central database and the tenant databases. Meaning, you can make specific attributes of the tenant models update as soon as the attributes get updated on the central model (and vice versa).

Usage

To use resource syncing, first, ensure you’re listening to the resource syncing events, (either using our default listeners – recommended, or your own custom listeners), e.g. in the TenancyServiceProvider’s events() method:

public function events()
{
return [
// ...
ResourceSyncing\Events\SyncedResourceSaved::class => [
ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::class,
],
ResourceSyncing\Events\SyncMasterDeleted::class => [
ResourceSyncing\Listeners\DeleteResourcesInTenants::class,
],
ResourceSyncing\Events\CentralResourceAttachedToTenant::class => [
ResourceSyncing\Listeners\CreateTenantResource::class,
],
ResourceSyncing\Events\CentralResourceDetachedFromTenant::class => [
ResourceSyncing\Listeners\DeleteResourceInTenants::class,
],
// Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops)
ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase::class => [],
...
];
}

Next, you’ll need two models: one for the central resource (SyncMaster), and one for the tenant resource (Syncable). You’ll also need to add a column for the global identifier to the tables of both resources – the central and tenant resources will be associated using this column. The default name for the column is “global_id” (to use a different column name, override the getGlobalIdentifierKeyName() method). It is recommended to make the global ID a string – in the ResourceSyncing trait, we hook into the resource’s creating event and if the global ID isn’t set, we generate it using the currently bound UniqueIdentifierGenerator.

Make the central resource model implement SyncMaster and the tenant resource Syncable. Then, use the ResourceSyncing trait in both models. The trait provides defaults for the methods required by the SyncMaster and Syncable interfaces. An important default to notice is the tenants() relationship default – it uses a pivot table named tenant_resources. You can use php artisan vendor:publish --tag=resource-syncing-migrations to publish the migration for the tenant_resources table. You’ll either need that table, or you’ll need to override the tenants() method to use a custom table.

You’ll need to add a few methods for which we can’t provide the defaults in the ResourceSyncing trait (feel free to override the defaults – e.g. if you want your resource to have a different global identifier key than global_id, override the getGlobalIdentifierKeyName() method), and for which you’ll likely want a custom value. Here’s an example of the models with the least possible configuration:

class CentralUser extends Model implements SyncMaster
{
use ResourceSyncing, CentralConnection;
public $table = 'users';
/** Class name of the tenant resource model. */
public function getTenantModelName(): string
{
return TenantUser::class;
}
/** Class name of the related central resource model (static::class since this is the central resource). */
public function getCentralModelName(): string
{
return static::class;
}
/**
* List of all attributes you want to keep in sync with the other resource.
*
* When this resource gets updated, which attributes should get updated * in the other resource too?
*/
public function getSyncedAttributeNames(): array
{
return [
'global_id',
'name',
'password',
'email',
];
}
}
/**
* You'll need to add the same methods to the tenant resource model,
* except for getTenantModelName(), since Syncable doesn't
* require that method.
*/
class TenantUser extends Model implements Syncable
{
use ResourceSyncing;
/** You can use any table name you want for the tenant resource. */
protected $table = 'users';
public function getCentralModelName(): string
{
return CentralUser::class;
}
public function getSyncedAttributeNames(): array
{
return [
'global_id',
'name',
'password',
'email',
];
}
}

Before looking into the more advanced things covered in the next sections, you can test that you got the implemenatation right by creating a central resource (e.g. a user in the central database) and a tenant resource (e.g. a user in the database of some tenant) with the same global ID, update some of the synced attributes and see if the other resource gets updated automatically.

Synced attributes

An important requirement of the Syncable interface is the getSyncedAttributeNames() method. In the method, you return a list of the attributes you want to keep in sync with the other resource – when the current resource gets updated, the synced attributes will get updated on the other resource.

class TenantUser extends Model implements Syncable
{
use ResourceSyncing;
...
/**
* The 'global_id', 'name', 'password', and 'email' attributes will update on the other resource when this one gets updated.
* Only the attributes returned in this method will be synced.
*/
public function getSyncedAttributeNames(): array
{
return [
'global_id',
'name',
'password',
'email',
// There's also a 'role' attribute, but we want to keep that out of sync
];
}
}
...
// A user with the name 'testing_user' and 'role' 'admin'
$centralUser = CentralUser::first();
tenancy()->initialize(Tenant::first());
// A user with the same name and 'role' 'user'
$tenantUser = TenantUser::firstWhere('global_id', $centralUser->global_id);
// Update the name to 'User' and 'role' to 'moderator'
$tenantUser->update(['name' => 'User', 'role' => 'moderator']);
tenancy()->end();
$centralUser = CentralUser::find($centralUser->id);
dump($centralUser->role); // 'admin' - same as before
dump($centralUser->name); // 'User' - updated

Creation attributes

When you create a tenant resource which doesn’t have any corresponding central resource, the central resource will get created using the tenant resource’s creation attributes automatically.

The synced attributes are used as the creation attributes by default, but you can override the getCreationAttributes() method and control which attributes will the automatically created central resource get from the tenant resource. You can also set defaults for the attributes – this is useful if one resource requires attributes that the other resource doesn’t have.

Creation attributes are also important for specifying what attributes of a central resource will be used for creating the tenant resource when attaching a central resource to a tenant.

// E.g. in TenantUser
class TenantUser implements Syncable
{
public function getSyncedAttributeNames(): array
{
return [
'global_id',
'name',
'password',
'email',
];
}
/**
* By default, getCreationAttributes() returns $this->getSyncedAttributeNames(),
* but because the other resource (CentralUser) has a required attribute that this resource doesn't have ('extra'),
* we'll have to provide a default value for it in the creation attributes.
*/
public function getCreationAttributes(): array
{
return [
// The other resource (in this case, CentralUser) will have its 'global_id', 'name', 'password', and 'email' copied from this resource
'global_id',
'name',
'password',
'email',
// The other resource's 'extra' will default to 'default value'
'extra' => 'default value',
];
}
}
...
$tenant = Tenant::find('foo');
tenancy()->initialize($tenant);
// Create a tenant user
$tenantUser = TenantUser::create([
'global_id' => 'user', // There's no central user with this global ID yet
'name' => 'User',
'password' => '1234',
'email' => 'user@testing.com',
]);
tenancy()->end();
// The central user got created with the tenant user
$centralUser = CentralUser::firstWhere('global_id', 'user');
dump($centralUser->name); // 'User'
dump($centralUser->password); // '1234'
dump($centralUser->email); // 'user@testing.com'
dump($centralUser->extra); // 'default value'

Cascade on deleting the central resource

When the central resource gets deleted, all related tenant resources can get deleted automatically. To enable this behavior, make your SyncMaster models implement the CascadeDeletes interface.

The interface requires the triggerDeleteEvent() method (provided by the ResourceSyncing trait) in which the SyncMasterDeleted event gets dispatched. The DeleteResourcesInTenants listener then handles deleting the resource from each tenant connected to the central resource via a pivot record.

class CentralUser implements SyncMaster, CascadeDeletes
{
use ResourceSyncing;
public function getTenantModelName(): string
{
return TenantUser::class;
}
...
}
class TenantUser implements Syncable
{
public function getCentralModelName(): string
{
return CentralUser::class;
}
...
}
...
$getTenantUsers() = fn () => array_filter([
Tenant::find('foo')->run(fn () => TenantUser::firstWhere('global_id', 'user')),
Tenant::find('bar')->run(fn () => TenantUser::firstWhere('global_id', 'user'))
]);
$centralUser = CentralUser::firstWhere('global_id', 'user');
dump(count($getTenantUsers())); // 2
$centralUser->delete();
dump(count($getTenantUsers())); // 0

Conditional sync

Whether or not a resource’s attributes will be synced (updated when the other resource gets saved) depends on the resource’s shouldSync() method. If the method returns true, the attributes will be synced, and if it returns false, they won’t. By default, the shouldSync() method returns true, but you can override it. For example, sync the attributes only if the resource’s email attribute is not null.

class CentralUser implements SyncMaster, CascadeDeletes
{
public function getTenantModelName(): string
{
return TenantUser::class;
}
public function shouldSync(): bool
{
return ! is_null($this->email);
}
public function getSyncedAttributeNames()
{
return [
'global_id',
'name',
'email',
];
}
}
...
$centralUser = CentralUser::create(['global_id' => 'foo', 'name' => 'foo', 'email' => null]);
$tenantUser = Tenant::first()->run(fn () => TenantUser::create(['global_id' => 'foo', 'name' => 'foo', 'email' => null]));
$centralUser->update(['name' => 'bar']);
dump(Tenant::first()->run(fn () => TenantUser::firstWhere('global_id', 'foo'))->name); // 'foo'
// Since the resource's email is no longer null and shouldSync() returns true, the columns will be synced now
$centralUser->update(['email' => 'foo@bar.test']);
dump(Tenant::first()->run(fn () => TenantUser::firstWhere('global_id', 'foo'))->name); // 'bar'

Soft deletes with resource syncing

By default, attributes of trashed resources aren’t synced. The trashed records aren’t included when we’re syncing the resources. To change that, assign the UpdateOrCreateSyncedResource::$scopeGetModelQuery property a closure that scopes the query using $query->withTrashed() (like in the example below). Now, the queries performed when syncing are going to include the trashed resources.

// Soft delete tenant user
$tenantUser->delete();
// Rename 'foo' to 'bar'
$centralUser->update(['name' => 'bar']);
dump(Tenant::first()->run(fn () => TenantUser::firstWhere('global_id', 'foo'))->name); // 'foo'
// E.g. in TenancyServiceProvider's boot()
UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) {
if ($query->hasMacro('withTrashed')) {
$query->withTrashed();
}
};
$centralUser->update(['name' => 'baz']);
dump(Tenant::first()->run(fn () => TenantUser::firstWhere('global_id', 'foo'))->name); // 'baz'

How it works

Resource syncing is built on events that are triggered when you save, delete, attach, or detach resources. The syncing happens while handling theses events.

The default event flow

Saving synced resources $centralResource->create/update(...) calls triggerSyncEvent() in your Syncable resource, which triggers the SyncedResourceSaved event handled by the UpdateOrCreateSyncedResource listener. The SyncedResourceSavedInForeignDatabase event is dispatched after that – it’s only used in the package tests.

Deleting central resource when it implements CascadeDeletes $centralResource->delete() calls triggerDeleteEvent() in your CascadeDeletes SyncMaster resource, which triggers the SyncMasterDeleted event handled by the DeleteResourcesInTenants listener.

Attaching tenants to central resources (and vice versa) $centralResource->tenants()->attach($tenant) calls triggerAttachEvent() in your SyncMaster resource, which triggers the CentralResourceAttachedToTenant event handled by the CreateTenantResource listener. The SyncedResourceSavedInForeignDatabase event is dispatched after that – it’s only used in the package tests.

Detaching tenants from central resources (and vice versa) $centralResource->tenants()->detach($tenant) calls triggerDetachEvent() in your SyncMaster resource, which triggers the CentralResourceDetachedFromTenant event handled by the DeleteResourceInTenant listener.

The event flow is fully customizable – you can override the triggerXEvent() methods and trigger any event you want. You can also use listeners other than the default ones used in the TenancyServiceProvider.

UpdateOrCreateSyncedResource

The UpdateOrCreateSyncedResource listener is responsible for handling the core of resource syncing – updating and creating resources.

When a resource gets saved, the listener determines if the resource is central or tenant.

If the resource is central, the listener loops through all tenants associated with the central resource ($centralResource->tenants) to check for tenant resources with the same global ID as the central resource’s. If the resource exists in the tenant database, update its synced attributes, or if it doesn’t exist, create the tenant resource from the central resource’s creation attributes.

If the resource is tenant, update the central resource’s synced attributes.

DeleteResourcesInTenants (cascade deletes)

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

CreateTenantResource and DeleteResourceInTenant (attach/detach events)

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.

Database schema

The main idea: have one resource in the central database and one resource in the tenant database (or in many different tenant databases), associated by the global identifier. The central resource belongs to many tenants (one central resource can have an associated resource in many tenant databases) – by default, we’re using a polymorphic relationship (MorphToMany, TenantMorphPivot model) with the tenant_resources pivot table for that.

TriggerSyncingEvents trait (attaching/detaching)

The default pivot (TenantMorphPivot) uses the TriggerSyncingEvents trait, which fires events on attaching/detaching resources to/from tenants (CentralResourceAttachedToTenant/CentralResourceDetachedFromTenant).

In the default event flow, attaching a tenant to a central resource creates a tenant resource associated with the central resource, and detaching a tenant from a central resource deletes the associated tenant resource from the tenant’s database.

Custom pivots

When using TenantMorphPivot as the pivot model, everything should work fine. But if you’re overriding the tenants() relationship method, make sure you use a pivot that uses the TriggerSyncingEvents trait, so that attaching/detaching works as expected. Also, note that with custom pivot classes (other than ones extending MorphPivot), attaching and detaching doesn’t work in both directions right away. $resource->tenants()->attach($tenant) works just fine, but $tenant->resources()->attach($resource) doesn’t work with non-polymorphic pivots because while attaching, we can’t reach the resource passed to attach().

$resource->tenants()->attach($tenant); // This direction works without issues, even without making the resource implement PivotWithRelation
$tenant->resources()->attach($resource); // Throws exception, unless the resource implements PivotWithRelation

To fix this, the pivot needs to implement the PivotWithRelation interface, which means you’ll need to add the getRelatedModel() method. In this method, return the model of the central resource. The method makes it possible to access the central resource when attaching the central resource to a tenant using $tenant->resources()->attach($resource) – while handling attaching, there’s no way of getting the related model ($resource). We’re only able to get the parent resource ($tenant), unless you provide the resource’s model in the getRelatedModel() method.

// Custom pivot example
class CustomTenantPivot extends TenantPivot implements PivotWithRelation
{
public function getRelatedModel(): Model
{
return $this->users()->getModel();
}
public function users(): BelongsToMany
{
return $this->belongsToMany(CentralUser::class);
}
}
// Usage of the custom pivot
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);
}
}

Changes compared to 3.x

You can provide creation attributes in the resource’s getCreationAttributes() method This method allows specifying which attributes of the current resource will be used when the other resource is being created (e.g. when attaching a central resource to a tenant, create an associated tenant resource using the central resource’s creation attributes).

The method also allows defining defaults for creating the other resource, e.g. when the other resource requires some attribute that the current resource doesn’t have (explained in the Usage -> Creation attributes section).

Creating a tenant resource also creates the central resource if it doesn’t exist yet.

Now, the sync event gets triggered:

  • only when shouldSync() returns true
  • when the resource got recently created or its synced attributes got updated (updating non-synced attributes doesn’t trigger the sync event now)

Cascading deletes If a class that implements SyncMaster and CascadeDeletes gets deleted, also delete its child models (in tenant DBs).

Soft deletes The UpdateOrCreateSyncedResource::$scopeGetModelQuery property lets you scope the queries performed while saving synced resources. Primarily, this allows soft deletes to work with resource syncing – syncing trashed resources works too now (soft deletes also work with CascadeDeletes).

Polymorphic relationships (attaching/detaching) 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 PivotWithRelation 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 PivotWithRelation or MorphPivot).

Polymorphic many-to-many is now the default for the tenants() relationship method provided by the ResourceSyncing trait (using TenantMorphPivot).

Global identifier defaults The resource syncing trait now provides defaults for the methods concerning the resource’s global identifier. Now, the getGlobalIdentifierKeyName() method returns “global_id”, and getGlobalIdentifierKey() now returns $this->getAttribute($this->getGlobalIdentifierKeyName()). So unless you want to override the key name, you don’t have to add the methods manually to your resources anymore.