ルートによってグローバルスコープを適用する

(この記事は個人ブログの転載です。)

ルートによってグローバルスコープを適用する - あしたからがんばる

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でサービスコンテナに登録しておき、管理画面以外の場合はグローバルスコープ適用フラグを設定しておく作戦です。

ちなみに、最初はこの設定はミドルウェアで行っていました。 以下のようなイメージです。

f:id:chiroruxx:20200604212300p:plain

モデル結合ルートに対応する

ですが、ミドルウェアではモデル結合ルートでグローバルスコープが効きませんでした。
以下のような流れになっていたようです。(用語は適当です)

f:id:chiroruxx:20200604212336p:plain

ここでは、以下のような流れになっています。

  1. モデル結合ルートの場合、ルートで指定されたモデルが存在するかを先にチェックします。(モデルが存在しない場合はミドルウェアを適用せずに404を返したいためだと思います)
  2. モデルの存在チェックをする際にモデルをbootします。
    • このタイミングではまだミドルウェアには到達していないので、 グローバルスコープの適用フラグは設定されていません。
    • なので、グローバルスコープが適用されません。(checkActiveする段階でsetActiveできていない)
  3. ミドルウェアでフラグを設定するも、既にbootされているので手遅れ・・・
  4. (ちなみに、コントローラからモデルを呼び出す際も、既にモデルは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();
            }
        });
    }
}

ちょっと「管理画面かどうか」のチェックは雑ですが、、、

ちなみに、イメージ的にはこんな感じの流れです。

f:id:chiroruxx:20200604212417p:plain

(UMLスキルが低くて下手っぴですが・・・)
ここでは、EventListenerはクロージャで表現しているので、クラスは作ってないです。

まとめ

「管理画面とその他の画面で取得条件を分ける」などは、よくあるケースだと思います。
今回は、以下の方法で対応しました。

  • グローバルスコープを使う
  • ルートをもとに、グローバルスコープを出し分ける
  • 出し分けのフラグはRouteMatchedイベントが発火されたタイミングで行う