💡

【Laravel】何故save()でGlobal Scopeが効かないのかを、設計思想から理解する

に公開

こんにちは。booostの開発者のma_meです。
皆さんはLaravelのGlobal Scopeが「取得(SELECT)の時にはちゃんと動くのに、
更新(UPDATE)や削除(DELETE)の時には条件が効かなくて焦った…」という経験はありませんか?
自分はありました。
この挙動は特に公式ドキュメントには明記されていませんが、実はLaravelの設計思想に深く関わる仕様でした。
この記事では、なぜGlobal Scopeが特定の場面で効かないのかをソースコードと照らし合わせて、具体的な解決策を提示します。

そもそもGlobal Scopeとは

まず、Global Scopeの基本的な機能をおさらいです。
Global Scopeをモデルに設定すると、そのモデルに対するクエリ(find()get()など)に、自動的に特定のWHERE句を追加されます。
例えば、論理削除(SoftDeletes)のように、常に特定の条件で絞り込みたい場合に非常に便利です。

Global Scopeの設定例booted()内でaddGlobalScopeを使い、
常にdateが2024-01-01より後のレコードのみを対象とするスコープを定義します。

use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;

class Calendar extends Model
{
    protected static function booted()
    {
        static::addGlobalScope('date', function (Builder $builder) {
            $builder->where('date', '>', '2024-01-01 00:00:00');
        });
    }
}

実行コードと発行されるSQL
このモデルでデータを取得すると、自動でスコープが適用されます。

// ここではdateに関するwhere句の指定は特にしていない
$calendar = Calendar::find(1);

実際に発行されるSQLには、dateに対するwhere句が追加されていることがわかります。

select * from `calendars`
 where `calendars`.`id` = ?
   and `date` > '2024-01-01 00:00:00'
 limit 1;

Global Scopeが追加される仕組み

Global Scope は registerGlobalScopes() というメソッドで、クエリビルダに登録されます。
これはfind()get()の内部で呼ばれるnewQuery()query() などのメソッド内で実行されています。
🔗 registerGlobalScopesの実装

🔗 queryの実装

🔗 newQueryの実装

問題点:saveやdeleteではスコープが効かない

ここからが本題です。このような機能を持つGlobal Scopeですが、モデルインスタンス経由の更新・削除時には適用されません。
具体的には下記メソッドが該当します。

  • save()
  • update()
  • delete()

検証コードfind()で取得したインスタンスに対してsave()delete()を実行してみます。

// 取得時はスコープが適用される
$calendar = Calendar::find(2);

if ($calendar) {
    // 書き込み時:スコープは適用されない(WHERE句は主キーのみ)
    $calendar->title = 'renamed';
    $calendar->save();

    // 削除時も同様にスコープは適用されない
    $calendar->delete();
}

発行されるSQL
select文とは異なり、update文とdelete文にはGlobal Scopeで定義したdateの条件が含まれておらず、
意図せず古い日付のレコードを更新・削除してしまう可能性があります。

-- save()実行時
update `calendars` set `title` = ?, `updated_at` = ? where `id` = ?;

-- delete()実行時
delete from `calendars` where `id` = ?;

なぜ更新・削除時にはスコープが適用されないのか?

原因はsave()delete()といったインスタンスメソッドが内部で呼び出す**newModelQuery()**というメソッドにあります。
newModelQuery()は、その名の通り新しいクエリビルダを生成するメソッドですが、これはGlobal Scopeを登録せずにビルダを初期化します。
Model.phpのソースコードにあるnewModelQuery()のDocコメントを見ると、その仕様が明確に書かれています。

/**
 * Get a new query builder that doesn't have any global scopes or eager loading.
 *
 * (意訳:Global ScopeやEager Loadingが一切ない、まっさらなクエリビルダを取得する)
 */
public function newModelQuery()
{
    // ...
}

🔗 newModelQueryの実装
🔗 saveの実装

これは、「モデルインスタンスの保存や削除は、そのレコードを一意に特定する主キーにのみ依存するべき」というLaravelの設計思想に基づいています。
Global Scopeのような取得時の条件に、永続化処理が影響されるべきではない、という意図が伺えます。
とはいえソースコードだけじゃなくて、ドキュメントにも記載が欲しい…

解決策

最もシンプルで確実な方法は、モデルインスタンスを介さず、クエリビルダを直接使って更新・削除処理を行うことです。
クエリビルダからupdate()delete()を呼び出す場合、Global Scopeは正常に適用されます。

// NG例:インスタンス経由だとスコープが効かない
// $calendar = Calendar::find(2);
// $calendar->delete();

// OK例:クエリビルダ経由ならquery()が呼びだされるため、スコープが適用される
Calendar::where('id', 2)->update(['title' => 'updated!']);
Calendar::where('id', 2)->delete();

まとめ

Global Scopeは、主に取得系のクエリに自動で条件を付与する機能です。
インスタンス経由のsave(), update(), delete()では、内部で呼び出されるnewModelQuery()の仕様によりGlobal Scopeが適用されません。
対策として、更新・削除はクエリビルダ経由で行うのが最もシンプルで安全です。

Global Scopeの便利な側面と、この仕様を正しく理解することで、予期せぬバグを防ぎ、より堅牢なアプリケーションを開発しましょう。

余談

今回のハマったポイントに対して、既に2017年に同じ議論をされていたissueを見つけました。
やっぱりハマるよね…
https://github.com/laravel/framework/issues/11989

Booost

Discussion