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

[前回](https://qiita.com/hitotch/items/e305e312f2f85f1f68ba)の続きです。
なお、[参考記事はこちら。](https://blog.usejournal.com/writing-a-full-featured-multi-tenant-laravel-app-from-scratch-a0e1a7350d9d)

　今回で連続記事の最後です。

　テナントの管理ユーザーに招待状Emailを送り、パスワード設定させて、/homeスクリーンが表示できるところまで作ります。ここまで作れば基盤づくりは終わりで、あとはいつも通りアプリを作れば、マルチテナントアプリが完成することになります。

# テナントの管理ユーザーに招待状を送る

　現状ではテナント作成時に作られた管理ユーザーにはランダムなパスワードが割り当てられ、コマンドライン上に表示されるだけの状況です。これを本番のマルチテナントアプリとして動作させられる状況にするため、管理ユーザーに招待状Emailを送付します。

　前提として、XAMPP付属のFake sendmailなどが動作する状況にしておいてください。Laravelからの送信テストも済ませておいてください。（[この辺が参考になります](https://laravel10.wordpress.com/2015/02/22/%E3%83%A1%E3%83%BC%E3%83%AB%E3%81%AE%E7%92%B0%E5%A2%83%E8%A8%AD%E5%AE%9A/)）

　実装をどうするかは悩むところですが、Laravel標準のパスワードリセットを使うことにします。テナントデータベースにも必要なテーブルがすでに整っているはずです。これで必要なコーディングはほぼすべて終わった状態からスタートできます。

## メール通知(Notification)を作成する

空のNotificationを作ります。

```
php artisan make:notification TenantCreated
```

```php:app/Notifications/TenantCreated
<?php
namespace App\Notifications;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Password;
class TenantCreated extends Notification
{
    private $hostname;
    public function __construct($hostname)
    {
        $this->hostname = $hostname;
    }
    public function via()
    {
        return ['mail'];
    }
    public function toMail($notifiable)
    {
        $token = Password::broker()->createToken($notifiable);
        $resetUrl = "https://{$this->hostname->fqdn}/password/reset/{$token}";
        $app = config('app.name');
        return (new MailMessage())
            ->subject("{$app} Invitation")
            ->greeting("Hello {$notifiable->name},")
            ->line("You have been invited to use {$app}!")
            ->line('To get started you need to set a password.')
            ->action('Set password', $resetUrl);
    }
}
```

　開発中はhttps://ではなくhttp://を使う場合も多いと思いますが、本番の安全性を重視してhttps://にしておきます。気になる場合は適宜`.env`に逃がすなどしてください。

　LaravelのNotificationの作り方を知っていれば、特筆すべきところは`hostname`の設定くらいです。テナントごとにfqdnが違うので、リセットリンクを生成するのに使っています。

## テナント作成で管理者に通知するように修正

　`app/Console/Commands/CreateTenant.php`を修正します。

```php:app/Console/Commands/CreateTenant.php
...
use App\Notifications\TenantCreated;
...
        // we'll create a random secure password for our to-be admin
        $password = str_random();
        $this->addAdmin($name, $email, $password)->notify(new TenantCreated($tenant["hostname"]));
        $this->info("Tenant '{$tenantname}' is created and is now accessible at {$tenant["hostname"]->fqdn}");
        // $this->info("Admin {$email} can log in using password {$password}");
        $this->info("Admin {$email} has been invited!");
...
```

　パスワードをコマンドラインに表示する代わりに、`->notify()`で通知を送るようにしました。

　**実際のメールアドレスを指定して**、テナントをもう一度作ってみてください。通知メールが届くはずです。

　**サイトが見つからないといった表示になる場合、https://をhttp://にしてみてください。また、サブドメイン名も含めたホスト名が``hosts``とApacheのVirtualHostに登録されているかも再確認してください。**

　ところが、メールにあるパスワードリセットボタンをクリックすると、404エラー、もしくは`Route [password.reset] not defined.`のようなエラーが出るはずです。これは、まだパスワードリセット用のRouteを設定していないからです。次にやるべきはパスワードリセット用のRouteを作ってControllerを作って・・・ですが、ご心配なく。LaravelのAuthを使えばコーディングは不要です。Authを使えるようにしましょう。

```
php artisan make:auth
```

　もう一度テナントを作ってみてください。リセットパスワード画面表示まで行けるはずです。

# パスワードリセットを動作するように修正

　パスワードリセットのためにメールアドレスとパスワードを打ち込むと、残念ながらまたエラーが出ます。今度は@`password_resets`テーブルが見つからないと出るはずです。これは、`password_reset`テーブル用のModelに`use UsesTenantConnection;`が指定されていないために起こる問題です。本来テナントデータベースの`password_reset`テーブルを見てほしいのに、システムデータベースを見てしまっています。

　UserのようにModelが触れてよいところに出てきていれば`use UsesTenantConnection;`とすればよいのですが、これができません。いろいろなアプローチはありますが、[参考記事](https://blog.usejournal.com/writing-a-full-featured-multi-tenant-laravel-app-from-scratch-a0e1a7350d9d)では「もっとも簡単でエレガントなやり方」としてミドルウェアで解決しているのでこれに従うことにします。（私見では、ベストの方法ではないかもしれないと思っていますが、間違った方法ではないと思います）

　作戦は、`routes/web.php` の `Auth::routes();` を必ず通ることを利用して、そこを通る場合はテナントデータベースを見るようにします。

## ミドルウェアを作成する

```
php artisan make:middleware EnforceTenancy
```

`app/Http/Middlware/EnforceTenancy.php`が生成されるので、以下のようにします。

```php:app/Http/Middlware/EnforceTenancy.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Support\Facades\Config;
class EnforceTenancy
{
    public function handle($request, Closure $next)
    {
        Config::set('database.default', 'tenant');
        return $next($request);
    }
}
```

　中身は、単にテナントデータベースを強制的に割り当てているだけです。

## ミドルウェアを割り当てる

　まずミドルウェアを`app/Http/Kernel.php`に登録します。

```php:app/Http/Kernel.php
...
protected $routeMiddleware = [
    ...
    'tenancy.enforce' => \App\Http\Middleware\EnforceTenancy::class,
];
...
```

　次に、`routes/web.php`で`Auth::routes();`にミドルウェアを割り当てます。

```php:routes/web.php
...
Route::group(['middleware' => 'tenancy.enforce'], function () {
    Auth::routes();
});
...
```

　これでOKです。

　ではパスワードリセットをやり直してみてください。今度はリセットパスワードがうまく動作するだけでなく、ログアウト、ログイン、などなどアカウントに関するすべてが動作するはずです。

　**うまくいかない場合、テナントを削除し、ブラウザのクッキーをクリアし、やり直してみてください。**

# まとめ

　ということで、最新のLaravel 5.7を使ってとても簡単にマルチテナントアプリの基盤が作れてしまいました。

　この仕組みをレンタルサーバーで実行できるか検証してみましたが、うまく動作します。ただし、一般的にはVPS以上でなければGRANT ROLEできるデータベースユーザーを作ることができないので、Webホスティング用の安いレンタルサーバーでは無理です。

　もっと聞きたいとか何か作ってほしいなどご要望がある場合は、簡単なのはコメントで答えますし、大きいのはお仕事として受けることもできますので、お気軽にご相談ください。

# おまけ

## おまけ１：テナントをクラス化

[参考記事](https://blog.usejournal.com/writing-a-full-featured-multi-tenant-laravel-app-from-scratch-a0e1a7350d9d)にもあるように、今後のことも考えてテナントをクラス化した場合のコードを以下に置いておきます。

``app/Tenant.php``が新設、あとの二つは書き換えです。コマンドの使い方は変更なし。

```
php artisan make:model Tenant
```

```app/Tenant.php
<?php
namespace App;
use Hyn\Tenancy\Environment;
use Hyn\Tenancy\Models\Hostname;
use Hyn\Tenancy\Models\Website;
use Illuminate\Support\Facades\Hash;
use Hyn\Tenancy\Contracts\Repositories\HostnameRepository;
use Hyn\Tenancy\Contracts\Repositories\WebsiteRepository;
use Illuminate\Support\Facades\Config;

/**
 * @property Website website
 * @property Hostname hostname
 * @property User admin
 */
class Tenant
{
    public function __construct($tenantname, User $admin = null)
    {
        $baseUrl = config('app.url_base');
        $fqdn = "{$tenantname}.{$baseUrl}";

        if ($this->hostname = Hostname::where('fqdn', $fqdn)->firstOrFail()) {
            $this->website = Website::where('id', $this->hostname->website_id)->firstOrFail();
        }

        $this->admin = $admin;
    }
    public function delete()
    {
        app(HostnameRepository::class)->delete($this->hostname, true);
        app(WebsiteRepository::class)->delete($this->website, true);
    }
    public static function createFrom($name, $email, $tenantname): Tenant
    {
        // 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);
        // make hostname current
        app(Environment::class)->tenant($website);
        $admin = static::makeAdmin($name, $email, str_random());

        return new Tenant($tenantname, $admin);
    }
    private static function makeAdmin($name, $email, $password): User
    {
        $admin = User::create(['name' => $name, 'email' => $email, 'password' => Hash::make($password)]);
        $admin->guard_name = 'web';
        $admin->assignRole('admin');
        return $admin;
    }

    public static function retrieveBy($tenantname): ?Tenant
    {
        $baseUrl = config('app.url_base');
        $fqdn = "{$tenantname}.{$baseUrl}";
        if (Hostname::where('fqdn', $fqdn)->exists()) {
            return new Tenant($tenantname);
        }
        return null;
    }
}
```

```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;
use Spatie\Permission\Models\Role;
use App\Notifications\TenantCreated;
use App\Tenant;
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 boise boise@example.com';
    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 = Tenant::createFrom($name, $email, $tenantname);
        $this->info("Tenant '{$tenantname}' is created and is now accessible at {$tenant->hostname->fqdn}");
        // invite admin
        $tenant->admin->notify(new TenantCreated($tenant->hostname));
        $this->info("Admin {$email} has been invited!");
    }
    private function tenantExists($tenantname)
    {
        $baseUrl = config('app.url_base');
        $fqdn = "{$tenantname}.{$baseUrl}";
        return Hostname::where('fqdn', $fqdn)->exists();
    }
}
```

```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 Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
use App\Tenant;
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 boise';
    public function handle()
    {
        // because this is a destructive command, we'll only allow to run this command
        // if you are on the local or testing
        if (!(app()->isLocal() || app()->runningUnitTests())) {
            $this->error('This command is only avilable on the local environment.');
            return;
        }
        $tenantname = $this->argument('tenantname');
        if ($tenant = Tenant::retrieveBy($tenantname)) {
            $tenant->delete();
            $this->info("Tenant {$tenantname} successfully deleted.");
        } else {
            $this->error("Couldn't find tenant {$tenantname}");
        }
    }
}
```

## おまけ２：さらにlaravel-adminを追加する

[ここまで作った状態からlaravel-adminを追加する](https://qiita.com/hitotch/items/35f817047e6060d06668)方法を別記事にあげました。