> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# afterQuery de Eloquent y el contrato de los resultados

> Aprende a agrupar el procesamiento de resultados en scopes locales con afterQuery de Eloquent. Revisa el orden de ejecución, los valores de retorno y las diferencias entre first, pluck, cursor y la paginación a partir de la implementación de Laravel 13.

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.

> [!NOTE]
>
> Esta página está dirigida a Laravel 13. Partiendo de [After Query Callbacks en la documentación oficial](https://github.com/laravel/docs/blob/13.x/eloquent.md#after-query-callbacks), 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`.

```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')
        );
    }
}
```

Quien lo llama puede encadenarlo como cualquier otro scope.

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

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

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

`$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](https://kawax.biz/es/advanced/eloquent-scopes.md).

## 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`.

```mermaid theme={null}
flowchart TD
    A["Registrar el Closure<br>con keyedById()"] --> B["Llamar a get()"]
    B --> C["Aplicar los scopes globales"]
    C --> D["Ejecutar el SQL y<br>crear los modelos"]
    D --> E["Cargar de forma ansiosa<br>las relaciones indicadas"]
    E --> F["Crear la Eloquent Collection"]
    F --> G["Ejecutar los Closures de afterQuery<br>en orden de registro"]
    G --> H["Devolver el resultado procesado"]
```

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](https://kawax.biz/es/eloquent-relationships.md).

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.

> [!WARNING]
>
> 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í.

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

    return $result;
}
```

Aquí no se usa `??`, sino `?:`, que se basa en la evaluación truthy de PHP.

| Valor de retorno del callback | Efecto en el resultado |
| - | - |
| Un objeto como una Collection | El objeto devuelto pasa al siguiente callback y a quien hizo la llamada |
| `null`, `false`, `0`, cadena vacía, array vacío | Se mantiene el resultado anterior |
| Una Collection vacía | Como es un objeto, el resultado se reemplaza por la Collection vacía |

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**.

| Método llamado | Resultado que recibe el callback | Observaciones |
| - | - | - |
| `get()` | Eloquent Collection | Se ejecuta con una Collection vacía incluso si hay 0 filas |
| `first()` / `firstOrFail()` | Eloquent Collection | No es el modelo en sí, sino una Collection de 0 o 1 elementos. El primero se extrae después del procesamiento |
| `paginate()` | Eloquent Collection con los modelos de la página | No es el Paginator. Si el total es 0, no se llama a `get()` y el callback no se ejecuta |
| `simplePaginate()` / `cursorPaginate()` | Eloquent Collection | Puede incluir un elemento extra para comprobar la página siguiente, así que procesa hasta `perPage + 1` elementos |
| `cursor()` | Eloquent Collection con un modelo | Se ejecuta para cada fila al consumir la LazyCollection y devuelve el primer elemento del resultado procesado |
| `lazy()` / `chunk()` / `chunkById()` | Eloquent Collection por chunk | Se ejecuta en cada `get()` interno. También puede ejecutarse con el resultado vacío que detecta el final |
| `pluck()` | Support Collection con valores | No son modelos. Se ejecuta después de los casts y mutators de la columna |
| `count()` / `exists()` | No se pasa | Omite los callbacks de Eloquent y delega en el query builder base |
| `toBase()->get()` | No se pasa | Los registros del lado de Eloquent no pasan al builder base. El builder base tiene su propio `afterQuery()` |

`first()` llama internamente a `limit(1)->get()->first()`. Usar `first()` no significa que debas cambiar el parámetro del Closure a `Post`.

> [!WARNING]
>
> 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](https://kawax.biz/es/eloquent-resources.md).

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](https://kawax.biz/es/database-testing.md) aísla los registros entre tests.

Crea el test en `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);
    }
}
```

Puedes ejecutar solo este test.

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

## 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

> [card] [Scopes de Eloquent](https://kawax.biz/es/advanced/eloquent-scopes.md)
>
> Repasa los fundamentos para agrupar restricciones SQL en scopes locales y globales.

> [card] [Colecciones de Eloquent](https://kawax.biz/es/eloquent-collections.md)
>
> Repasa la diferencia entre las Collections de modelos y las Support Collections.

> [card] [Observers de Eloquent y eventos de modelo](https://kawax.biz/es/advanced/eloquent-observers.md)
>
> Repasa los eventos por modelo, distintos del procesamiento de resultados obtenidos.

> [card] [Recursos de API](https://kawax.biz/es/eloquent-resources.md)
>
> Separa la transformación de los datos que devuelve la API del guardado de los modelos.

## Fuentes primarias consultadas

* [Documentación oficial de 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)
* [Test de integración oficial de Laravel: AfterQueryTest](https://github.com/laravel/framework/blob/v13.40.0/tests/Integration/Database/AfterQueryTest.php)


## Related topics

- [Scopes de Eloquent](https://kawax.biz/es/advanced/eloquent-scopes.md)
- [Temas avanzados](https://kawax.biz/es/advanced/index.md)
- [Factorías de Eloquent](https://kawax.biz/es/eloquent-factories.md)
- [Stores de caché personalizados y contratos en paquetes](https://kawax.biz/es/advanced/package-cache-drivers.md)
- [Casts personalizados de Eloquent](https://kawax.biz/es/advanced/eloquent-casts.md)


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