> ## 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-CN/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-CN/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-CN/eloquent-resources.md)进行转换。

官方文档中有一个向模型添加搜索结果摘要属性的示例。对模型属性的赋值也可能改变要保存的内容。请避免将带有数据库中不存在的展示用属性的模型直接 `save()` 的设计。

## 注册会保留在查询构建器上

每次调用 `afterQuery()` 都会添加一个 Closure，执行后注册也不会消失。使用同一个构建器再次查询时会再次执行，同一个作用域调用两次，回调也会被注册两次。克隆构建器时，也会继承已注册的回调。

在需要多次查询的处理或长生命周期的 worker 中，应为每个用途创建新的查询，并让回调只做无副作用的结果处理。如果在其中放入发送邮件或持久化操作，很容易把分块或游标带来的多次执行误认为业务上的一次处理。

在扩展包中提供可复用的作用域时，也请将支持的查询方法作为公开 API 明确说明，并测试类型和调用次数的契约。

## 测试不同查询路径的差异

在使用方的 Laravel 13 应用中，不仅要确认键的处理，还要确认 `first()`、`cursor()` 和空页时的调用情况。

以下示例假设已有前面的 `Post` 模型，以及包含 `id`、`title`、`body`、`created_at`、`updated_at` 的 `posts` 表迁移。使用 [RefreshDatabase](https://kawax.biz/zh-CN/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-CN/advanced/eloquent-scopes.md)
>
> 了解将 SQL 约束集中到本地作用域和全局作用域的基础知识。

> [card] [Eloquent 集合](https://kawax.biz/zh-CN/eloquent-collections.md)
>
> 了解处理模型的 Collection 与 Support Collection 的区别。

> [card] [Eloquent Observers 与模型事件](https://kawax.biz/zh-CN/advanced/eloquent-observers.md)
>
> 了解与查询结果处理不同的、以模型为单位的事件。

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


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