henryavila/laravel-nova-multitenancy
Adds multitenancy support to Laravel Nova by scoping resources, queries, and panel data to the current tenant. It helps isolate Nova administration per tenant and provides configuration for tenant-aware resource handling, routing, and access control in multi-tenant applications.
v3.3.0 is a compatibility release: the only change is Laravel 11 support (PR #11). There are no API changes, no deprecations, and no migration steps beyond bumping your framework constraint. If you are already on v3.2.0 and Laravel 10, there is nothing to do; if you are planning a Laravel 11 upgrade, this is the version that unblocks you.
Minimal setup path:
composer require henryavila/laravel-nova-multitenancy. On Laravel 11 this requires PHP 8.2+ and a Nova release that itself supports Laravel 11 (Nova 4.27+ or Nova 5). Confirm your laravel/nova constraint resolves before you start the framework upgrade, otherwise you will chase phantom dependency conflicts.php artisan vendor:publish --tag=nova-multitenancy-config, then read config/nova-multitenancy.php top to bottom. It is short, and every behaviour you will later debug (tenant resolution source, session key, redirect target after switching) is driven from there.User model implements the package's HasTenants contract and uses the matching trait (this is what gives you $user->tenants and the "current tenant" relationship). Your Tenant model implements the IsTenant contract and its trait. These two contracts are the whole integration surface; everything else builds on them.web group (or to the Nova middleware stack in NovaServiceProvider). Order matters: it must run after Authenticate/StartSession so it can read the logged-in user and the session.First real use case: a single-database, single-deployment SaaS where a users table has a many-to-many tenant_user pivot, and every domain table carries a tenant_id. You do not need separate databases to get value here — the package's job is answering "which tenant is this request in?" and exposing that answer to Nova and to your query scopes.
Resolution strategy — pick one and stay on it. The common configurations are (a) subdomain/domain-based, where the tenant is derived from the host, and (b) session-based, where the user picks a tenant from the switcher and it is persisted. Session-based is what most Nova-first apps want, because Nova is an admin panel and admins frequently belong to more than one tenant. Domain-based is better when tenants are externally addressable. Mixing them produces the classic bug where a user lands on acme.example.com but the session still says globex.
Scoping queries. The reliable pattern is a global scope on your tenant-owned models that reads the current tenant from the package (via its facade, e.g. NovaMultiTenancy::getTenant()) and applies where('tenant_id', ...). Do this on the Eloquent model, not in NovaResource::indexQuery(), so that non-Nova code paths, policies, and relationship counts are scoped identically. Nova resources then need no per-resource tenancy logic at all.
Resource visibility per tenant. Where tenants get genuinely different Nova experiences (different tools, cards, or resources), gate registration in NovaServiceProvider::tools()/resources() on the current tenant rather than hiding things client-side. Be aware that Nova caches its resource/tool manifest — flush php artisan nova:cache (or your configured cache) after changing tenant-conditional registration, or you will see the wrong menu for the wrong tenant.
Switching tenants in a request. A controller action that validates the user actually belongs to the requested tenant ($user->tenants()->whereKey($id)->exists()), calls the package's set-tenant method, and redirects back. Never trust a tenant id from the request without that membership check — this is the single most important authorization boundary in a multi-tenant admin panel.
Background work. Anything queued or scheduled has no HTTP session, so the tenant must be carried explicitly: put the tenant id on the job payload and set the context in handle() before touching models. The package exposes a "run this closure for tenant X" helper — wrapping job bodies in it is cleaner than manual set/forget pairs because it restores the previous context for you.
Artisan commands. Tenant-sensitive commands should take a --tenant= option and iterate tenants rather than running against an unscoped query. If you forget, a "cleanup old records" command will happily delete across every tenant.
Testing. Set the tenant in setUp() (or a Pest beforeEach) via the package's setter, and build a TenantFactory with per-tenant user factories. Assert the negative case too: user A of tenant 1 gets a 404/403 when fetching tenant 2's record by ID. Those tests are cheap and catch the scoping regressions that global scopes silently introduce.
Combining with stancl/tenancy or similar. This package is a Nova-layer tenant context, not a database-per-tenant bootstrapper. If you run database-per-tenant, use the other package for the actual switching and use this one only to make Nova tenant-aware; do not let both own the "current tenant" concept.
try/finally around the setter, or use the package's run-for-tenant helper which does the restore for you. Symptom: the first job after a deploy is fine, the tenth writes into the wrong tenant.Cache::remember('stats') returns the previous tenant's payload. Either prefix keys with the tenant id or use tenant-tagged stores, otherwise, you will only notice in production.StartSession or before Nova's auth middleware, it will silently resolve to null and every model will look unscoped — which reads as "tenancy is broken" rather than "middleware is misplaced." When debugging, dd()/log the current tenant in a Nova route first.unique on users.email prevents the same person existing in two tenants; and a global unique on tenant-owned slugs breaks the second tenant. Use composite uniques (tenant_id, email). This is a schema decision that is expensive to reverse.belongsToCurrentTenant() check rather than repeating it in every policy.config/nova-multitenancy.php in any environment with config:cache, run php artisan config:clear. A stale cached config is the usual cause of "I changed the session key and nothing happened."illuminate/* to ^10, (c) your config/nova-multitenancy.php was re-published or diffed — the L11 release did not announce config changes, so an unmodified published config should remain valid, but diff it against the package's stub anyway. Run php artisan nova:cache and your tenancy test suite afterwards.User and Tenant are the intended customisation seam, along with the published config. If you need custom resolution (header-based, JWT claim, impersonation by a super-admin), implement it inside the middleware/config rather than patching the vendor package — the latter will bite you on the next Laravel bump like this one.How can I help you explore Laravel packages today?