tenancy icon indicating copy to clipboard operation
tenancy copied to clipboard

Add Many‑to‑Many Relationship Support (e.g., HasTenants trait)

Open Saad5400 opened this issue 1 year ago • 4 comments

Description
It would be great if the tenancy package provided a trait that automatically handles tenant associations for any model using a polymorphic many‑to‑many pivot table (e.g., tenantables). This approach would allow developers to attach multiple tenants to a variety of models without having to maintain a dedicated tenant_id column on each table. The trait would:

  1. Define a polymorphic many‑to‑many relationship to the Tenant model.
  2. Include a global scope that filters models by the current tenant.
  3. Automatically attach the current tenant upon creation if none is already set.

Why this should be added

  • Flexibility: Instead of limiting tenant associations to a single model type (like User), a polymorphic approach allows multiple models (e.g., User, Post, Invoice) to share the same pivot structure.
  • Reusability: Developers don’t need to replicate tenant relationship logic across different models or maintain multiple pivot tables.
  • Cleaner Database Schema: Polymorphic relationships reduce schema clutter by avoiding a dedicated tenant ID column on every table that needs tenancy support.
  • Simplicity: A shared trait and scope make setup easier, lowering the barrier to using multi‑tenancy across an application’s entire domain.

This feature would offer a more generalized, out‑of‑the‑box approach for developers needing multi‑tenant logic on multiple models.

Saad5400 avatar Feb 15 '25 09:02 Saad5400

Did ChatGPT write this text? Despite all of those words I have no idea what you're actually trying to say.

stancl avatar Feb 15 '25 09:02 stancl

Yeah I was being lazy, my bad.

I'm trying to do a Many users to Many tenants. Where one user could have access to many tenants instead of being tied to only one.

I think I got it working, but it'd be cool to have it in the package directly as a trait. I'd be happy to open a PR.

Saad5400 avatar Feb 15 '25 09:02 Saad5400

You still haven't said what types of mappings you're talking about. Is this referencing resource syncing?

stancl avatar Feb 18 '25 07:02 stancl

I'm referring to a many-to-many relationship with the tenant model in a single database.

Any model (User, Post, Invoice, etc.) can be attached to one or more tenants via a single pivot table.

The trait would automatically apply a global scope to filter models by the currently active tenant.

Automatic Attachment: When creating a new model instance, if no tenant is specified, the trait could automatically associate it with the current tenant.

This what I did:

Trait:

<?php

declare(strict_types=1);

namespace App\Tenancy\Traits;

use App\Tenancy\Database\TenantMorphScope;

trait HasTenants
{
    /**
     * The name of the pivot table used for the polymorphic relationship.
     */
    public static $tenantPivotTable = 'tenantables';

    /**
     * The foreign key on the pivot table that references the tenant model.
     */
    public static $tenantForeignKey = 'tenant_id';

    /**
     * The morph name used for this polymorphic relation.
     */
    public static $morphName = 'tenantable';

    /**
     * The name of the column on the tenantables table that references the tenantable model.
     */
    public static $tenantableIdColumn = 'tenantable_id';

    /**
     * Define a polymorphic many-to-many relationship with the Tenant model.
     *
     * This assumes your tenant model is set in your config at:
     * config('tenancy.tenant_model')
     */
    public function tenants()
    {
        return $this->morphToMany(
            config('tenancy.tenant_model'),
            static::$morphName,             // The morph name used for this polymorphic relation
            static::$tenantPivotTable,      // The pivot table name (tenantables)
            static::$tenantableIdColumn,    // The local key on the pivot table (User's integer ID)
            static::$tenantForeignKey,      // The foreign key on the pivot table (Tenant's UUID)
        );
    }


    /**
     * Boot the trait to add a global scope and automatically attach the current tenant.
     */
    public static function bootHasTenants()
    {
        // Add a global scope so that queries on models using this trait are
        // automatically filtered to include only those associated with the current tenant.
        static::addGlobalScope(new TenantMorphScope);

        // When a new model is created, automatically attach the current tenant
        // if tenancy is initialized and no tenant has been attached yet.
        static::created(function ($model) {
            if (function_exists('tenancy') && tenancy()->initialized) {
                if (!$model->relationLoaded('tenants') || $model->tenants->isEmpty()) {
                    $model->tenants()->attach(tenant()->getTenantKey());
                }
            }
        });
    }
}

Scope:

<?php

declare(strict_types=1);

namespace App\Tenancy\Database;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class TenantMorphScope implements Scope
{
    public function apply(Builder $builder, Model $model)
    {
        // If tenancy isn't initialized, don't apply the scope
        if (! function_exists('tenancy') || ! tenancy()->initialized) {
            return;
        }

        // Filter by models that have a tenant matching the current tenant's ID
        $builder->whereHas('tenants', function (Builder $query) {
            $query->where('id', tenant()->getTenantKey());
        });
    }

    public function extend(Builder $builder)
    {
        // Provide a convenient macro to remove this global scope
        $builder->macro('withoutTenancy', function (Builder $builder) {
            return $builder->withoutGlobalScope($this);
        });
    }
}

It'd be cool if it's already built in the package. And I'd be happy to open a PR for it!

Saad5400 avatar Feb 18 '25 14:02 Saad5400

Appreciate the writeup. Since single-db tenancy (based on model traits) is essentially a userland feature, with necessary code changes, this doesn't require a feature in Tenancy (people can just implement something like the above in their own app). I'm going to close this for now and if there's a lot of demand for this being included by default I can consider adding it at any point since it doesn't require breaking changes 👍

stancl avatar Aug 03 '25 21:08 stancl