arielespinoza07/tenancy-laravel

Laravel adapter for tenancy-core — multi-tenant middleware pipeline, Eloquent scoping, events, queue propagation, and testing helpers.

Maintainers

Package info

github.com/ArielEspinoza07/tenancy-laravel

pkg:composer/arielespinoza07/tenancy-laravel

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-06-16 23:35 UTC

This package is auto-updated.

Last update: 2026-06-16 23:43:02 UTC


README

CI Latest Version Total Downloads PHP Version License

Laravel adapter for arielespinoza07/tenancy-core. Provides the service provider, middleware pipeline, Eloquent trait, facade, Blade directives, and testing helpers to add multi-tenancy to a Laravel application.

Requirements

  • PHP 8.5+
  • Laravel 13+

Installation

composer require arielespinoza07/tenancy-laravel
php artisan tenancy:install

This publishes config/tenancy.php and prints the required next steps.

Setup

1. Tenant table

The minimum schema your tenant table must satisfy:

Schema::create('tenants', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->string('domain')->nullable();
    $table->string('status')->default('active'); // active | pending | suspended | deleted
    $table->json('metadata')->nullable();
    $table->timestamps();
});

2. Tenant model

Create a plain Eloquent model for the table. No interface implementation is required on the model itself — the repository handles the mapping.

final class Tenant extends Model
{
    protected $fillable = ['name', 'slug', 'domain', 'status', 'metadata'];

    protected $casts = ['metadata' => 'array'];
}

3. Bind the required interfaces

In your AppServiceProvider:

use Tenancy\Contracts\Repositories\MembershipRepositoryInterface;
use Tenancy\Contracts\Repositories\TenantLookupInterface;
use Tenancy\Contracts\Repositories\TenantPermissionRepositoryInterface;

public function register(): void
{
    $this->app->bind(TenantLookupInterface::class, TenantRepository::class);
    $this->app->bind(MembershipRepositoryInterface::class, MembershipRepository::class);
    $this->app->bind(TenantPermissionRepositoryInterface::class, TenantPermissionRepository::class);
}

TenantLookupInterface

Resolves a tenant record from the database. Map your Eloquent model to the TenantRecord value object provided by tenancy-core:

use Tenancy\Contracts\Records\TenantRecordInterface;
use Tenancy\Contracts\Repositories\TenantLookupInterface;
use Tenancy\Enums\TenantStatus;
use Tenancy\Records\TenantRecord;

final class TenantRepository implements TenantLookupInterface
{
    public function findById(int|string $id): ?TenantRecordInterface
    {
        return ($model = Tenant::find($id)) ? $this->toRecord($model) : null;
    }

    public function findBySlug(string $slug): ?TenantRecordInterface
    {
        return ($model = Tenant::where('slug', $slug)->first()) ? $this->toRecord($model) : null;
    }

    public function findByDomain(string $domain): ?TenantRecordInterface
    {
        return ($model = Tenant::where('domain', $domain)->first()) ? $this->toRecord($model) : null;
    }

    private function toRecord(Tenant $model): TenantRecord
    {
        return new TenantRecord(
            id: $model->id,
            name: $model->name,
            slug: $model->slug,
            domain: $model->domain,
            metadata: $model->metadata ?? [],
            tenantStatus: TenantStatus::from($model->status),
        );
    }
}

MembershipRepositoryInterface

Used by the tenant.access middleware to verify the authenticated user belongs to the resolved tenant:

use Tenancy\Contracts\Repositories\MembershipRepositoryInterface;

final class MembershipRepository implements MembershipRepositoryInterface
{
    public function existsActiveMembership(int|string $userId, int|string $tenantId): bool
    {
        return TenantMember::where('user_id', $userId)
            ->where('tenant_id', $tenantId)
            ->where('status', 'active')
            ->exists();
    }
}

TenantPermissionRepositoryInterface

Used by the tenant.permission middleware and @tenantCan Blade directive:

use Tenancy\Contracts\Repositories\TenantPermissionRepositoryInterface;

final class TenantPermissionRepository implements TenantPermissionRepositoryInterface
{
    public function userHasPermission(int|string $tenantId, int|string $userId, string $permission): bool
    {
        return TenantRole::where('tenant_id', $tenantId)
            ->where('user_id', $userId)
            ->whereJsonContains('permissions', $permission)
            ->exists();
    }
}

4. Enable resolution strategies

Uncomment the strategies your application needs in config/tenancy.php. Each entry maps a strategy class to its priority — higher priority runs first:

'strategies' => [
    \Tenancy\Resolution\Strategies\ApiKeyTenantResolutionStrategy::class       => 100,
    \Tenancy\Resolution\Strategies\CustomDomainTenantResolutionStrategy::class => 90,
    \Tenancy\Resolution\Strategies\HeaderTenantResolutionStrategy::class       => 80,
    \Tenancy\Resolution\Strategies\HeaderTenantSlugResolutionStrategy::class   => 70,
    \Tenancy\Resolution\Strategies\PathTenantResolutionStrategy::class         => 60,
    \Tenancy\Resolution\Strategies\SessionTenantResolutionStrategy::class      => 50,
    \Tenancy\Resolution\Strategies\SubdomainTenantResolutionStrategy::class    => 40,
],

Enable only what your application uses. If more than one active strategy succeeds for the same request, resolution fails with a conflict exception (HTTP 409).

Strategy Resolves from Requires
ApiKeyTenantResolutionStrategy Authorization: Bearer or X-API-Key header TenantApiKeyLookupInterface
CustomDomainTenantResolutionStrategy Custom domain (findByDomain) TenantLookupInterface
HeaderTenantResolutionStrategy X-Tenant-ID header TenantLookupInterface
HeaderTenantSlugResolutionStrategy X-Tenant-Slug header TenantLookupInterface
PathTenantResolutionStrategy URL path segment (/acme/dashboard) TenantLookupInterface
SessionTenantResolutionStrategy Session key (tenant_id by default) TenantLookupInterface
SubdomainTenantResolutionStrategy Subdomain (acme.app.com) TenantLookupInterface

If you enable ApiKeyTenantResolutionStrategy, also bind TenantApiKeyLookupInterface:

$this->app->bind(TenantApiKeyLookupInterface::class, TenantApiKeyRepository::class);

Protecting routes

Route macros

// Resolves tenant + verifies user membership
Route::tenant(function () {
    Route::get('/dashboard', DashboardController::class);
    Route::resource('/projects', ProjectController::class);
});

// Resolves tenant + verifies user membership + checks permission
Route::tenantCan('billing:manage', function () {
    Route::get('/billing', BillingController::class);
});

Both macros combine the correct middleware in the right order. Use them instead of stacking middleware aliases manually.

Middleware aliases

When you need finer control:

Alias Purpose
resolve.tenant Resolves the tenant and sets it as the current context. Must come first.
tenant.access Verifies the authenticated user has an active membership in the tenant.
tenant.permission:{name} Checks a specific permission. Requires tenant.access before it.
tenant.persist Writes the resolved tenant ID to the session after the response.
tenant.inertia Shares tenant data with Inertia (requires inertiajs/inertia-laravel).

Example with explicit aliases:

Route::middleware(['resolve.tenant', 'tenant.access', 'tenant.permission:reports:view'])
    ->get('/reports', ReportController::class);

Optional resolution

To resolve the tenant when present but not require it:

Route::middleware(['resolve.tenant:optional'])->group(function () {
    Route::get('/pricing', PricingController::class);
});

Exception to HTTP mapping

Exception HTTP status
TenantNotFoundException 404 Not Found
TenantSuspendedException 503 Service Unavailable
TenantAuthorizationException 403 Forbidden
TenantResolutionConflictException 409 Conflict
TenantContextMissingException 500 Internal Server Error

Blade directives

@tenant
    <p>Viewing as tenant: {{ CurrentTenant::get()->record->name }}</p>
@endtenant

@tenantCan('billing:manage')
    <a href="/billing">Billing</a>
@endtenantCan

@tenantCan returns false for unauthenticated users and for users without the given permission in the current tenant.

Facade

use Tenancy\Laravel\Facades\CurrentTenant;

$context = CurrentTenant::get();       // throws TenantContextMissingException if not set
$context->record->id;
$context->record->name;
$context->record->slug;
$context->source;                      // TenantResolutionSource enum

if (CurrentTenant::has()) { ... }

// Run a callback under a specific tenant (saves and restores previous context)
CurrentTenant::scoped($context, function () {
    // $context is the active tenant here
});

// Run a callback without any tenant context
CurrentTenant::withoutTenant(function () {
    // no tenant inside here
});

The terminate() method on ResolveTenant automatically clears the context after each request, making it safe for Laravel Octane, Swoole, and RoadRunner.

Eloquent — BelongsToTenant

Add the trait to any model that belongs to a tenant:

use Tenancy\Laravel\Models\BelongsToTenant;

final class Project extends Model
{
    use BelongsToTenant;
}

What it does:

  • Applies a global scope that filters all queries to the current tenant automatically.
  • Fills the tenant_id foreign key on creating when not already set.

Strict scope (default: on)

With strict_scope = true in config, querying a BelongsToTenant model without an active tenant context throws TenantContextMissingException instead of silently returning records from all tenants:

// No tenant context set — throws TenantContextMissingException
Project::all();

// Explicit escape hatch — intentional cross-tenant read
Project::withoutTenantScope()->all();

This protects against accidental data exposure in optional-resolution routes, jobs that forget to restore context, or commands that omit context setup. Set strict_scope = false to revert to silent no-op behaviour, but be aware you take responsibility for preventing unscoped reads.

The creating asymmetry

The strict scope only fires on read operations (SELECT, UPDATE, DELETE). Model creation (INSERT) is never blocked.

When no tenant context is set:

// Throws TenantContextMissingException
Project::all();

// Does NOT throw — inserts with tenant_id = null
Project::create(['name' => 'System project']);

This is intentional. Seeders, migrations, admin commands, and system-level jobs often need to create records without an HTTP tenant context. The global scope cannot make safe assumptions about intent at write time — a null tenant_id may be valid (nullable column, system record) or invalid (DB constraint rejects it). That distinction belongs to your schema and your code, not the trait.

When you do need to create a record under a specific tenant from a command or job, use CurrentTenant::scoped():

$context = new TenantContext($record, TenantResolutionSource::System);

CurrentTenant::scoped($context, function () {
    Project::create(['name' => 'Onboarding project']);
    // tenant_id filled automatically from context
});

Escape hatches:

// Query across all tenants regardless of context
Project::withoutTenantScope()->get();

// Query a specific tenant regardless of current context
Project::forTenant($tenantId)->get();

The foreign key defaults to tenant_id. Change the global default in config, or override per model:

public static function tenantForeignKey(): string
{
    return 'organisation_id';
}

SPA / Inertia

Session persistence for SPAs

SPAs (Livewire, Vue, React) resolve the tenant from the URL on the first request, then rely on the session for subsequent AJAX requests. Add tenant.persist to write the resolved tenant ID to the session:

Route::middleware(['resolve.tenant', 'tenant.access', 'tenant.persist'])->group(function () {
    // ...
});

Then enable SessionTenantResolutionStrategy in config so subsequent requests resolve from the session.

Sharing tenant data with Inertia

Install the Inertia Laravel adapter:

composer require inertiajs/inertia-laravel

Implement CurrentTenantTransformerContract to define the data shape shared with the frontend:

use Tenancy\Contracts\Context\CurrentTenantInterface;
use Tenancy\Laravel\Support\CurrentTenantTransformerContract;

final class TenantTransformer implements CurrentTenantTransformerContract
{
    public function toInertia(CurrentTenantInterface $tenant): array
    {
        $record = $tenant->get()->record;

        return [
            'id'   => $record->id,
            'name' => $record->name,
            'slug' => $record->slug,
        ];
    }
}

Bind it in your AppServiceProvider:

$this->app->bind(CurrentTenantTransformerContract::class, TenantTransformer::class);

Add tenant.inertia to your tenant routes:

Route::middleware(['resolve.tenant', 'tenant.access', 'tenant.inertia'])->group(function () {
    // Inertia pages receive $page.props.tenant
});

Queue jobs

Tenant context lives in the current process's memory. It is not serialized with the job payload. When a queue worker picks up a job, it starts a fresh process with no tenant context — any BelongsToTenant query will throw TenantContextMissingException (with strict scope) or return cross-tenant data (without it).

Use the TenantAware trait and SetTenantContext middleware to carry the tenant ID through the queue:

use Tenancy\Laravel\Jobs\Concerns\TenantAware;
use Tenancy\Laravel\Jobs\Middleware\SetTenantContext;

class GenerateMonthlyReport implements ShouldQueue
{
    use TenantAware;

    public function __construct(private int $month)
    {
        $this->captureCurrentTenant(); // captures tenant ID while HTTP context is still active
    }

    public function middleware(): array
    {
        return [app(SetTenantContext::class)];
    }

    public function handle(): void
    {
        // tenant context is restored here — BelongsToTenant scopes work correctly
        $invoices = Invoice::whereMonth('issued_at', $this->month)->get();
    }
}

How it works:

  1. captureCurrentTenant() stores the current tenant's ID as $this->tenantId — a plain scalar that serializes with the job payload.
  2. In the worker, SetTenantContext middleware looks up the tenant by ID before handle() runs, restores the context via CurrentTenant::scoped(), and clears it when handle() returns.
  3. TenantResolutionSource::System is used as the resolution source, since the tenant was restored programmatically rather than from an HTTP request.

Important notes:

  • captureCurrentTenant() must be called in the constructor, while the dispatching request still has an active tenant context.
  • If tenantId is null (job dispatched outside a tenant context), SetTenantContext skips context restoration and handle() runs without one. With strict scope enabled, any BelongsToTenant query will then throw.
  • If the tenant cannot be found at job execution time (deleted between dispatch and execution), TenantNotFoundException is thrown and the job fails.
  • If your job already defines middleware() for other purposes, add SetTenantContext to the returned array rather than replacing it:
public function middleware(): array
{
    return [
        app(SetTenantContext::class),
        new RateLimited('reports'),
    ];
}

Tenant switching

Use SwitchTenant when an admin or support user needs to operate inside a specific tenant's context — for example, an admin panel where a user clicks "switch to tenant X" and navigates the application as that tenant.

use Tenancy\Laravel\Services\SwitchTenant;

// Switch to a tenant — sets context and persists to session
app(SwitchTenant::class)->to($tenantId, auth()->id());

// Revert — clears context and forgets session key
app(SwitchTenant::class)->revert(auth()->id());

to() fires a TenantSwitched event. revert() fires a TenantReverted event when a context was active. Both events carry the userId for audit logging.

to() throws TenantNotFoundException if the tenant does not exist.

Because to() writes to the session, subsequent requests will resolve the switched tenant automatically via SessionTenantResolutionStrategy — no additional work is needed.

Cache

Cache::tenant() returns a cache store scoped to the current tenant. When a tenant context is set it returns a tagged store under tenant:{id}. When no context is set it returns the default store.

// Scoped to current tenant — key resolves to "tenant:7:invoice.count"
Cache::tenant()->remember('invoice.count', 3600, fn () => Invoice::count());
Cache::tenant()->put('settings', $settings, 3600);
Cache::tenant()->get('settings');
Cache::tenant()->has('settings');
Cache::tenant()->forget('settings');

// Flush all cache entries for the current tenant (requires a taggable cache driver)
Cache::tenant()->flush();

All standard cache methods are available — Cache::tenant() returns a full Repository implementation.

Note: flush() requires a cache driver that supports tags (Redis, Memcached). For file or database drivers, use explicit forget() calls instead.

Rate limiting

A tenant named rate limiter is registered automatically. It scopes limits per tenant when a context is set, and falls back to per-IP when there is no tenant context.

Route::middleware(['resolve.tenant', 'tenant.access', 'throttle:tenant'])
    ->group(function () {
        Route::get('/api/invoices', InvoiceController::class);
    });

The default limit is 100 requests per minute per tenant. Override in config:

'rate_limit' => [
    'per_minute' => 200,
],

Events

Event Fired when
TenantResolved A tenant is successfully resolved by ResolveTenant
TenantAccessDenied EnsureTenantAccess rejects a user (no active membership)
TenantPermissionDenied EnsureTenantPermission rejects a user (missing permission)
TenantContextCleared ResolveTenant::terminate() clears the context after a request
TenantSwitched SwitchTenant::to() switches the active tenant
TenantReverted SwitchTenant::revert() clears a switched tenant context

All events are in the Tenancy\Laravel\Events namespace.

use Tenancy\Laravel\Events\TenantResolved;
use Tenancy\Laravel\Events\TenantAccessDenied;
use Tenancy\Laravel\Events\TenantSwitched;

// TenantResolved — context of the resolved tenant
final class LogTenantResolution
{
    public function handle(TenantResolved $event): void
    {
        Log::info('Tenant resolved', [
            'tenant_id' => $event->context->record->id,
            'source'    => $event->context->source->value,
        ]);
    }
}

// TenantAccessDenied — context + userId
final class LogAccessDenied
{
    public function handle(TenantAccessDenied $event): void
    {
        Log::warning('Tenant access denied', [
            'tenant_id' => $event->context->record->id,
            'user_id'   => $event->userId,
        ]);
    }
}

// TenantSwitched — newContext, userId, and optional previousContext
final class LogTenantSwitch
{
    public function handle(TenantSwitched $event): void
    {
        Log::info('Tenant switched', [
            'from'    => $event->previousContext?->record->id,
            'to'      => $event->newContext->record->id,
            'user_id' => $event->userId,
        ]);
    }
}

Testing

Add InteractsWithTenant to your test cases:

use Tenancy\Laravel\Testing\InteractsWithTenant;
use Tenancy\Enums\TenantStatus;
use Tenancy\Records\TenantRecord;

uses(InteractsWithTenant::class);

it('shows the dashboard for a tenant member', function () {
    $record = new TenantRecord(
        id: 1,
        name: 'Acme',
        slug: 'acme',
        domain: null,
        metadata: [],
        tenantStatus: TenantStatus::Active,
    );

    $this->actingAsTenant($record)
        ->actingAs($user)
        ->get('/dashboard')
        ->assertOk();
});

it('applies the tenant scope to queries', function () {
    $this->actingAsTenant($record);

    $projects = Project::all(); // only this tenant's projects
    expect($projects->every(fn ($p) => $p->tenant_id === $record->id))->toBeTrue();
});

it('clears the tenant context with removeTenant', function () {
    $this->actingAsTenant($record)->removeTenant();

    expect(app(CurrentTenantInterface::class)->has())->toBeFalse();
});

actingAsTenant accepts an optional TenantResolutionSource as the second argument (defaults to TenantResolutionSource::Session).

Assertions

// Assert a specific tenant is active
$this->assertTenantIs($record);

// Assert no tenant context is set
$this->assertNoTenant();

// Assert the resolution source of the active context
$this->assertTenantResolutionSource(TenantResolutionSource::Header);

All assertion methods return $this for chaining.

Configuration reference

// config/tenancy.php

'foreign_key' => 'tenant_id',         // FK column used by BelongsToTenant

'strict_scope' => true,               // Throw TenantContextMissingException on unscoped reads
                                       // Set to false to silently return all-tenant records instead

'session' => [
    'key' => 'tenant_id',             // Session key for SessionTenantResolutionStrategy
],

'strategies' => [                      // class => priority (higher runs first)
    // Uncomment to enable:
    // \Tenancy\Resolution\Strategies\ApiKeyTenantResolutionStrategy::class       => 100,
    // \Tenancy\Resolution\Strategies\CustomDomainTenantResolutionStrategy::class => 90,
    // \Tenancy\Resolution\Strategies\HeaderTenantResolutionStrategy::class       => 80,
    // \Tenancy\Resolution\Strategies\HeaderTenantSlugResolutionStrategy::class   => 70,
    // \Tenancy\Resolution\Strategies\PathTenantResolutionStrategy::class         => 60,
    // \Tenancy\Resolution\Strategies\SessionTenantResolutionStrategy::class      => 50,
    // \Tenancy\Resolution\Strategies\SubdomainTenantResolutionStrategy::class    => 40,
],

'custom_domain' => [
    'platform_domains'        => [],   // Your own domains to exclude from custom domain lookup
    'throw_when_unregistered' => true, // Throw NotFoundException for unrecognised domains
],

'header' => [
    'id'   => 'X-Tenant-ID',          // Header name for HeaderTenantResolutionStrategy
    'slug' => 'X-Tenant-Slug',        // Header name for HeaderTenantSlugResolutionStrategy
],

'path' => [
    'prefix'            => null,       // Optional prefix before the slug segment
    'reserved_segments' => [...],      // Segments never treated as tenant slugs
],

'sub_domain' => [
    'base_domain'         => env('TENANCY_BASE_DOMAIN', ''),
    'reserved_subdomains' => ['www', 'app', 'admin', 'api'],
],

'cache' => [
    'key' => 'tenant',                // Prefix for Cache::tenant() — produces "tenant:{id}:{key}"
],

'rate_limit' => [
    'per_minute' => 100,              // Requests per minute per tenant for the "tenant" rate limiter
],

Contributing

See CONTRIBUTING.md for setup instructions, code conventions, and PR guidelines.

License

MIT License