arielespinoza07/tenancy-laravel
Composer 安装命令:
composer require arielespinoza07/tenancy-laravel
包简介
Laravel adapter for tenancy-core — multi-tenant middleware pipeline, Eloquent scoping, events, queue propagation, and testing helpers.
README 文档
README
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_idforeign key oncreatingwhen 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:
captureCurrentTenant()stores the current tenant's ID as$this->tenantId— a plain scalar that serializes with the job payload.- In the worker,
SetTenantContextmiddleware looks up the tenant by ID beforehandle()runs, restores the context viaCurrentTenant::scoped(), and clears it whenhandle()returns. TenantResolutionSource::Systemis 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
tenantIdis null (job dispatched outside a tenant context),SetTenantContextskips context restoration andhandle()runs without one. With strict scope enabled, anyBelongsToTenantquery will then throw. - If the tenant cannot be found at job execution time (deleted between dispatch and execution),
TenantNotFoundExceptionis thrown and the job fails. - If your job already defines
middleware()for other purposes, addSetTenantContextto 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 explicitforget()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
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 4
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-16