Single-database tenancy

Single-database tenancy comes with lower devops complexity, but larger code complexity than multi-database tenancy, since you have to scope things manually, and won't be able to integrate some third-party packages.

It is preferable when you have too many shared resources between tenants, and don't want to make too many cross-database queries.

To use single-database tenancy, make sure you disable the DatabaseTenancyBootstrapper which is responsible for switching database connections for tenants.

You can still use the other tenancy bootstrappers to separate tenant caches, filesystems, etc.

Also make sure you have disabled the database creation jobs (CreateDatabase, MigrateDatabase, SeedDatabase ...) from listening to the TenantCreated event.

Concepts

In single-database tenancy, there are 4 types of models:

  • your Tenant model
  • primary models — models that directly belongTo tenants
  • secondary models — models that indirectly belongTo tenants
    • e.g. Comment belongsTo Post belongsTo Tenant
    • or more complex, Vote belongsTo Tenant belongsTo Post belongsTo Tenant
  • global models — models that are not scoped to any tenant whatsoever

To scope your queries correctly, apply the Stancl\Tenancy\Database\Concerns\BelongsToTenant trait on primary models. This will ensure that all calls to your parent models are scoped to the current tenant, and that calls to their child relations are scoped through the parent relationships.

And that's it. Your models are now automatically scoped to the current tenant, and not scoped at all when there's no current tenant (e.g. in a central admin panel).

However, there's one edge case to keep in mind. Consider the following set-up:

class Post extends Model
{
    use BelongsToTenant;

    public function comments()
    {
        return $this->hasMany(Comment::class);
    }
}

class Comment extends Model
{
    public function post()
    {
        return $this->belongsTo(Post::class);
    }
}

Looks correct, but you might still accidentally access another tenant's comments.

If you use this:

Comment::all();

then the model has no way of knowing how to scope that query, since it doesn't directly belong to the tenant. Also note that in practice, you really shouldn't be doing this much. You should ideally access secondary models through parent models in every single case.

However, sometimes you might have a use case where you really need to do that in the tenant context. For that reason, we also provide you with a BelongsToPrimaryModel trait, which lets you scope calls like the one above to the current tenant, by loading the parent relationship — which gets automatically scoped to the current tenant — on them.

So, to give you an example, you would do this:

class Comment extends Model
{
        use BelongsToPrimaryModel;

    public function getRelationshipToPrimaryModel(): string
    {
        return 'post';
    }

    public function post()
    {
        return $this->belongsTo(Post::class);
    }
}

And this will automatically scope the Comment::all() call to the current tenant. Note that the limitation of this is that you need to be able to define a relationship to a primary model, so if you need to do this on the "Vote" in Vote belongsTo Comment belongsTo Post belongsTo Tenant, you need to define some strange relationship. Laravel supports HasOneThrough, but not BelongsToThrough, so you'd need to do some hacks around that. For that reason, I recommend avoiding these Comment::all()-type queries altogether.

Database considerations

Unique indexes

If you'd have a unique index such as:

$table->unique('slug');

in a standard non-tenant, or multi-database-tenant, application, you need to scope this unique index to the tenant, meaning you'd do this on primary models:

$table->unique(['tenant_id', 'slug']);

and this on secondary models:

// Imagine we're in a 'comments' table migration

$table->unique(['post_id', 'user_id']);

TODO: Unique constraints, validation, mention that eloquent models will be scoped, but DB calls won't

Validation

The unique and exists validation rules of course aren't scoped to the current tenant, so you need to scope them manually like this:

Rule::unique('posts', 'slug')->where('tenant_id', tenant('id'));

If that feels like a chore, you may use the Stancl\Tenancy\Database\Concerns\HasScopedValidationRules trait on your custom Tenant model to add methods for these two rules.

Tenants

You'll be able to use these two methods:

// You may retrieve the current tenant using the tenant() helper.
// $tenant = tenant();

$rules = [
    'id' => $tenant->exists('posts'),
    'slug' => $tenant->unique('posts'),
]

Low-level database queries

And the final thing to keep in mind is that DB facade calls, or any other types of direct database queries, of course won't be scoped to the current tenant.

The package can only provide scoping logic for the abstraction logic that Eloquent is, it can't do anything with low level database queries.

Be careful with using them.

Making global queries

To disable the tenant scope, simply add withoutTenancy() to your query.

Customizing the column name

If you'd like to customize the column name to use e.g. team_id instead of tenant_id — if that makes more sense given your business terminology — you can do that by setting this static property in a service provider or some such class:

use Stancl\Tenancy\Database\Concerns\BelongsToTenant;

BelongsToTenant::$tenantIdColumn = 'team_id';

Note that this is universal to all your primary models, so if you use team_id somewhere, you use it everywhere — you can't use both team_id and tenant_id.