> ## 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 將結果處理集中到區域作用域中。根據 Laravel 13 的實作，確認執行順序、回傳值，以及 first、pluck、cursor、分頁之間的差異。

當你想將查詢到的模型整理成可以用 ID 查找的形式，或想把結果處理集中到區域作用域時，可以使用 `afterQuery()`。它不只能新增 SQL 條件，還能向查詢建構器註冊一個對查詢結果執行的 Closure。

不過，**它並不是「每執行一次 SQL 就會收到相同型別結果的鉤子」**。依照查詢方法的不同，傳入的 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`。

如果只在一個地方處理，`get()->keyBy('id')` 會更單純。當你希望**在作用域或重複使用的查詢中也包含查詢後的處理**時，再選擇 `afterQuery()`。作用域本身的定義方式請參考 [Eloquent 作用域](https://kawax.biz/zh-TW/advanced/eloquent-scopes.md)。

## 解讀 get 的執行順序

`Eloquent\Builder::get()` 會套用全域作用域、產生模型、對指定的關聯進行預先載入，最後執行 `afterQuery` 的回呼。

```mermaid theme={null}
flowchart TD
    A["透過 keyedById()<br>註冊 Closure"] --> B["呼叫 get()"]
    B --> C["套用全域作用域"]
    C --> D["執行 SQL 並<br>產生模型"]
    D --> E["預先載入<br>指定的關聯"]
    E --> F["建立 Eloquent Collection"]
    F --> G["依註冊順序執行<br>afterQuery 的 Closure"]
    G --> H["回傳處理後的結果"]
```

在一般的 `get()` 中，回呼可以使用已預先載入的關聯。存取未指定的關聯可能會產生額外的 SQL，因此請搭配[預先載入](https://kawax.biz/zh-TW/eloquent-relationships.md)進行設計。

它與 `retrieved` 模型事件的時機不同。`retrieved` 會在 `Model::newFromBuilder()` 產生每個模型時觸發，早於 `get()` 的預先載入。處理一批查詢結果模型的 `afterQuery()` 與各模型的事件並不負責相同的工作。

> [!WARNING]
>
> 這是 `Eloquent\Builder::get()` 的流程。`cursor()` 不會預先載入關聯，回呼也會針對每個模型個別執行。請不要期待它與 `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/zh-TW/eloquent-resources.md)進行轉換。

官方文件中有一個為模型加入搜尋結果摘要屬性的範例。對模型屬性的指派也可能改變要儲存的內容。請避免將帶有資料庫中不存在的顯示用屬性的模型直接 `save()` 的設計。

## 註冊會保留在查詢建構器上

每次呼叫 `afterQuery()` 都會新增一個 Closure，執行後註冊也不會消失。使用同一個建構器再次查詢時會再次執行，同一個作用域呼叫兩次，回呼也會被註冊兩次。複製（clone）建構器時，也會繼承已註冊的回呼。

在需要多次查詢的處理或長時間執行的 worker 中，請為每個用途建立新的查詢，並讓回呼只做沒有副作用的結果處理。若在其中放入寄送郵件或持久化操作，很容易把區塊或游標造成的多次執行誤認為業務上的一次處理。

在套件中提供可重複使用的作用域時，也請將支援的查詢方法明確列為公開 API，並測試型別與呼叫次數的契約。

## 測試不同查詢路徑的差異

在使用端的 Laravel 13 應用程式中，不只要確認鍵的處理，也要確認 `first()`、`cursor()` 與空頁面時的呼叫情況。

以下範例假設已有前面的 `Post` 模型，以及包含 `id`、`title`、`body`、`created_at`、`updated_at` 的 `posts` 資料表遷移。使用 [RefreshDatabase](https://kawax.biz/zh-TW/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/zh-TW/advanced/eloquent-scopes.md)
>
> 了解將 SQL 條件集中到區域作用域與全域作用域的基礎。

> [card] [Eloquent 集合](https://kawax.biz/zh-TW/eloquent-collections.md)
>
> 了解處理模型的 Collection 與 Support Collection 的差異。

> [card] [Eloquent Observers 與模型事件](https://kawax.biz/zh-TW/advanced/eloquent-observers.md)
>
> 了解與查詢結果處理不同、以模型為單位的事件。

> [card] [API 資源](https://kawax.biz/zh-TW/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 的 Scope](https://kawax.biz/zh-TW/advanced/eloquent-scopes.md)
- [進階主題](https://kawax.biz/zh-TW/advanced/index.md)
- [进阶主题](https://kawax.biz/zh-CN/advanced/index.md)
- [搜尋](https://kawax.biz/zh-TW/search.md)
- [Eloquent 集合](https://kawax.biz/zh-TW/eloquent-collections.md)


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