EasyAdmin 5 embarque quatorze filtres natifs, de TextFilter à NullFilter en passant par les spécialisés (CountryFilter, TimezoneFilter). Ils couvrent l'écrasante majorité des besoins. Quand ils ne suffisent pas, on écrit un filtre custom : deux fichiers, une interface, un FormType. Le cas concret de ce billet est tiré du repo de ce blog : FeaturedPostFilter, le filtre « À la une » de la liste des billets. Il a l'avantage de contenir les deux niveaux de difficulté dans une seule classe : deux états binaires en IS NULL (le cas d'école) et une plage de dates active avec OR logique et paramètre préparé (le cas avancé).
Quand un filtre natif suffit
Avant d'écrire un custom, vérifier que les natifs ne couvrent pas le cas. Sur ce projet, le filtre par statut d'un Post passe par ChoiceFilter natif paramétré avec un enum. Code observé dans PostCrudController :
#[\Override]
public function configureFilters(Filters $filters): Filters
{
return parent::configureFilters($filters)
->add(
ChoiceFilter::new('status', 'Statut')
->setChoices([
'En ligne' => ContentStatus::ONLINE->value,
'Brouillon' => ContentStatus::DRAFT->value,
'Planifié' => ContentStatus::SCHEDULED->value,
]),
)
->add(FeaturedPostFilter::new())
;
}ChoiceFilter accepte un mapping libellé vers valeur, EasyAdmin construit le <select> et fait le WHERE lui-même. Aucun code à écrire au-delà de cette déclaration. La dernière ligne ajoute le filtre custom qui nous occupe : on y revient.
La règle : si le filtre se ramène à un WHERE column = value avec une liste finie de valeurs, le natif suffit. Le piège classique : croire qu'un binaire « défini / non défini » impose un custom. Non, NullFilter fait exactement ça sur une propriété nullable.
Le custom devient nécessaire quand la valeur du filtre ne correspond pas directement à une colonne, ou quand le WHERE implique plusieurs colonnes ou des opérateurs combinés. C'est précisément le cas « À la une » : le statut featured n'est pas un booléen mais le couple (featuredAt, featuredUntil). « Actuellement à la une », ça se dit featuredAt <= NOW() ET (featuredUntil IS NULL OU featuredUntil >= NOW()). Aucun filtre natif ne sait exprimer ça.
Le contrat : FilterInterface et FilterTrait
Tout filtre custom implémente EasyCorp\Bundle\EasyAdminBundle\Contracts\Filter\FilterInterface et utilise EasyCorp\Bundle\EasyAdminBundle\Filter\FilterTrait. L'interface impose la méthode apply(), qui greffe la condition dans le QueryBuilder. Le trait fournit les setters standard (setFilterFqcn, setProperty, setLabel, setFormType) pour configurer le filtre à la construction.
Pas de constructeur. À la place, une méthode statique nommée new() joue le rôle de factory et chaîne les setters. Code observé dans src/Controller/Admin/Filter/FeaturedPostFilter.php :
final class FeaturedPostFilter implements FilterInterface
{
use FilterTrait;
public static function new(string $propertyName = 'featuredAt', ?string $label = 'À la une'): self
{
return new self()
->setFilterFqcn(self::class)
->setProperty($propertyName)
->setLabel($label)
->setFormType(FeaturedPostFilterType::class)
;
}
// ... apply() ...
}Quatre setters obligatoires. setFilterFqcn(self::class) indique à EasyAdmin la classe du filtre (utilisée pour l'URL et le routing). setProperty($propertyName) désigne la propriété de l'entité ciblée, featuredAt par défaut ici. setLabel() est le titre affiché dans la sidebar des filtres. setFormType() pointe vers la classe FormType qui rend le champ de saisie côté UI.
Au passage : le new self()->setFilterFqcn(...) sans parenthèses autour du new n'est pas une coquille, c'est du PHP 8.4+.
apply(), la méthode pivot
C'est la seule méthode imposée par FilterInterface. Signature exacte :
#[\Override]
public function apply(
QueryBuilder $queryBuilder,
FilterDataDto $filterDataDto,
?FieldDto $fieldDto,
EntityDto $entityDto,
): voidQuatre paramètres injectés par EasyAdmin :
$queryBuilder: le QueryBuilder Doctrine en cours de construction, sur lequel on greffe nos clauses viaandWhere().$filterDataDto: la valeur saisie par l'utilisateur (getValue()), l'alias de l'entité racine (getEntityAlias()), et le nom de la propriété (getProperty()).$fieldDto: métadonnées du champ visuel correspondant côté liste (nullable, pas toujours pertinent).$entityDto: métadonnées de l'entité (FQCN, clé primaire, etc.).
Le pattern qui revient : récupérer la valeur, valider qu'elle a un sens, puis brancher la clause SQL. apply() retourne void et modifie le QueryBuilder par effet de bord.
Les deux états simples : IS NULL / IS NOT NULL
Le début de la méthode couvre les deux premiers choix du filtre, « Jamais à la une » et « Déjà passé à la une ». Deux états, deux clauses SQL :
#[\Override]
public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void
{
$value = $filterDataDto->getValue();
if (!\is_string($value) || $value === '') {
return;
}
$alias = $filterDataDto->getEntityAlias();
if ($value === 'never') {
$queryBuilder->andWhere(\sprintf('%s.featuredAt IS NULL', $alias));
} elseif ($value === 'ever') {
$queryBuilder->andWhere(\sprintf('%s.featuredAt IS NOT NULL', $alias));
}
// ... le cas 'current', section suivante ...
}Trois choses à noter.
Validation explicite de la valeur. Le if (!is_string($value) || $value === '') couvre le cas où aucun choix n'a été fait dans le <select> (placeholder « Tous », valeur vide). On retourne tôt sans toucher au QueryBuilder. EasyAdmin garantit que getValue() retourne null ou la valeur saisie, mais le check is_string ajoute une couche de sécurité, utile si on hérite plus tard d'un FormType qui retourne autre chose.
Alias dynamique, colonnes en dur. On récupère $filterDataDto->getEntityAlias() au lieu de hardcoder 'entity' : EasyAdmin peut changer l'alias selon le contexte (sous-requêtes, jointures internes), et un hardcode peut marcher aujourd'hui et casser dans deux versions du bundle. Les noms de colonnes, eux, restent en dur dans le SQL : ce filtre est intrinsèquement lié au couple (featuredAt, featuredUntil). Le setProperty() de la factory sert au routage EasyAdmin, pas à la clause.
sprintf sans paramètre préparé. Pas de :value qu'on binderait via setParameter() : les deux clauses possibles (IS NULL, IS NOT NULL) ne contiennent aucune entrée utilisateur. Le risque d'injection SQL est nul, l'utilisateur ne contrôle ni $alias ni les colonnes, et ne peut envoyer que never, ever ou current, validés en amont par le FormType. Dès qu'une valeur dynamique entre dans la clause, on passe par setParameter(). C'est le cas juste en dessous.
Détail qui a son importance sur ce projet : if/elseif plutôt qu'un match, et ce n'est pas un oubli. La QA tourne en PHPStan max avec shipmonk/phpstan-rules, dont forbidUnusedMatchResult. Or andWhere() est appelé pour son effet de bord, on n'utilise pas sa valeur de retour : un match dont le résultat part à la poubelle déclencherait la règle, et capturer le résultat pour rien se ferait épingler par Rector. L'if/elseif reste lisible (des branches alternatives simples) et passe les linters.
L'état avancé : la plage active
Le troisième choix, « À la une maintenant », est celui qui justifie le filtre custom. La suite de la méthode :
} elseif ($value === 'current') {
$paramNow = 'featuredFilterNow_' . uniqid();
$queryBuilder
->andWhere(\sprintf('%s.featuredAt IS NOT NULL', $alias))
->andWhere(\sprintf('%s.featuredAt <= :%s', $alias, $paramNow))
->andWhere(\sprintf('(%s.featuredUntil IS NULL OR %s.featuredUntil >= :%s)', $alias, $alias, $paramNow))
->setParameter($paramNow, new \DateTimeImmutable())
;
}Trois points d'intérêt par rapport au cas d'école.
La valeur dynamique passe en paramètre préparé. NOW() côté PHP, c'est un DateTimeImmutable bindé via setParameter(). La règle posée en section 4 s'applique dès la première valeur qui n'est pas structurelle.
Le uniqid() dans le nom du paramètre. Le SQL généré contient :featuredFilterNow_5e8a9b0c4d3f1 plutôt que :featuredFilterNow. Raison : si plusieurs filtres ou conditions bindaient un paramètre du même nom (rare mais possible avec des jointures ou des sous-requêtes), il y aurait collision dans le QueryBuilder. Avec uniqid(), chaque invocation est unique. Coût négligeable, sécurité d'usage.
L'OR logique inline. La troisième condition (%s.featuredUntil IS NULL OR %s.featuredUntil >= :%s) tient dans un seul andWhere() avec des parenthèses. Si on l'écrivait en deux andWhere() séparés, Doctrine connecterait les deux par AND, et featuredUntil IS NULL AND featuredUntil >= NOW() est vide par construction. Les parenthèses sont obligatoires pour grouper l'OR avant que le AND global s'applique.
Le FilterType côté formulaire
Le FormType vit dans src/Form/Type/Admin/FeaturedPostFilterType.php. Il décrit le champ de saisie qu'EasyAdmin rend dans la sidebar. Code intégral :
final class FeaturedPostFilterType extends AbstractType
{
#[\Override]
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'choices' => [
'Jamais à la une' => 'never',
'Déjà passé à la une' => 'ever',
'À la une maintenant' => 'current',
],
'placeholder' => 'Tous',
]);
}
#[\Override]
public function getParent(): string
{
return ChoiceType::class;
}
}Deux choses qui méritent un mot.
extends AbstractType et getParent() vers ChoiceType, pas extends ChoiceType. C'est le pattern Symfony Form classique pour composer plutôt qu'hériter d'un type concret. On récupère les comportements de ChoiceType (rendu <select>, normalisation des choix, validation) sans réimplémenter buildForm() ni buildView(). Tout ce qu'on customise se passe dans configureOptions().
placeholder => 'Tous'. C'est la première option du <select>, celle qui correspond à « aucun filtre actif ». Sa valeur soumise est vide (''), c'est ce que le early return d'apply() détecte pour ne rien faire. Sans placeholder, EasyAdmin présenterait une option vide non labellisée, ce qui surprend les utilisateurs.
Bonus qui ne se voit pas dans la sidebar : un filtre custom devient une URL. Sur ce projet, le dashboard admin pose des deep links vers la liste pré-filtrée, et le bandeau « À la une » pointe sur la valeur ever. Trois options bien nommées, c'est aussi trois liens partageables.
Brancher le filtre dans un CrudController
Une fois le filtre et son FormType écrits, le branchement tient en une ligne, déjà vue en section 1 :
->add(FeaturedPostFilter::new())Pas de configuration au point d'usage : toute la config vit dans la factory new() du filtre lui-même. Et l'appel à parent::configureFilters($filters) garde la chaîne d'héritage saine : si demain le parent abstract des trois CRUDs content (Post, Page, Category) déclare des filtres communs, PostCrudController les héritera sans y toucher.
Le mot de la fin
Deux limites pratiques à connaître.
La première : apply() retourne void et modifie le QueryBuilder par effet de bord. Rien n'empêche d'y chaîner trois andWhere() qui font chacun une jointure différente, ou d'ajouter un setMaxResults() au milieu d'un filtre. C'est l'API Doctrine pure : utile à savoir, dangereux à pratiquer sans avoir mesuré au profiler.
La seconde : la valeur reçue dans $filterDataDto->getValue() est typée string (elle vient d'un formulaire HTML). Si on veut la traiter en int, en DateTimeImmutable ou en enum, c'est à nous de caster, et de gérer le cas où le cast échoue. EasyAdmin n'enchaîne pas de transformer entre le FormType et le filtre, contrairement à un FormType utilisé dans un contrôleur classique.
Le QueryBuilder qu'on récupère dans apply() est le même que celui de Doctrine, ce qui veut dire qu'on peut tout faire. Y compris se tirer une balle dans le pied avec une requête N+1 par filtre activé.
La visite ne s'arrête pas là. Prochain morceau : les champs custom complets via BlocksField, là où on sort pour de bon des sentiers battus d'EasyAdmin.
Une coquille, une erreur dans ce billet ? Signale-la-moi.