Tenancy for Laravel の初期設定方法と機能の紹介

こんにちは、技術部の Y です。この記事では 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
```
				
			

サービスプロバイダの登録

以下の位置に追加する必要があるようです。

				
					```php: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(),
```
				
			

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

				
					```php: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;
}
```

				
			

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

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

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

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

				
			

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

				
					```php: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 ディレクトリ配下にコピーしておきます。

				
					```bash
cp database/migrations/2014_10_12_000000_create_users_table.php database/migrations/tenant/
```
				
			
				
					```php: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 に接続して権限を変更します。

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

				
			

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

				
					```yml: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 が表示されることを確認してみます。

				
					```php: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 を作成します。

				
					```bash
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
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 というジョブが実行されています。

				
					```php: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.
    ],
```
				
			

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

				
					```bash
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を変更

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

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

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

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

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

				
					```bash
curl foo.localhost
["Brandyn Hoppe II"]

curl bar.localhost
["Monserrate Heller"]
```

				
			

Tenancy for Laravel の主要設定

静的プロパティ

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

				
					```php: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を適用します。

				
					```php: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');
    });
});
```
				
			
				
					```bash
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の値の優先順位で識別されます。

				
					```php: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');
    });
});
```
				
			
				
					```bash
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 に紐づくテナントで初期化しています。

				
					```php: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');
});
```
				
			
				
					```bash
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
```

				
			

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

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

				
					```php: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
    ],
```

```php: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 モデルを例に確認してみます

				
					```bash
php artisan make:model Post --migration
```

				
			
				
					```php: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);
    }
}
```

```php:migrationファイル
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('tenant_id');
            $table->timestamps();
        });
    }
```
				
			

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

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

データ用意

				
					```bash
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 のみが取得される

				
					```bash
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 モデルを定義して確認してみます

				
					```bash
php artisan make:model Comment --migration
```

```php: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);
    }
}

```

```php: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()で取得されるデータを出力するように変更

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

データ用意

				
					```bash
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 のみが取得される

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

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

				
			

データベースの考慮

ユニーク インデックス

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

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

バリデーション

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

```php
$tenant = tenant();

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

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

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

まとめ

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

スーパーソフトウエアの採用情報

あなたが活躍できるフィールドと充実した育成環境があります

blank
blank