> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# EloquentのafterQueryと取得結果の契約

> EloquentのafterQueryでローカルスコープに結果加工をまとめる方法を解説します。実行順、返り値、first・pluck・cursor・ページネーションの違いをLaravel 13の実装から確認します。

取得したモデルをIDで引ける形に揃えたいときや、ローカルスコープに結果加工をまとめたいときは、`afterQuery()` を利用できます。SQLの条件を追加するだけでなく、取得結果に対して実行するClosureもクエリビルダーへ登録できます。

ただし、**「SQLが1回実行されるたびに、同じ型の結果が渡されるフック」ではありません**。取得メソッドによって、渡されるCollectionの中身や呼び出し回数が変わります。

> [!NOTE]
>
> このページはLaravel 13を対象にしています。[公式ドキュメントのAfter Query Callbacks](https://github.com/laravel/docs/blob/13.x/eloquent.md#after-query-callbacks)を出発点に、Eloquentのビルダーと公式テストから利用時の境界を確認します。

## ローカルスコープに結果加工をまとめる

モデルをIDで参照する処理を繰り返す場合は、結果のキーを揃えるスコープを用意できます。次の例は `app/Models/Post.php` に定義します。

```php theme={null}
<?php

namespace App\Models;

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

class Post extends Model
{
    protected $fillable = ['title', 'body'];

    #[Scope]
    protected function keyedById(Builder $query): void
    {
        $query->afterQuery(
            fn (Collection $posts): Collection => $posts->keyBy('id')
        );
    }
}
```

呼び出し側は通常のスコープと同じようにチェーンできます。

```php theme={null}
use App\Models\Post;

$posts = Post::keyedById()->orderBy('id')->get();

$post = $posts->get(42);
```

`$posts` は引き続きEloquent Collectionで、要素も `Post` のままです。`get(42)` はキーが `42` のモデルを返し、存在しなければ `null` を返します。`select()` で取得列を限定する場合は、キーに使う `id` も含めてください。

1か所だけで加工するなら、`get()->keyBy('id')` の方が単純です。`afterQuery()` は、スコープや再利用するクエリに**取得後の処理も含めたいとき**に選びます。スコープ自体の定義方法は[Eloquentのスコープ](https://kawax.biz/jp/advanced/eloquent-scopes.md)を参照してください。

## getの実行順を読む

`Eloquent\Builder::get()` は、グローバルスコープの適用、モデルの生成、指定されたリレーションのEagerロードを行い、最後に `afterQuery` のコールバックを実行します。

```mermaid theme={null}
flowchart TD
    A["keyedById()で<br>Closureを登録"] --> B["get()を呼び出す"]
    B --> C["グローバルスコープを適用"]
    C --> D["SQLを実行し<br>モデルを生成"]
    D --> E["指定されたリレーションを<br>Eagerロード"]
    E --> F["Eloquent Collectionを作成"]
    F --> G["afterQueryのClosureを<br>登録順に実行"]
    G --> H["加工後の結果を返す"]
```

通常の `get()` では、コールバックからEagerロード済みのリレーションを利用できます。指定していないリレーションへのアクセスは、追加のSQLを発生させる可能性があるため、[Eagerロード](https://kawax.biz/jp/eloquent-relationships.md)と組み合わせて設計します。

`retrieved` モデルイベントとはタイミングが異なります。`retrieved` は `Model::newFromBuilder()` で各モデルを生成するときに発火し、`get()` のEagerロードより前です。取得したモデルのまとまりを扱う `afterQuery()` と、各モデルのイベントは同じ役割ではありません。

> [!WARNING]
>
> これは `Eloquent\Builder::get()` の流れです。`cursor()` はリレーションをEagerロードせず、コールバックもモデル1件ずつに対して実行します。`get()` と同じバッチ処理を期待しないでください。

## 返り値は次のコールバックへ渡される

`afterQuery()` は登録順にClosureを追加します。実行部分の `applyAfterQueryCallbacks()` は、次の実装になっています。

```php theme={null}
public function applyAfterQueryCallbacks($result)
{
    foreach ($this->afterQueryCallbacks as $afterQueryCallback) {
        $result = $afterQueryCallback($result) ?: $result;
    }

    return $result;
}
```

ここで使われているのは `??` ではなく、PHPのtruthy判定を使う `?:` です。

| コールバックの返り値 | 結果への反映 |
| - | - |
| Collectionなどのオブジェクト | 返したオブジェクトが次のコールバックと呼び出し元へ渡る |
| `null`、`false`、`0`、空文字列、空配列 | それまでの結果を維持する |
| 空のCollection | オブジェクトなので、空のCollectionへ置き換わる |

Collectionを直接変更し、何も返さない `void` のコールバックも利用できます。その場合は、変更した同じオブジェクトを維持します。一方、新しいCollectionを作った場合は、そのCollectionを返す必要があります。

内部の引数と返り値は `mixed` ですが、自由な型への変換を公開APIにするのは避けます。例えば `get()` の結果を文字列や配列に置き換えると、`first()` が続けて呼ぶ `->first()` などの処理を壊します。スコープでは、モデル・Collectionの型・件数・並び順を保つ加工を基本にしてください。

## 取得メソッドごとの違い

次の表は、**Eloquentのビルダーに登録した** `afterQuery()` の扱いです。

| 呼び出すメソッド | コールバックに渡る結果 | 注意点 |
| - | - | - |
| `get()` | Eloquent Collection | 0件でも空のCollectionで実行する |
| `first()` / `firstOrFail()` | Eloquent Collection | モデルそのものではなく、0件または1件のCollection。加工後に先頭を取り出す |
| `paginate()` | ページのモデルを含むEloquent Collection | Paginatorではない。総件数が0なら `get()` を呼ばず、コールバックも実行しない |
| `simplePaginate()` / `cursorPaginate()` | Eloquent Collection | 次ページ確認用の1件が含まれる場合があり、最大で `perPage + 1` 件を加工する |
| `cursor()` | モデル1件を含むEloquent Collection | LazyCollectionを消費するときに各行で実行し、加工結果の先頭を返す |
| `lazy()` / `chunk()` / `chunkById()` | チャンクごとのEloquent Collection | 内部の `get()` ごとに実行する。終了判定用の空の結果にも実行される場合がある |
| `pluck()` | 値を含むSupport Collection | モデルではない。対象列のCastやMutatorの処理後に実行する |
| `count()` / `exists()` | 渡されない | Eloquentのコールバックを通らず、基底のクエリビルダーへ処理を委譲する |
| `toBase()->get()` | 渡されない | Eloquent側の登録は基底のビルダーへ移らない。基底には別の `afterQuery()` がある |

`first()` は内部で `limit(1)->get()->first()` を呼びます。`first()` を使うからといって、Closureの引数を `Post` に変更してはいけません。

> [!WARNING]
>
> このページの `keyedById()` はモデルを取得する用途のスコープです。`Post::keyedById()->pluck('id')` には使えません。`pluck()` は `Illuminate\Support\Collection` を渡すため、例の `Illuminate\Database\Eloquent\Collection` 型と一致しません。型宣言を緩めるだけでは、値からモデルの属性を参照する処理は安全になりません。

値だけが必要ならスコープを使わず `Post::query()->pluck('id')` とします。モデルの加工後に値を取り出すなら `Post::keyedById()->get()->pluck('id')` と書けます。後者の `pluck()` は、クエリビルダーではなく取得済みのCollectionに対する操作です。

### ページ件数や次の取得位置を変えない

`paginate()` の総件数はSQLで集計し、その後にページのモデルを取得します。取得後にCollectionをフィルタしても、総件数は再計算されません。

また、`simplePaginate()` と `cursorPaginate()` は加工後の結果を使って次ページを判断します。`chunkById()` はチャンク末尾のIDを次の取得位置に使います。コールバック内で件数や順序を変えると、ページ判定や継続位置が本来のクエリと一致しなくなることがあります。

絞り込み・ソート・取得列はSQL側で指定し、`afterQuery()` では取得結果の意味を変えない加工に留めます。APIの表示形式を変えるだけなら、[APIリソース](https://kawax.biz/jp/eloquent-resources.md)での変換も検討してください。

公式ドキュメントには、モデルへ検索結果の抜粋属性を追加する例があります。モデル属性への代入は保存対象の変更にもなり得ます。DBに存在しない表示用属性を付けたモデルを、そのまま `save()` する設計は避けてください。

## 登録はクエリビルダーに残る

`afterQuery()` を呼ぶたびにClosureが追加され、実行しても登録は消えません。同じビルダーで再び取得すれば再実行され、同じスコープを二重に呼べばコールバックも二重に登録されます。ビルダーをcloneした場合も、登録済みのコールバックを引き継ぎます。

取得を複数回行う処理や長寿命ワーカーでは、用途ごとに新しいクエリを作り、コールバックは副作用のない結果加工にします。メール送信や永続化を置くと、チャンクやカーソルによる複数回の実行を、業務上の1回の処理と取り違えやすくなります。

パッケージで再利用するスコープを提供する場合も、対応する取得メソッドを公開APIとして明示し、型や呼び出し回数の契約をテストしてください。

## 取得経路の違いをテストする

利用側のLaravel 13アプリケーションでは、キーの加工だけでなく、`first()`・`cursor()`・空のページでの呼び出しを確認します。

次の例は、先ほどの `Post` モデルと、`id`・`title`・`body`・`created_at`・`updated_at` を持つ `posts` テーブルのマイグレーションがあることを前提にしています。[RefreshDatabase](https://kawax.biz/jp/database-testing.md)で、テスト間のレコードを分離します。

`tests/Feature/AfterQueryTest.php` にテストを作成します。

```php theme={null}
<?php

namespace Tests\Feature;

use App\Models\Post;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class AfterQueryTest extends TestCase
{
    use RefreshDatabase;

    public function test_get_preserves_models_and_keys_them_by_id(): void
    {
        $first = Post::create(['title' => 'First', 'body' => 'First body']);
        $second = Post::create(['title' => 'Second', 'body' => 'Second body']);

        $posts = Post::keyedById()->orderBy('id')->get();

        $this->assertInstanceOf(Collection::class, $posts);
        $this->assertSame([$first->getKey(), $second->getKey()], $posts->keys()->all());
        $this->assertTrue($first->is($posts->get($first->getKey())));
    }

    public function test_first_passes_a_collection_to_the_callback(): void
    {
        $expected = Post::create(['title' => 'First', 'body' => 'First body']);
        $sizes = [];

        $post = Post::query()
            ->afterQuery(function (Collection $posts) use (&$sizes): void {
                $sizes[] = $posts->count();
            })
            ->first();

        $this->assertSame([1], $sizes);
        $this->assertTrue($expected->is($post));
    }

    public function test_cursor_runs_callbacks_when_each_model_is_consumed(): void
    {
        $first = Post::create(['title' => 'First', 'body' => 'First body']);
        $second = Post::create(['title' => 'Second', 'body' => 'Second body']);
        $sizes = [];

        $cursor = Post::keyedById()->orderBy('id')
            ->afterQuery(function (Collection $posts) use (&$sizes): void {
                $sizes[] = $posts->count();
            })
            ->cursor();

        $this->assertSame([], $sizes);
        $this->assertSame([$first->getKey(), $second->getKey()], $cursor->pluck('id')->all());
        $this->assertSame([1, 1], $sizes);
    }

    public function test_empty_get_runs_a_callback_but_zero_total_pagination_does_not(): void
    {
        $sizes = [];
        $query = Post::query()->whereKey(-1)
            ->afterQuery(function (Collection $posts) use (&$sizes): void {
                $sizes[] = $posts->count();
            });

        $this->assertCount(0, $query->get());
        $this->assertSame([0], $sizes);

        $page = $query->paginate(perPage: 15, page: 1);

        $this->assertSame(0, $page->total());
        $this->assertSame([0], $sizes);
    }
}
```

対象のテストだけを実行できます。

```bash theme={null}
php artisan test --filter=AfterQueryTest
```

## 保守時のチェックリスト

* スコープが対応する取得メソッドと、Closureの引数・返り値の型を明示する。
* `get()` の空の結果と、総件数0の `paginate()` を区別して検証する。
* チャンクやカーソルでは、呼び出し回数と取得順序が変わらないことを確認する。
* コールバックの二重登録やビルダーの再利用で、業務上の副作用が発生しない設計にする。
* Laravelの更新時には、`get()`・`pluck()`・`cursor()` と `BuildsQueries` の呼び出し経路を再確認する。

## 関連ページ

> [card] [Eloquentのスコープ](https://kawax.biz/jp/advanced/eloquent-scopes.md)
>
> SQLの制約をローカルスコープとグローバルスコープにまとめる基本を確認します。

> [card] [Eloquentコレクション](https://kawax.biz/jp/eloquent-collections.md)
>
> モデルを扱うCollectionとSupport Collectionの違いを確認します。

> [card] [Eloquent Observersとモデルイベント](https://kawax.biz/jp/advanced/eloquent-observers.md)
>
> 取得結果の加工とは異なる、モデル単位のイベントを確認します。

> [card] [APIリソース](https://kawax.biz/jp/eloquent-resources.md)
>
> APIへ返す表示データの変換を、モデルの保存処理から分離します。

## 参照した一次情報

* [Laravel公式ドキュメント: After Query Callbacks](https://github.com/laravel/docs/blob/f7a20c401a30827991659174d48d958f92517a9e/eloquent.md#after-query-callbacks)
* [Laravel Framework v13.40.0: Eloquent Builder](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Database/Eloquent/Builder.php)
* [Laravel Framework v13.40.0: BuildsQueries](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Database/Concerns/BuildsQueries.php)
* [Laravel Framework v13.40.0: Model](https://github.com/laravel/framework/blob/v13.40.0/src/Illuminate/Database/Eloquent/Model.php)
* [Laravel公式統合テスト: AfterQueryTest](https://github.com/laravel/framework/blob/v13.40.0/tests/Integration/Database/AfterQueryTest.php)


## Related topics

- [Eloquentのスコープ](https://kawax.biz/jp/advanced/eloquent-scopes.md)
- [応用トピック](https://kawax.biz/jp/advanced/index.md)
- [進階主題](https://kawax.biz/zh-TW/advanced/index.md)
- [パッケージの動的リレーション登録](https://kawax.biz/jp/advanced/package-dynamic-relations.md)
- [Eloquentコレクション](https://kawax.biz/jp/eloquent-collections.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.