Laravel 18 min de lecture

Construire un système de filtres/tri d’API réutilisable dans Laravel (sans package)

#laravel#filamentphp

On crée un mécanisme générique et sécurisé pour filtrer et trier des listes en API REST avec Eloquent, incluant relations, dates, opérateurs et pagination. Objectif: un QueryFilters plug-and-play, sans dépendances externes.

Construire un système de filtres/tri d’API réutilisable dans Laravel (sans package)

Créer un mécanisme de filtrage et de tri générique, sécurisé et réutilisable évite de réécrire du code dans chaque contrôleur. Dans ce tutoriel, on va définir une grammaire d’URL claire, implémenter une classe QueryFilters plug-and-play, couvrir les opérateurs courants, gérer les relations et intégrer le tout dans un endpoint REST Laravel. Le tout sans dépendances externes.

Objectif

L’objectif est de construire un composant unique qui sait traduire des paramètres d’URL (filter, sort, page) en contraintes Eloquent correctement typées et sécurisées. On veut gérer les égalités, ensembles, bornes numériques, recherches partielles, plages de dates, booléens, filtres relationnels, compteurs, enums, champs dérivés, ainsi que le tri multi-colonnes, y compris sur des relations. Le composant doit être réutilisable dans n’importe quel contrôleur et lever des erreurs 422 structurées quand la requête contient des clés non autorisées.

Objectif, prérequis et jeu de données d’exemple

Nous allons exposer l’endpoint GET /api/orders qui acceptera des filtres sur le statut, le montant et le client, ainsi que des tris multiples strictement whitelists. Le whitelisting est central: tout filtre ou tri non prévu doit être rejeté (filtres) ou ignoré (tris), afin de prévenir les injections et surcharges involontaires.

Il faut Laravel 10 ou 11, PHP 8.2+, et une base SQL (MySQL ou PostgreSQL). Une compréhension d’Eloquent est nécessaire, notamment les relations et les scopes.

Le modèle d’exemple Order possède les champs id, status (enum), total_cents (int), created_at, et une relation belongsTo vers Customer (id, email). Un schéma minimal pourrait ressembler à ceci:

// app/Enums/OrderStatus.php
namespace App\Enums;

enum OrderStatus: string
{
    case pending = 'pending';
    case paid = 'paid';
    case cancelled = 'cancelled';
}
// app/Models/Order.php
namespace App\Models;

use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    protected $casts = [
        'status' => OrderStatus::class,
        'created_at' => 'immutable_datetime',
    ];

    protected $fillable = ['status', 'total_cents', 'customer_id'];

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }
}
// app/Models/Customer.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Customer extends Model
{
    protected $fillable = ['email'];

    public function orders(): HasMany
    {
        return $this->hasMany(Order::class);
    }
}

Définir la grammaire d’URL pour filter/sort/pagination

On définit une grammaire simple et expressive afin de pouvoir compacter des requêtes complexes dans l’URL tout en gardant une validation stricte. Par exemple, on peut filtrer les statuts avec filter[status]=paid,cancelled, imposer un minimum sur le total avec filter[total][gte]=1000, ou chercher par email client via filter[customer.email]=@example.com. Pour les plages de dates, on encode filter[created_between][from]=2024-01-01 et filter[created_between][to]=2024-01-31, ce qui se traduit par un whereBetween inclusif sur created_at. Le tri multi-colonnes suit la convention sort=-created_at,total_cents, où le signe moins indique l’ordre décroissant. La pagination utilise page[number]=1&page[size]=25, avec des bornes 1..100 et une valeur par défaut définie côté serveur.

Les types doivent être normalisés de façon stricte: les booléens 'true'/'false'/'1'/'0' sont convertis en booléens PHP, les entiers et décimaux sont validés et castés, les tableaux sont séparés par virgules, et les dates doivent être en ISO 8601 (YYYY-MM-DD ou datetimes complets). Par exemple, /api/orders?filter[status]=paid,cancelled&sort=-created_at&page[number]=1&page[size]=25 est une requête valide et non ambiguë.

Créer une classe QueryFilters dédiée et réutilisable

La classe App/Support/QueryFilters centralise la lecture de la Request, la validation par whitelisting, l’application des filtres et du tri, et la gestion d’erreurs 422. Elle reçoit la Request, une map de résolveurs de filtres (clé autorisée -> closure) et une carte de tris autorisés (colonne ou closure). Elle expose une méthode apply(Builder $q) qui retourne le Builder pour pouvoir chaîner paginate ou get.

Voici une implémentation complète et réutilisable:

// app/Support/QueryFilters.php
namespace App\Support;

use Closure;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;

class QueryFilters
{
    public function __construct(
        protected Request $request,
        protected array $filterResolvers = [], // ['status' => fn(Builder $q, $value) => ...]
        protected array $sortHandlers = [],    // ['created_at' => null | Closure(Builder, 'asc'|'desc')]
        protected ?string $defaultSort = null  // ex: '-created_at'
    ) {}

    public function apply(Builder $q): Builder
    {
        $this->applyFilters($q);
        $this->applySorts($q);
        return $q;
    }

    protected function applyFilters(Builder $q): void
    {
        $raw = $this->request->query('filter', []);
        $flat = $this->flattenFilters($raw);

        $unknown = array_diff(array_keys($flat), array_keys($this->filterResolvers));
        if (!empty($unknown)) {
            $errors = [];
            foreach ($unknown as $key) {
                $errors[] = [
                    'source' => "filter.$key",
                    'detail' => 'clé non autorisée',
                ];
            }
            throw new HttpResponseException(response()->json(['errors' => $errors], 422));
        }

        foreach ($flat as $key => $value) {
            $resolver = $this->filterResolvers[$key] ?? null;
            if ($resolver instanceof Closure) {
                $resolver($q, $value);
            }
        }
    }

    protected function applySorts(Builder $q): void
    {
        $raw = (string) $this->request->query('sort', '');
        $parts = array_filter(array_map('trim', explode(',', $raw)));
        $applied = 0;

        foreach ($parts as $part) {
            $dir = Str::startsWith($part, '-') ? 'desc' : 'asc';
            $name = ltrim($part, '-+');

            $handler = $this->sortHandlers[$name] ?? null;

            // handler peut être: null -> tri simple sur colonne; Closure -> tri personnalisé
            if (array_key_exists($name, $this->sortHandlers)) {
                if ($handler instanceof Closure) {
                    $handler($q, $dir);
                } else {
                    // Tri simple: on présume que $name est une colonne pleinement qualifiée ou sûre
                    $q->orderBy($name, $dir);
                }
                $applied++;
            }
        }

        if ($applied === 0 && $this->defaultSort) {
            $dir = Str::startsWith($this->defaultSort, '-') ? 'desc' : 'asc';
            $name = ltrim($this->defaultSort, '-+');
            $handler = $this->sortHandlers[$name] ?? null;

            if (array_key_exists($name, $this->sortHandlers)) {
                if ($handler instanceof Closure) {
                    $handler($q, $dir);
                } else {
                    $q->orderBy($name, $dir);
                }
            }
        }
    }

    protected function flattenFilters(array $raw): array
    {
        // Convertit filter[total][gte]=100 en 'total.gte' => 100
        // et filter[status]=paid,cancelled en 'status' => ['paid', 'cancelled']
        $flat = [];

        foreach ($raw as $key => $value) {
            if (is_array($value)) {
                // cas nested ex: ['total' => ['gte' => '1000']]
                foreach ($value as $subKey => $subValue) {
                    $compound = "$key.$subKey";
                    $flat[$compound] = $this->normalizeValue($compound, $subValue);
                }
            } else {
                $flat[$key] = $this->normalizeValue($key, $value);
            }
        }

        return $flat;
    }

    protected function normalizeValue(string $key, mixed $value): mixed
    {
        if (is_array($value)) {
            return array_map(fn($v) => $this->normalizeScalar($v), $value);
        }

        // valeur scalar potentiellement "a,b,c"
        if (is_string($value) && str_contains($value, ',')) {
            return array_map(fn($v) => $this->normalizeScalar($v), array_map('trim', explode(',', $value)));
        }

        return $this->normalizeScalar($value);
    }

    protected function normalizeScalar(mixed $v): mixed
    {
        if (is_bool($v) || is_int($v) || is_float($v)) {
            return $v;
        }

        if (!is_string($v)) {
            return $v;
        }

        $lower = strtolower($v);
        if ($lower === 'true' || $v === '1') {
            return true;
        }
        if ($lower === 'false' || $v === '0') {
            return false;
        }

        // integer strict
        if (preg_match('/^-?\d+$/', $v)) {
            return (int) $v;
        }

        // decimal strict
        if (preg_match('/^-?\d+\.\d+$/', $v)) {
            return (float) $v;
        }

        // date ISO simple "YYYY-MM-DD" ou datetime ISO
        if (preg_match('/^\d{4}-\d{2}-\d{2}(T.*)?$/', $v)) {
            try {
                return CarbonImmutable::parse($v);
            } catch (\Throwable) {
                // on laisse en string si parse échoue; validation métier plus loin
            }
        }

        return $v;
    }
}

Dans cette version, la QueryFilters ne connaît rien de vos colonnes ni de vos relations. Elle délègue tout le travail à des closures de filtrage et de tri que vous fournissez. Elle s’assure que seules les clés déclarées sont acceptées et qu’un tri par défaut est appliqué si nécessaire.

Implémenter les opérateurs de base (eq/in/gte/lt/like/date/bool)

Pour rendre l’écriture des résolveurs agréable, créez une petite collection de helpers dans un trait ou directement dans un fichier utilitaire. Voici des exemples de closures avec Eloquent pour couvrir les opérateurs standards.

Égalité et ensembles: un statut unique utilise where, une liste de statuts utilise whereIn. Par exemple, un résolveur 'status' peut accepter soit une string, soit un tableau.

// Exemple de résolveur 'status'
'status' => function (Builder $q, mixed $value) {
    $column = 'orders.status';
    if (is_array($value)) {
        $q->whereIn($column, $value);
    } else {
        $q->where($column, '=', $value);
    }
},

Bornes numériques: pour total_cents, on expose des sous-clés gte/gt/lte/lt. Ces clés sont explicitement whitelistées et convertissent les décimaux en entiers si vous exposez aussi un filtre en euros.

// total.gte / total.gt / total.lte / total.lt sur total_cents
'total.gte' => fn(Builder $q, $v) => $q->where('orders.total_cents', '>=', (int) $v),
'total.gt'  => fn(Builder $q, $v) => $q->where('orders.total_cents', '>',  (int) $v),
'total.lte' => fn(Builder $q, $v) => $q->where('orders.total_cents', '<=', (int) $v),
'total.lt'  => fn(Builder $q, $v) => $q->where('orders.total_cents', '<',  (int) $v),

Recherche partielle: pour un LIKE insensible à la casse, on normalise le terme et on échappe les wildcards. Sur PostgreSQL, on peut utiliser ILIKE, sur MySQL on force un collation insensitive.

// like case-insensitive sécurisé
function likeTerm(string $term): string {
    $term = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $term);
    return "%$term%";
}

// Exemple: 'customer.email' pour un filtre direct (égal ou partiel)
'customer.email' => function (Builder $q, mixed $value) {
    $term = is_string($value) ? $value : (string) $value;
    $q->whereHas('customer', function (Builder $cq) use ($term) {
        $cq->where('customers.email', 'LIKE', likeTerm($term));
    });
},

Plages de dates: pour created_between, on valide la présence de from et to, on s’assure que from <= to, et on utilise whereBetween inclusif en UTC.

'created_between' => function (Builder $q, mixed $value) {
    $from = $value['from'] ?? null;
    $to   = $value['to'] ?? null;

    if (!$from || !$to) {
        throw new \Illuminate\Http\Exceptions\HttpResponseException(
            response()->json(['errors' => [[
                'source' => 'filter.created_between',
                'detail' => 'from et to sont requis'
            ]]], 422)
        );
    }

    if ($from > $to) {
        throw new \Illuminate\Http\Exceptions\HttpResponseException(
            response()->json(['errors' => [[
                'source' => 'filter.created_between',
                'detail' => 'from doit être antérieur ou égal à to'
            ]]], 422)
        );
    }

    // CarbonImmutable déjà renvoyé par normalizeScalar si au format ISO
    $q->whereBetween('orders.created_at', [$from, $to]);
},

Booléens: les chaînes 'true', 'false', '1', '0' sont converties en bool strict. On peut ensuite appliquer whereHas/whereDoesntHave ou un simple where sur une colonne booléenne.

'has_items' => function (Builder $q, mixed $value) {
    $bool = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
    if ($bool === null) {
        throw new \Illuminate\Http\Exceptions\HttpResponseException(
            response()->json(['errors' => [[
                'source' => 'filter.has_items',
                'detail' => 'valeur booléenne invalide'
            ]]], 422)
        );
    }

    if ($bool) {
        $q->whereHas('items');
    } else {
        $q->whereDoesntHave('items');
    }
},

Filtres sur relations et champs dérivés

La dot-notation pour les relations permet d’écrire des filtres comme customer.email avec whereHas('customer', ...). Cet usage limite la profondeur à une seule relation pour éviter les requêtes trop coûteuses. Par exemple, filter[customer.email]=@example.com se traduit par un whereHas sur la relation customer, et l’email est comparé en LIKE insensible à la casse si on autorise la recherche partielle.

Pour des compteurs, on peut filtrer sur items_count en ajoutant withCount('items') puis en appliquant un having. Par exemple, filter[items_count.gte]=3 activera withCount('items') et ajoutera having('items_count', '>=', 3) sans modifier la sélection principale.

Les enums PHP 8.1, comme OrderStatus, se castent nativement par Laravel. On peut alors valider que la valeur envoyée fait partie de la liste blanche et filtrer sur la colonne status en toute sécurité. Par exemple, si le client envoie filter[status]=paid,cancelled, on s’assure que chaque valeur est dans OrderStatus::cases() avant de construire le whereIn.

Pour les champs dérivés, exposer total_eur en lecture tout en filtrant sur total_cents est pratique. Par exemple, filter[total_eur.gte]=12.34 est converti en cents via round(12.34 * 100) puis appliqué sur total_cents. Cela permet d’offrir une API plus ergonomique sans perdre la précision interne.

// items_count.gte via withCount + having
'items_count.gte' => function (Builder $q, mixed $value) {
    $q->withCount('items')->having('items_count', '>=', (int) $value);
},

// enum validation sur status
'status' => function (Builder $q, mixed $value) {
    $allowed = array_map(fn($c) => $c->value, \App\Enums\OrderStatus::cases());
    $vals = is_array($value) ? $value : [$value];
    foreach ($vals as $v) {
        if (!in_array($v, $allowed, true)) {
            throw new \Illuminate\Http\Exceptions\HttpResponseException(
                response()->json(['errors' => [[
                    'source' => 'filter.status',
                    'detail' => "valeur '$v' invalide"
                ]]], 422)
            );
        }
    }
    if (count($vals) > 1) {
        $q->whereIn('orders.status', $vals);
    } else {
        $q->where('orders.status', '=', $vals[0]);
    }
},

// total_eur.gte converti en cents
'total_eur.gte' => function (Builder $q, mixed $value) {
    $float = is_numeric($value) ? (float) $value : 0.0;
    $cents = (int) round($float * 100);
    $q->where('orders.total_cents', '>=', $cents);
},

Tri multi-colonnes et cas avancés

Le tri se déclare via sort=-created_at,total_cents et s’applique dans l’ordre fourni, de façon stable. On whitelist explicitement les clés triables, et toute clé non autorisée est silencieusement ignorée. Par défaut, si aucun sort n’est fourni, on trie par -created_at.

Le tri sur une relation comme customer.email se fait proprement avec un JOIN ciblé ou un sous-select. Par exemple, un leftJoin sur customers avec une orderBy('customers.email') est suffisant pour une relation belongsTo, sans sélectionner les colonnes dupliquées. Voici un handler de tri relationnel:

// handler de tri: customer.email
'customer.email' => function (Builder $q, string $dir) {
    // On évite de polluer la sélection: Eloquent sélectionne 'orders.*' par défaut
    $q->leftJoin('customers', 'customers.id', '=', 'orders.customer_id')
      ->orderBy('customers.email', $dir)
      ->select('orders.*'); // on s'assure que seule la table principale est sélectionnée
},

Pour un tri calculé, par exemple prioriser certains statuts, on utilise un CASE WHEN déterministe avec des valeurs whitelists. Ce type de tri ne prend pas d’entrée utilisateur brute; on l’expose sous un nom logique comme status_priority.

// tri calculé via CASE WHEN
'status_priority' => function (Builder $q, string $dir) {
    // priorité: paid (1), pending (2), cancelled (3)
    $case = "CASE orders.status
                WHEN 'paid' THEN 1
                WHEN 'pending' THEN 2
                WHEN 'cancelled' THEN 3
             END";
    $q->orderByRaw("$case $dir");
},

Intégration dans le contrôleur et format de réponse

On intègre QueryFilters dans le contrôleur OrdersController en définissant la map des filtres autorisés et des tris autorisés. On applique ensuite paginate avec un page[size] borné et une valeur par défaut.

// app/Http/Controllers/OrdersController.php
namespace App\Http\Controllers;

use App\Models\Order;
use App\Support\QueryFilters;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Pagination\LengthAwarePaginator;

class OrdersController extends BaseController
{
    public function index(Request $request)
    {
        // Spécification des filtres autorisés
        $filters = [
            'status'             => function ($q, $v) {
                $allowed = array_map(fn($c) => $c->value, \App\Enums\OrderStatus::cases());
                $vals = is_array($v) ? $v : [$v];
                foreach ($vals as $vv) {
                    if (!in_array($vv, $allowed, true)) {
                        abort(response()->json(['errors' => [[
                            'source' => 'filter.status',
                            'detail' => "valeur '$vv' invalide"
                        ]]], 422));
                    }
                }
                if (count($vals) > 1) $q->whereIn('orders.status', $vals);
                else $q->where('orders.status', '=', $vals[0]);
            },
            'total.gte'          => fn($q, $v) => $q->where('orders.total_cents', '>=', (int) $v),
            'total.lt'           => fn($q, $v) => $q->where('orders.total_cents', '<',  (int) $v),
            'customer.email'     => function ($q, $v) {
                $term = is_string($v) ? $v : (string) $v;
                $pattern = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $term) . '%';
                $q->whereHas('customer', fn($cq) => $cq->where('customers.email', 'LIKE', $pattern));
            },
            'created_between'    => function ($q, $v) {
                $from = $v['from'] ?? null;
                $to   = $v['to'] ?? null;
                if (!$from || !$to || $from > $to) {
                    abort(response()->json(['errors' => [[
                        'source' => 'filter.created_between',
                        'detail' => 'from/to invalides ou inversés'
                    ]]], 422));
                }
                $q->whereBetween('orders.created_at', [$from, $to]);
            },
            'has_items'          => function ($q, $v) {
                $bool = filter_var($v, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
                if ($bool === null) {
                    abort(response()->json(['errors' => [[
                        'source' => 'filter.has_items',
                        'detail' => 'valeur booléenne invalide'
                    ]]], 422));
                }
                $bool ? $q->whereHas('items') : $q->whereDoesntHave('items');
            },
            'items_count.gte'    => function ($q, $v) {
                $q->withCount('items')->having('items_count', '>=', (int) $v);
            },
            'total_eur.gte'      => function ($q, $v) {
                $cents = (int) round(((float) $v) * 100);
                $q->where('orders.total_cents', '>=', $cents);
            },
            // Alias composite: search -> ID exact ou email client partiel
            'search'             => function ($q, $v) {
                $term = trim((string) $v);
                $q->where(function ($qq) use ($term) {
                    if (preg_match('/^\d+$/', $term)) {
                        $qq->where('orders.id', (int) $term);
                    }
                    $pattern = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $term) . '%';
                    $qq->orWhereHas('customer', fn($cq) => $cq->where('customers.email', 'LIKE', $pattern));
                });
            },
        ];

        // Tris autorisés
        $sorts = [
            'created_at'     => null, // tri simple sur orders.created_at
            'total_cents'    => null,
            'customer.email' => function ($q, $dir) {
                $q->leftJoin('customers', 'customers.id', '=', 'orders.customer_id')
                  ->orderBy('customers.email', $dir)
                  ->select('orders.*');
            },
            'status_priority' => function ($q, $dir) {
                $q->orderByRaw(
                    "CASE orders.status WHEN 'paid' THEN 1 WHEN 'pending' THEN 2 WHEN 'cancelled' THEN 3 END $dir"
                );
            },
        ];

        $filtersEngine = new QueryFilters(
            request: $request,
            filterResolvers: $filters,
            sortHandlers: $sorts,
            defaultSort: '-created_at'
        );

        $size = (int) $request->integer('page.size', 25);
        $size = max(1, min($size, 100)); // bornes 1..100
        $page = (int) $request->integer('page.number', 1);
        $page = max(1, $page);

        $query = Order::query()->with('customer');
        $filtersEngine->apply($query);

        /** @var LengthAwarePaginator $paginator */
        $paginator = $query->paginate($size, ['*'], 'page[number]', $page)->appends($request->query());

        return response()->json([
            'data' => $paginator->items(),
            'meta' => [
                'total' => $paginator->total(),
                'page'  => $paginator->currentPage(),
                'size'  => $paginator->perPage(),
            ],
            'links' => [
                'self' => $paginator->url($paginator->currentPage()),
                'next' => $paginator->nextPageUrl(),
                'prev' => $paginator->previousPageUrl(),
            ],
        ]);
    }
}

On déclare enfin la route API:

// routes/api.php
use App\Http\Controllers\OrdersController;
use Illuminate\Support\Facades\Route;

Route::get('/orders', [OrdersController::class, 'index']);

Une requête invalide comme /api/orders?filter[unknown]=x renverra un 422 avec un corps { "errors": [ { "source": "filter.unknown", "detail": "clé non autorisée" } ] }, alors que des tris non whitelistés seront simplement ignorés.

Sécurité et performance

La sécurité commence par l’utilisation exclusive des bindings d’Eloquent, sans interpolation brute provenant de l’utilisateur. Chaque filtre et tri doit être explicitement whitelisté. Le fait de lever une 422 pour des filtres inconnus rend le comportement prévisible côté client et évite les surprises.

Côté performance, il est crucial d’indexer les colonnes fréquemment utilisées dans les clauses WHERE et ORDER BY, comme orders.status, orders.created_at, orders.customer_id et orders.total_cents, ainsi que customers.email si vous triez/filtrez dessus. Il faut également limiter la profondeur relationnelle des filtres (par exemple une relation maximum) et restreindre le nombre de jointures pour prévenir les explosions combinatoires. Des bornes raisonnables sur page[size] et la cardinalité des opérateurs IN réduisent le risque d’attaques par amplification. Enfin, les timeouts et des budgets de requêtes mesurés par instrumentation aident à repérer les cas pathologiques.

Tests rapides et exemples concrets

Des tests Pest rendent la grammaire robuste. On peut écrire des tests paramétrés pour chaque opérateur. Par exemple, pour eq/in/gte/lt/like/date/bool, on crée des ordres en usine, puis on requête l’API avec des filtres et on vérifie la réponse.

// tests/Feature/OrdersFilteringTest.php
use App\Enums\OrderStatus;
use App\Models\Customer;
use App\Models\Order;
use function Pest\Laravel\getJson;

it('filtre par status in', function () {
    Order::factory()->create(['status' => OrderStatus::paid]);
    Order::factory()->create(['status' => OrderStatus::cancelled]);
    Order::factory()->create(['status' => OrderStatus::pending]);

    $resp = getJson('/api/orders?filter[status]=paid,cancelled')
        ->assertOk()
        ->json();

    expect(collect($resp['data']))->toHaveCount(2);
});

it('applique total.gte et total.lt', function () {
    Order::factory()->create(['total_cents' => 500]);
    Order::factory()->create(['total_cents' => 1500]);

    $resp = getJson('/api/orders?filter[total][gte]=1000&filter[total][lt]=2000')
        ->assertOk()
        ->json();

    expect(collect($resp['data']))->toHaveCount(1);
});

it('filtre par customer.email partiel', function () {
    $c1 = Customer::factory()->create(['email' => 'alice@example.com']);
    $c2 = Customer::factory()->create(['email' => 'bob@other.com']);
    Order::factory()->for($c1)->create();
    Order::factory()->for($c2)->create();

    $resp = getJson('/api/orders?filter[customer.email]=@example.com')
        ->assertOk()
        ->json();

    expect(collect($resp['data']))->toHaveCount(1);
});

it('rejette un filtre non whitelisté', function () {
    getJson('/api/orders?filter[hack]=1')
        ->assertStatus(422)
        ->assertJson([
            'errors' => [[
                'source' => 'filter.hack',
                'detail' => 'clé non autorisée'
            ]]
        ]);
});

it('tri multi-colonnes', function () {
    Order::factory()->create(['total_cents' => 100, 'created_at' => now()->subDay()]);
    Order::factory()->create(['total_cents' => 200, 'created_at' => now()]);

    $resp = getJson('/api/orders?sort=-created_at,total_cents')->assertOk()->json();
    // On s'assure que l'élément le plus récent est premier
    expect($resp['data'][0]['total_cents'])->toBe(200);
});

Pour essayer manuellement, voici quelques requêtes curl/HTTPie:

# Statuts multiples + tri décroissant par date
curl -s "http://localhost/api/orders?filter[status]=paid,cancelled&sort=-created_at"

# Plage de dates + pagination
http GET :/api/orders filter[created_between][from]==2024-01-01 filter[created_between][to]==2024-01-31 page[number]==2 page[size]==25

# Recherche composite: ID ou email
curl -s "http://localhost/api/orders?filter[search]=alice"

# Filtres relationnels et compteurs
http GET :/api/orders filter[customer.email]==@example.com filter[items_count.gte]==3

# Tri sur relation
http GET :/api/orders sort==customer.email

Bonus: factorisation et packaging interne

Une fois la QueryFilters en place, on peut factoriser davantage avec une macro sur le Builder. Cette macro encapsule la création et l’application du moteur de filtres pour réduire le bruit dans les contrôleurs.

// app/Providers/AppServiceProvider.php (boot)
use App\Support\QueryFilters;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;

Builder::macro('filter', function (Request $request, array $resolvers, array $sorts, ?string $defaultSort = null) {
    (new QueryFilters($request, $resolvers, $sorts, $defaultSort))->apply($this);
    return $this;
});

On peut également créer une classe OrdersFilters qui expose les maps dédiées, afin de centraliser la spécification par ressource et de la réutiliser dans les tests et la documentation.

// app/Filters/OrdersFilters.php
namespace App\Filters;

use Illuminate\Database\Eloquent\Builder;

class OrdersFilters
{
    public static function resolvers(): array
    {
        return [
            // ... mêmes closures que dans le contrôleur
        ];
    }

    public static function sorts(): array
    {
        return [
            'created_at'      => null,
            'total_cents'     => null,
            'customer.email'  => function (Builder $q, string $dir) {
                $q->leftJoin('customers', 'customers.id', '=', 'orders.customer_id')
                  ->orderBy('customers.email', $dir)
                  ->select('orders.*');
            },
            'status_priority' => function (Builder $q, string $dir) {
                $q->orderByRaw(
                    "CASE orders.status WHEN 'paid' THEN 1 WHEN 'pending' THEN 2 WHEN 'cancelled' THEN 3 END $dir"
                );
            },
        ];
    }

    public static function defaultSort(): string
    {
        return '-created_at';
    }
}

Dans le contrôleur, on réduit alors l’intégration à une seule ligne tap:

use App\Filters\OrdersFilters;

$query = Order::query()->with('customer')
    ->tap(fn($q) => $q->filter($request, OrdersFilters::resolvers(), OrdersFilters::sorts(), OrdersFilters::defaultSort()));

Pour la documentation, générer un schéma OpenAPI/Swagger décrivant filter, sort et page est simple puisque la grammaire est déterministe. On peut décrire filter comme un objet avec des propriétés whitelistées (status, total.gte, created_between.from, created_between.to, etc.) et documenter les formats (ISO 8601 pour created_between).

Enfin, instrumenter avec des logs et des métriques permet d’observer la latence moyenne, les clés de filtres les plus utilisées, la taille des IN, et les occurrences de 422 dues aux clés non autorisées. Un middleware léger qui mesure le temps et inspecte les paramètres peut suffire pour alimenter vos dashboards internes.

Checklist

Avant de publier, il est utile de relire le code pour vérifier la cohérence des noms de colonnes, d’exécuter les tests automatisés pour chaque opérateur et cas relationnel, et de tester manuellement quelques requêtes représentatives avec curl ou HTTPie. Une fois validé, vous pouvez merger et déployer, puis surveiller les métriques afin d’ajuster la whitelist, les index et les limites si besoin.

Ressources

La documentation Eloquent de Laravel décrit en détail whereHas, withCount, orderBy et la pagination, et constitue une référence précieuse pour approfondir les opérations avancées sur le Query Builder. Les pages officielles de Laravel sur la validation, les réponses JSON et les exceptions expliquent comment retourner proprement des erreurs 422 structurées. Les spécifications OpenAPI et Swagger aident à formaliser la grammaire d’URL et à générer des clients. Enfin, Pest fournit une syntaxe expressive pour écrire des tests rapides et lisibles qui couvrent toutes les branches de votre système de filtres.

Conclusion

Avec une grammaire d’URL claire, une validation stricte et une implémentation centrée sur des résolveurs whitelists, vous obtenez un système de filtres et de tri d’API robuste, sécurisé et facile à maintenir. La classe QueryFilters isole la complexité et se réutilise partout, tout en restant flexible pour couvrir les relations, les champs dérivés, les enums et les tris avancés. En ajoutant des tests et un peu d’instrumentation, vous aurez une base solide pour faire évoluer vos APIs sans dépendre d’un package externe.