> ## 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 d'Eloquent et le contrat des résultats

> Découvrez comment regrouper le traitement des résultats dans des scopes locaux avec afterQuery d'Eloquent. Vérifiez l'ordre d'exécution, les valeurs de retour et les différences entre first, pluck, cursor et la pagination à partir de l'implémentation de Laravel 13.

Lorsque vous voulez accéder aux modèles récupérés par leur ID, ou regrouper le traitement des résultats dans un scope local, vous pouvez utiliser `afterQuery()`. En plus d'ajouter des conditions SQL, il permet d'enregistrer dans le query builder une Closure exécutée sur les résultats récupérés.

Cependant, **ce n'est pas « un hook qui reçoit un résultat du même type à chaque exécution SQL »**. Le contenu de la Collection transmise et le nombre d'appels varient selon la méthode de récupération.

> [!NOTE]
>
> Cette page concerne Laravel 13. En partant de [After Query Callbacks dans la documentation officielle](https://github.com/laravel/docs/blob/13.x/eloquent.md#after-query-callbacks), elle vérifie les limites d'utilisation à partir du builder Eloquent et des tests officiels.

## Regrouper le traitement des résultats dans un scope local

Si vous accédez souvent aux modèles par leur ID, vous pouvez préparer un scope qui uniformise les clés des résultats. Définissez l'exemple suivant dans `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')
        );
    }
}
```

L'appelant peut l'enchaîner comme n'importe quel scope.

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

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

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

`$posts` reste une Eloquent Collection, et ses éléments restent des `Post`. `get(42)` renvoie le modèle dont la clé est `42`, ou `null` s'il n'existe pas. Si vous limitez les colonnes récupérées avec `select()`, incluez aussi l'`id` utilisé comme clé.

Si vous ne traitez les résultats qu'à un seul endroit, `get()->keyBy('id')` est plus simple. Choisissez `afterQuery()` lorsque vous voulez **inclure le traitement post-récupération dans un scope ou une requête réutilisée**. Pour la définition des scopes eux-mêmes, consultez [Les scopes Eloquent](https://kawax.biz/fr/advanced/eloquent-scopes.md).

## Comprendre l'ordre d'exécution de get

`Eloquent\Builder::get()` applique les scopes globaux, crée les modèles, charge en eager loading les relations demandées, puis exécute en dernier les callbacks `afterQuery`.

```mermaid theme={null}
flowchart TD
    A["Enregistrer la Closure<br>avec keyedById()"] --> B["Appeler get()"]
    B --> C["Appliquer les scopes globaux"]
    C --> D["Exécuter le SQL et<br>créer les modèles"]
    D --> E["Charger en eager loading<br>les relations demandées"]
    E --> F["Créer l'Eloquent Collection"]
    F --> G["Exécuter les Closures afterQuery<br>dans l'ordre d'enregistrement"]
    G --> H["Renvoyer le résultat traité"]
```

Avec un `get()` classique, les callbacks peuvent utiliser les relations chargées en eager loading. L'accès à des relations non demandées peut déclencher du SQL supplémentaire, concevez donc le scope en tenant compte de l'[eager loading](https://kawax.biz/fr/eloquent-relationships.md).

Le moment diffère de celui de l'événement de modèle `retrieved`. `retrieved` est déclenché à la création de chaque modèle dans `Model::newFromBuilder()`, avant l'eager loading de `get()`. `afterQuery()`, qui traite l'ensemble des modèles récupérés, et les événements de chaque modèle n'ont pas le même rôle.

> [!WARNING]
>
> Il s'agit du flux de `Eloquent\Builder::get()`. `cursor()` ne charge pas les relations en eager loading et exécute les callbacks modèle par modèle. N'attendez pas le même traitement par lot qu'avec `get()`.

## La valeur de retour est transmise au callback suivant

`afterQuery()` ajoute les Closures dans l'ordre d'enregistrement. La partie qui les exécute, `applyAfterQueryCallbacks()`, est implémentée ainsi.

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

    return $result;
}
```

Ce n'est pas `??` qui est utilisé ici, mais `?:`, qui repose sur l'évaluation truthy de PHP.

| Valeur de retour du callback | Effet sur le résultat |
| - | - |
| Un objet comme une Collection | L'objet renvoyé est transmis au callback suivant et à l'appelant |
| `null`, `false`, `0`, chaîne vide, tableau vide | Le résultat précédent est conservé |
| Une Collection vide | Comme c'est un objet, le résultat est remplacé par la Collection vide |

Vous pouvez aussi utiliser un callback `void` qui modifie directement la Collection sans rien renvoyer. Dans ce cas, le même objet modifié est conservé. En revanche, si vous créez une nouvelle Collection, vous devez la renvoyer.

En interne, l'argument et la valeur de retour sont `mixed`, mais évitez de faire de conversions de type arbitraires une API publique. Par exemple, remplacer le résultat de `get()` par une chaîne ou un tableau casse l'appel `->first()` que `first()` effectue ensuite. Dans les scopes, limitez-vous à des traitements qui conservent les modèles, le type de Collection, le nombre d'éléments et l'ordre.

## Différences selon la méthode de récupération

Le tableau suivant montre comment est traité un `afterQuery()` **enregistré sur le builder Eloquent**.

| Méthode appelée | Résultat transmis au callback | Remarques |
| - | - | - |
| `get()` | Eloquent Collection | S'exécute avec une Collection vide même avec 0 ligne |
| `first()` / `firstOrFail()` | Eloquent Collection | Pas le modèle lui-même, mais une Collection de 0 ou 1 élément. Le premier est extrait après le traitement |
| `paginate()` | Eloquent Collection contenant les modèles de la page | Pas le Paginator. Si le total est 0, `get()` n'est pas appelé et le callback n'est pas exécuté |
| `simplePaginate()` / `cursorPaginate()` | Eloquent Collection | Peut contenir un élément supplémentaire pour vérifier la page suivante, jusqu'à `perPage + 1` éléments traités |
| `cursor()` | Eloquent Collection contenant un modèle | S'exécute pour chaque ligne lors de la consommation de la LazyCollection et renvoie le premier élément du résultat traité |
| `lazy()` / `chunk()` / `chunkById()` | Eloquent Collection par chunk | S'exécute à chaque `get()` interne. Peut aussi s'exécuter sur le résultat vide servant à détecter la fin |
| `pluck()` | Support Collection de valeurs | Pas des modèles. S'exécute après les casts et mutators de la colonne ciblée |
| `count()` / `exists()` | Non transmis | Contourne les callbacks Eloquent et délègue au query builder de base |
| `toBase()->get()` | Non transmis | Les enregistrements côté Eloquent ne sont pas transférés au builder de base. Celui-ci possède son propre `afterQuery()` |

`first()` appelle en interne `limit(1)->get()->first()`. Utiliser `first()` ne signifie pas qu'il faut changer le paramètre de la Closure en `Post`.

> [!WARNING]
>
> Le scope `keyedById()` de cette page sert à récupérer des modèles. Vous ne pouvez pas l'utiliser avec `Post::keyedById()->pluck('id')`. `pluck()` transmet une `Illuminate\Support\Collection`, qui ne correspond pas au type `Illuminate\Database\Eloquent\Collection` de l'exemple. Assouplir la déclaration de type ne suffit pas à rendre sûr un code qui lit des attributs de modèle à partir de valeurs.

Si vous n'avez besoin que de valeurs, n'utilisez pas le scope et écrivez `Post::query()->pluck('id')`. Pour extraire des valeurs après avoir traité les modèles, vous pouvez écrire `Post::keyedById()->get()->pluck('id')`. Le `pluck()` du second cas est une opération sur la Collection récupérée, pas sur le query builder.

### Ne pas modifier le nombre d'éléments par page ni la position de récupération suivante

`paginate()` calcule le total en SQL, puis récupère les modèles de la page. Filtrer la Collection après la récupération ne recalcule pas le total.

De plus, `simplePaginate()` et `cursorPaginate()` utilisent le résultat traité pour déterminer s'il existe une page suivante. `chunkById()` utilise l'ID en fin de chunk comme position de récupération suivante. Si vous modifiez le nombre d'éléments ou l'ordre dans le callback, la détection de page ou la position de reprise peuvent ne plus correspondre à la requête d'origine.

Spécifiez le filtrage, le tri et les colonnes dans le SQL, et limitez `afterQuery()` à des traitements qui ne changent pas le sens des résultats récupérés. Si vous voulez seulement modifier le format de sortie d'une API, envisagez aussi une transformation avec les [ressources API](https://kawax.biz/fr/eloquent-resources.md).

La documentation officielle contient un exemple qui ajoute aux modèles un attribut d'extrait de résultat de recherche. Assigner des attributs de modèle peut aussi modifier ce qui sera enregistré. Évitez les conceptions qui appellent directement `save()` sur des modèles portant des attributs d'affichage inexistants en base de données.

## L'enregistrement reste sur le query builder

Chaque appel à `afterQuery()` ajoute une Closure, et son exécution ne supprime pas l'enregistrement. Une nouvelle récupération avec le même builder la réexécute, et appeler deux fois le même scope enregistre aussi deux fois le callback. Cloner le builder transmet également les callbacks enregistrés.

Dans le code qui effectue plusieurs récupérations ou dans les workers de longue durée, créez une nouvelle requête pour chaque usage et limitez les callbacks à un traitement des résultats sans effet de bord. Si vous y placez l'envoi d'e-mails ou la persistance, on confond facilement les multiples exécutions dues aux chunks ou aux curseurs avec une seule opération métier.

Si vous fournissez des scopes réutilisables dans un package, indiquez aussi les méthodes de récupération prises en charge comme API publique et testez le contrat sur les types et le nombre d'appels.

## Tester les différences entre les chemins de récupération

Dans l'application Laravel 13 utilisatrice, vérifiez non seulement le traitement des clés, mais aussi les appels avec `first()`, `cursor()` et les pages vides.

L'exemple suivant suppose le modèle `Post` précédent et une migration de la table `posts` avec `id`, `title`, `body`, `created_at` et `updated_at`. [RefreshDatabase](https://kawax.biz/fr/database-testing.md) isole les enregistrements entre les tests.

Créez le test dans `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);
    }
}
```

Vous pouvez exécuter uniquement ce test.

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

## Liste de vérification pour la maintenance

* Indiquez les méthodes de récupération prises en charge par le scope, ainsi que les types du paramètre et de la valeur de retour de la Closure.
* Vérifiez séparément le résultat vide de `get()` et un `paginate()` dont le total est 0.
* Pour les chunks et les curseurs, confirmez que le nombre d'appels et l'ordre de récupération ne changent pas.
* Concevez le code pour qu'un double enregistrement de callback ou la réutilisation d'un builder ne provoque pas d'effet de bord métier.
* Lors des mises à jour de Laravel, revérifiez les chemins d'appel de `get()`, `pluck()`, `cursor()` et `BuildsQueries`.

## Pages associées

> [card] [Les scopes Eloquent](https://kawax.biz/fr/advanced/eloquent-scopes.md)
>
> Revoyez les bases pour regrouper des contraintes SQL dans des scopes locaux et globaux.

> [card] [Collections Eloquent](https://kawax.biz/fr/eloquent-collections.md)
>
> Revoyez la différence entre les Collections de modèles et les Support Collections.

> [card] [Observers Eloquent et événements de modèle](https://kawax.biz/fr/advanced/eloquent-observers.md)
>
> Revoyez les événements par modèle, distincts du traitement des résultats récupérés.

> [card] [Ressources API](https://kawax.biz/fr/eloquent-resources.md)
>
> Séparez la transformation des données renvoyées par l'API de l'enregistrement des modèles.

## Sources primaires consultées

* [Documentation officielle 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 d'intégration officiel de Laravel : AfterQueryTest](https://github.com/laravel/framework/blob/v13.40.0/tests/Integration/Database/AfterQueryTest.php)


## Related topics

- [Scopes Eloquent](https://kawax.biz/fr/advanced/eloquent-scopes.md)
- [Sujets avancés](https://kawax.biz/fr/advanced/index.md)
- [Stores de cache personnalisés et contrats pour les packages](https://kawax.biz/fr/advanced/package-cache-drivers.md)
- [Contrats](https://kawax.biz/fr/contracts.md)
- [Query Builder](https://kawax.biz/fr/query-builder.md)


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