romegadigital/multitenancy
A simple multi-tenancy package for Laravel that scopes Eloquent models to the current tenant using a tenant_id column and global scopes. Includes middleware for tenant resolution, a Tenant model, and tenant-aware helpers for isolating data in a single database.
The package is archived (last release May 2023, effectively frozen at Laravel 9/10-era support), so treat it as a reference implementation rather than a long-term dependency. Pin your Laravel version before installing and expect to vendor-patch anything that breaks.
composer require romegadigital/multitenancy
php artisan vendor:publish --tag=multitenancy-config # config/multitenancy.php
php artisan vendor:publish --tag=multitenancy-migrations
php artisan migrate # landlord DB: `tenants` table
multitenancy.domain (or the APP_DOMAIN/MULTITENANCY_DOMAIN env var the config reads) so the package can strip the subdomain from Host. Everything hinges on this being correct.web group (or a dedicated route group, which is better) in app/Http/Kernel.php. Order matters: it must run after StartSession and before anything that touches the tenant DB.php artisan tenant:make acme --name="Acme Corp"
This creates the Tenant record, provisions the database (default naming pattern lives in config), and runs your migrations against it.http://acme.your-app.test. If you get the landlord app, the middleware isn't resolving; if you get a Connection refused, the tenant DB wasn't created.First useful check: in a tinker session on a tenant subdomain, tenancy()->tenant (or Tenant::current()) should return the model; DB::connection()->getDatabaseName() should be the tenant DB.
The core mental model. Two databases coexist: a landlord connection holding tenants (and anything global — plans, users directory, API keys) and a tenant connection that the middleware swaps at request start. The Tenant model stores the subdomain, DB name, and connection name; the package sets Tenant::current() and reconfigures database.connections.<tenant> at runtime.
Route segregation. Keep landlord routes (marketing, signup, billing) and tenant routes in separate files and apply the tenant middleware only to the tenant group. This avoids the "how do I know whether a tenant exists before routing?" problem entirely.
// routes/tenant.php
Route::middleware(['web', 'tenant'])->group(function () {
Route::get('/', DashboardController::class);
});
// routes/web.php — landlord only
Route::get('/', LandingController::class);
Resolving the tenant outside HTTP. For jobs, commands, and tests, the middleware isn't there. Either call the package's tenant-switching API explicitly or wrap work in a helper:
$tenant = Tenant::where('subdomain', 'acme')->first();
$tenant->makeCurrent(); // or tenancy()->initialize($tenant)
try {
DoWork::dispatchSync();
} finally {
tenancy()->forgetTenant(); // or $tenant->forget()
}
Commands for the tenant lifecycle. Learn the tenant:* artisan family — make, migrate, seed, list, delete — and use them in CI/deploy scripts. For per-tenant schema changes, run php artisan tenant:migrate after any migration commit; relying on the landlord migrate alone will silently leave tenants stale.
Tenant-scoped models. Models living in the tenant DB need a $connection set (the package usually exposes a base model or trait for this) and must never reference landlord tables in joins — cross-database joins are where this package's opacity bites hardest. Keep relations within one database; use IDs + service calls across the boundary.
Testing. Use a per-test tenant in setUp(): provision a tenant against SQLite-in-memory or a throwaway schema, call makeCurrent(), run the test, then forgetTenant(). Without the teardown the static current-tenant leaks between tests and you'll get flaky, order-dependent failures.
Extension points worth knowing. Config's tenant_model (swap for your own subclass with billing columns), the Tenant contract (if you implement your own resolver), and the middleware itself (subclass it to add reserved-subdomain checks, www redirects, or trial-expiry gates before the connection swap).
config:cache will bite you. The domain value must come from config, and cached config makes env() calls return null. Run php artisan config:clear when subdomain resolution suddenly stops working.www, api, admin, mail) will try to resolve as tenants and 404 or, worse, match an existing tenant. Guard against this in a middleware subclass or by validating at tenant creation.DB::purge($connection) on switch/forget; otherwise a worker picks up the previous tenant's connection and you get cross-tenant data leaks. This is the single most dangerous failure mode.initialize/forget wrapper, or dispatch on a tenant-tagged queue. The package does not do this for you.tenant:make needs CREATE DATABASE rights on the MySQL user. On managed hosts (RDS, PlanetScale, Heroku) this often fails — pre-provision databases and register tenants manually.SESSION_COOKIE per subdomain) and a tenant subdirectory to storage paths, or tenants will share state.Host match a tenants.subdomain row? (2) Is the connection name in config/database.php set? (3) Is tenancy()->tenant non-null in the controller? A php artisan tinker one-liner per subdomain beats reading logs.tenant:delete is gated and backed by a mysqldump (or snapshot) in production.tenant_model, also re-publish the migration — the default migration's foreign keys and indexes expect the shipped schema, and a half-migrated landlord DB produces confusing "table not found" errors.migrations watermark per tenant and only run for laggards.How can I help you explore Laravel packages today?