Saltar al contenido principal
Cuando quieres que los modelos obtenidos se puedan consultar por ID, o agrupar el procesamiento de resultados en un scope local, puedes usar afterQuery(). Además de añadir condiciones SQL, permite registrar en el query builder un Closure que se ejecuta sobre los resultados obtenidos. Sin embargo, no es “un hook que recibe un resultado del mismo tipo cada vez que se ejecuta una consulta SQL”. El contenido de la Collection recibida y el número de llamadas cambian según el método de obtención.
Esta página está dirigida a Laravel 13. Partiendo de After Query Callbacks en la documentación oficial, revisa los límites de uso a partir del builder de Eloquent y de los tests oficiales.

Agrupar el procesamiento de resultados en un scope local

Si consultas modelos por ID de forma repetida, puedes preparar un scope que asigne las claves de los resultados. Define el siguiente ejemplo en app/Models/Post.php.
Quien lo llama puede encadenarlo como cualquier otro scope.
$posts sigue siendo una Eloquent Collection y sus elementos siguen siendo Post. get(42) devuelve el modelo cuya clave es 42, o null si no existe. Si limitas las columnas obtenidas con select(), incluye también el id que se usa como clave. Si solo procesas los resultados en un lugar, get()->keyBy('id') es más sencillo. Elige afterQuery() cuando quieras incluir el procesamiento posterior a la obtención en un scope o en una consulta reutilizada. Para saber cómo definir los scopes, consulta Scopes de Eloquent.

Entender el orden de ejecución de get

Eloquent\Builder::get() aplica los scopes globales, crea los modelos, carga de forma ansiosa las relaciones indicadas y, por último, ejecuta los callbacks de afterQuery. Con un get() normal, los callbacks pueden usar las relaciones cargadas de forma ansiosa. Acceder a relaciones no indicadas puede generar SQL adicional, así que diseña el scope junto con la carga ansiosa. El momento es distinto al del evento de modelo retrieved. retrieved se dispara al crear cada modelo en Model::newFromBuilder(), antes de la carga ansiosa de get(). afterQuery(), que trabaja con el conjunto de modelos obtenidos, y los eventos de cada modelo no cumplen la misma función.
Este es el flujo de Eloquent\Builder::get(). cursor() no carga relaciones de forma ansiosa y ejecuta los callbacks modelo a modelo. No esperes el mismo procesamiento por lotes que con get().

El valor de retorno se pasa al siguiente callback

afterQuery() añade los Closures en orden de registro. La parte que los ejecuta, applyAfterQueryCallbacks(), está implementada así.
Aquí no se usa ??, sino ?:, que se basa en la evaluación truthy de PHP. También puedes usar un callback void que modifique la Collection directamente sin devolver nada. En ese caso se mantiene el mismo objeto modificado. En cambio, si creas una Collection nueva, debes devolverla. Internamente el argumento y el valor de retorno son mixed, pero evita convertir a tipos arbitrarios como parte de la API pública. Por ejemplo, si reemplazas el resultado de get() por una cadena o un array, rompes la llamada ->first() que hace first() a continuación. En los scopes, limita el procesamiento a cambios que conserven los modelos, el tipo de Collection, el número de elementos y el orden.

Diferencias entre los métodos de obtención

La siguiente tabla muestra cómo se trata afterQuery() registrado en el builder de Eloquent. first() llama internamente a limit(1)->get()->first(). Usar first() no significa que debas cambiar el parámetro del Closure a Post.
El scope keyedById() de esta página está pensado para obtener modelos. No puedes usarlo con Post::keyedById()->pluck('id'). pluck() pasa un Illuminate\Support\Collection, que no coincide con el tipo Illuminate\Database\Eloquent\Collection del ejemplo. Relajar la declaración de tipo no basta para que el código que lee atributos de modelo a partir de valores sea seguro.
Si solo necesitas valores, no uses el scope y escribe Post::query()->pluck('id'). Para extraer valores después de procesar los modelos, puedes escribir Post::keyedById()->get()->pluck('id'). El pluck() del segundo caso es una operación sobre la Collection obtenida, no sobre el query builder.

No cambiar el número de páginas ni la siguiente posición de obtención

paginate() calcula el total con SQL y después obtiene los modelos de la página. Filtrar la Collection después de obtenerla no recalcula el total. Además, simplePaginate() y cursorPaginate() usan el resultado procesado para decidir si hay una página siguiente. chunkById() usa el ID al final de cada chunk como siguiente posición de obtención. Si cambias el número de elementos o el orden dentro del callback, la detección de páginas o la posición de continuación pueden dejar de coincidir con la consulta original. Indica el filtrado, la ordenación y las columnas en el SQL, y limita afterQuery() a procesamientos que no cambien el significado de los resultados obtenidos. Si solo quieres cambiar el formato de salida de una API, considera también transformarlo con recursos de API. La documentación oficial incluye un ejemplo que añade a los modelos un atributo con un extracto de los resultados de búsqueda. Asignar atributos de modelo también puede cambiar lo que se guarda. Evita diseños que llamen directamente a save() sobre modelos con atributos de presentación que no existen en la base de datos.

El registro permanece en el query builder

Cada llamada a afterQuery() añade un Closure, y ejecutarlo no elimina el registro. Si vuelves a obtener resultados con el mismo builder, se ejecuta de nuevo, y si llamas dos veces al mismo scope, el callback también se registra dos veces. Al clonar el builder también se heredan los callbacks registrados. En código que obtiene resultados varias veces o en workers de larga duración, crea una consulta nueva para cada propósito y limita los callbacks a un procesamiento de resultados sin efectos secundarios. Si colocas ahí el envío de correos o la persistencia, es fácil confundir varias ejecuciones por chunks o cursores con una única operación de negocio. Si ofreces scopes reutilizables en un paquete, indica también los métodos de obtención compatibles como API pública y prueba el contrato de tipos y número de llamadas.

Probar las diferencias entre las vías de obtención

En la aplicación Laravel 13 que lo utiliza, comprueba no solo el procesamiento de claves, sino también las llamadas con first(), cursor() y páginas vacías. El siguiente ejemplo presupone el modelo Post anterior y una migración de la tabla posts con id, title, body, created_at y updated_at. RefreshDatabase aísla los registros entre tests. Crea el test en tests/Feature/AfterQueryTest.php.
Puedes ejecutar solo este test.

Lista de comprobación para el mantenimiento

  • Indica los métodos de obtención que admite el scope y los tipos del parámetro y del valor de retorno del Closure.
  • Verifica por separado el resultado vacío de get() y un paginate() con total 0.
  • En chunks y cursores, confirma que el número de llamadas y el orden de obtención no cambian.
  • Diseña el código para que registrar dos veces un callback o reutilizar un builder no cause efectos secundarios de negocio.
  • Al actualizar Laravel, vuelve a revisar las rutas de llamada de get(), pluck(), cursor() y BuildsQueries.

Páginas relacionadas

Scopes de Eloquent

Repasa los fundamentos para agrupar restricciones SQL en scopes locales y globales.

Colecciones de Eloquent

Repasa la diferencia entre las Collections de modelos y las Support Collections.

Observers de Eloquent y eventos de modelo

Repasa los eventos por modelo, distintos del procesamiento de resultados obtenidos.

Recursos de API

Separa la transformación de los datos que devuelve la API del guardado de los modelos.

Fuentes primarias consultadas

Última modificación el 6 de noviembre de 2026