worldesports/laravel-auto-tenancy
Automatic multi-tenancy for Laravel. Automatically scopes models, queries, and database connections to the current tenant with minimal configuration, so you can add tenant isolation to existing apps without rewriting your code. A lightweight package for building SaaS and multi-tenant apps.
Install and publish:
composer require worldesports/laravel-auto-tenancy
php artisan vendor:publish --provider="WorldEsports\AutoTenancy\AutoTenancyServiceProvider"
Then read config/auto-tenancy.php before touching anything else — it defines the central connection name, the tenant connection template, the tenant model, and the column/attribute used to resolve a tenant from the authenticated user. Everything else in the package is downstream of those four values. The second place to look is the package's middleware directory (src/Http/Middleware or similar); that is where the actual DB::purge / config(['database.connections.…']) / DB::reconnect handoff happens, and it is short enough to read top-to-bottom in a few minutes.
Mental model: one central database holds users, tenants, and anything global; each tenant owns a physically separate database. Before authentication there is no tenant, so the app runs against the central connection. After authentication, a middleware resolves the tenant from the user and swaps the default connection for the rest of the request.
First use case to validate end-to-end:
User and/or Tenant model as the config instructs.Route::get('/whoami', fn () => [
DB::connection()->getName(),
DB::connection()->getDatabaseName(),
auth()->user()->tenant_id,
]);
If those three agree, the package is working. If they don't, the middleware is in the wrong place in the stack — see Gotchas.
Before you invest: the license is NOASSERTION. That is a legal blocker for most companies until clarified with the maintainer. With 33 stars and zero dependents there is effectively no community to fall back on, so budget a half-day to read the entire src/ tree — it is almost certainly a few hundred lines, and you need to know it cold because you will be maintaining it.
Middleware placement is the whole design. The package is "post-authentication," which means the switch must happen after the session/auth middleware has resolved a user but before any controller, policy, or model touches a tenant table. Register it into the web group explicitly after StartSession and Authenticate, or attach it per-route-group:
Route::middleware(['auth', 'auto-tenancy'])->group(function () {
// tenant-scoped routes
});
Keep a parallel, tenant-free route group for login, registration, billing, and the tenant-selection screen. Those routes must never see a switched connection.
Resolution strategies. The default is "tenant from auth()->user()," which covers the SaaS case. For admin impersonation and support tooling, add a second resolver that reads a signed session key set by a deliberate "enter tenant" action, and make sure it is gated by a policy — otherwise a crafted session key is a full cross-tenant read.
Provisioning as an explicit pipeline. Do not create the database inside the registration controller. Write a queued job (ProvisionTenant) that: creates the DB, switches the connection manually, runs Artisan::call('migrate', ['--path' => 'database/migrations/tenant', '--force' => true]), seeds defaults, and marks the tenant active. Idempotency matters here — a retried queue job must not fail halfway and leave a half-migrated database. Record a provisioned_at timestamp and check it before re-running.
Queue jobs lose the connection. A serialized job carries no database connection. Capture the tenant identifier on the job and re-establish it in handle():
public function __construct(public int $tenantId) {}
public function handle(): void
{
Tenancy::forTenant($this->tenantId, fn () => $this->doWork());
}
If the package doesn't expose a helper like that, write a small TenantAware trait with a run() wrapper around the same purge/config/reconnect calls the middleware uses — reusing the package's own switch logic rather than duplicating it keeps behaviour consistent.
Console and scheduler are yours to handle. php artisan migrate will only hit whatever connection is default at boot, i.e. central. Build a tenants:artisan style command that iterates active tenants and calls the requested command per tenant with --force. Same for scheduled jobs — a scheduled closure runs once, centrally; wrap the tenant loop inside it.
Testing. Two databases per test is painful. The pragmatic pattern is sqlite :memory: for central and a per-test file-backed or in-memory tenant connection, with RefreshDatabase on the central connection plus an explicit migrate of the tenant migration path in setUp(). Avoid DatabaseTransactions across both connections — it will not roll back the tenant side and you will get bleed-through between tests that looks like a package bug.
Keep central and tenant models apart. Maintain an explicit list of central-only models and be disciplined: never write a relationship from a tenant model to a central model, and never join across the two. Cross-boundary work belongs in application code (fetch IDs centrally, pass them into the tenant context).
config('database.connections.tenant.database') without DB::purge('tenant') will silently keep using the previous database. Verify the package purges; if you write your own switch anywhere, purge first.finally block.config:cache freezes the connection template. Do not compute the tenant database name in config/database.php at boot — it will be baked in. Set it at runtime only.DB::connection()->getName() and getDatabaseName() at three points: middleware entry, controller entry, and after any manual DB::reconnect. Nine times out of ten the answer is "the middleware never ran" or "we switched the default but a model pinned $connection."$connection on models. Any model with a hardcoded protected $connection ignores the switch entirely. Grep for it.DB::transaction() is not atomic. Use an outbox or compensating action.tenants:migrate --dry-run check in CI that compares migration state across all tenants.NOASSERTION license, 33 stars, no dependents, no visible security policy. Fork it into your own namespace, pin the commit, and treat it as vendored code rather than a dependency you can upgrade casually. The upside: the surface area is small enough that owning it is cheap — which is exactly why reading all of src/ on day one is the right call.How can I help you explore Laravel packages today?