MichaelNabil230/laravel-multi-tenancy

为您的 Laravel 应用程序提供自动多租户功能。

v1.0.0 2023-01-12 16:01 UTC

README

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

请在这里添加您的描述。请限制在一段或两段内。请考虑添加一个小示例。

安装

您可以通过 composer 安装此软件包

composer require michaelnabil230/laravel-multi-tenancy

您可以使用以下命令发布并运行迁移

php artisan multi-tenancy:install

这是发布后的配置文件内容

use MichaelNabil230\MultiTenancy\Events;
use MichaelNabil230\MultiTenancy\Listeners;

return [

    /**
     * NameServer of the server for ex:'ns1.contabo.net'.
     */
    'name_server' => null,

    /**
     * The list of domains hosting your central app.
     *
     * Only relevant if you're using the domain or subdomain identification middleware.
     */
    'central_domains' => [
        '127.0.0.1',
        'localhost',
    ],

    /*
     * These fields are used by tenant:artisan command to match one or more tenant
     */
    'artisan_search_fields' => [
        'id',
    ],

    /**
     * All events for tenancy
     */
    'events' => [
        // Tenant events
        Events\Tenant\CreatingTenant::class => [],
        Events\Tenant\TenantCreated::class => [
            Listeners\SeedDatabase::class,
        ],
        Events\Tenant\SavingTenant::class => [],
        Events\Tenant\TenantSaved::class => [],
        Events\Tenant\UpdatingTenant::class => [],
        Events\Tenant\TenantUpdated::class => [],
        Events\Tenant\DeletingTenant::class => [],
        Events\Tenant\TenantDeleted::class => [],

        // Domain events
        Events\Domain\CreatingDomain::class => [],
        Events\Domain\DomainCreated::class => [],
        Events\Domain\SavingDomain::class => [],
        Events\Domain\DomainSaved::class => [],
        Events\Domain\UpdatingDomain::class => [],
        Events\Domain\DomainUpdated::class => [],
        Events\Domain\DeletingDomain::class => [],
        Events\Domain\DomainDeleted::class => [],

        // DataBase events 
        Events\DataBase\SeedingDatabase::class => [],
        Events\DataBase\DatabaseSeeded::class => [],

        // Tenancy events
        Events\Tenancy\InitializingTenancy::class => [],
        Events\Tenancy\TenancyInitialized::class => [
            Listeners\BootstrapTenancy::class,
        ],
        Events\Tenancy\EndingTenancy::class => [],
        Events\Tenancy\TenancyEnded::class => [
            Listeners\RevertToCentralContext::class,
        ],

        Events\Tenancy\BootstrappingTenancy::class => [],
        Events\Tenancy\TenancyBootstrapped::class => [],
        Events\RevertingToCentralContext::class => [],
        Events\RevertedToCentralContext::class => [],
    ],

    /**
     * Tenancy bootstrappers are executed when the tenancy is initialized.
     * Their responsibility is making Laravel features tenant-aware.
     *
     * To configure their behavior, see the config keys below.
     */
    'bootstrappers' => [
        MichaelNabil230\MultiTenancy\Bootstrappers\CacheTenancyBootstrapper::class,
        MichaelNabil230\MultiTenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
        MichaelNabil230\MultiTenancy\Bootstrappers\QueueTenancyBootstrapper::class,
        // MichaelNabil230\MultiTenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed
    ],

    /**
     * Redis tenancy config. Used by RedisTenancyBootstrapper.
     *
     * Note: You need phpredis to use Redis tenancy.
     *
     * Note: You don't need to use this if you're using Redis only for the cache.
     * Redis tenancy is only relevant if you're making direct Redis calls,
     * either using the Redis facade or injecting it as a dependency.
     */
    'redis' => [
        'prefix_base' => 'tenant', // Each key in Redis will be prepended by this prefix_base, followed by the tenant id.
        'prefixed_connections' => [ // Redis connections whose keys are prefixed, to separate one tenant's keys from another.
            // 'default',
        ],
    ],

    /**
     * Cache tenancy config. Used by CacheTenancyBootstrapper.
     *
     * This works for all Cache facade calls, cache() helper
     * calls and direct calls to injected cache stores.
     */
    'cache_prefix_key' => 'tenant_id_',

    /**
     * The session key Middleware in `ScopeSessions`
     */
    'session_key' => 'ensure_valid_tenant_session_tenant_id',

    /**
     * Filesystem tenancy config. Used by FilesystemTenancyBootstrapper.
     */
    'filesystem' => [
        /**
         * Each disk listed in the 'disks' array will be suffixed by the suffix_base, followed by the tenant_id.
         */
        'suffix_base' => 'tenant',
        'disks' => [
            'local',
            'public',
            // 's3',
        ],

        /**
         * Use this for local disks.
         */
        'root_override' => [
            // Disks whose roots should be override after storage_path() is suffixed.
            'local' => '%storage_path%/app/',
            'public' => '%storage_path%/app/public/',
        ],

        /**
         * Should storage_path() be suffixed.
         *
         * Note: Disabling this will likely break local disk tenancy. Only disable this if you're using an external file storage service like S3.
         *
         * For the vast majority of applications, this feature should be enabled. But in some
         * edge cases, it can cause issues (like using Passport with Vapor - see #196), so
         * you may want to disable this if you are experiencing these edge case issues.
         */
        'suffix_storage_path' => true,

        /**
         * By default, asset() calls are made multi-tenant too. You can use mix()
         * for global, non-tenant-specific assets. However, you might have some issues when using
         * packages that use asset() calls inside the tenant app. To avoid such issues, you can
         * disable asset() helper tenancy and explicitly use tenant_asset() calls in places
         * where you want to use tenant-specific assets (product images, avatars, etc).
         */
        'asset_helper_tenancy' => true,
    ],

    /**
     * Features are classes that provide additional functionality
     * not needed for the tenancy to be bootstrapped. They are run
     * regardless of whether the tenancy has been initialized.
     *
     * See the documentation page for each class to
     * understand which ones you want to enable.
     */
    'features' => [
        // MichaelNabil230\MultiTenancy\Features\TelescopeTags::class,
        // MichaelNabil230\MultiTenancy\Features\TenantSetting::class,
    ],

    /**
     * Parameters used by the db:seed command.
     */
    'seeder_parameters' => [
        '--class' => 'TenantDatabaseSeeder',
        '--force' => true,
    ],

    /**
     * Subscription for Tenant
     */
    'subscription' => [
        /**
         * Enable if you need tenant has subscription in your application
         */
        'enable' => false,

        /**
         * Route for the subscription index
         */
        'route' => null,

        /**
         * All events for subscription
         */
        'events' => [
            // Subscription events
            Events\Subscription\SubscriptionCreated::class => [],
            Events\Subscription\SubscriptionUpdated::class => [],
            Events\Subscription\SubscriptionCancelled::class => [],
            Events\Subscription\SubscriptionChangePlan::class => [],
            Events\Subscription\SubscriptionRenewed::class => [],
            Events\Subscription\SubscriptionResume::class => [],
        ],
    ],
];

配置

该软件包高度可配置。此页面涵盖了您可以在 config/multi-tenancy.php 文件中配置的内容,但请注意,还有更多内容可以配置。一些内容可以通过扩展类(例如 Tenant 模型)来更改,并且许多内容可以使用静态属性来更改。这些内容通常会在文档的相应页面上提到,但并非每次都会提到。因此,请不要害怕深入研究软件包的源代码——每当您使用的类有一个 public static 属性时,它就被认为是可以配置的。

静态属性

您可以像这样设置静态属性(示例)

\MichaelNabil230\MultiTenancy\MultiTenancy::$onFail = function () {
    return redirect('https://my-central-domain.com/');
};

将这些调用放在您的 app/Providers/TenancyServiceProviderboot() 方法中是个不错的选择。

租户模型

MultiTenancy::tenant();

// Can be change with the pass model name.
MultiTenancy::useTenantModel(Model::class);

此配置指定了软件包应使用哪个 Tenant 模型。您很可能正在使用一个自定义模型,正如 [Tenants] 所要求的,所以请确保在配置中更改它。

域名模型

MultiTenancy::domain();

// Can be change with the pass model name.
MultiTenancy::useDomainModel(Model::class);

类似于租户模型配置。如果您正在使用自定义模型进行域名,请在此配置中更改它。如果您根本不使用域名(例如,如果您正在使用路径或请求数据进行标识),则可以完全忽略此配置键。

中央域名

multi-tenancy.central_domains

这是托管您的 [中央应用] 的域名的列表。这由(其他事物之一)使用

  • InitializeTenancyBySubdomain 中间件,以检查当前主机名是否是您中央域名之一的子域名。

引导程序

multi-tenancy.bootstrappers

此配置数组允许您启用、禁用或添加您自己的 [租户引导程序]。

缓存

multi-tenancy.cache.*

此部分与缓存分离相关,特别是与 CacheTenancyBootstrapper 相关。

注意:要使用缓存分离,您需要使用支持标签的缓存存储,通常是 Redis。

请参阅配置中的此部分,它用注释进行了说明。

文件系统

multi-tenancy.filesystem.*

此部分与存储分离相关,特别是与 FilesystemTenancyBootstrapper 相关。

请参阅配置中的此部分,它用注释进行了说明。

Redis

multi-tenancy.redis.*

此部分与 Redis 数据分离相关,特别是与 RedisTenancyBootstrapper 相关。

注意:要使用此引导程序,您需要 phpredis。

请参阅配置中的此部分,它用注释进行了说明。

功能

multi-tenancy.features

此配置数组允许您启用、禁用或添加您自己的 [功能类]。

Seeder 参数

multi-tenancy.seeder_parameters

与迁移参数相同,但用于 tenants:seedSeedDatabase 作业。

事件系统

此软件包基于事件,这使得它非常灵活。

默认情况下,事件配置如下,使软件包工作如下

  • 一个请求为租户路由而来并触发了标识中间件
  • 标识中间件找到正确的租户并执行
$this->tenancy->initialize($tenant);
  • MichaelNabil230\MultiTenancy\Tenancy 类设置 $tenant 为当前租户并触发 TenancyInitialized 事件
  • BootstrapTenancy 类捕获该事件并执行称为 [租户引导程序] 的类。
  • 租户引导程序会对应用程序进行修改,使其“限定”在当前租户上。这默认包括:
    • 切换数据库连接
    • 后缀文件系统路径
    • 使队列存储租户 ID 并在处理时初始化租户

同样,所有上述内容都是可配置的。您甚至可以禁用所有租户引导程序,只使用租户识别并在 MichaelNabil230\MultiTenancy\Tenancy 中手动围绕租户存储应用程序。选择权在您手中。

租户服务提供商

此软件包附带一个非常方便的服务提供商,当您安装软件包时,它会添加到您的应用程序中。此服务提供商用于将侦听器映射到特定于包的事件,并且是您放置任何租户特定服务容器调用的位置 —— 以避免污染您的 AppServiceProvider。

注意,您可以在任何地方注册此包的事件 任何您想要的地方。服务提供商中的事件/侦听器映射仅用于使您的生活更轻松。如果您想手动注册侦听器,如下例所示,您可以。

Event::listen(TenancyInitialized::class, BootstrapTenancy::class);

引导租户

默认情况下,BootstrapTenancy 类正在监听 TenancyInitialized 事件(正如您在上述示例中所见)。该侦听器将执行配置的租户引导程序,使应用程序过渡到租户上下文。您可以在 [租户引导程序] 中了解更多关于此信息。

相反,当触发 TenancyEnded 事件时,RevertToCentralContext 事件将应用程序转换回中央上下文。

可用事件

注意:一些数据库事件(SeedingDatabaseDatabaseSeeded 以及可能的其他事件)在 租户上下文中触发。 根据您的应用程序如何引导租户,您可能需要在这些事件的侦听器中具体交互与中央数据库 —— 即如果您需要的话。

注意:所有事件都位于 MichaelNabil230\MultiTenancy\Events 命名空间中。

租户

  • 初始化租户
  • 租户已初始化
  • 结束租户
  • 租户已结束
  • 引导租户
  • 租户已引导
  • 还原到中央上下文
  • 已还原到中央上下文

注意初始化租户和引导租户之间的区别。租户在将租户加载到 Tenancy 对象中时初始化。而引导是在初始化的结果 之后发生的 —— 如果您使用自动租户,则 BootstrapTenancy 类会监听 TenancyInitialized 事件,并在执行完引导程序后,触发一个事件表示租户已引导。如果您想在应用程序过渡到租户上下文后执行某些操作,则应使用引导事件。

租户

以下事件是在默认的 Tenant 实现中触发 Eloquent 事件的结果(最常用的事件用粗体表示)

  • 创建租户
  • 租户已创建
  • 保存租户
  • 租户已保存
  • 更新租户
  • 租户已更新
  • 删除租户
  • 租户已删除

域名

这些事件是可选的。只有当您为租户使用域名时,它们才与您相关。

  • 创建域名
  • 域名已创建
  • 保存域名
  • 域名已保存
  • 更新域名
  • 域名已更新
  • 删除域名
  • 域名已删除

数据库

这些事件也是可选的。只有当您使用多数据库租户时,它们才与您相关。

  • 播种数据库
  • 数据库已播种

租户路由

您可以在 routes/tenant.php 中注册租户路由。这些路由没有应用任何中间件,并且它们的控制器命名空间在 app/Providers/TenancyServiceProvider 中指定。

默认情况下,您将看到以下设置

Route::middleware([
    'web',
    InitializeTenancyByDomain::class,
])->group(function () {
    Route::get('/', function () {
        return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id');
    });
});

此组内的路由将具有web中间件组、初始化中间件,最后是应用于下面的中间件。

例如,您可以为api路由组执行相同的操作。

此外,您可以使用与域名不同的初始化中间件。有关完整列表,请参阅[租户识别]。

概念

在单数据库租户中,有4种类型的模型

  • 您的租户模型
  • 主模型 —— 直接属于租户的模型
  • 次级模型 —— 间接属于租户的模型
    • 例如,评论属于Post,而Post又属于租户
    • 或者更复杂的情况,投票属于Comment,而Comment属于Post,最后Post属于租户
  • 全局模型 —— 不属于任何租户的模型

为了正确地范围查询,请在主模型上应用MichaelNabil230\MultiTenancy\Traits\BelongsToTenant特性。这将确保所有对父模型的调用都是针对当前租户的,并且对它们的子关系的调用是通过父关系进行范围限制的。

就是这样。您的模型现在自动针对当前租户进行范围限制,当没有当前租户时(例如,在中央管理面板中)则不进行范围限制。

然而,有一个边缘情况需要注意。考虑以下设置

class Post extends Model
{
    use BelongsToTenant;

    public function comments()
    {
        return $this->hasMany(Comment::class);
    }
}

class Comment extends Model
{
    public function post()
    {
        return $this->belongsTo(Post::class);
    }
}

看起来是正确的,但您可能仍然会意外地访问另一个租户的评论。

如果您使用这个

Comment::all();

那么模型无法知道如何范围这个查询,因为它并不直接属于租户。还请注意,在实际应用中,您真的不应该这样做很多。您应该在所有情况下都通过父模型访问次级模型。

然而,有时您可能会在租户上下文中遇到您真正需要这样做的情况。为此,我们还为您提供了一个BelongsToPrimaryModel特性,它允许您通过加载父关系(这将自动针对当前租户进行范围限制)来范围像上面的调用。

例如,您会这样做

class Comment extends Model
{
	use BelongsToPrimaryModel;

    public function getRelationshipToPrimaryModel(): string
    {
        return 'post';
    }

    public function post()
    {
        return $this->belongsTo(Post::class);
    }
}

这将自动将Comment::all()调用针对当前租户进行范围限制。请注意,这个限制是您必须能够定义到一个主模型的关系,所以如果需要在VoteVote属于Comment,而Comment属于Post,最后Post属于租户)上进行此类操作,您需要定义一些奇怪的关系。Laravel支持HasOneThrough,但不支持BelongsToThrough,因此您需要做一些修改。因此,我建议您完全避免这些类型的Comment::all()查询。

数据库考虑事项

唯一索引

如果您有一个唯一索引,例如

$table->unique('slug');

在标准的非租户应用或多数据库租户应用中,您需要将此唯一索引范围到租户,这意味着您将在主模型上这样做:

$table->unique(['tenant_id', 'slug']);

以及在此处对次级模型执行:

// Imagine we're in a 'comments' table migration

$table->unique(['post_id', 'user_id']);

验证

当然,uniqueexists验证规则并没有针对当前租户进行范围限制,因此您需要手动进行范围限制,如下所示:

Rule::unique('posts', 'slug')->where('tenant_id', tenant('id'));

您将能够使用这两种方法

// You may retrieve the current tenant using the tenant() helper.
// $tenant = tenant();

$rules = [
    'id' => $tenant->exists('posts'),
    'slug' => $tenant->unique('posts'),
]

低级数据库查询

最后要考虑的是,DB外观调用或任何其他类型的直接数据库查询当然不会针对当前租户进行范围限制。

此包只能为Eloquent的抽象逻辑提供范围逻辑,它无法对低级数据库查询做任何事情。

请小心使用它们。

使Artisan命令租户感知

通过应用TenantAware特性,可以使命令成为租户感知的。使用该特性时,需要在命令签名中附加{--tenant=*}{--tenant=}

注意:如果您附加了{--tenant=*},则在执行命令时未提供tenant选项,则该命令将对所有租户执行。

use Illuminate\Console\Command;
use MichaelNabil230\MultiTenancy\Commands\Concerns\TenantAware;

class YourFavoriteCommand extends Command
{
    use TenantAware;

    protected $name = 'your-favorite-command';

    public function handle()
    {
        return $this->line('The tenant is '. Tenant::current()->name);
    }
}

在执行命令时,将为每个租户调用handle方法。

php artisan your-favorite-command 

使用上面的示例,每个租户的名称将被写入命令的输出中。

您也可以为特定租户执行命令

php artisan your-favorite-command --tenant=1

为租户运行种子数据 artisan 命令

它是否可以运行到特定租户或所有租户的种子命令

php artisan tenants:seed --tenant=123

为创建新租户运行 artisan 命令

它是否可以通过快速创建租户来运行创建命令

php artisan create:tenant shop-1

执行全局查询

要禁用租户作用域,只需在查询中添加withoutTenancy()即可。

订阅部分

就这样,我们只需要在用户模型中使用该特性!现在用户可以订阅计划。

创建计划

use MichaelNabil230\MultiTenancy\MultiTenancy;

$plan = MultiTenancy::plan()->create([
    'name' => 'Pro',
    'description' => 'Pro plan',
    'price' => 9.99,
    'invoice_period' => 1,
    'invoice_interval' => 'month',
    'trial_period' => 15,
    'trial_interval' => 'day',
]);

// Create multiple plan features at once
$plan->features()->saveMany([
    new Feature(['name' => 'listings']),
    new Feature(['name' => 'pictures_per_listing']),
    new Feature(['name' => 'listing_duration_days', 'resettable_period' => 1, 'resettable_interval' => 'month']),
    new Feature(['name' => 'listing_title_bold'])
]);

获取所有计划

获取所有带部分和功能的计划

$plans = MultiTenancy::plans();

获取计划详情

您可以使用以下直观的 API 查询计划的详细信息:

$plan = MultiTenancy::plan()->find(1);

// Get all plan features                
$plan->features;

// Get all plan subscriptions
$plan->subscriptions;

// Check if the plan is free
$plan->isFree();

// Check if the plan has trial period
$plan->hasTrial();

无论是$plan->features还是$plan->subscriptions都是集合,由关系驱动,因此您可以像查询任何正常的 Eloquent 关系一样查询这些关系。例如:$plan->features()->where('name', 'listing_title_bold')->first()

创建订阅

您可以使用在Tenant模型中提供的createSubscription(Plan $plan, Carbon $startDate = null)函数将租户订阅到计划。首先,获取您的订阅者模型实例,通常是您的租户模型和租户订阅的计划实例。一旦检索到模型实例,您可以使用createSubscription方法创建模型的订阅。

$tenant = MultiTenancy::tenant()->find(1);
$plan = MultiTenancy::plan()->find(1);

$tenant->createSubscription($plan, Carbon::now()->addMonths(4));

createSubscription的第一个参数是租户订阅的计划实例,有一个可选的第二个参数可以指定自定义开始日期为Illuminate\Support\Carbon的实例(默认情况下,如果没有提供,则从现在开始)。

更改计划

您可以通过以下方式轻松更改订阅计划:

$plan = MultiTenancy::plan()->find(2);
$subscription = MultiTenancy::subscription()->find(1);

// Change subscription plan
$subscription->changePlan($plan);

如果两个计划(当前计划和新的计划)具有相同的计费频率(例如,invoice_periodinvoice_interval),则订阅将保留相同的计费日期。如果计划的计费频率不同,则订阅将具有新的计划计费频率,从更改的日期开始,并将清除订阅的使用数据。此外,如果新计划有一个试用期,并且是新的订阅,则将应用试用期。

检查订阅状态

要使订阅被视为活动状态,以下条件之一必须是true

  • 订阅有一个活动的试用期。
  • 订阅的ends_at在将来。
$user->subscribed($planId);

或者您可以使用以下订阅模型中可用的方法

$user->subscription($planId)->active();
$user->subscription($planId)->canceled();
$user->subscription($planId)->ended();
$user->subscription($planId)->onTrial();

已取消的订阅,如果有一个活动的试用期或ends_at在将来,则被视为活动状态。

续订订阅

要续订订阅,您可以使用订阅模型中可用的renew方法。这将根据所选计划设置新的ends_at日期,并将清除订阅的使用数据。

$user->subscription($planId)->renew();

已取消的订阅,如果有一个已结束的期间,则不能续订。

取消订阅

要取消订阅,只需在用户的订阅上使用cancel方法即可。

$user->subscription($planId)->cancel();

作用域

订阅模型

// Get subscriptions by plan
$subscriptions = MultiTenancy::subscription()->planId($planId)->get();

// Get subscriptions recurring
$subscriptions = MultiTenancy::subscription()->recurring()->get();

// Get subscriptions canceled
$subscriptions = MultiTenancy::subscription()->canceled()->get();

// Get subscriptions not canceled
$subscriptions = MultiTenancy::subscription()->notCanceled()->get();

// Get subscriptions with ended period
$subscriptions = MultiTenancy::subscription()->ended()->get();

// Get subscriptions with on trial period
$subscriptions = MultiTenancy::subscription()->onTrial()->get();

// Get subscriptions with expired trial period
$subscriptions = MultiTenancy::subscription()->expiredTrial()->get();

// Get subscriptions with not on trial
$subscriptions = MultiTenancy::subscription()->notOnTrial()->get();

// Get subscriptions with on grace period
$subscriptions = MultiTenancy::subscription()->onGracePeriod()->get();

// Get subscriptions with not on grace period
$subscriptions = MultiTenancy::subscription()->notOnGracePeriod()->get();

模型

多租户订阅使用4个模型

MichaelNabil230\MultiTenancy\Models\Plan;
MichaelNabil230\MultiTenancy\Models\Feature;
MichaelNabil230\MultiTenancy\Models\Subscription;
MichaelNabil230\MultiTenancy\Models\Section;

支持

测试

composer test

变更日志

请参阅变更日志获取有关最近更改的更多信息。

贡献

请参阅贡献指南以获取详细信息。

安全漏洞

请查阅我们的安全策略了解如何报告安全漏洞。

鸣谢

许可协议

MIT 许可协议 (MIT)。请参阅许可文件获取更多信息。