solutosoft/laravel-multitenant
A Laravel package for building multi-tenant applications with tenant resolution and tenant-aware database connections, models, and migrations. It helps isolate tenant data across domains or subdomains while keeping a single codebase.
composer require solutosoft/laravel-multitenant — before you do, open the package's composer.json and confirm the illuminate/* constraint covers your Laravel version. The last release is from March 2024, so Laravel 11/12 apps should test on a branch first.php artisan vendor:publish --provider="Solutosoft\Multitenant\MultitenantServiceProvider") and read config/multitenant.php end to end — it's short and defines the tenant model class, the route parameter name used to resolve the tenant, and the foreign key column convention ({tenant}_id).Account, Team, or Organization) with the tenant trait (Multitenantable). This makes it the thing the middleware resolves.Solutosoft\Multitenant\BelongsToMultitenant). This registers a global scope on account_id and fills the foreign key on create().Solutosoft\Multitenant\Http\Middleware\Multitenant on your tenant routes (not globally — see Gotchas), typically after auth.Route::get('/accounts/{account}/posts', ...). Inside the controller, Post::all() returns only that account's posts and Post::create([...]) gets account_id injected for you.Where to look first: the src/ directory is small enough to read in twenty minutes. Do that rather than relying on the README — the trait names, the scope class name, and the "no tenant resolved" behaviour are the three things you need to know exactly.
Two resolution strategies, pick per route group. For a staff/admin surface, resolve tenancy from the URL (/accounts/{account}/...) so people can switch tenants by navigating. For an end-user surface, resolve from the authenticated user's tenant so URLs stay clean. The middleware supports both; don't mix them in the same route group without thinking about it.
Nested resources. Child routes under {account} inherit tenancy from the parent parameter automatically, but the tenant model's own route binding is not scoped — a user can hit /accounts/{other_account}/posts and get an empty list rather than a 404. Add an explicit authorization check (policy or a Gate::before) on the tenant parameter, because the global scope silently hides the leak instead of failing loudly.
Cross-tenant / backoffice queries. Use Post::withoutGlobalScope(MultitenantScope::class) for reporting, admin dashboards, and export jobs. Wrap these in a clearly named repository method (Post::forAllTenants()) so they're greppable — ad-hoc withoutGlobalScope calls scattered through controllers are how tenant isolation bugs get shipped.
Queues and scheduled tasks. Context does not survive serialization. Capture the tenant id into the job payload in the constructor, and in handle() re-establish context (set the tenant model / call the resolver) inside a try/finally that resets it. For scheduled commands, loop tenants explicitly and set context per iteration — never rely on the previous iteration's state.
Migrations. Every tenant-scoped table gets foreignId('account_id')->index() (or whatever your config uses) plus composite unique indexes: unique on ['account_id', 'slug'], ['account_id', 'email'], etc. A global unique index on email is a bug the moment a second tenant signs up.
Factories and seeders. Models created by factories still go through the model events, so account_id gets filled — but only if there's a resolved tenant. In tests, create the tenant first, then actingAs($user) or hit the route so the middleware runs. Factories that run outside a request (seeders, php artisan tinker) need the tenant set explicitly.
Tests. For every tenant-scoped resource, write the two-tenant test: create records under A and B, authenticate as A, assert B's record is 404 and A's is 200. It's ten lines and it's the only thing that reliably catches a missing trait or a missing middleware.
Keep a thin seam. Extend the package traits in your own App\Models\Concerns\BelongsToTenant that composes the package trait. When you outgrow single-database tenancy (or the package goes stale), you swap one file instead of touching thirty models.
throw new TenantNotResolvedException in a booted() check or a scope wrapper). This is the single most important thing to verify on day one.SubstituteBindings. Check your $middlewarePriority if you see the tenant resolved too late or not at all.DB::table('posts'), DB::select(...), and raw whereRaw joins have no global scope. Audit for these; they're the usual source of "why can tenant A see tenant B's rows in the export?"create() fill vs. mass assignment. The trait sets the foreign key via a model event. If you've added account_id to $fillable, a malicious payload can set it explicitly — check whether the event overwrites an explicit value (it usually does, but confirm) and consider leaving it out of $fillable entirely.withTrashed() call doesn't remove the tenant scope, which is usually what you want but surprises people during restore operations.finally blocks, or you'll get a worker processing tenant B's job under tenant A's scope — a silent and very bad data leak.php artisan config:cache after publishing; in deployment pipelines this is a classic "works locally, wrong tenant in prod" failure.->toSql() or a DB::listen() dump is the fastest way to confirm where account_id = ? is actually being appended. If that clause isn't in the SQL, nothing else matters.composer.json, vendor-read the scope and trait classes, and keep your own concerns-layer wrapper so a swap to stancl/tenancy or hand-rolled scopes is a contained change. Budget for that conversation now rather than during an incident.How can I help you explore Laravel packages today?