felipearnold/filament-multi-tenancy-start-kit
Laravel/Filament starter kit for multi-tenant SaaS applications. It scaffolds tenant-aware panels and onboarding, giving you a clean foundation to build secure, isolated admin experiences faster.
This is a starter kit, not a general-purpose library. Treat it as a reference implementation you fork/copy into an application, not as a dependency you'll keep bumping. With 64 stars and a description that is essentially Filament's own marketing blurb (it doesn't actually describe tenancy), assume documentation is thin and the code is the documentation.
Where to look first, in this order:
composer.json — the require block tells you which Filament major version it targets. This decides everything: Filament v2 has no native tenancy, v3 does (->tenant() on the panel). If it pins filament/filament: ^2.x, the kit is hand-rolling scoping and you'll inherit that pattern.app/Providers/Filament/*PanelProvider.php — the ->tenant(...) call, tenant middleware, and ->tenantMiddleware([...], isPersistent: true) are the heart of the setup.database/migrations/ — look for the tenant table, the tenant_id/team_id foreign keys, and the membership pivot. This is where correctness lives or dies.app/Models/ — the tenant model and the User ↔ tenant relationship (teams(), currentTeam(), or a membership pivot with roles).tests/ — if there are none, lower your expectations; isolation bugs are invisible until production.Minimal setup:
composer require felipearnold/filament-multi-tenancy-start-kit
php artisan vendor:publish --tag="filament-multi-tenancy-start-kit-migrations" # check README for the real tag
php artisan migrate
php artisan make:filament-user # or use the kit's seeder
Then register the panel provider if the package doesn't auto-discover it, create two tenants, attach your user to both, and log in.
Your first use case should be a smoke test, not a feature. Create Tenant A and Tenant B, put one record in each of a single resource, log in as a user belonging to A only, and confirm: (a) you see only A's record, (b) hitting B's record by URL returns 404/403, (c) the tenant switcher doesn't list B. Do this before writing a single line of business logic.
Tenant resolution via the panel. The idiomatic Filament v3 approach the kit should be using:
$panel
->tenant(Team::class, ownershipRelationship: 'team', slugAttribute: 'slug')
->tenantMiddleware([
PersistTenantMiddleware::class, // or the kit's equivalent
], isPersistent: true);
isPersistent: true is what makes the tenant survive navigation instead of being re-derived from the URL each request. If the kit doesn't set it, expect the tenant to reset when you click into a non-tenant-scoped page.
Scoping resources explicitly when the panel-level tenancy isn't enough. For resources where tenant_id isn't a direct column (e.g. Invoice belongs to Project belongs to Team), don't fight the automatic scoping — scope in the query:
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->whereBelongsTo(Filament::getTenant());
}
And the inverse for genuinely global resources (currencies, countries, plans):
protected static bool $isScopedToTenant = false;
Be deliberate about this flag. Every resource should be a conscious yes or no.
Membership and roles are two separate concerns. The kit will give you membership (user belongs to tenant). Roles usually need a third layer: a team_user pivot with a role column, or spatie/laravel-permission with the teams feature enabled. Wire Filament's ->tenant(..., ) together with the role check in canAccessPanel() or a policy — don't rely on the UI hiding things.
Onboarding flow. The pattern that scales: registration creates a user and a personal tenant; subsequent invitations attach the user to an existing tenant. Keep the invite acceptance route outside the tenant-scoped panel (->tenantMiddleware(..., isPersistent: false) on that route, or a plain controller) so a user can accept before they have a "current" tenant.
Tenant-aware background work. Any job, command, or scheduled task touching tenant data needs explicit context:
class RecalculateStats implements ShouldQueue
{
public function __construct(public int $tenantId) {}
public function handle(): void
{
Filament::setTenant(Team::findOrFail($this->tenantId));
// ... work
}
}
Never rely on Filament::getTenant() inside a queue worker — it's empty there.
Tenant switching UX. Use the built-in tenant menu (->tenantMenu()), and if you need a "switch tenant" action outside the panel chrome, redirect to a tenant-scoped URL rather than trying to set session state manually. The URL is the source of truth in Filament's model.
Audit the migrations before running them anywhere real. A 64-star starter kit is a code sample. Check for: nullable tenant_id on tables that must be scoped, missing composite indexes (['tenant_id', 'slug'] rather than a global unique on slug), and foreign keys without cascadeOnDelete(). Orphaned rows across tenants are painful to clean up later.
Unique constraints are the classic bug. users.email unique globally means a person can't be in two tenants with separate accounts; unique per tenant means login needs tenant context before authentication. Decide explicitly, and check what the kit chose.
Prefer copying over depending. Because this is a start kit, composer require and then commit the published files into app/ and database/. You will need to modify the tenant model, the middleware, and the invitation flow — fighting a vendor package you can't cleanly override is worse than owning 400 lines of code.
Isolation leaks to test for, in order of likelihood:
Model::find($id) or route-model binding on a resource that has $isScopedToTenant = false — instant cross-tenant read.Select/BelongsTo options that query the related model without a tenant filter.logo.png collide.withoutGlobalScopes() anywhere in the codebase — grep for it.Cache and storage collisions. If the kit uses Cache::remember('settings'), two tenants share it. Prefix everything with Filament::getTenant()->getKey(), or use a tenant-aware cache driver. Same for Storage::disk('local')->put(...) paths and generated PDF/report files.
Filament version drift. Most tenancy tutorials and snippets you'll find online are v3. If the kit is v2-era, Filament::getTenant() doesn't exist and you're looking at custom middleware resolving from the route. Don't mix patterns — pick the version's idiom and stay in it.
Debugging "I can see another tenant's data":
dd(Filament::getTenant()); // is tenancy even resolving?
dd(static::getEloquentQuery()->toSql()); // what's the actual query?
Then check middleware order in the panel provider — tenancy middleware must run before the resource query is built, and PersistTenantMiddleware needs to come after session start.
Extension points worth knowing: the panel's ->tenant() signature accepts a custom slugAttribute and ownershipRelationship; Tenant::class can be any model implementing the right contract; and ->tenantMiddleware() accepts your own classes, which is where you'd inject "redirect to tenant selection if none chosen" behavior.
Set a review budget. Before adopting, spend 30 minutes writing one cross-tenant isolation test per resource type. If the kit doesn't make that easy (factories, seeders, a test helper that logs in as a tenant member), that's a signal about how much scaffolding you'll be writing yourself — at which point installing Filament's native tenancy and writing the team model by hand may be the faster path.
How can I help you explore Laravel packages today?