Laravel 5.7 でフル機能マルチテナント・SaaS型アプリ基盤をゼロから3時間で構築する Part1

> 　マルチテナントのLaravelアプリを作りたいと考えていますか？各テナントのデータベースを分けて管理したり、テナント管理ユーザーを割り当てたり、テナント内で権限管理できるようにしたり、テナントの作成や削除を自動化したり、その後のテナント管理UI構築まで考えた形で作ったりしたいと考えていますか？そのために必要なプラグインのチュートリアルが不親切で意味が分からずイライラしたりしていますか？
　もしそうだとしたら、この記事を読んでいるあなたは正しい場所にたどり着いています。

　これは、今回の[参考記事](https://blog.usejournal.com/writing-a-full-featured-multi-tenant-laravel-app-from-scratch-a0e1a7350d9d)の冒頭部分の意訳です。(今回の記事に合うように、内容を少し端折ってます)

　I would like to show the big thanks to Ashok who is the author of the article referred.

　この記事では、Laravelでマルチテナントアプリを作成する方法を、備忘録を兼ねてまとめていきます。お仕事での調査ですが、自社用調査であり、まあこれくらいいいかということで、日本のLaravel応援、日本の技術者応援ということで情報公開します。
　:thumbsup:※ちなみに、お仕事受けることもできますのでご相談ください。うちはニューヨークの会社です。:thumbsup:

　正直言って、この方法はとてもよくできています。これまでにあった小手先の技ではなく、完全にコントロールされたテナント管理が可能になります。今使わないとしても、知っておいて損はないはずです。

# はじめに

　Laravelは、PHPの世界だけではなく様々なフレームワークと比べても大変優秀です。私もこれまで.Net環境なども含めいくつかのMVC Webアプリフレームワークを扱ってきましたが、Laravelは中規模アプリを作成する上で他に勝るとも劣らない環境と言えます。特にそのエコシステムは優秀で、シンプルかつわかりやすいだけでなく、素晴らしいドキュメントやチュートリアルもそろっています。
　唯一の問題は、ちょっと複雑なアプリを組もうとすると、途端に情報が少なくなることです。今回のマルチテナントアプリはまさにそうで、英語も含めてほとんど情報がありません。実際には**Laravelでマルチテナントアプリを作ることは、とても簡単**なのに。**私は調査や最新版への対応のためのコード修正まで全部含めて1日でマルチテナントアプリの基本部分を作り終えました**。ここにまとめた内容があれば**3時間もあれば十分**でしょう。

# この記事のゴール

　この記事では、マルチテナントアプリの基本部分を作り終えるところまでを具体的に書きます。

　マルチテナント実現のため、[Laravel Tenancy Hyn](https://laravel-tenancy.com/)を利用します。

## マルチテナントアプリの仕様

　今回の「マルチテナントアプリ」は、以下のような仕様を目指します。

- アプリのソースコードは１つ。テナントごとにコピーしない。
- システム（テナント管理）データベースと、テナントデータベースはわける。
- テナントデータベースは、テナント１つにつき１つとする。
- テナントを作ると、テナントデータベースと専用データベースユーザーが作られる。
- テナントを消すと、テナントデータベースと専用データベースユーザーが削除される。
- システムデータベース用のMigrationとテナントデータベース用のMigrationは分ける。
- コマンドラインからMigrationしたら、システムデータベース用Migrationだけが実行される。
- テナントを作った時、テナントデータベース用Migrationを実行してデータベースを初期化する。
- ユーザー管理はテナントごとに独立させる。
- テナントごとにユーザーの権限を設定できるようにする。
- テナントを作成した時に自動的にテナント管理者に招待状を送付し、パスワード設定させる。
- テナント画面からシステムデータベースの情報にアクセスできる余地を残しておく。

## 要件

　Laravelやマルチテナントライブラリーによって、構築方法やソースコードが違ってきます。参考記事ではTenancy Hyn 5.1を使っていますが、この記事では最新の5.3を使います。5.2からCustomerが廃止されているので、参考記事とはソースコードが異なります。
　この記事はWindows 10 ProにXAMPP 7.2.12を導入した状態で書いています。
　重要なバージョンは以下です。

- Laravel 5.7
- Tenancy Hyn 5.3
- MariaDB 10.1.37 (XAMPP 7.2.12に入っているもの)

# テナントを作成できるところまで作る

## 空のプロジェクトを作成する

　まず空のLaravelプロジェクトを作成します。(必要に応じバージョンを指定してください) 名前はここでは参考記事に倣って`townhouse`にします。

```
composer create-project --prefer-dist laravel/laravel townhouse
```

　今回はXAMPP 7.2.12に入っているMariaDB 10.1.37を使うので、`Providers/AppServiceProvider.php`のboot()内にStringの長さ制限を追記しておきます。

```php:Providers/AppServiceProvider.php
...
use Illuminate\Support\Facades\Schema;
...
    public function boot()
    {
        //
        Schema::defaultStringLength(191);
    }
```

## システムデータベースを作成する
　システムデータベースを作成します。ここでは、参考記事に倣って`townhousedb`という名前にします。

　さらにシステムデータベースユーザーを作ります。このユーザーは、テナントのデータベースを作成するときに使われるユーザーなので、`GRANT OPTION`を指定する必要があります。詳しくは[こちら](https://laravel-tenancy.com/docs/hyn/5.3/installation)。開発用データベースなら、最上位権限を与えておけばいいでしょう。

## Laravelにシステムデータベースを登録する

　まず`config/database.php`の`connections`セクションに、以下を追記します。

```php:config/database.php
        // Tenancy
        'system' => [
            'driver' => env('TENANCY_CONNECTION', 'mysql'),
            'host' => env('TENANCY_HOST', '127.0.0.1'),
            'port' => env('TENANCY_PORT', '3306'),
            'database' => env('TENANCY_DATABASE', 'tenancy'),
            'username' => env('TENANCY_USERNAME', 'tenancy'),
            'password' => env('TENANCY_PASSWORD', ''),
            'unix_socket' => env('DB_SOCKET', ''),
            'charset' => 'utf8mb4',
            'collation' => 'utf8mb4_unicode_ci',
            'prefix' => '',
            'strict' => true,
            'engine' => null,
        ]
```

　次に、`.env`の末尾に、以下を追記します。

```:.env
TENANCY_CONNECTION=mysql
TENANCY_HOST=127.0.0.1
TENANCY_PORT=3306
TENANCY_DATABASE=townhousedb
TENANCY_USERNAME=システムユーザー名
TENANCY_PASSWORD=システムユーザーパスワード
```

　`TENANCY_USERNAME`と`TENANCY_PASSWORD`は、さっき作ったシステムデータベースユーザーの情報を入れます。

## マルチテナントライブラリーTenancy Hynをインストールする

[Laravel Tenancy Hyn](https://laravel-tenancy.com/)

**※この記事は、Hynバージョン5.3を利用する前提で書かれています。バージョンが変わると大きく仕様が異なってサンプルコードが動作しませんので十分注意してください。特に参考記事で使っている5.1から5.2でCustomerテーブルが廃止されるという大きな変更があります。**

```
composer require hyn/multi-tenant
```

　今回MariaDBを使うので(MySQLでも同じ)、以下の記述を`.env`に追記します。詳しくは[Hynのドキュメント](https://laravel-tenancy.com/docs/hyn/5.3/requirements)で。

```:.env
LIMIT_UUID_LENGTH_32=true
```

　さらに、マルチテナントアプリを実現するために重要な「テナントのMigrationをシステムのMigrationと分ける」の準備をしておきます。`database/migrations`にある以下の２つのファイルを、テナント用の特別なフォルダ`database/migrations/tenant`を新しく作ってそこに移動（コピーではない）させます。

``2014_10_12_000000_create_users_table.php``
``2014_10_12_100000_create_password_resets_table.php``

　これにより、あとでテナントを作った時に、これらのMigrationが自動的に実行されます。特別なコーディングは不要です。

　最後に、プロジェクトをマルチテナントアプリにコンバートします。参考記事とは違って、Tenancy Hynの公式の方法を使います。

```
php artisan vendor:publish --tag=tenancy
```

Tenancy Hynに必要なテーブルを作ります。

```
php artisan migrate --database=system
```

　データベースを直接見ると、`hostnames`, `migrations`, それに `websites`のテーブルがあるはずです。`users`や`password_resets`テーブルはないのが正解です。

## テナント作成用artisanコマンドを作る

　テナント作成のUIは今回は作りません。マルチテナントアプリには必ずしもテナント管理UIが必要というわけではなく、契約されたらエンジニアがテナントを作ればよい場合もありますから、この記事ではシンプルにテナント作成用artisanコマンドを作るにとどめます。動作は単純なので、artisanコマンドを参考にUIをあとで作るのは簡単です。

　まずはartisanコマンドのひな型を作ります。

```
php artisan make:command CreateTenant
```

　出来上がった`app/Console/Commands/CreateTenant.php`の中身を以下のようにします。

```php:app/Console/Commands/CreateTenant.php```
<?php
namespace App\Console\Commands;
use App\User;
use Hyn\Tenancy\Contracts\Repositories\HostnameRepository;
use Hyn\Tenancy\Contracts\Repositories\WebsiteRepository;
use Hyn\Tenancy\Environment;
use Hyn\Tenancy\Models\Hostname;
use Hyn\Tenancy\Models\Website;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;

class CreateTenant extends Command
{
    protected $signature = 'tenant:create {name} {email} {tenantname}';
    protected $description = 'Creates a tenant with the provided name and email address e.g. php artisan tenant:create john john@example.com cafejohn';
    public function handle()
    {
        $name = $this->argument('name');
        $email = $this->argument('email');
        $tenantname = $this->argument('tenantname');
        if ($this->tenantExists($tenantname)) {
            $this->error("A tenant with name '{$tenantname}' already exists.");
            return;
        }
        $tenant = $this->registerTenant($name, $email, $tenantname);
        app(Environment::class)->tenant($tenant["website"]);

        // we'll create a random secure password for our to-be admin
        $password = str_random();
        $this->addAdmin($name, $email, $password);
        $this->info("Tenant '{$tenantname}' is created and is now accessible at {$tenant["hostname"]->fqdn}");
        $this->info("Admin {$email} can log in using password {$password}");
    }
    private function tenantExists($tenantname)
    {
        $baseUrl = config('app.url_base');
        $fqdn = "{$tenantname}.{$baseUrl}";
        return Hostname::where('fqdn', $fqdn)->exists();
    }
    private function registerTenant($name, $email, $tenantname)
    {
        // create a website
        $website = new Website;
        app(WebsiteRepository::class)->create($website);
        // associate the website with a hostname
        $hostname = new Hostname;
        $baseUrl = config('app.url_base');
        $hostname->fqdn = "{$tenantname}.{$baseUrl}";
        app(HostnameRepository::class)->attach($hostname, $website);
        return ["hostname"=>$hostname, "website"=>$website];
    }
    private function addAdmin($name, $email, $password)
    {
        $admin = User::create(['name' => $name, 'email' => $email, 'password' => Hash::make($password)]);
        return $admin;
    }
}
```

　中身の詳細な説明は省略しますが、おおよそ、現在指定されたテナント名が存在しないことを確認して、与えられたテナント名でテナントを作り、その中に管理者ユーザーを与えられた名前とEmailで作っています。

　この時点ではまだ権限は付与していませんが、この記事のあとでやります。

　テナントを実際に作る前に、もうひとつやるべきことがあります。テナントのhostnameのFQDN（Fully Qualified Domain Name、サブドメインを含む完全なドメイン名）を生成するのに必要なベースURLの設定です。テナントは、アプリのベースとなるドメイン名に追加されたサブドメイン名に紐づいたhostnameに紐づいたwebsiteとして管理されます。たとえば、アプリのベースドメイン名がexample.comだとして、cafeというテナントを作ったら、このテナントのFQDNはcafe.example.comになり、cafe.example.comというFQDNは１つのhostnameレコードと紐づいていて、このhostnameは１つのwebsiteと紐づいています。
　`config/app.php`の末尾あたりと、`.env`の`APP_URL`設定あたりで、以下のように追記・変更します。アプリのベースドメインは、例えば`example.com`のようになります。

```php:config/app.php
    /*
    |--------------------------------------------------------------------------
    | Tenancy
    |--------------------------------------------------------------------------
    |
    */
    'url_base' => env('APP_URL_BASE', 'http://localhost'),
];
```

```:.env
APP_URL_BASE=アプリのベースドメイン
APP_URL=http://${APP_URL_BASE}
```

　`config/app.php`の`APP_URL_BASE`設定は、`.env`に設定があればそちらで上書きされることになるので、第2パラメータは重要ではありません。

　ベースドメイン名については、あとで実際にこのドメイン名を含むURLでLaravelにアクセスするので、`hosts`設定などで実際にアクセスできるようにしておいてください。テナントを作った後にサブドメイン名も追加されるので、その点も注意が必要です。

　ではでは、いよいよテナントを作ってみましょう。

```
php artisan tenant:create john john@example.com cafejohn
```

　うまくいったら以下のように表示されます。

```
Tenant 'cafejohn' is created and is now accessible at cafejohn.ドメイン名.com
```

## テナント削除用artisanコマンドを作る

　同様に削除コマンドを作ります。

```
php artisan make:command DeleteTenant
```

```php:app/Console/Commands/DeleteTenant.php
<?php
namespace App\Console\Commands;
use Hyn\Tenancy\Contracts\Repositories\HostnameRepository;
use Hyn\Tenancy\Contracts\Repositories\WebsiteRepository;
use Hyn\Tenancy\Environment;
use Hyn\Tenancy\Models\Hostname;
use Hyn\Tenancy\Models\Website;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
class DeleteTenant extends Command
{
    protected $signature = 'tenant:delete {tenantname}';
    protected $description = 'Deletes a tenant of the provided name. Only available on the local environment e.g. php artisan tenant:delete cafejohn';
    public function handle()
    {
        // because this is a destructive command, we'll only allow to run this command
        // if you are on the local environment
        if (!app()->isLocal()) {
            $this->error('This command is only avilable on the local environment.');
            return;
        }
        $tenantname = $this->argument('tenantname');
        $this->deleteTenant($tenantname);
    }
    private function deleteTenant($tenantname)
    {
        $baseUrl = config('app.url_base');
        $fqdn = "{$tenantname}.{$baseUrl}";

        if ($hostname = Hostname::where('fqdn', $fqdn)->firstOrFail()) {
            $website = Website::where('id', $hostname->website_id)->firstOrFail();
            app(HostnameRepository::class)->delete($hostname, true);
            app(WebsiteRepository::class)->delete($website, true);
            $this->info("Tenant {$tenantname} successfully deleted.");
        }
    }
}
```

　特筆することは少ないですが、'if (!app()->isLocal())'の判定で、このコマンドがローカル環境でしか実行できないように制限していることに注目してください。

　この削除コマンドでは、テナントを実際に削除するのですが、その時にデータベースやデータベースユーザーまで削除するかなど、条件を指定したいはずです。そのために、設定ファイル`tenancy.php`を利用できるようにします。

```
php artisan vendor:publish
```

　コマンドを実行すると、何をPublishするか聞かれるので、

**Provider: Hyn\Tenancy\Providers\Tenants\ConfigurationProvider**

　を選択します。これにより`config/tenancy.php`が生成されます。以下の行を探して、変更します。

```php:config/tenancy.php
...
        'auto-delete-tenant-directory' => env('TENANCY_DIRECTORY_AUTO_DELETE', false),
...
        'auto-delete-tenant-database' => env('TENANCY_DATABASE_AUTO_DELETE', false),
...
        'auto-delete-tenant-database-user' => env('TENANCY_DATABASE_AUTO_DELETE_USER', false),
...
```

　さらに、`.env`に実際の設定値を追記します。

```php:.env
TENANCY_DIRECTORY_AUTO_DELETE=true
TENANCY_DATABASE_AUTO_DELETE=true
TENANCY_DATABASE_AUTO_DELETE_USER=true
```

　上から、ディレクトリの自動削除、データベースの自動削除、データベースユーザーの自動削除、です。

　準備が整ったので、さっきのテナントを消してみましょう。

```
php artisan tenant:delete cafejohn
```

　データベースをあけて、すべて削除されたことを確認してください。

[→ Part2へ](https://qiita.com/hitotch/items/e305e312f2f85f1f68ba)