🍩

Tenancy for Laravel 入門

2023/08/11に公開

この記事ではTenancy for Laravel というパッケージの初期設定の方法や機能の一部を紹介しています。
詳細な情報はドキュメントを参照してください。
https://tenancyforlaravel.com/
https://github.com/archtechx/tenancy

イントロダクション

Tenancy for Laravelとは

Tenancy for Laravelは、Laravelアプリケーションにマルチテナント環境を構築するためのパッケージです。
このパッケージを利用することで、1つのLaravelアプリケーションを複数のテナントと共有しつつ、各テナント専用のデータや設定を持つことが可能になります。

以下のような特徴があげられます。

  • 自動的なデータの分離
    自動的に使用するデータベース、キャッシュ、ファイルを切り替えてくれる。
  • 柔軟性が高い
    イベント駆動のアーキテクチャになっており、カスタマイズができる。
    単一データベース、複数データベースどちらの設定も可能である。

マルチテナントとは

マルチテナントとは、多数のユーザーや組織が1つのアプリケーションを共有する設計のことです。
リソースのコストを削減できたり、メンテナンス性が高まるといった利点があります。
アプリケーションを利用する独立したユーザーグループや組織を「テナント」と呼びます。
テナントを管理する際のアプローチとして、単一のデータベース内での管理や、各テナントごとに別のデータベースを持つ方法があります。

クイックスタート

Quickstart Tutorial になぞって、インストール、初期設定、軽い動作確認を行います。
動作確認では、各テナントが自身のデータを自動で取得できることを確認します。

動作環境
Sailで環境を作成し、Breezeで認証機能を作成したところから始めます。
Tenancy for Laravel: 3.7.0
Laravel: 10.18.0
PHP: 8.2.8
MySQL: 8.0.32
https://readouble.com/laravel/10.x/ja/sail.html
https://readouble.com/laravel/10.x/ja/starter-kits.html

インストールと初期設定

初期設定の段階で、各テナントは独自のデータベースを持ち、サブドメインによって識別されるように設定されています。

composer require

composer require stancl/tenancy

インストールコマンドを実行すると、サービスプロバイダ、ルート、マイグレーション、設定ファイルが作成されます。

php artisan tenancy:install

テーブルの作成

php artisan migrate

サービスプロバイダの登録
以下の位置に追加する必要があるようです。

config/app.php
    'providers' => ServiceProvider::defaultProviders()->merge([
        /*
         * Package Service Providers...
         */

        /*
         * Application Service Providers...
         */
        App\Providers\AppServiceProvider::class,
        App\Providers\AuthServiceProvider::class,
        // App\Providers\BroadcastServiceProvider::class,
        App\Providers\EventServiceProvider::class,
        App\Providers\RouteServiceProvider::class,
        App\Providers\TenancyServiceProvider::class, // ここに追加
    ])->toArray(),

テナントを表すモデルの作成

app/Providers/RouteServiceProvider.php
<?php

namespace App\Models;

use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;

class Tenant extends BaseTenant implements TenantWithDatabase
{
    use HasDatabase, HasDomains;
}

作成したモデルを設定ファイルに指定

config/tenancy.php
'tenant_model' => \App\Models\Tenant::class,

central_domainsの設定
セントラルドメインと呼ばれるものを指定します。
セントラルドメインは、LPや登録画面などの全てのテナントで共通して使用される画面や機能にアクセスするためのドメインを指します。

config/tenancy.php
'central_domains' => [
    '127.0.0.1',
    'localhost',
],

RouteServiceProviderの設定を変更
既存のRouteServiceProviderのルート設定を修正します。
上記で設定したセントラルドメインに対してのみ、このルーティング設定を適用するように変更します。

app/Providers/RouteServiceProvider.php
    public function boot(): void
    {
        RateLimiter::for('api', function (Request $request) {
            return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
        });

        // 変更
        $this->routes(function () {
            $this->mapApiRoutes();
            $this->mapWebRoutes();
        });
    }

    // 追加
    protected function mapWebRoutes()
    {
        foreach ($this->centralDomains() as $domain) {
            Route::middleware('web')
                ->domain($domain)
                ->namespace($this->namespace)
                ->group(base_path('routes/web.php'));
        }
    }

    // 追加
    protected function mapApiRoutes()
    {
        foreach ($this->centralDomains() as $domain) {
            Route::prefix('api')
                ->domain($domain)
                ->middleware('api')
                ->namespace($this->namespace)
                ->group(base_path('routes/api.php'));
        }
    }

    // 追加
    protected function centralDomains(): array
    {
        return config('tenancy.central_domains');
    }

テナント用のマイグレーションファイルの作成
database/migrationsの下に、tenantというディレクトリが作成されています。
テナントで実行したいマイグレーションファイルに関してはこのディレクトリ内に配置します。
今回はBreezeで作成されたcreate_users_table.phpをtenantディレクトリ配下にコピーしておきます。

cp database/migrations/2014_10_12_000000_create_users_table.php database/migrations/tenant/
database/migrations/tenant/2014_10_12_000000_create_users_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('users');
    }
};

MySQL ユーザーの権限設定
デフォルトのsailユーザーはセントラルデータベースに対してのみCRUDの権限を持っています。
各テナント用のデータベースに対しても同じ権限を実行できるように修正が必要です。

MySQLに接続して権限を変更します。

docker-compose exec mysql bash
# .envに記載されているパスワードを使用
mysql -u root -p
GRANT ALL PRIVILEGES on *.* to 'sail'@'%';
FLUSH PRIVILEGES;

また、コンテナを再実行するたびに権限を付与するために、上記のSQLを記載したファイルを作成し、docker-compose.ymlに追記を行います。
ここではupdate-privileges.sqlというファイルを作成しています。

docker-compose.yml
    mysql:
        image: 'mysql/mysql-server:8.0'
        ports:
            - '${FORWARD_DB_PORT:-3306}:3306'
        environment:
            MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
            MYSQL_ROOT_HOST: '%'
            MYSQL_DATABASE: '${DB_DATABASE}'
            MYSQL_USER: '${DB_USERNAME}'
            MYSQL_PASSWORD: '${DB_PASSWORD}'
            MYSQL_ALLOW_EMPTY_PASSWORD: 1
        volumes:
            - 'sail-mysql:/var/lib/mysql'
            - './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
	    # 追加
            - './update-privileges.sql:/docker-entrypoint-initdb.d/update-privileges.sql'
        networks:
            - sail
        healthcheck:
            test:
                - CMD
                - mysqladmin
                - ping
                - '-p${DB_PASSWORD}'
            retries: 3
            timeout: 5s

動作確認

インストール時に作成されるroutes/tenant.phpには、テナントに対するルーティングが定義されています。
初期設定では、サブドメインを使用してテナントを識別する仕組みが実装されています。

異なるサブドメインを用いてアクセスすると、それぞれのテナントIDが表示されることを確認してみます。

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

テナントの作成
foo.localhostというサブドメインのテナント1とbar.localhostというサブドメインのテナント2を作成します。

php artisan tinker
> $tenant1 = App\Models\Tenant::create(['id' => 'foo']);
> $tenant1->domains()->create(['domain' => 'foo.localhost']);
>
> $tenant2 = App\Models\Tenant::create(['id' => 'bar']);
> $tenant2->domains()->create(['domain' => 'bar.localhost']);

テナントモデルを作成すると、新規にデータベースとテーブルが作成されています。

mysql> show databases;
+----------------------------+
| Database                   |
+----------------------------+
| information_schema         |
| mysql                      |
| performance_schema         |
| sys                        |
| tenancy_for_laravel_sample |
| tenantbar                  |
| tenantfoo                  |
| testing                    |
+----------------------------+

mysql> use tenantfoo

mysql> show tables;
+---------------------+
| Tables_in_tenantfoo |
+---------------------+
| migrations          |
| users               |
+---------------------+

これは、サービスプロバイダでイベントとリスナーが定義されているためです。
TenantCreatedというイベントが発生したため、CreateDatabaseとMigrateDatabaseというジョブが実行されています。

app/Providers/TenancyServiceProvider.php
    Events\TenantCreated::class => [
	JobPipeline::make([
	    Jobs\CreateDatabase::class,
	    Jobs\MigrateDatabase::class,
	    // Jobs\SeedDatabase::class,

	    // Your own jobs to prepare the tenant.
	    // Provision API keys, create S3 buckets, anything you want!

	])->send(function (Events\TenantCreated $event) {
	    return $event->tenant;
	})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
    ],

各テナントにアクセスすると、テナントが識別できていることが確認できます。

curl foo.localhost
This is your multi-tenant application. The id of the current tenant is foo

curl bar.localhost
This is your multi-tenant application. The id of the current tenant is bar

次に、各テナントにアクセスした際に、テナントに紐づくユーザーのみが取得できることを確認してみます。
routes/tenant.phpを変更

routes/tenant.php
Route::middleware([
    'web',
    InitializeTenancyByDomain::class,
    PreventAccessFromCentralDomains::class,
])->group(function () {
    Route::get('/', function () {
        // 変更
        return(\App\Models\User::all()->pluck("name"));
    });
});

テナントにそれぞれユーザーを作成

php artisan tinker
> App\Models\Tenant::all()->runForEach(function () {
        App\Models\User::factory()->create();
    });
mysql> select name from tenantfoo.users;
+------------------+
| name             |
+------------------+
| Brandyn Hoppe II |
+------------------+

mysql> select name from tenantbar.users;
+-------------------+
| name              |
+-------------------+
| Monserrate Heller |
+-------------------+

各テナントにアクセスすると、テナントに紐づくユーザーのみが取得されることが確認できます。

curl foo.localhost
["Brandyn Hoppe II"]

curl bar.localhost
["Monserrate Heller"]

Tenancy for Laravelの主要設定

静的プロパティ

パッケージ内のパブリックな静的プロパティは設定可能なものです。
app/Providers/TenancyServiceProviderbootメソッドに追加することで上書きすることができます。

app/Providers/TenancyServiceProvider
    public function boot()
    {
        $this->bootEvents();
        $this->mapRoutes();

        $this->makeTenancyMiddlewareHighestPriority();

        // 追加
        \Stancl\Tenancy\Middleware\InitializeTenancyByDomain::$onFail = function () {
    return redirect('https://my-central-domain.com/');
};
    }

テナントの識別方法

クイックスタートではサブドメインによってテナントを識別していましたが、識別方法として以下が用意されています。

  • ドメイン
  • サブドメイン
  • パス
  • リクエストデータ

パスによる識別

ミドルウェアにInitializeTenancyByPath::classを適用します。

routes/tenant.php
Route::group([
    'prefix' => '/{tenant}',
    'middleware' => [InitializeTenancyByPath::class],
], function () {
    Route::get('/', function () {
        return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id');
    });
});
curl localhost/foo
This is your multi-tenant application. The id of the current tenant is foo

curl localhost/bar
This is your multi-tenant application. The id of the current tenant is bar

リクエストデータによる識別

APIのサーバーとしての役割を持っていた場合、この方法が有効かもしれません。
ミドルウェアにInitializeTenancyByPath::classを適用します。
初期設定では、リクエストヘッダーのX-Tenantの値、リクエストパラメーターのtenantの値の優先順位で識別されます。

routes/tenant.php
Route::group([
    'middleware' => [InitializeTenancyByRequestData::class],
], function () {
    Route::get('/request', function () {
        return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id');
    });
});
curl -H "X-Tenant: foo" http://localhost:/request
This is your multi-tenant application. The id of the current tenant is foo

curl -H "X-Tenant: bar" http://localhost:/request
This is your multi-tenant application. The id of the current tenant is bar

手動によるテナントの初期化

ミドルウェアを使用せずに、手動でテナントの初期化を行うこともできます。
Stancl\Tenancy\Tenancyクラスのinitializeメソッドに、テナントのオブジェクトを渡すことでテナントの初期化を行えます。
動作確認のために、ここではリクエストパラメータに渡されたIDに紐づくテナントで初期化しています。

routes/tenant.php
Route::get('/manual', function() {
    $tenant = \App\Models\Tenant::find($_GET['tenant']);
    tenancy()->initialize($tenant);
    return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id'); 
});
curl "http://localhost/manual?tenant=foo"
This is your multi-tenant application. The id of the current tenant is foo

curl "http://localhost/manual?tenant=bar"
This is your multi-tenant application. The id of the current tenant is bar

単一データベースでのテナント管理

初期設定ではテナントごとにデータベースを持つ仕組みになっているため、
以下の設定ファイルを書き換えます。

config/tenancy.php
    'bootstrappers' => [
        // 以下をコメントアウト
        // Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper::class,
        Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class,
        Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
        Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
        // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed
    ],
app/Providers/TenancyServiceProvider.php
    public function events()
    {
        return [
            // Tenant events
            Events\CreatingTenant::class => [],
	    // 配列の中身をコメントアウト
            Events\TenantCreated::class => [
                // JobPipeline::make([
                //     Jobs\CreateDatabase::class,
                //     Jobs\MigrateDatabase::class,
                //     // Jobs\SeedDatabase::class,

                //     // Your own jobs to prepare the tenant.
                //     // Provision API keys, create S3 buckets, anything you want!

                // ])->send(function (Events\TenantCreated $event) {
                //     return $event->tenant;
                // })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
            ],
	    
	    ()
	];
    }

モデルの概念

単一データベースの場合、モデルは以下の4つの概念に分類されます。

  • Tenant model
  • primary models
        - テナントに直接紐づくモデル
  • secondary models
      - テナントに直接紐づかないモデル
  • global models
        - どのテナントにもスコープされないモデル

primary models

primary modelsにStancl\Tenancy\Database\Concerns\BelongsToTenantトレイトを適用することで、グローバルスコープが適用され、テナントに紐づくデータのみを取得することができます。
デフォルトでは、テナントに紐づくレコードを識別するためのカラムとして、tenant_idが設定されています。

Postモデルを例に確認してみます

php artisan make:model Post --migration
app/Models/Post.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;

class Post extends Model
{
    use BelongsToTenant;

    protected $fillable = [
        'name',
        'tenant_id',
    ];

    public function comments()
    {
        return $this->hasMany(Comment::class);
    }
}
migrationファイル
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('tenant_id');
            $table->timestamps();
        });
    }

Post::all()で取得されるデータを出力するように変更

routes/tenant.php
Route::get('/manual', function() {
    $tenant = \App\Models\Tenant::find($_GET['tenant']);
    tenancy()->initialize($tenant);
    return \App\Models\Post::all()->pluck('name');
});

データ用意

php artisan tinker
> App\Models\Post::create(['name' => 'A', 'tenant_id' => 'foo']);
> App\Models\Post::create(['name' => 'B', 'tenant_id' => 'foo']);
> App\Models\Post::create(['name' => 'C', 'tenant_id' => 'bar']);
> App\Models\Post::create(['name' => 'D', 'tenant_id' => 'bar']);

テナントに紐づくPostのみが取得される

curl "http://localhost/manual?tenant=foo"
["A","B"]

curl "http://localhost/manual?tenant=bar"
["C","D"]

secondary models

secondary modelsにはStancl\Tenancy\Database\Concerns\BelongsToPrimaryModelトレイトを適用することで、primary modelとの関連からテナントに紐づくデータのみを取得することができます。

Postモデルに紐づくCommentモデルを定義して確認してみます

php artisan make:model Comment --migration
app/Models/Comment.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel;

class Comment extends Model
{
    use BelongsToPrimaryModel;

    protected $fillable = [
        'name',
        'post_id',
    ];

    // 紐づくprimary modelを定義する必要があります
    public function getRelationshipToPrimaryModel(): string
    {
        return 'post';
    }

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

migrationファイル
    public function up(): void
    {
            Schema::create('comments', function (Blueprint $table) {
                $table->id();
                $table->string('name');
                $table->unsignedBigInteger('post_id');
                $table->timestamps();
                $table->foreign('post_id')->references('id')->on('posts');
            });
    }

Comment::all()で取得されるデータを出力するように変更

routes/tenant.php
Route::get('/manual', function() {
    $tenant = \App\Models\Tenant::find($_GET['tenant']);
    tenancy()->initialize($tenant);
    return \App\Models\Comment::all()->pluck('name');
});

データ用意

php artisan tinker
> App\Models\Comment::create(['name' => 'Comment1', 'post_id' => 1]);
> App\Models\Comment::create(['name' => 'Comment2', 'post_id' => 1]);
> App\Models\Comment::create(['name' => 'Comment3', 'post_id' => 2]);
> App\Models\Comment::create(['name' => 'Comment4', 'post_id' => 2]);

テナントに紐づくCommentのみが取得される

curl "http://localhost/manual?tenant=foo"
["Comment1","Comment2"]

curl "http://localhost/manual?tenant=bar"
["Comment3","Comment4"]

データベースの考慮

ユニーク インデックス

テナントごとに一意にしたい場合は以下のように書きます。

マイグレーションファイル
$table->unique(['tenant_id', 'slug']);

バリデーション

  • 手動での方法
Rule::unique('posts', 'slug')->where('tenant_id', tenant('id'));
  • Stancl\Tenancy\Database\Concerns\HasScopedValidationRulesを使用する方法
    テナントモデルにトレイトを適用することで、バリデーションメソッドを使用することができます。
app/Models/Tenant.php
class Tenant extends BaseTenant implements TenantWithDatabase
{
    use HasDatabase, HasDomains, HasScopedValidationRules;
}
$tenant = tenant();

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

低レベルのデータベースクエリ

DBファサードを使用してのクエリなどはスコープが自動で適用されないため、自身で対象のテナントを絞り込む必要があります。

まとめ

ドキュメントの特徴にも記載されている通り、カスタマイズできる項目が多そうだと感じました。
複数データベースを採用する場合は、データベースの作成や初期データの作成を自動で行えるため、恩恵が大きそうと思いました。
ただ単一データベースを採用する場合でも、データベース以外に、ファイルやキャッシュを自動で分離してくれるため、採用するメリットはありそうだと思いました。

Discussion