Laravel

Laravelのクエリスコープの使い方|Scope属性とscope接頭辞の違いとグローバルスコープの実装

クエリスコープは、Eloquentモデルに何度も書く絞り込み条件へ名前を付け、User::popular()->active() のように呼び出せるようにする機能です。この書き方はLaravel v12.4.0(2025年3月29日リリース)で変わりました。従来の scopeActive() という接頭辞に代えて #[Scope] 属性を付けた protected メソッドが追加され、Laravel 13.x の公式ドキュメントは属性形だけを掲載しています。ただし接頭辞形が動かなくなったわけではありません。日本語の解説記事の多くは接頭辞形のままで属性形に触れていないため、ここではLaravel 13.x のフレームワーク実装まで確認したうえで、2つの記法の判定順、動的スコープ、グローバルスコープの適用と解除、そしてスコープが呼べないときの原因を整理します。コード例の戻り値は void で統一しました。

まとめ

  • 新規に書くローカルスコープは #[Scope] 属性+protected メソッドが標準です。Laravel v12.4.0(2025-03-29)で追加され、Laravel 13.x の公式ドキュメントはこの形だけを載せています。
  • 既存の scopeXxx() 形式はLaravel 13でもそのまま動きます。hasNamedScope() が接頭辞と属性の両方を見て、callNamedScope() は属性を優先し、無ければ接頭辞へフォールバックします。一斉の書き換えは不要です。
  • 属性を付けるメソッドは protected にしてください。public だと User::popular() という静的呼び出しが __callStatic() を通らず、PHPがエラーを出します。
  • グローバルスコープは php artisan make:scope でクラスを作り、#[ScopedBy] 属性かモデルの booted() で適用します。解除は withoutGlobalScope()withoutGlobalScopes()withoutGlobalScopesExcept() の3種類です。
  • スコープの条件は、後から orWhere() を繋いでも自動では括られません。ORを混ぜるときは閉包に入れるか、高階の ->orWhere->active() を使ってください。

クエリスコープの2種類と使い分けの判断基準

ローカルスコープとグローバルスコープの役割分担

ローカルスコープは、呼び出したクエリにだけ条件を足します。User::popular() と書いたときだけ「投票数が100を超える」という条件が付き、書かなければ付きません。対してグローバルスコープは、そのモデルに対するすべてのクエリに条件を付けます。Laravelのソフトデリートは、この仕組みで削除済みレコードを既定の取得結果から外しています。

判断の軸は「その条件を書き忘れたときに事故になるか」です。マルチテナントのテナントID絞り込みや、公開状態でないレコードの非表示は、書き忘れが情報漏えいに直結するためグローバルスコープが向きます。一方で「人気ユーザー」「今月分」のような集計軸は、付けたり外したりするのが普通なのでローカルスコープにします。

グローバルスコープを避けるべき場面

グローバルスコープは条件の書き忘れを防げますが、無条件に増やすべきものではありません。避けたほうがよい典型は次の2つです。

  • 管理画面と公開画面で同じモデルを共有する場合。管理画面側では毎回 withoutGlobalScope() を書くことになり、書き忘れると「管理画面なのに下書きが見えない」という逆向きの不具合になります。呼び出し側が外し方を知らないと解除できない条件は、暗黙の前提として残り続けます。
  • 集計・バッチ処理でモデルを使い回す場合。生成されるSQLに常に条件が混ざるため、インデックスの効き方が読みにくくなります。実行計画を見ながら詰めたいクエリでは、条件を呼び出し側に明示するほうが調査が速く済みます。

迷ったらローカルスコープから始めてください。あとからグローバルへ昇格させるのは容易ですが、逆方向は「どこが暗黙に依存しているか」を全て洗う必要があり、コストが跳ね上がります。

ローカルスコープの2つの記法(Scope属性とscope接頭辞)

Laravel 12.4以降の標準記法(Scope属性)

Illuminate\Database\Eloquent\Attributes\Scope を読み込み、メソッドに #[Scope] を付けます。メソッドは protected にし、第1引数で Builder $query を受け取ります。戻り値は同じクエリビルダか void のどちらかにしてください。

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

class User extends Model
{
    #[Scope]
    protected function popular(Builder $query): void
    {
        $query->where('votes', '>', 100);
    }

    #[Scope]
    protected function active(Builder $query): void
    {
        $query->where('active', 1);
    }
}

呼び出し側はメソッド名をそのまま使い、連結もできます。

$users = User::popular()->active()->orderBy('created_at')->get();

接頭辞形との一番の違いは、メソッド名が意図どおりの名前になる点です。scopePopular() と定義して popular() と呼ぶ、という名前のねじれが無くなるため、定義ジャンプが scopePopular() を経由せず直接当たります。命名の揺れが気になる場合はLaravelの命名規則一覧|テーブル・モデル・コントローラーからコーディング規約までも合わせて確認してください。

scope接頭辞がLaravel 13でも動く仕組み

公式ドキュメントから接頭辞形の記述が消えたため「使えなくなった」と読まれがちですが、Laravel 13.x のフレームワーク本体には接頭辞の解決処理が残っています。次の定義はLaravel 13でも有効です。

class User extends Model
{
    public function scopePopular(Builder $query): void
    {
        $query->where('votes', '>', 100);
    }
}

呼び出し側の書き方は属性形と変わりません。

$users = User::popular()->get();

根拠は Illuminate\Database\Eloquent\Model の実装にあります。スコープの有無を判定する hasNamedScope() は接頭辞と属性のいずれかを満たせば真を返し、実際に呼び出す callNamedScope() は属性付きメソッドを先に試し、無ければ接頭辞付きメソッドへ回します。

// Illuminate\Database\Eloquent\Model(13.x)
public function hasNamedScope($scope)
{
    return method_exists($this, 'scope'.ucfirst($scope)) ||
        static::isScopeMethodWithAttribute($scope);
}

public function callNamedScope($scope, array $parameters = [])
{
    if ($this->isScopeMethodWithAttribute($scope)) {
        return $this->{$scope}(...$parameters);
    }

    return $this->{'scope'.ucfirst($scope)}(...$parameters);
}

つまり優先順位は属性が先、接頭辞が後です。同じスコープ名に対して属性形と接頭辞形の両方を定義すると属性側だけが呼ばれ、接頭辞側は無視されます。移行の途中で二重定義を残さないでください。

新規実装と既存コードでの記法の選び分け

結論として、新規のスコープは属性形で書き、既存の接頭辞形は動いている限り書き換えないのが妥当です。接頭辞形はフレームワーク側の分岐として明示的に残されており、Laravel 13のアップグレードガイドにも削除の予告はありません。数十個のスコープを一括置換しても呼び出し名は変わらないため、回帰テストの手間に見合うリターンがありません。

書き換えるとすれば、そのモデルに手を入れる別の理由があるときに限ります。1ファイルずつ属性形へ寄せていけば、二重定義の事故も起きません。両者の違いは次のとおりです。

観点 Scope属性 scope接頭辞
定義 #[Scope] を付けたメソッド メソッド名の先頭に scope
可視性 protectedprivate は不可) public
メソッド名 呼び出し名と同じ 呼び出し名と別
追加バージョン v12.4.0(2025-03-29) 12.4.0より前から
13.x公式docsの掲載 あり なし(実装は残存)
同名で両方定義した場合 こちらが呼ばれる 無視される

属性形を使うにはLaravel 12.4以上が要ります。Laravel 11以前から上げる場合は、先にフレームワーク側の動作要件とアップグレード手順を確認してください。Laravel 12の対応PHPバージョンと動作要件|サポート期限・11からのアップグレード・変更点に前提をまとめています。

引数を受け取る動的スコープの定義

条件の値を呼び出し側から渡したいときは、$query の後ろに引数を足します。

#[Scope]
protected function ofType(Builder $query, string $type): void
{
    $query->where('type', $type);
}

第2引数以降が呼び出し時の引数になります。

$users = User::ofType('admin')->get();

列挙値で状態を持たせている場合は、文字列ではなくEnumを引数に取ると呼び出し側のタイプミスを型で弾けます。カラム定義とEloquent側のキャストの合わせ方はLaravelのmigrationでenumを扱う実践ガイド|カラム定義・値の追加・Eloquent連携【Laravel 13対応】にまとめています。

複数スコープの連結とorWhereでの条件崩れ

スコープの連結は User::popular()->active() のようにAND条件として積み上がります。問題が起きるのはORを混ぜたときです。まず押さえるべきは、スコープが持つ条件は、後から orWhere() を繋いでも自動では括られないという点です。

// vip() が votes > 100 と active = 1 の2条件を持つ場合
User::vip()->orWhere('name', 'x')->toSql();
// => where "votes" > ? and "active" = ? or "name" = ?
//    スコープ側のANDは括られない

「votesが100超かつactive、またはnameがx」のつもりでも、実際には「votes超過 かつ (activeまたはname)」と読める並びのSQLになります。ORの結合順を期待どおりにしたいなら、括りを自分で作る必要があります。

一方、ORを含むクエリに後からスコープを適用した場合は、それ以前の条件がまとめて括られます。

User::popular()->orWhere('name', 'x')->active()->toSql();
// => where ("votes" > ? or "name" = ?) and "active" = ?
//    スコープ適用時に、それ以前のor入りの条件がまとめて括られる

これは Builder::callScope() が、スコープ適用の前後でwhere句を2つのかたまりに切り分け、ORを含むかたまりだけをネストさせるためです(addNewWheresWithinGroup()groupWhereSliceForScope())。つまりフレームワークが括ってくれるのは「スコープを適用した瞬間」だけで、スコープを適用したあとに足したORは対象外になります。

ORでスコープを足すときは、次のどちらかにしてください。

// 閉包に入れて渡す(orWhereの論理グルーピングが効く)
$users = User::popular()->orWhere(function (Builder $query) {
    $query->active();
})->get();
// => where "votes" > ? or ("active" = ?)

// 高階orWhere。閉包なしで同じSQLになる
$users = User::popular()->orWhere->active()->get();

前者の括弧は orWhere() に閉包を渡したことによる論理グルーピングで、後者の括弧は Builder::callScope() によるネストです。経路は違いますが結果のSQLは同じになります。ORが絡むクエリは、生成されたSQLを一度確認してから本番に入れてください。

グローバルスコープの実装・適用・解除

make:scopeによるクラス生成とapplyメソッドの実装

Artisanコマンドでひな形を作ります。生成先は app/Models/Scopes です。

php artisan make:scope PublishedScope

生成されるクラスは Illuminate\Database\Eloquent\Scope インターフェースを実装し、apply() メソッド1つだけを持ちます。

namespace App\Models\Scopes;

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

class PublishedScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('status', 'published');
    }
}

ScopedBy属性とbootedメソッドによる2通りの適用

モデルへの割り当ては #[ScopedBy] 属性が最短です。

use App\Models\Scopes\PublishedScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;

#[ScopedBy([PublishedScope::class])]
class Post extends Model
{
}

条件付きで登録したい場合や、閉包で済ませたい場合は booted() を上書きして addGlobalScope() を呼びます。閉包で登録するときは第1引数に名前を渡してください。この名前が後述の解除で必要になります。

use Illuminate\Database\Eloquent\Builder;

class Post extends Model
{
    protected static function booted(): void
    {
        // クラスで登録する場合
        static::addGlobalScope(new PublishedScope);

        // 閉包で登録する場合は第1引数に名前を付ける
        static::addGlobalScope('published', function (Builder $builder) {
            $builder->where('status', 'published');
        });
    }
}

withoutGlobalScope系メソッドによる解除

解除は用途別に3種類あります。クラス名で1つ外す、まとめて外す、指定したもの以外を外す、の使い分けです。

// クラス指定で1つだけ外す
Post::withoutGlobalScope(PublishedScope::class)->get();

// 閉包で登録したものは登録時の名前で外す
Post::withoutGlobalScope('published')->get();

// すべて外す
Post::withoutGlobalScopes()->get();

// 一部だけ外す
Post::withoutGlobalScopes([FirstScope::class, SecondScope::class])->get();

// 指定したもの以外を外す
Post::withoutGlobalScopesExcept([SecondScope::class])->get();

withoutGlobalScopesExcept() は「ソフトデリートのスコープだけは残したい」といった場面で効きます。逆に、すべて外す withoutGlobalScopes() を管理画面で使うと SoftDeletingScope も一緒に外れ、deleted_at is null の条件が消えて削除済みレコードまで混ざります。意図しない限りは避けてください。

addSelectによるselect句の上書き回避

グローバルスコープでカラムを追加する場合、select ではなく addSelect を使います。select は呼び出し側が組み立てたselect句を置き換えてしまい、必要な列が消えます。

public function apply(Builder $builder, Model $model): void
{
    // select だとクエリ側のselect句を置き換えてしまう
    $builder->addSelect('posts.published_at');
}

条件に使うカラムへインデックスが張られているかも併せて確認してください。全クエリに条件が乗る以上、インデックスの有無がそのままアプリ全体の応答時間に出ます。インデックスを含むスキーマ変更の進め方はLaravelにおけるマイグレーションの基本とその重要性を参照してください。

withAttributesによる条件のモデル生成への引き継ぎ

スコープで絞った条件を、そのスコープ経由で作るモデルの初期値にも使いたいことがあります。withAttributes() を使うと、where条件の追加と生成時の属性設定を1か所で書けます。

#[Scope]
protected function draft(Builder $query): void
{
    $query->withAttributes([
        'hidden' => true,
    ]);
}

取得側にはwhere条件が付き、そのスコープから作った行には同じ値が入ります。

// where hidden = true が付き、createした行にも hidden=true が入る
$draft = Post::draft()->create(['title' => 'In Progress']);
$draft->hidden; // true

where条件を付けず生成時の初期値としてだけ使いたい場合は asConditions: false を指定します。「下書きだけを取得するスコープから下書きを新規作成する」といった、取得と生成で同じ条件を二重管理していた箇所を1つにまとめられます。

スコープが呼べない・効かない場合の原因切り分け

属性形に移行した直後は、次の順で確認すると原因が絞れます。

  • メソッドが public になっている。属性形のスコープは protected が前提です。public だと静的呼び出しがモデルの __callStatic() を経由せず、非静的メソッドの静的呼び出しとしてPHPがエラーにします。
  • モデル内部から $this->ofType(...) で呼んでいる。属性形はクエリビルダを通して初めてスコープとして扱われます。モデル内部からは static::query()->ofType('admin') のようにクエリビルダ経由で呼びます。
  • 属性のインポートを間違えている。ローカルスコープの属性は Illuminate\Database\Eloquent\Attributes\Scope です。グローバルスコープのインターフェース Illuminate\Database\Eloquent\Scope とはクラスが別なので、IDEの自動補完でインターフェース側を読み込むと属性が認識されず、スコープとして呼べません。
  • 属性形と接頭辞形を二重定義している。前述のとおり属性側が優先され、接頭辞側は呼ばれません。移行途中の消し忘れが原因なら、接頭辞形のメソッドを削除します。
  • グローバルスコープが効かないbooted() ではなく boot() を上書きし、parent::boot() を呼び忘れていると登録処理ごと飛びます。#[ScopedBy] 属性で書けば登録漏れ自体が起きません。

2つ目のモデル内部からの呼び出しは見落としやすいので、書き方を並べておきます。

class User extends Model
{
    #[Scope]
    protected function ofType(Builder $query, string $type): void
    {
        $query->where('type', $type);
    }

    public static function admins()
    {
        // $this->ofType(...) ではなくクエリビルダ経由で呼ぶ
        return static::query()->ofType('admin')->get();
    }
}

実際に発行されたSQLを確かめるのが最短です。->toSql() を挟むか、対話シェルでモデルを直接叩いて条件の有無を見てください。手順はphp artisan tinkerの使い方|Laravel Tinkerの基本コマンドとEloquent操作にまとめています。

よくある質問

scopeXxxという接頭辞はLaravel 13で使えなくなりましたか

使えます。公式ドキュメントの記載が属性形だけになったため誤解されやすいのですが、Laravel 13.x の Illuminate\Database\Eloquent\Model には接頭辞を解決する分岐が残っており、hasNamedScope()callNamedScope() の両方が接頭辞形を扱います。Laravel 13のアップグレードガイドにも削除の予告はないため、既存コードを急いで書き換える必要はありません。

Scope属性を付けたメソッドはpublicでもよいですか

protected にしてください。公式ドキュメントも protected を指定しています。理由は呼び出し経路です。モデルの __callStatic() は、アクセスできないメソッドが静的に呼ばれたときに発火してスコープへ振り分けます。public だと非静的メソッドの静的呼び出しになり、__callStatic() が呼ばれる前にPHPがエラーを出します。

ただし属性を認識する isScopeMethodWithAttribute() の判定条件は「privateでないこと」なので、public のままでも User::query()->popular() のようにクエリビルダを起点にすれば動きます。動かないのは User::popular() という静的呼び出しの形だけです。private にすると属性そのものが無視され、未定義メソッドの例外になります。

ローカルスコープとグローバルスコープはどちらを使うべきですか

条件を書き忘れたときに不具合や情報漏えいになるものはグローバル、付け外しが前提のものはローカルです。管理画面と公開画面で同じモデルを共有する場合は、解除の書き忘れが逆向きの不具合を生むため、ローカルスコープにして呼び出し側で明示するほうが安全でしょう。

グローバルスコープを一時的に外すにはどうしますか

withoutGlobalScope() にスコープのクラス名を渡します。閉包で登録した無名スコープは、addGlobalScope() の第1引数に渡した名前の文字列で外します。複数まとめて外すなら withoutGlobalScopes()、特定のものだけ残すなら withoutGlobalScopesExcept() を使ってください。

スコープの中から別のスコープを呼べますか

呼べます。スコープの第1引数で受け取る Builder $query はクエリビルダなので、$query->active() のように別のスコープをそのまま繋げられます。ただしモデルクラスの内部から $this->ofType(...) の形で呼ぶとスコープとして解決されないため、static::query()->ofType('admin') のようにクエリビルダを起点にしてください。

関連記事

資料請求

RELATED POSTS 関連記事