【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の実装
問題点: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()
{
// ...
}
これは、「モデルインスタンスの保存や削除は、そのレコードを一意に特定する主キーにのみ依存するべき」という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を見つけました。
やっぱりハマるよね…
Discussion