Skip to main content

Multi-Tenancy

The system is multi-tenant: one Laravel application serves multiple law firms, each with complete data isolation. The tenant boundary is the law_firms table. Every piece of firm data carries a law_firm_id foreign key.


How Filament Tenancy Works

Filament v3 has built-in tenant support. Registering LawFirm as the tenant model causes Filament to:

  1. Prefix all panel URLs with the tenant slug: /admin/{firm-slug}/matters
  2. Inject the current tenant into every Eloquent query via a global scope
  3. Redirect unauthenticated or cross-tenant requests automatically
// app/Providers/Filament/AdminPanelProvider.php
->tenant(LawFirm::class, slugAttribute: 'slug')
->tenantMiddleware([ApplyTenantScopes::class], isPersistent: true)

The LawFirm Model

// app/Models/Firm/LawFirm.php
class LawFirm extends Model implements HasTenants
{
public function getTenantSlug(): string
{
return $this->slug;
}

public function canAccessTenant(Model $tenant): bool
{
// User must have an active FirmUserProfile for this firm
return $this->firmUserProfiles()
->where('user_id', auth()->id())
->where('is_active', true)
->exists();
}
}

Scoping Models to the Tenant

Every model that belongs to a firm implements a global scope that filters by the current tenant automatically. This means every Matter::all() call returns only matters for the current firm — no developer needs to remember to add ->where('law_firm_id', ...).

// app/Models/Matter/Matter.php
class Matter extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new TenantScope);
}
}

// app/Models/Scopes/TenantScope.php
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if (Filament::hasTenant()) {
$builder->where('law_firm_id', Filament::getTenant()->id);
}
}
}

Tables that require tenant scoping (carry law_firm_id):

law_firms               ← the tenant itself
firm_user_profiles matters clients
client_users partner_firms firm_partner_relationships
user_role_assignments resource_access_grants
billing_activity_codes firm_tax_rates lawyer_rate_plans
invoices documents appointments
support_access_sessions admin_resource_locks

Three User Types and Tenant Resolution

Each panel resolves its tenant differently:

PanelGuardTenant resolved from
Admin /adminwebURL slug → law_firms.slug
Client /portalclientclient_users.user_idclient_contacts.client_idclients.law_firm_id
Partner /partnerpartnerpartner_attorney_users.user_idpartner_attorneys.partner_firm_idfirm_partner_relationships.law_firm_id

Octane Safety for Tenant Context

Under Octane, the tenant must be resolved fresh on every request — it must never be stored in a singleton. Use scoped() bindings for any class that holds the current tenant:

// AppServiceProvider.php
$this->app->scoped(CurrentTenant::class, fn() => new CurrentTenant());

// NEVER:
$this->app->singleton(CurrentTenant::class, fn() => new CurrentTenant());
// ↑ This leaks firm A's tenant context into firm B's next request