maize-tech/laravel-tenant-aware
maize-tech/laravel-tenant-aware helps Laravel apps support multi-tenancy by resolving the active tenant, automatically scoping Eloquent models and queries, and keeping tenant data isolated with simple configuration and middleware.
maize-tech/laravel-tenant-aware adds multi-tenancy to a Laravel app with minimal surgery: you tag models with a trait, the package applies a global scope filtering every query by the current tenant and auto-fills the tenant foreign key on create.
composer require maize-tech/laravel-tenant-aware
php artisan vendor:publish --tag=tenant-aware-config
Then, in order:
config/tenant-aware.php). It defines which column/model is used to identify a tenant and how rows are tied together — this is the single source of truth for the rest of the integration.tenant_id (or whatever the config points at) on every tenant-scoped table. Prefer composite indexes like (tenant_id, created_at) and (tenant_id, email) from day one; retrofitting indexes after the fact is the expensive part.First use case: a single-tenant-per-user SaaS. Add tenant_id to posts, users, invoices; put the trait on those models; add middleware that resolves the tenant from the authenticated user's team and calls the package's setter. From that point Post::all() is automatically scoped, Post::create([...]) is automatically stamped, and you have not written a single where().
If you are upgrading from 2.1.x: 2.2.0 requires PHP 8.2+ (8.1 support dropped) and adds Laravel 13 support. Bump your CI matrix and composer.json platform constraint before anything else.
Tenant resolution in middleware, not in controllers. Resolve once, set once, and let the rest of the stack be ignorant of tenancy:
final class SetTenant
{
public function handle($request, Closure $next)
{
$tenant = $request->user()?->tenant
?? Tenant::where('domain', $request->getHost())->firstOrFail();
TenantAware::setTenant($tenant);
try {
return $next($request);
} finally {
TenantAware::forgetTenant(); // critical for Octane / long-running workers
}
}
}
Cross-tenant admin screens. Use the package's bypass/withoutTenant()-style helper rather than manually stripping scopes on every model. Wrap the whole scope in a closure so it cannot leak:
$allInvoices = TenantAware::withoutTenant(fn () => Invoice::with('tenant')->paginate());
Queue jobs. Serialisation does not carry tenant context. Capture the tenant id on the job, re-hydrate at the start of handle(), and clear it in a finally:
public function handle(): void
{
TenantAware::setTenant(Tenant::findOrFail($this->tenantId));
try { $this->report->generate(); }
finally { TenantAware::forgetTenant(); }
}
Console commands and scheduled tasks. Iterate tenants explicitly; there is no request to resolve from:
Tenant::eachById(function (Tenant $tenant) {
TenantAware::setTenant($tenant);
$this->call('invoices:remind');
});
Route model binding and validation. Scoped queries mean Route::get('/invoices/{invoice}') returns 404 for another tenant's record for free — a genuine security win, and worth a feature test. For uniqueness, remember unique:invoices,number is now wrong; use Rule::unique('invoices')->where('tenant_id', tenant()->id).
Testing. Use RefreshDatabase, and in setUp()/tearDown() always set and clear the tenant. Build factories that accept a tenant and default it to a fresh one. Write one explicit test per model asserting that a second tenant's rows are invisible — that single test is what stops a silent data-leak regression after a refactor.
Seeding and onboarding. New-tenant provisioning should run inside a tenant context so seeds auto-stamp: TenantAware::setTenant($tenant) then run the seeder. Shared plans/roles tables stay untenant-scoped and are seeded outside.
setTenant() in a job or command does not throw; it quietly returns zero rows. Log the active tenant id on every request/job, or add a boot-time assertion in non-production environments.horizon, queue:work), the facade/singleton holds state across jobs. Always pair setTenant() with a finally { forgetTenant(); }. Laravel's default queue:work is long-lived — this is a real bug, not a theoretical one.DB:: bypass everything. DB::table('invoices'), whereRaw, and some third-party packages silently ignore the scope. Grep for DB::table on tenant tables and add explicit where('tenant_id', ...). Report/aggregate builders are the usual offenders.$invoice->lines is fine if InvoiceLine carries the trait; if it doesn't, you get cross-tenant rows through the parent's key path. Audit every relationship target.tenant_id is fillable and present in $request->all(), a client can stamp a row into another tenant. Either keep it out of $fillable entirely (the package fills it) or validate it against the active tenant.tenant:{$id}:...), and Storage paths/flysystem disks must be per-tenant. The package scopes the database only — everything else is yours.tenant_id as non-nullable with a foreign key on a table that already has rows requires an explicit backfill step; do it in three migrations (add nullable → backfill → make non-nullable + index), not one.composer why-not php 8.1 style check in CI and confirm your laravel/framework constraint allows the new major before adopting.withoutGlobalScope() calls through controllers — the latter erodes the guarantee that makes the package worth using.DB::? a stale withoutGlobalScope() left in a service class? That checklist resolves most "the package is broken" reports.How can I help you explore Laravel packages today?