(この記事は個人ブログの転載です。)
ルートによってグローバルスコープを適用する - あしたからがんばる
Backcheck事業部の前田です。
グローバルスコープまわりでハマっていて、PHPユーザーズSlackの皆さんに色々と助けていただきました。ありがとうございます。
多くの知見を得たので、ここにまとめておきます。
例題
Laravelでブログを作成します。 ここでの重要な要件は以下です。
- 記事は「公開ステータス」を持ちます
- 公開ステータスが非公開の記事は閲覧者に見えてはいけません
- 公開ステータスが非公開の記事は管理画面では見えないといけません
グローバルスコープ
さて、「公開ステータスが非公開の記事は閲覧者に見えてはいけません」という要件を満たす簡単な方法は以下のような方法です。
<?php $posts = Post::where('publish_status', 'published')->get();
簡単に思いつき、簡単に実装できます。
ただし、この方法の場合、記事を取得する箇所はすべてこのwhere句を書かないといけません。面倒ですね。
また、今後の機能改修を考えると、where句を入れ忘れる事故が発生しそうです。怖いですね。
そこで、グローバルスコープを適用させます。
グローバルスコープを適用することで、非公開の記事を存在しないものとして扱えます。
Post
<?php class Post extends Model { protected static function boot(): void { parent::boot(); static::addGlobalScope('status', function (Builder $query): void { $query->where('publish_status', 'published'); }); } }
PostController
<?php $posts = Post::all();
都度where句を書かなくて良くなりました。
where句を書き忘れる心配もありませんね。
ルートによってグローバルスコープを適用させる
もうひとつの要件、「公開ステータスが非公開の記事は管理画面では見えないといけません」を考えると、現状のままでは難しいです。
グローバルスコープにより、管理画面でも非公開の記事が閲覧できなくなるからです。
<?php $posts = Post::withoutGlobalScope('status')->get();
のようにすれば取れなくもないのですが、管理画面で毎回グローバルスコープを外す処理を書くのは面倒です。
よって、「管理画面以外ではグローバルスコープを適用させる」のようにできると良さそうです。
そこで、以下のようにしてみました。
Post
<?php class Post extends Model { protected static function boot(): void { parent::boot(); if (app(PublishStatusScopeManager::class)->isActive()) { static::addGlobalScope('status', function (Builder $query): void { $query->where('publish_status', 'published'); }); } } }
PublishStatusScopeManager
をsingletonでサービスコンテナに登録しておき、管理画面以外の場合はグローバルスコープ適用フラグを設定しておく作戦です。
ちなみに、最初はこの設定はミドルウェアで行っていました。 以下のようなイメージです。
モデル結合ルートに対応する
ですが、ミドルウェアではモデル結合ルートでグローバルスコープが効きませんでした。
以下のような流れになっていたようです。(用語は適当です)
ここでは、以下のような流れになっています。
- モデル結合ルートの場合、ルートで指定されたモデルが存在するかを先にチェックします。(モデルが存在しない場合はミドルウェアを適用せずに404を返したいためだと思います)
- モデルの存在チェックをする際にモデルをbootします。
- ミドルウェアでフラグを設定するも、既にbootされているので手遅れ・・・
- (ちなみに、コントローラからモデルを呼び出す際も、既にモデルはboot済みなのでboot処理は呼ばれません。)
ここで僕は「ぐぬぬぬ・・・」と悩んだのですが、最初に紹介したslackで「RouteMatchedイベントの後に設定すれば良いのではないか?」と教えてもらい、解決しました!(本当にありがとうございます!)
コード的にはこんな感じです。
EventServiceProvider
<?php class EventServiceProvider extends ServiceProvider { // ...省略 public function boot() { parent::boot(); Event::listen(RouteMatched::class, function (RouteMatched $event) { if (!Str::startsWith($event->route->uri, 'admin/')) { app(PublishedStatusScopeManager::class)->setActive(); } }); } }
ちょっと「管理画面かどうか」のチェックは雑ですが、、、
ちなみに、イメージ的にはこんな感じの流れです。
(UMLスキルが低くて下手っぴですが・・・)
ここでは、EventListenerはクロージャで表現しているので、クラスは作ってないです。
まとめ
「管理画面とその他の画面で取得条件を分ける」などは、よくあるケースだと思います。
今回は、以下の方法で対応しました。