mapeveri/multi-tenancy-bundle
A multi-tenancy bundle for PHP applications that simplifies tenant isolation, tenant-aware database connections, and request-based tenant resolution. It provides the building blocks to run multiple tenants from a single codebase with minimal configuration.
Reality check first: this is not a Laravel package. Despite the name, mapeveri/multi-tenancy-bundle is a Symfony bundle ("type": "symfony-bundle" in composer.json). There is no service provider, no facade, no package auto-discovery, no config/multitenancy.php. On a Laravel app, composer require mapeveri/multi-tenancy-bundle will install inert code that the framework never boots. That is your first and most important finding.
Minimal steps for a Laravel dev:
composer.json. The README carries an explicit "this repo is not maintained anymore" warning, 11 stars, 0 dependents, and no ecosystem to inherit fixes from. Treat it as reading material for a spike, cloned to a scratch directory.*Bundle class (entry point) — this is the Symfony analogue of a service provider,Configuration class — this is the analogue of your config/*.php file,kernel.request listener or event subscriber that populates tenant context,request arrives → resolver picks a tenant → context is stored → data access is scoped. That maps 1:1 onto a Laravel middleware + a scoped Eloquent trait, and that mapping is the entire practical value of this repository.If you actually need multi-tenancy on Laravel today, evaluate stancl/tenancy or spatie/laravel-multitenancy instead — a Symfony bundle cannot be the answer to a Laravel question, maintained or not.
Pattern A — Port the resolver/listener into middleware (the realistic day-to-day move).
The bundle resolves a tenant early in the request lifecycle and stores it scoped to the request. In Laravel that is a middleware registered before auth/session middleware:
$request->getHost()) or a header, deterministically.app()->instance(Tenant::class, $tenant) and set it on a request-scoped TenantContext singleton.terminate() (and especially under Octane, where the container persists across requests).Pattern B — Port the Doctrine filter into an Eloquent global scope.
The bundle's isolation, where it exists, is likely a Doctrine SQL filter. The Laravel equivalent:
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', fn ($q) => $q->where('tenant_id', app(TenantContext::class)->id()));
static::creating(fn ($m) => $m->tenant_id ??= app(TenantContext::class)->id());
}
}
Apply it to every tenant-owned model, plus an auto-fill on creating so writes cannot land under the wrong tenant. This mirrors the filter behavior at the layer where developers actually work.
Pattern C — Connection switching for database-per-tenant.
If you lift the bundle's connection-switching idea, do it in middleware:
config(['database.default' => "tenant_{$tenant->id}"]) and register the connection config at runtime.DB::purge($connection) after switching, or you will serve one tenant's data from a stale pooled connection.migrate per tenant as an explicit, tenant-parameterized command — never a global artisan migrate that silently hits the wrong database.Pattern D — Re-bind context in every async boundary.
Tenant context does not survive process boundaries. For any job, scheduled command, or queued listener:
public function handle(): void
{
app(TenantContext::class)->set(Tenant::find($this->tenantId));
// ... do work
}
Carry tenantId in the job payload explicitly. This is the single most common way tenancy implementations leak in production: a job dispatched under tenant A executes in a worker that last served tenant B.
Integration tip: the bundle's "configurable isolation for different deployment models" is a design smell worth noting — isolation model is an architectural decision (shared schema + scoping vs. schema-per-tenant vs. database-per-tenant), not a runtime toggle. Pick one, write it down, and make the scoping mechanism uniform.
config/ merge, no auto-discovery. Nothing will "just work" — and the bundle's Laravel-3-era naming (*Bundle) suggests conventions that no longer exist in any framework you're on.composer.json constraints before any spike, not after.DB::select), withoutGlobalScopes(), native query builders, and relations defined without the scope all bypass the filter. Grep for these in code review; they are where leaks hide.withoutGlobalScopes() in tests is a trap. Your isolation test must assert that tenant A's user gets 404/empty for tenant B's records — a test that passes with scoping disabled proves nothing.users:1 collides across tenants. Prefix with the tenant ID at every storage edge, not just the database.terminate(), and consider a TenantContext::flush() in the worker's request-reset hook.How can I help you explore Laravel packages today?