面板构建器
多租户(Multi-tenancy)
概述
多租户(Multi-tenancy)是一个应用程序的单个实例为多个客户提供服务的概念。每个客户都有自己的数据和访问规则,这些规则禁止他们查看或修改彼此的数据。这是 SaaS 应用程序中的常见模式。用户通常属于用户组(通常称为团队或组织)。记录归群组所有,用户可以是多个群组的成员。这适用于用户需要在数据上进行协作的应用程序。
多租户是一个非常敏感的话题。了解多租户的安全含义以及如何正确实现它很重要。如果部分或错误地实现,属于一个租户的数据可能会暴露给另一个租户。Filament 提供了一组工具来帮助你在应用中实现多租户,但如何使用这些工具取决于你。Filament 不对你的应用的安全性提供任何保证。你有责任确保你的应用是安全的。有关详细信息,请参阅安全部分。
一对多租户
术语"多租户"是宽泛的,在不同语境下可能有不同意思。Filament 的多租户系统指的是用户属于多个租户(组织、团队、公司等),并且可能会在租户间切换。
如果你的用例较为简单,不需要多对多关联,那么你不需要在 Filament 中安装多租户。你可以使用观察者和全局查询范围设置。
比如你有一个数据库字段 users.team_id
,你可以让用户使用全局查询范围,将所有记录查询范围设置为同一个 team_id
:
use Illuminate\Database\Eloquent\Builder; class Post extends Model{ protected static function booted(): void { static::addGlobalScope('team', function (Builder $query) { if (auth()->hasUser()) { $query->where('team_id', auth()->user()->team_id); // or with a `team` relationship defined: $query->whereBelongsTo(auth()->user()->team); } }); }}
要在创建时自动设置 team_id
,你可以使用观察者:
class PostObserver{ public function creating(Post $post): void { if (auth()->hasUser()) { $post->team_id = auth()->user()->team_id; // or with a `team` relationship defined: $post->team()->associate(auth()->user()->team); } }}
设置租户
要设置租户,你需要在配置中指定"租户"(比如-团队 team 或组织 organization)模型:
use App\Models\Team;use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenant(Team::class);}
你同时需要告诉 Filament 用户属于哪个租户。你可以在 App\Models\User
模型上实现 HasTenants
:
<?php namespace App\Models; use Filament\Models\Contracts\FilamentUser;use Filament\Models\Contracts\HasTenants;use Filament\Panel;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\BelongsToMany;use Illuminate\Foundation\Auth\User as Authenticatable;use Illuminate\Support\Collection; class User extends Authenticatable implements FilamentUser, HasTenants{ // ... public function teams(): BelongsToMany { return $this->belongsToMany(Team::class); } public function getTenants(Panel $panel): Collection { return $this->teams; } public function canAccessTenant(Model $tenant): bool { return $this->teams()->whereKey($tenant)->exists(); }}
上例中,用户属于多个团队,因此它有一个 teams()
关联。getTenants()
方法返回用户所属的团队。Filament 使用它来罗列出用户有权访问的租户。
为安全起见,你也需要实现 HasTenants
接口的 canAccessTenant()
,以防止用户通过猜测租户 ID 并将其放入到 URL 来访问租户数据。
你可能希望用户可以注册新团队.
添加租户注册页面
注册页面将允许用户创建新租户
登录后访问你的应用时,如果用户还没有租户,他们将被重定向到此页面。
要设置注册页面,你需要创建一个继承 Filament\Pages\Tenancy\RegisterTenant
的新页面类。这是一个全页 Livewire 组件。你可以把它放在任何你想要的地方,比如 app/Filament/Pages/Tenancy/RegisterTeam.php
:
namespace App\Filament\Pages\Tenancy; use App\Models\Team;use Filament\Forms\Components\TextInput;use Filament\Forms\Form;use Filament\Pages\Tenancy\RegisterTenant; class RegisterTeam extends RegisterTenant{ public static function getLabel(): string { return 'Register team'; } public function form(Form $form): Form { return $form ->schema([ TextInput::make('name'), // ... ]); } protected function handleRegistration(array $data): Team { $team = Team::create($data); $team->members()->attach(auth()->user()); return $team; }}
你可以将任何表单组件添加到 form()
方法中,并在 handleRegistration()
方法内创建团队。
现在,我们需要告诉 Filament 启用这个页面。我们可以在配置中实现:
use App\Filament\Pages\Tenancy\RegisterTeam;use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenantRegistration(RegisterTeam::class);}
自定义租户注册页面
你可以覆盖注册页基类上所需的任何方法,使其按你的要求运行。甚至 $view
属性也可以被重写以使用你选择的自定义视图。
添加租户简介页面
简介也将允许用户编辑租户信息。
要设置简介页,你需要创建一个新的页面类,使之继承 Filament\Pages\Tenancy\EditTenantProfile
。这是一个全页 Livewire 组件。你可以将其放在任何希望的位置,比如 app/Filament/Pages/Tenancy/EditTeamProfile.php
:
namespace App\Filament\Pages\Tenancy; use Filament\Forms\Components\TextInput;use Filament\Forms\Form;use Filament\Pages\Tenancy\EditTenantProfile; class EditTeamProfile extends EditTenantProfile{ public static function getLabel(): string { return 'Team profile'; } public function form(Form $form): Form { return $form ->schema([ TextInput::make('name'), // ... ]); }}
你可以添加任何表单组件到 form()
方法中。它们将直接保存到租户模型中。
现在,我们需要告诉 Filament 去使用该页面。我们可以在配置实现该功能:
use App\Filament\Pages\Tenancy\EditTeamProfile;use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenantProfile(EditTeamProfile::class);}
自定义租户简介页
你可以重写覆盖简介页基类上所需的任何方法,使其按你的要求运行。甚至 $view
属性也可以被重写以使用你选择的自定义视图。
访问当前租户
在应用的任何地方,你都可以使用 Filament::getTenant()
方法访问当前请求的租户模型:
use Filament\Facades\Filament; $tenant = Filament::getTenant();
Billing
使用 Laravel Spark
Filament 提供 Laravel Spark 的账单集成。你的用户可以开始定义并管理它们的账单信息。
要安装该集成,首先要安装 Spark 并为你的租户模型配置它。
现在,你可以使用 Composer 安装 Filament 的 spark billing provider:
composer require filament/spark-billing-provider
在配置,将 Spark 设置为 tenantBillingProvider()
:
use Filament\Billing\Providers\SparkBillingProvider;use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenantBillingProvider(new SparkBillingProvider());}
现在,用户可以通过单击租户菜单中的链接来管理他们的账单。
要求订阅
要求订阅,才能使用应用的任何部分,你可以使用 requiresTenantSubscription()
的配置方法:
use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->requiresTenantSubscription();}
现在,如果用户没有激活订阅,将会被重定向到账单页面。
要求对特定资源或页面进行订阅
有时,你只希望应用中的特定资源和页面需要订阅。你可以在资源或页面类的 isTenantSubscriptionRequired()
方法中返回 true
来实现该功能。
public static function isTenantSubscriptionRequired(Panel $panel): bool{ return true;}
你如果使用 requiresTenantSubscription()
配置方法,那么你可以从该方法中返回 false
以允许作为异常访问资源或者页面。
编写自定义账单集成
账单集成很容易编写。你只需实现 Filament\Billing\Providers\Contracts\Provider
接口。该接口有两个方法。
getRouteAction
用在获取用户访问账单页面时需要运行的路由动作。它可以是回调函数、或者控制器名、或者 Livewire 组件 - 任何当使用 Route::get()
时可以正常工作的行为。比如,你可以使用回调函数重定向到账单页面。
getSubscribedMiddleware()
返回检测租户是否激活订阅的中间件名。如果用户未激活订阅该中间件应该将用户重定向到账单页面。
以下是账单提供者,它为路由操作使用回调函数、为订阅中间件使用中间件:
use App\Http\Middleware\RedirectIfUserNotSubscribed;use Filament\Billing\Providers\Contracts\Provider;use Illuminate\Http\RedirectResponse; class ExampleBillingProvider implements Provider{ public function getRouteAction(): string { return function (): RedirectResponse { return redirect('https://billing.example.com'); }; } public function getSubscribedMiddleware(): string { return RedirectIfUserNotSubscribed::class; }}
自定义账单路由 slug
使用配置中的 tenantBillingRouteSlug
方法,你可以自定义用于账单路由的 URL slug:
use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenantBillingRouteSlug('billing');}
自定义租户菜单
租户切换菜单是管理员(admin)布局的特色。它完全是可定制的。
要注册新菜单项到租户菜单中,你可以使用配置:
use App\Filament\Pages\Settings;use Filament\Navigation\MenuItem;use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenantMenuItems([ MenuItem::make() ->label('Settings') ->url(fn (): string => Settings::getUrl()) ->icon('heroicon-m-cog-8-tooth'), // ... ]);}
自定义注册链接
要自定义租户菜单上的注册链接,请使用 register
数组键注册一个新的菜单项:
use Filament\Navigation\MenuItem;use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenantMenuItems([ 'register' => MenuItem::make()->label('Register new team'), // ... ]);}
自定义 Profile 链接
要在租户按钮上自定义 Profile 链接,请使用 profile
作为数组键注册新的项目:
use Filament\Navigation\MenuItem;use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenantMenuItems([ 'profile' => MenuItem::make()->label('Edit team profile'), // ... ]);}
自定义账单链接
要自定义租户菜单上的账单链接,请先使用 billing
数组键注册一个新菜单项:
use Filament\Navigation\MenuItem;use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenantMenuItems([ 'billing' => MenuItem::make()->label('Manage subscription'), // ... ]);}
条件性隐藏租户菜单项
使用 visible()
或者 hidden()
方法,并传入条件,你也可以条件性地隐藏租户菜单项。传入函数则会推迟条件求值,直到菜单被实际渲染:
use Filament\Navigation\MenuItem; MenuItem::make() ->label('Settings') ->visible(fn (): bool => auth()->user()->can('manage-team')) // or ->hidden(fn (): bool => ! auth()->user()->can('manage-team'))
从租户菜单项中发送 POST
HTTP 请求
你可用将 URL 传入到 postAction()
方法,从租户菜单项中发送 POST
HTTP 请求:
use Filament\Navigation\MenuItem; MenuItem::make() ->label('Lock session') ->postAction(fn (): string => route('lock-session'))
隐藏租户菜单
通过 tenantMenu(false)
方法,你可以隐藏租户菜单:
use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenantMenu(false);}
不过这是 Filament 租户特性不适用于你的项目的一个信号。如果每个用户只属于一个租户,你应该使用一对多租户。
设置头像
开箱即用,Filament 使用 ui-avatars.com 去生成基于用户名的头像。不过,如果你的用户模型中有一个 avatar_url
属性,则会使用该属性。要自定义 Filament 如何获取用户头像 URL,你可以实现 HasAvatar
契约:
<?php namespace App\Models; use Filament\Models\Contracts\FilamentUser;use Filament\Models\Contracts\HasAvatar;use Illuminate\Database\Eloquent\Model; class Team extends Model implements HasAvatar{ // ... public function getFilamentAvatarUrl(): ?string { return $this->avatar_url; }}
这个 getFilamentAvatarUrl()
方法用于检索当前用户的头像。如果该方法返回的是 null
,那么 Filament 将会回退到 ui-avatars.com。
你可以创建新的头像 Provider,将 ui-avatars.com 替换成其他服务。点击此处学习如何替换头像服务提供者
配置租户关联
当创建和展示带有租户的记录时,Filament 需要访问每个资源的两个 Eloquent 关联 - 一个是 "ownership" 关联,它在资源的模型类中定义,以及一个租户模型类上的关联。默认情况下,Filament 会按照标准的 Laravel 规范去尝试猜测这些关联的名称。比如,如果租户模型是 App\Models\Team
,那它会在资源模型类中寻找 team()
关联。如果资源模型是 App\Models\Post
,它会在租户模型类中查找 posts()
关联。
自定义 ownership 关联名
你可以在 tenant()
配置方法中使用 ownershipRelationship
参数,自定义跨所有资源使用的 ownership 的关联名。本例中,资源模型类定义了一个 owner
关联:
use App\Models\Team;use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenant(Team::class, ownershipRelationship: 'owner');}
此外,你可以在资源类中设置 $tenantOwnershipRelationshipName
静态属性,该属性用于自定义该资源的 ownership 关联名。下例中, Post
模型类定义了 owner
关联:
use Filament\Resources\Resource; class PostResource extends Resource{ protected static ?string $tenantOwnershipRelationshipName = 'owner'; // ...}
自定义资源关联名
你可以在资源类中设置 $tenantRelationshipName
自定义用于获取该资源的关联名。本例中,租户模型类中定义了一个 blogPosts
关联:
use Filament\Resources\Resource; class PostResource extends Resource{ protected static ?string $tenantOwnershipRelationshipName = 'blogPosts'; // ...}
配置 slug 属性
使用像团队这样的租户时,你可能想添加 slug 而不是团队 ID 作为 URL。你可以在 tenant()
配置方法中的 slugAttribute
参数实现该目的:
use App\Models\Team;use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenant(Team::class, slugAttribute: 'slug');}
配置 name 属性
默认情况下,Filament 会使用租户的 name
属性在应用中展示其名称。要对此进行修改,可以实现 HasName
契约:
<?php namespace App\Models; use Filament\Models\Contracts\HasName;use Illuminate\Database\Eloquent\Model; class Team extends Model implements HasName{ // ... public function getFilamentName(): string { return "{$this->name} {$this->subscription_plan}"; }}
getFilamentName()
方法用于检索当前用户的名称。
设置当前租户标签
在侧边栏的租户切换器内,你可能想在当前团队名上添加诸如 "Active team" 这样的小标签。你可以在租户模型上实现 HasCurrentTenantLabel
契约来实现该功能:
<?php namespace App\Models; use Filament\Models\Contracts\HasCurrentTenantLabel;use Illuminate\Database\Eloquent\Model; class Team extends Model implements HasCurrentTenantLabel{ // ... public function getCurrentTenantLabel(): string { return 'Active team'; }}
设置默认租户
用户登录后,Filament 会将用户重定向到 getTenant()
方法返回的第一个租户。
有时,你可能希望对此进行修改。比如,你可能会保持哪个团队是最后活跃的,并将用户重定向到那个团队。
要自定义该内容,你可以在用户上实现 HasDefaultTenant
契约:
<?php namespace App\Models; use Filament\Models\Contracts\FilamentUser;use Filament\Models\Contracts\HasDefaultTenant;use Filament\Models\Contracts\HasTenants;use Filament\Panel;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\BelongsTo; class User extends Model implements FilamentUser, HasDefaultTenant, HasTenants{ // ... public function getDefaultTenant(Panel $panel): ?Model { return $this->latestTeam; } public function latestTeam(): BelongsTo { return $this->belongsTo(Team::class, 'latest_team_id'); }}
将中间件应用到 tenant-aware 路由
你可以在面板配置文件中传入一个中间件类数组到 tenantMiddleware()
方法,将其他的中间件应用到所有 tenant-aware 路由:
use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenantMiddleware([ // ... ]);}
默认情况下,中间件会在页面首次加载时运行,不过不会在随后的 Livewire AJAX 请求中运行。如果你想在每个请求时都运行中间件,你可以传入 true
作为 tenantMiddleware()
方法的第二个参数,使之持久化:
use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenantMiddleware([ // ... ], isPersistent: true);}
添加租户路由前缀
默认情况下,URL 结构会将 tenant ID 或 slug 放在面板路径之后。如果你想使用其他 URL 分段加上前缀,请使用 tenantRoutePrefix()
方法:
use App\Models\Team;use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->path('admin') ->tenant(Team::class) ->tenantRoutePrefix('team');}
此前,租户 1 的 URL 结构是 /admin/1
。现在,是 /admin/team/1
。
使用域名识别租户
使用租户时,你可能想像 team1.example.com/posts
这样使用域名或者子域名路由,而不是使用像 /team1/posts
这样的路由前缀。你可以使用 tenantDomain()
方法以及 tenant()
配置方法来实现该功能。tenant
参数对应于该租户模型的 slug 属性:
use App\Models\Team;use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenant(Team::class, slugAttribute: 'slug') ->tenantDomain('{tenant:slug}.example.com');}
上例中,租户在应用主域名的子域名中。你也可以将系统设置为在租户解析整个域名:
use App\Models\Team;use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenant(Team::class, slugAttribute: 'domain') ->tenantDomain('{tenant:domain}');}
本例中,domain
属性应该是一个有效的域名,比如 example.com
或 subdomain.example.com
。
注意:当参数用于整个域名(
tenantDomain('{tenant:domain}')
)时,Filament 将在应用中为所有tenant
参数注册一个[a-z0-9.\-]+
的全局路由模式。这是因为 Laravel 默认不允许路由中使用.
字符。这可能会与其他使用租户的面板或者其他使用tenant
路由参数的部分发送冲突。
禁用某个资源的租户
默认情况下,面板中的所有资源将租约范围设置为当前租户。如果你的资源要在不同租户之间共享,你可以通过在资源类上将 $isScopedToTenant
静态属性设置成 false
禁用租约:
protected static bool $isScopedToTenant = false;
禁用所有资源的租赁
如果你想让每个资源的租赁使用 opt-in 而非 opt-out,你可以在服务提供者的 boot()
方法或中间件中调用 Resource::scopeToTenant(false)
:
use Filament\Resources\Resource; Resource::scopeToTenant(false);
现在,你可以在资源类中将 $isScopedToTenant
静态属性设置为 true
来让每个资源手动加入(opt-in)租赁:
protected static bool $isScopedToTenant = true;
租户安全
理解多租户的安全含义以及如何合理地实现是很重要的。如果部分或错误地实现,属于一个租户的数据可能会暴露给另一个租户。Filament 提供了一组工具来帮助你在应用中实现多租户,但如何使用这些工具取决于你。Filament 不对你的应用的安全性提供任何保证。你有责任确保你的应用是安全的。
以下是 Filament 提供的帮你在应用中实现多租户的一系列特性:
-
自动将资源查询范围确定为当前租户。用于获取资源记录的基本 Eloquent 查询会自动将查询范围确定为当前租户。此查询用于渲染资源的列表表格,也用于在编辑或查看记录时解析当前 URL 中的记录。这意味着,如果用户试图查看不属于当前租户的记录,他们将收到 404 错误。
-
将新资源记录自动关联到当前租户。
接下来是 Filament 当前不提供的一些特性:
-
将关系管理器记录的查询范围限定到当前租户。在使用关系管理器时,在绝大多数情况下,查询的范围不需要限定为当前租户,因为它已经在父记录中的限定了查询范围,而父记录本身的查询范围是当前租户。例如,如果
team
租户模型有一个Author
资源,并且该资源设置了posts
关联和关系管理器,并且帖子(Post)只属于一个作者(Author),则无需确定查询范围。这是因为用户无论如何都只能看到属于当前团队(team)的作者(Author),因此只能看到属于这些作者的帖子。如有需要,你可以设置 Eloquent 查询范围。 -
表单组件及过滤器范围。当使用
Select
、CheckboxList
或者Repeater
表单组件,亦或者SelectFilter
或者其他类似的能够自动获取"选项"或数据库数据(通常使用relationship()
方法获取)的 Filament 组件时,这个数据没有限定查询范围。主要原因是,这些功能通常不属于 Filament 面板构建包,它无从得知在那个上下文中的使用及租户的存在。即便它能访问租户,也无从配置租户关联。要限定这些组件的查询范围,你需要传递一个查询范围限定为当前用户的查询函数。比如,如果你使用Select
表单组件从关联中选择author
,你可以这样做:
use Filament\Facades\Filament;use Filament\Forms\Components\Select;use Illuminate\Database\Eloquent\Builder; Select::make('author_id') ->relationship( name: 'author', titleAttribute: 'name', modifyQueryUsing: fn (Builder $query) => $query->whereBelongsTo(Filament::getTenant()), );
使用 tenant-aware 中间件设置全局查询范围
在面板中使用时,设置 Eloquent 模型的全局查询范围可能有用。这能让你忘掉将查询范围为当前租户,而让查询范围自动生效。要实现该功能,你可以创建一个新的中间件类,如 ApplyTenantScopes
:
php artisan make:middleware ApplyTenantScopes
在 handle()
方法内,你可以设置任何你希望的全局查询范围:
use App\Models\Author;use Closure;use Filament\Facades\Filament;use Illuminate\Database\Eloquent\Builder;use Illuminate\Http\Request; class ApplyTenantScopes{ public function handle(Request $request, Closure $next) { Author::addGlobalScope( fn (Builder $query) => $query->whereBelongsTo(Filament::getTenant()), ); return $next($request); }}
现在,你可以为所有 tenant-aware 路由注册该中间件,并确保在所有 Livewire AJAX 请求时都使用该中间件,使之持久化:
use Filament\Panel; public function panel(Panel $panel): Panel{ return $panel // ... ->tenantMiddleware([ ApplyTenantScopes::class, ], isPersistent: true);}
Edit on GitHubStill need help? Join our Discord community or open a GitHub discussion