多租户是一种概念,指一个应用程序的单个实例为多个客户提供服务。每个客户都有自己的数据和访问规则,这些规则阻止他们查看或修改彼此的数据。这在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);
}
}
}要设置租户,您需要在 配置 中指定“租户”(例如团队或组织)模型:
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\Pages\Tenancy\RegisterTenant;
use Filament\Schemas\Schema;
class RegisterTeam extends RegisterTenant
{
public static function getLabel(): string
{
return 'Register team';
}
public function form(Schema $schema): Schema
{
return $schema
->components([
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\Pages\Tenancy\EditTenantProfile;
use Filament\Schemas\Schema;
class EditTeamProfile extends EditTenantProfile
{
public static function getLabel(): string
{
return 'Team profile';
}
public function form(Schema $schema): Schema
{
return $schema
->components([
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();Filament 提供与 Laravel Spark 计费集成。您的用户可以启动订阅并管理其账单信息。
要安装此集成,首先 安装 Spark 并为您的租户模型进行配置。
现在,你可以使用 Composer 安装用于 Spark 的 Filament 计费提供程序:
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 组件——任何在 Laravel 中正常使用 Route::get() 时有效的方式。例如,您可以使用回调函数简单地重定向到您自己的账单页面。
getSubscribedMiddleware() 返回一个中间件的名称,该中间件应被用于检查租户是否拥有有效的订阅。这个中间件在用户没有有效订阅时,应将他们重定向到账单页面。
下面是一个示例计费提供者,它使用回调函数作为路由动作,并使用中间件作为订阅中间件:
use App\Http\Middleware\RedirectIfUserNotSubscribed;
use Filament\Billing\Providers\Contracts\BillingProvider;
use Illuminate\Http\RedirectResponse;
class ExampleBillingProvider implements BillingProvider
{
public function getRouteAction(): string
{
return function (): RedirectResponse {
return redirect('https://billing.example.com');
};
}
public function getSubscribedMiddleware(): string
{
return RedirectIfUserNotSubscribed::class;
}
}您可以自定义用于计费路由的 URL slug,使用 配置 中的 tenantBillingRouteSlug() 方法:
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->tenantBillingRouteSlug('billing');
}租户切换菜单在管理员布局中提供。它完全可自定义。
每个菜单项都由一个动作表示,并且可以以相同的方式进行自定义。要注册新项,您可以将动作传递给配置的tenantMenuItems()方法:
use App\Filament\Pages\Settings;
use Filament\Actions\Action;
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->tenantMenuItems([
Action::make('settings')
->url(fn (): string => Settings::getUrl())
->icon('heroicon-m-cog-8-tooth'),
// ...
]);
}您可以使用 searchableTenantMenu() 方法在 配置 中以允许租户被搜索:
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->searchableTenantMenu();
}这会在用户列表中租户数量超过10个时自动启用。您可以使用 searchableTenantMenu(false) 禁用它。
要自定义租户菜单中的注册链接,请使用register数组键注册一个新项,并传递一个函数,该函数自定义操作对象:
use Filament\Actions\Action;
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->tenantMenuItems([
'register' => fn (Action $action) => $action->label('Register new team'),
// ...
]);
}为了自定义租户菜单起始处的用户个人资料链接,使用 profile 数组键注册一个新项,并传入一个用于 自定义操作 对象的函数:
use Filament\Actions\Action;
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->tenantMenuItems([
'profile' => fn (Action $action) => $action->label('Edit team profile'),
// ...
]);
}要自定义租户菜单中的计费链接,使用 profile 数组键注册一个新项,并传递一个函数,该函数 自定义操作 对象:
use Filament\Actions\Action
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->tenantMenuItems([
'billing' => fn (Action $action) => $action->label('Manage subscription'),
// ...
]);
}您也可以通过使用 visible() 或 hidden() 方法来有条件地隐藏租户菜单项, 传入一个要检查的条件。传入一个函数将延迟条件评估直到菜单实际被渲染时:
use Filament\Actions\Action;
Action::make('settings')
->visible(fn (): bool => auth()->user()->can('manage-team'))
// or
->hidden(fn (): bool => ! auth()->user()->can('manage-team'))POST HTTP 请求您可以从租户菜单项发送一个 POST HTTP 请求,通过将 URL 传递给 url() 方法,并且还可以使用 postToUrl():
use Filament\Actions\Action;
Action::make('lockSession')
->url(fn (): string => route('lock-session'))
->postToUrl()你可以隐藏租户菜单 通过使用 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。
您可以轻松地将 ui-avatars.com 替换为不同的服务,通过创建一个新的头像提供程序。 您可以在此处了解如何做到这一点。
当创建和列出与租户关联的记录时,Filament 需要访问每个资源的两个 Eloquent 关系 - 一个在资源模型类上定义的“所有权”关系,以及一个在租户模型类上的关系。默认情况下,Filament 将尝试根据标准的 Laravel 约定猜测这些关系的名称。例如,如果租户模型是 App\Models\Team,它将在资源模型类上查找名为 team() 的关系。如果资源模型类是 App\Models\Post,它将在租户模型类上查找名为 posts() 的关系。
您可以使用 tenant() 配置方法上的 ownershipRelationship 参数,一次性自定义适用于所有资源的所有权关系的名称。在此示例中,资源模型类定义了一个 owner 关系:
use App\Models\Team;
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->tenant(Team::class, ownershipRelationship: 'owner');
}另一种方法是,您可以在资源类上设置 $tenantOwnershipRelationshipName 静态属性,然后可以使用它来自定义仅用于该资源的所有权关系名称。在此示例中,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 $tenantRelationshipName = 'blogPosts';
// ...
}当使用一个租户(例如一个团队)时,您可能希望在 URL 中添加一个 slug 字段,而不是团队的 ID。您可以通过 tenant() 配置方法上的 slugAttribute 参数来实现这一点:
use App\Models\Team;
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->tenant(Team::class, slugAttribute: 'slug');
}默认情况下,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() 方法用于检索当前用户的名称。
在租户切换器中,您可能希望在当前团队的名称上方添加一个像 "当前团队" 这样的小标签。您可以通过在租户模型上实现 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 会将用户重定向到从 getTenants() 方法返回的第一个租户。
有时, 您可能希望更改此设置. 例如, 您可能会存储哪个团队上次处于活跃状态, 并将用户重定向到该团队.
要自定义此功能,您可以在用户上实现 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');
}
}你可以将额外的中间件应用于所有租户感知路由,方法是将一个中间件类数组传递给 面板配置文件 中的 tenantMiddleware() 方法:
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 结构会将租户 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');
}之前,URL 结构是 /admin/1 用于租户 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;如果您希望为每个资源选择加入租户模式而非选择退出,您可以在一个服务提供者的 boot() 方法中或一个中间件中调用 Resource::scopeToTenant(false):
use Filament\Resources\Resource;
Resource::scopeToTenant(false);现在,您可以通过在资源类上将 $isScopedToTenant 静态属性设置为 true,从而为每个资源选择启用租户模式:
protected static bool $isScopedToTenant = true;了解多租户的安全隐患以及如何正确实施它非常重要。如果实施不完全或不正确,属于一个租户的数据可能会暴露给另一个租户。Filament 提供了一套工具来帮助你在应用程序中实现多租户,但如何使用这些工具取决于你自己。Filament 对你的应用程序的安全性不提供任何保证。确保你的应用程序安全是你的责任。
下面是 Filament 提供的一些功能列表,可帮助您在应用程序中实现多租户:
对属于已启用租户面板的 租户感知 资源的 Eloquent 模型查询进行自动全局范围限定。用于获取资源记录的查询会自动限定到当前租户。此查询用于渲染资源的列表表格,并且在编辑或查看记录时,也用于从当前 URL 解析记录。这意味着,如果用户尝试查看不属于当前租户的记录,他们将收到 404 错误。
withoutGlobalScope(filament()->getTenancyScopeName()) 方法。withoutGlobalScopes() 方法并传入一个包含你想要禁用的全局作用域的数组。新创建的 Eloquent 模型与当前租户的自动关联。当为 租户感知型 资源创建新记录时,租户将自动与该记录关联。这意味着该记录将属于当前租户,因为外键列会自动设置为租户的 ID。这是通过 Filament 在资源的 Eloquent 模型上注册一个用于 creating 和 created 事件的事件监听器来完成的。
creating 事件监听器,并将其与 filament()->getTenant() 关联起来。唯一性 和 存在性 验证Laravel 的 unique 和 exists 验证规则默认不使用 Eloquent 模型查询数据库,因此它不会使用模型上定义的任何全局作用域,包括用于多租户的。因此,即使在不同的租户中存在一个具有相同值的软删除记录,验证仍将失败。
如果您希望两个租户拥有完整的数据隔离,您应该使用 scopedUnique() 或 scopedExists() 方法,它们取代了 Laravel 的 unique 和 exists 实现,而采用通过模型查询数据库、并应用模型上定义的任何全局作用域(包括多租户作用域)的实现:
use Filament\Forms\Components\TextInput;
TextInput::make('email')
->scopedUnique()
// or
->scopedExists()欲了解更多信息,请参阅关于unique()和exists()的验证文档。
由于只有面板中存在资源的模型会自动作用域到当前租户,因此在你的面板中使用其他 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(
'tenant',
fn (Builder $query) => $query->whereBelongsTo(Filament::getTenant()),
);
return $next($request);
}
}你现在可以注册此中间件适用于所有租户感知路由,并确保它在所有 Livewire AJAX 请求中都被使用通过使其持久化:
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->tenantMiddleware([
ApplyTenantScopes::class,
], isPersistent: true);
}