> ## 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 and the result contract

> Learn how to bundle result processing into local scopes with Eloquent afterQuery. Check execution order, return values, and the differences between first, pluck, cursor, and pagination against the Laravel 13 implementation.

When you want retrieved models keyed by ID, or want to bundle result processing into a local scope, you can use `afterQuery()`. Beyond adding SQL conditions, you can register a Closure on the query builder that runs against the retrieved results.

However, **it is not "a hook that receives a result of the same type every time SQL runs once"**. The contents of the Collection passed in and the number of calls change depending on the retrieval method.

> [!NOTE]
>
> This page targets Laravel 13. Starting from [After Query Callbacks in the official documentation](https://github.com/laravel/docs/blob/13.x/eloquent.md#after-query-callbacks), it checks the boundaries of usage against the Eloquent builder and the official tests.

## Bundle result processing into a local scope

If you repeatedly look up models by ID, you can prepare a scope that keys the results. Define the following example in `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')
        );
    }
}
```

Callers can chain it like any other scope.

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

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

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

`$posts` is still an Eloquent Collection, and its items are still `Post` models. `get(42)` returns the model whose key is `42`, or `null` if none exists. If you limit the retrieved columns with `select()`, include the `id` used as the key.

If you only process the results in one place, `get()->keyBy('id')` is simpler. Choose `afterQuery()` when you want **to include post-retrieval processing in a scope or a reused query**. For how to define scopes themselves, see [Eloquent scopes](https://kawax.biz/en/advanced/eloquent-scopes.md).

## Read the execution order of get

`Eloquent\Builder::get()` applies global scopes, hydrates models, eager loads the requested relationships, and finally runs the `afterQuery` callbacks.

```mermaid theme={null}
flowchart TD
    A["Register the Closure<br>with keyedById()"] --> B["Call get()"]
    B --> C["Apply global scopes"]
    C --> D["Run SQL and<br>hydrate models"]
    D --> E["Eager load the<br>requested relationships"]
    E --> F["Create the Eloquent Collection"]
    F --> G["Run afterQuery Closures<br>in registration order"]
    G --> H["Return the processed result"]
```

With a regular `get()`, callbacks can use relationships that were eager loaded. Accessing relationships you did not request may trigger additional SQL, so design the scope together with [eager loading](https://kawax.biz/en/eloquent-relationships.md).

The timing differs from the `retrieved` model event. `retrieved` fires when each model is created in `Model::newFromBuilder()`, which is before eager loading in `get()`. `afterQuery()`, which handles the batch of retrieved models, and per-model events do not serve the same role.

> [!WARNING]
>
> This is the flow of `Eloquent\Builder::get()`. `cursor()` does not eager load relationships, and it runs callbacks for one model at a time. Do not expect the same batch processing as `get()`.

## Return values are passed to the next callback

`afterQuery()` adds Closures in registration order. The part that runs them, `applyAfterQueryCallbacks()`, is implemented as follows.

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

    return $result;
}
```

This uses `?:`, which relies on PHP's truthiness check, not `??`.

| Callback return value | Effect on the result |
| - | - |
| An object such as a Collection | The returned object is passed to the next callback and to the caller |
| `null`, `false`, `0`, empty string, empty array | The previous result is kept |
| An empty Collection | Because it is an object, the result is replaced with the empty Collection |

You can also use a `void` callback that modifies the Collection directly and returns nothing. In that case, the same modified object is kept. If you create a new Collection, on the other hand, you must return it.

The internal argument and return types are `mixed`, but avoid making arbitrary type conversions part of your public API. For example, replacing the result of `get()` with a string or array breaks the `->first()` call that `first()` makes next. In scopes, stick to processing that preserves the models, the Collection type, the item count, and the order.

## Differences between retrieval methods

The following table shows how `afterQuery()` **registered on the Eloquent builder** is handled.

| Method called | Result passed to the callback | Notes |
| - | - | - |
| `get()` | Eloquent Collection | Runs with an empty Collection even when there are 0 rows |
| `first()` / `firstOrFail()` | Eloquent Collection | Not the model itself, but a Collection of 0 or 1 items. The first item is taken after processing |
| `paginate()` | Eloquent Collection with the page's models | Not the Paginator. If the total is 0, `get()` is not called and the callback does not run |
| `simplePaginate()` / `cursorPaginate()` | Eloquent Collection | May include one extra item for the next-page check, so up to `perPage + 1` items are processed |
| `cursor()` | Eloquent Collection with one model | Runs for each row as the LazyCollection is consumed, and returns the first item of the processed result |
| `lazy()` / `chunk()` / `chunkById()` | Eloquent Collection per chunk | Runs for each internal `get()`. May also run on the empty result used to detect the end |
| `pluck()` | Support Collection of values | Not models. Runs after casts and mutators for the target column are applied |
| `count()` / `exists()` | Not passed | Skips the Eloquent callbacks and delegates to the base query builder |
| `toBase()->get()` | Not passed | Registrations on the Eloquent side are not moved to the base builder. The base builder has its own `afterQuery()` |

`first()` internally calls `limit(1)->get()->first()`. Using `first()` does not mean you should change the Closure's parameter to `Post`.

> [!WARNING]
>
> The `keyedById()` scope on this page is meant for retrieving models. You cannot use it with `Post::keyedById()->pluck('id')`. `pluck()` passes an `Illuminate\Support\Collection`, which does not match the `Illuminate\Database\Eloquent\Collection` type in the example. Loosening the type declaration alone does not make code that reads model attributes from values safe.

If you only need values, skip the scope and write `Post::query()->pluck('id')`. To extract values after processing the models, you can write `Post::keyedById()->get()->pluck('id')`. The `pluck()` in the latter is an operation on the retrieved Collection, not on the query builder.

### Do not change page counts or the next retrieval position

`paginate()` counts the total in SQL and then retrieves the page's models. Filtering the Collection after retrieval does not recalculate the total.

Also, `simplePaginate()` and `cursorPaginate()` use the processed result to decide whether there is a next page. `chunkById()` uses the ID at the end of a chunk as the next retrieval position. If you change the count or order inside a callback, page detection and the continuation position may stop matching the original query.

Specify filtering, sorting, and selected columns in SQL, and limit `afterQuery()` to processing that does not change the meaning of the retrieved results. If you only want to change the API output format, also consider transforming it with [API resources](https://kawax.biz/en/eloquent-resources.md).

The official documentation includes an example that adds a search result excerpt attribute to models. Assigning to model attributes can also change what gets saved. Avoid designs that call `save()` directly on models that carry display-only attributes that do not exist in the database.

## Registrations stay on the query builder

Each call to `afterQuery()` adds a Closure, and running it does not remove the registration. Retrieving again with the same builder runs it again, and calling the same scope twice registers the callback twice. Cloning the builder also carries over the registered callbacks.

In code that retrieves multiple times or in long-lived workers, create a new query for each purpose, and keep callbacks to side-effect-free result processing. If you put email sending or persistence there, it becomes easy to mistake multiple runs from chunks or cursors for a single business operation.

When you provide reusable scopes in a package, also state the supported retrieval methods as part of the public API, and test the contract for types and call counts.

## Test the differences between retrieval paths

In the consuming Laravel 13 application, check not only the key processing but also the calls for `first()`, `cursor()`, and empty pages.

The following example assumes the `Post` model from earlier and a migration for a `posts` table with `id`, `title`, `body`, `created_at`, and `updated_at`. [RefreshDatabase](https://kawax.biz/en/database-testing.md) isolates records between tests.

Create the test in `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);
    }
}
```

You can run only this test.

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

## Maintenance checklist

* State the retrieval methods the scope supports, and the Closure's parameter and return types.
* Verify an empty `get()` result and a `paginate()` with a total of 0 separately.
* For chunks and cursors, confirm that the call count and retrieval order do not change.
* Design so that registering a callback twice or reusing a builder does not cause business side effects.
* When you update Laravel, recheck the call paths of `get()`, `pluck()`, `cursor()`, and `BuildsQueries`.

## Related pages

> [card] [Eloquent scopes](https://kawax.biz/en/advanced/eloquent-scopes.md)
>
> Review the basics of bundling SQL constraints into local and global scopes.

> [card] [Eloquent collections](https://kawax.biz/en/eloquent-collections.md)
>
> Review the difference between Collections that hold models and Support Collections.

> [card] [Eloquent observers and model events](https://kawax.biz/en/advanced/eloquent-observers.md)
>
> Review per-model events, which differ from processing retrieved results.

> [card] [API resources](https://kawax.biz/en/eloquent-resources.md)
>
> Separate the transformation of display data returned by your API from model persistence.

## Primary sources consulted

* [Laravel official documentation: 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 official integration test: AfterQueryTest](https://github.com/laravel/framework/blob/v13.40.0/tests/Integration/Database/AfterQueryTest.php)


## Related topics

- [Eloquent Scopes](https://kawax.biz/en/advanced/eloquent-scopes.md)
- [Advanced Topics](https://kawax.biz/en/advanced/index.md)
- [Eloquent relationships](https://kawax.biz/en/eloquent-relationships.md)
- [Laravel Scout](https://kawax.biz/en/scout.md)
- [Search](https://kawax.biz/en/search.md)


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