genealabs/laravel-multi-tenant-manager
Multi-tenant manager for Laravel from GeneaLabs, aimed at tenant resolution and scoping in multi-tenant apps. Early-stage package with limited adoption, so review its maintenance status and test coverage before relying on it in production.
Treat this as a source-reading exercise first; there is no usable documentation. The only release is 0.1.0 (Aug 2021) with a changelog entry of "initial functionality," 1 star, and zero dependents — so every claim below is inferred from the package description and standard Laravel package conventions, not from a README.
1. Install with an exact pin, not a caret.
composer require genealabs/laravel-multi-tenant-manager:0.1.0
At 0.x a minor bump may be breaking. Do not write ^0.1. Commit the lockfile.
2. Read these four things before writing any code (approx. 2–4 hours):
composer.json — the php and illuminate/* constraints. This is your first go/no-go: a 2021 package likely caps at Laravel 8.src/ — the service provider, any facade, any middleware class, and the tenant model/contract. Confirm whether it is a self-contained library or a client for something external.database/migrations directory.tests/ directory — if it is absent, your confidence in every behaviour below drops sharply.3. Publish whatever it publishes and read the diff.
php artisan vendor:publish --provider="GeneaLabs\LaravelMultiTenantManager\...ServiceProvider"
Check for config, migrations, and routes separately. Each one you publish is an upgrade surface you now own — commit the published files so config drift shows up as a diff.
4. First use case: resolve, do not scope.
Before wiring database switching, write a throwaway route that resolves a tenant from the subdomain and dumps the result:
Route::get('/whoami', function () {
return [
'host' => request()->getHost(),
'tenant' => optional(app(/* tenant resolver */))->current()?->id,
'connection' => DB::getDefaultConnection(),
];
});
Hit it with curl -H "Host: acme.test" http://localhost:8000/whoami. That single request tells you whether resolution works, what object shape the tenant is, and whether the package touched the DB connection as a side effect. Only move on to per-tenant connections and Eloquent scoping once this is boring and predictable.
Where to look first if it misbehaves: the service provider's register()/boot() order, then the middleware's position in app/Http/Kernel.php. Almost every "tenant is null" report traces to one of those two.
Wrap it behind your own contract. This is the single most valuable pattern given the package's maturity. Define App\Tenancy\TenantResolver (and TenantContext) in your app, bind an adapter implementation in a service provider, and let nothing outside that adapter import the package namespace. Swapping to stancl/tenancy or a hand-rolled resolver then becomes a binding change, not a refactor. The adapter costs a day; it is the cheapest insurance you will buy on this package.
Middleware: register globally, resolve early, and know what runs before it. Tenant resolution must happen before anything tenant-sensitive — auth guard lookups, cache key prefixes, config overrides, and any Eloquent call. In Kernel.php, put it near the top of $middleware (global) so it also covers routes you add later. If the package ships its own middleware class, prefer registering that class rather than aliasing it to a name — fewer strings to typo.
Database switching: switch, then purge. Per-tenant connections mean the default connection changes at runtime. After switching, clear anything that cached the old connection — DB::purge(), and any repository that resolved a Connection instance in its constructor. The recurring bug is a singleton service holding a stale PDO from the previous tenant.
Queue jobs must carry the tenant explicitly. A job dispatched under tenant A does not know it is tenant A when the worker picks it up. Serialize the tenant identifier in the job payload and re-establish context in handle() (via middleware() on the job or an explicit resolver call at the top of handle), and always reset context in a finally block. Note that this is exactly the kind of hook the description claims ("tenant-aware jobs") — verify the package actually provides it before assuming; if it doesn't, write the trait yourself.
Eloquent scoping: verify the scope, don't trust it. If the package installs a global scope, prove it with a test that creates two tenants' rows and asserts Model::count() returns one tenant's worth. Also assert the negative case: what happens when no tenant is resolved? A global scope that silently returns zero rows in console commands and seeders produces very confusing debugging sessions.
Testing pattern. Feature tests should set the host explicitly:
$this->withServerVariables(['HTTP_HOST' => 'acme.test'])->get('/dashboard');
Add a small helper trait that provisions a tenant, switches context, and tears it down in tearDown(). Without that teardown, a leaked tenant context makes test order matter — and order-dependent tenants fail only in CI, on a shuffled run.
Pin exactly, and re-evaluate only on a deliberate diff-reading upgrade. Pre-1.0 with one release means a 0.1.x bump can change behaviour with no changelog signal. Diff src/ and config/ yourself before bumping.
Config defaults can change behaviour with no code diff in your repo. Publish and commit the config. Diff it on every upgrade. This is the failure mode that produces "it worked yesterday" with a clean git log.
config:cache in production will freeze your tenant resolution config. If resolution is config-driven (domain→tenant maps, connection templates), remember that php artisan config:cache runs at deploy time. Anything that needs to vary per request must not live in a cached config lookup.
Connection state pollution is the top production risk. Classic Laravel request lifecycles tear down per request, so a leaked default connection is often invisible in dev. Under Octane or a long-lived queue worker, the same leak crosses tenants. Before adopting, test explicitly: two consecutive requests, different hosts, same worker process — assert the second request sees its own tenant's data. If that fails, the package is not Octane-safe and must be treated as per-request only.
Console commands and seeders run with no tenant. Anything the global scope touches will behave differently there. Provide an explicit runFor($tenant, fn () => ...) helper and a --tenant= option on tenant-sensitive commands.
Local subdomain dev is a papercut. acme.localhost and php artisan serve don't always agree on getHost(). Test with an explicit Host header via curl or withServerVariables rather than assuming the browser will hand you the subdomain you expect. Add the hosts entries to your onboarding doc on day one.
Debugging: log the resolution decision, not just the result. Log the host, the strategy tried, and the tenant id at resolution time. When a request lands on the wrong tenant, you want the input to the resolver, not a null downstream. DB::getDefaultConnection() at the top of a middleware is a surprisingly effective tripwire.
Extension points to verify before you depend on them. The description advertises "configurable tenant providers." Check in src/ whether that seam is an interface you can implement, a final class, or a config enum. If it's a final class, your adapter must wrap the whole package instead of just the resolver — plan accordingly. Same question for lifecycle hooks: are they real events you can Event::listen to, or just method calls buried in the middleware?
No support channel exists. No docs, no issue history, no maintainer cadence. That is acceptable for a low-blast-radius utility in a non-critical path; it is not acceptable on the auth, payments, or data-isolation path. Keep the adapter boundary and treat "replace it" as a planned outcome, not a contingency.
How can I help you explore Laravel packages today?