Tenancy for Laravel 入門
この記事ではTenancy for Laravel というパッケージの初期設定の方法や機能の一部を紹介しています。
詳細な情報はドキュメントを参照してください。
イントロダクション
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
インストールと初期設定
初期設定の段階で、各テナントは独自のデータベースを持ち、サブドメインによって識別されるように設定されています。
composer require
composer require stancl/tenancy
インストールコマンドを実行すると、サービスプロバイダ、ルート、マイグレーション、設定ファイルが作成されます。
php artisan tenancy:install
テーブルの作成
php artisan migrate
サービスプロバイダの登録
以下の位置に追加する必要があるようです。
'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
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;
}
作成したモデルを設定ファイルに指定
'tenant_model' => \App\Models\Tenant::class,
central_domainsの設定
セントラルドメインと呼ばれるものを指定します。
セントラルドメインは、LPや登録画面などの全てのテナントで共通して使用される画面や機能にアクセスするためのドメインを指します。
'central_domains' => [
'127.0.0.1',
'localhost',
],
RouteServiceProviderの設定を変更
既存のRouteServiceProviderのルート設定を修正します。
上記で設定したセントラルドメインに対してのみ、このルーティング設定を適用するように変更します。
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/
<?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というファイルを作成しています。
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が表示されることを確認してみます。
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というジョブが実行されています。
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
を変更
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/TenancyServiceProvider
のboot
メソッドに追加することで上書きすることができます。
public function boot()
{
$this->bootEvents();
$this->mapRoutes();
$this->makeTenancyMiddlewareHighestPriority();
// 追加
\Stancl\Tenancy\Middleware\InitializeTenancyByDomain::$onFail = function () {
return redirect('https://my-central-domain.com/');
};
}
テナントの識別方法
クイックスタートではサブドメインによってテナントを識別していましたが、識別方法として以下が用意されています。
- ドメイン
- サブドメイン
- パス
- リクエストデータ
パスによる識別
ミドルウェアにInitializeTenancyByPath::class
を適用します。
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
の値の優先順位で識別されます。
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に紐づくテナントで初期化しています。
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
単一データベースでのテナント管理
初期設定ではテナントごとにデータベースを持つ仕組みになっているため、
以下の設定ファイルを書き換えます。
'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
],
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
<?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);
}
}
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('tenant_id');
$table->timestamps();
});
}
Post::all()で取得されるデータを出力するように変更
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
<?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);
}
}
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()で取得されるデータを出力するように変更
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を使用する方法
テナントモデルにトレイトを適用することで、バリデーションメソッドを使用することができます。
class Tenant extends BaseTenant implements TenantWithDatabase
{
use HasDatabase, HasDomains, HasScopedValidationRules;
}
$tenant = tenant();
$rules = [
'id' => $tenant->exists('posts'),
'slug' => $tenant->unique('posts'),
]
低レベルのデータベースクエリ
DBファサードを使用してのクエリなどはスコープが自動で適用されないため、自身で対象のテナントを絞り込む必要があります。
まとめ
ドキュメントの特徴にも記載されている通り、カスタマイズできる項目が多そうだと感じました。
複数データベースを採用する場合は、データベースの作成や初期データの作成を自動で行えるため、恩恵が大きそうと思いました。
ただ単一データベースを採用する場合でも、データベース以外に、ファイルやキャッシュを自動で分離してくれるため、採用するメリットはありそうだと思いました。
Discussion