Skip to main content
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.
This page targets Laravel 13. Starting from After Query Callbacks in the official documentation, 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.
Callers can chain it like any other scope.
$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.

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. 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. 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.
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.
This uses ?:, which relies on PHP’s truthiness check, not ??. 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. first() internally calls limit(1)->get()->first(). Using first() does not mean you should change the Closure’s parameter to Post.
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. 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 isolates records between tests. Create the test in tests/Feature/AfterQueryTest.php.
You can run only this test.

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

Eloquent scopes

Review the basics of bundling SQL constraints into local and global scopes.

Eloquent collections

Review the difference between Collections that hold models and Support Collections.

Eloquent observers and model events

Review per-model events, which differ from processing retrieved results.

API resources

Separate the transformation of display data returned by your API from model persistence.

Primary sources consulted

Last modified on November 6, 2026