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 inapp/Models/Post.php.
$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.
Return values are passed to the next callback
afterQuery() adds Closures in registration order. The part that runs them, applyAfterQueryCallbacks(), is implemented as follows.
?:, 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 howafterQuery() 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.
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 toafterQuery() 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 forfirst(), 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.
Maintenance checklist
- State the retrieval methods the scope supports, and the Closure’s parameter and return types.
- Verify an empty
get()result and apaginate()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(), andBuildsQueries.
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.