Sonata Admin 4.x — patterns Kohinos, breaking changes, sécurité
Sonata Admin 4.x apporte plusieurs breaking changes vis-à-vis de la 3.x
(suppression de $this->trans(), DatagridMapper rendu final,
ProxyQueryInterface raboté, configuration par attributs PHP). Cette page documente
les patterns Kohinos pour écrire ou maintenir un Admin sans retomber dans les pièges détectés
pendant la migration SF4 → SF7.
Carte des Admins Kohinos
Tous les Admins du projet héritent de App\Admin\KohinosAdmin (lui-même
extends AbstractAdmin), ce qui mutualise translator, security, max-per-page et
le rendu du bouton create. Liste non exhaustive :
| Admin | Entité | Actions custom notables | Rôle requis (typique) |
|---|---|---|---|
DonAdmin | Don (STI DonAdherent/DonPrestataire) | create?expediteur_type=…, dropdown create | ROLE_TRESORIER / ROLE_SUPER_ADMIN |
AdherentAdmin | Adherent | Batch actions, vue show riche | ROLE_ADMIN_ADHERENT_* |
PrestataireAdmin | Prestataire | Batch (validation, mise hors-ligne), routes custom | ROLE_ADMIN_PRESTATAIRE_* |
SolidoumeParamAdmin | SolidoumeParameter | Batch + route execute | ROLE_SOLIDOUME |
SepaMandateAdmin | SepaMandate | Validation trésorier (workflow), révocation | ROLE_TRESORIER |
FluxAdmin / FluxEurosAdmin / FluxSsaAdmin | Flux (STI) | Liste filtrée par type, lecture seule sur les écritures financières | ROLE_TRESORIER |
OperationAdmin & dérivés | Operation* | Lecture seule (intégrité comptable) | ROLE_TRESORIER |
RecurringMlcSubscriptionRequestAdmin, RecurringPurchaseRequestAdmin, RecurringSsaSubscriptionRequestAdmin | Requêtes SEPA récurrentes | Annulation atomique en cascade du mandat | ROLE_TRESORIER |
HelloAssoAdmin | Logs / configurations HelloAsso | Audit des webhooks | ROLE_SUPER_ADMIN |
GlobalParameterAdmin | GlobalParameter | Paramètres globaux MLC | ROLE_SUPER_ADMIN |
AuditLogAdmin, EmailLogAdmin, ConsentAdmin, GdprRequestAdmin | Traces & RGPD | Lecture seule | ROLE_SUPER_ADMIN |
Pool unique sonata.admin ; configuration centralisée dans
config/packages/sonata_admin.yaml. Le security handler utilisé est
app.security.handler.role (rôles fins), pas le handler ACL par défaut.
Breaking changes Sonata 4.x
-
$this->trans('key')n'existe plus. Remplacer par :
Symptôme historique :$this->getTranslator()->trans('key', [], $this->getTranslationDomain());Call to undefined method Admin::trans(). -
DatagridMapperestfinal. Donc non mockable avec PHPUnit. Conséquence pratique :configureDatagridFilters()ne se teste plus en unitaire — soitmarkTestIncomplete(), soit test d'intégration viaWebTestCase. -
ProxyQueryInterfacene déclare plusgetRootAlias()niandWhere(). Ces méthodes existent toujours sur l'implémentation Doctrine ORM mais passent par__call. Les appeler directement contre l'interface lève method not found à l'analyse statique. Caster vers l'implémentation ou éviter ces accès en filtre. -
Configuration par attributs PHP plutôt qu'XML/YAML. Sonata 4 préfère
#[AutoconfigureTag('sonata.admin')]+ paramètres en constructeur. Sur Kohinos on utilise encore le tagsonata.adminen YAML pour rester rétrocompatible — voirconfig/services.yaml.
Pattern de mocks pour les tests
Le constructeur d'AbstractAdmin attend un code, une classe et un
baseControllerName — déjà branché par le compiler-pass Sonata. Mocker un Admin sans
disableOriginalConstructor() finit en spaghetti.
$admin = $this->getMockBuilder(DonAdmin::class)
->disableOriginalConstructor()
->onlyMethods(['getTranslator', 'getRequest', 'getModelManager'])
->getMock();
$admin->method('getTranslator')->willReturn($translator);
$admin->method('getRequest')->willReturn($request);
- Pourquoi
disableOriginalConstructor? Le constructeur Admin instancie un grand graphe d'objets (route generator, label translator strategy, model manager, datagrid builder…). En test unitaire on n'a aucun de ces composants câblés. - Quand passer en test d'intégration ? Dès qu'un test touche à
DatagridMapper, àconfigureFormFieldsqui dépend du subject, ou à un filtre Doctrine.WebTestCase+ login simulé + assertion sur la page list/edit rendue. - Pas de mock du pool Sonata dans les tests métier : les services qui dépendent d'un Admin doivent recevoir l'Admin par injection, pas via le pool global.
Méthodes configure*
| Méthode | Signature | Usage Kohinos |
|---|---|---|
configureFormFields |
FormMapper $form |
Ajouter les champs en réutilisant les FormTypes Symfony (TextType,
EntityType, MoneyType, etc.). Toujours typer explicitement —
ne pas passer null comme type de champ, Sonata 4 ne devine plus. |
configureListFields |
ListMapper $list |
Colonnes de la liste + colonne _action avec les actions inline
(edit, delete, custom). Toujours fermer la liste par une
colonne actions explicite ; les row actions orphelines ne s'affichent pas. |
configureDatagridFilters |
DatagridMapper $filter |
Filtres de la sidebar. Garder les filtres simples — les filtres complexes (multi-jointures) cassent l'ergonomie et le cache Sonata. Préférer une route custom. |
configureShowFields |
ShowMapper $show |
Vue détail. Sur Kohinos, beaucoup d'Admins financiers exposent un show
riche (Adhérent, Prestataire) là où la liste reste compacte. |
configureRoutes |
RouteCollectionInterface $collection |
Déclarer les actions custom : $collection->add('execute', $this->getRouterIdParameter().'/execute').
Toujours associer un {id} quand l'action porte sur une instance. |
configureBatchActions |
array $actions |
Ajouter / retirer des actions de masse (validate, archive…).
La sécurité doit être doublée côté handler — le bouton est rendu
conditionnellement, le handler doit re-vérifier le rôle. |
configureActionButtons |
array $buttonList, string $action, ?object $object |
Override des boutons en haut de page. Cas Kohinos : DonAdmin remplace
le bouton create par un dropdown {Don adhérent / Don prestataire}. |
Sécurité Admin
- Au niveau Admin : déclaration
security:dans le tagsonata.adminou viagetAccessMapping(). Mais ce n'est qu'un garde-fou — chaque action sensible doit revérifier le rôle. - Au niveau action custom : dans le contrôleur Sonata associé,
$this->denyAccessUnlessGranted('ROLE_TRESORIER')avant tout side-effect. Pattern observé dansDonAdmin::configureFormFieldsqui adapte le formulaire selonisGranted('ROLE_TRESORIER')/'ROLE_COMPTOIR'. - CSRF tokens : les formulaires Sonata standards ont un CSRF token
par défaut. Mais les actions custom invoquées en GET non.
Toute action qui mute des données doit être en POST avec token
_token+ vérificationisCsrfTokenValid(). - Batch actions : la sécurité dans
configureBatchActionsne sert qu'à cacher le bouton. Le handler doit revérifier le rôle ET la propriété des objets traités (cas multi-MLC). - Hidden fields : ne jamais faire confiance à un
champ
HiddenTypecôté POST sans validation. Préférer'mapped' => false+ restauration explicite dansprePersist/preUpdate.
Pattern champ hidden survivant au POST
Cas DonAdmin::expediteur_type (cf. commit 530372533) — le type
d'expéditeur (adhérent vs prestataire) détermine la sous-classe STI DonAdherent
ou DonPrestataire. Doctrine STI exige de choisir la sous-classe dès
l'instanciation, donc le formulaire doit transporter cette information de manière
fiable entre createNewInstance() et le POST final.
// 1. createNewInstance() — lit la query string ?expediteur_type=...
public function createNewInstance(): object
{
$expediteurType = $this->resolveExpediteurType();
return $expediteurType === 'prestataire' ? new DonPrestataire() : new DonAdherent();
}
// 2. configureFormFields() — déclare le champ NON mappé sur l'entité
$formMapper->add('expediteur_type', HiddenType::class, [
'mapped' => false,
'data' => $this->resolveExpediteurType(),
'required' => false,
]);
// 3. resolveExpediteurType() — source de vérité centralisée
// Ordre de résolution :
// a) query string ?expediteur_type=... (GET initial)
// b) payload POST {uniqid}[expediteur_type] (resubmit Sonata)
// c) fallback : 'adherent'
private function resolveExpediteurType(): string
{
$request = $this->getRequest();
$fromQuery = (string) $request->query->get('expediteur_type', '');
if ($fromQuery !== '') { return $fromQuery; }
foreach ($request->request->all() as $value) {
if (is_array($value) && isset($value['expediteur_type'])) {
return (string) $value['expediteur_type'];
}
}
return 'adherent';
}
L'astuce REFERENCE_PLACEHOLDER = '(en cours de création)' du même Admin
illustre une autre contrainte : Flux::$reference porte un
#[Assert\NotBlank] qui est évalué avant prePersist().
Solution : on pose une valeur sentinelle dans createNewInstance(), le
prePersist l'écrase ensuite par la vraie référence dérivée de l'expéditeur.
Translations dans Admin
L'API a changé : $this->trans() n'existe plus. Le pattern Kohinos :
// ❌ Sonata 3.x — NE FONCTIONNE PLUS
$label = $this->trans('admin.don.label');
// ✅ Sonata 4.x
$label = $this->getTranslator()->trans(
'admin.don.label',
[],
$this->getTranslationDomain()
);
- Le translation domain par défaut d'un Admin Kohinos est
messages(et nonSonataAdminBundle) — c'est ce que renvoiegetTranslationDomain(), qui reste la source canonique. - Fichiers de traduction :
translations/messages.fr.yamlpour les libellés métier,translations/SonataAdminBundle.fr.xliffpour les libellés natifs Sonata (boutons, en-têtes). - Dans les templates Twig d'Admin, utiliser
{{ 'key'|trans({}, admin.translationDomain) }}plutôt que de coder le domaine en dur.
Anti-patterns à bannir
$this->trans('…')dans un Admin Sonata 4.x.- Logique métier (calcul de solde, génération de référence financière, envoi d'email)
directement dans une méthode
configure*ou un lifecycle hook Admin : déléguer à un Service injecté (DonExecutionService,SepaNotificationService, etc.). - Modification du hash de mot de passe via Admin (interdit par les
règles d'invariants — voir Invariants). Le hash est
géré par
UserPasswordHasherInterfaceuniquement. - Action custom sans déclaration explicite dans
configureRoutes(): Sonata ne la connaîtra pas et le bouton d'action listé ne pointera nulle part. - Batch action sans re-vérification de rôle ni de propriété MLC dans le handler.
- Appel direct à
$proxyQuery->getRootAlias()ouandWhere()contreProxyQueryInterface— passe via PHPStan, casse à l'analyse stricte. - Mock de
DatagridMapper— il estfinal, le test échouera. HiddenTypemappé sur l'entité sans validation côtéprePersist/preUpdate: faille de confiance triviale.
Tester un Admin
Trois niveaux selon ce qu'on veut vérifier :
| Niveau | Quoi tester | Outils |
|---|---|---|
| Unitaire | Méthodes pures, helpers (resolveExpediteurType, formatage,
createNewInstance sans subject) |
PHPUnit + mocks (constructor disabled), mock du Request et
RequestStack |
| Intégration | configureFormFields/configureDatagridFilters, validation
des forms, hooks prePersist/preUpdate |
KernelTestCase + container réel + EntityManager SQLite in-memory |
| Fonctionnel | Routes Admin (list, create, edit, custom action), sécurité, redirections, batch | WebTestCase + loginUser() + assertions sur le HTML rendu |
# Suite ciblée — toujours filtrer, jamais lancer toute la suite
vendor/bin/phpunit tests/Admin/DonAdminTest.php
vendor/bin/phpunit tests/Admin/SolidoumeParamAdminTest.php
vendor/bin/phpunit --filter resolveExpediteurType
# Routes Admin multi-rôles (smoke test)
python3 scripts/test-routes.py --role super_admin --only-errors
python3 scripts/test-routes.py --role tresorier --only-errors
Filet de couverture global (SonataAdminCoverageTest)
tests/Functional/Admin/SonataAdminCoverageTest.php énumère tous les
admins du pool Sonata (sonata.admin.pool, ~46 admins hors enfants) et
vérifie en super_admin que chaque route list et
create répond HTTP 200 (~90 routes). Tout admin cassé après un
refactor (template absent, service mal câblé, query invalide, référence vers une
entité supprimée) fait échouer le test avec l'URL et le code HTTP fautifs.
- Admins CMS (news, document, faq, page) : masqués en 403 par
VoterSecurityHandlerquandUSE_WORDPRESS != 'false'— le test force le front natif viaGlobalParameterFixture::enableCmsAdmins(). - Redirections internes : une seule redirection sous
/admin/est suivie (ex. classification category : list → tree), jamais vers/login(ça masquerait un 403). - Références supprimées (purge RGPD, soft-delete) : les listes
utilisent le template null-safe
list_many_to_one_safe.html.twig+ la fonction Twigkohinos_entity_exists()(App\Twig\EntityExistsExtension, déclarée manuellement dansservices.yamlcarsrc/Twigest exclu de l'autoregistration). Les lignes cassées affichent « (supprimé) » au lieu de provoquer une 500. - Subject dans les templates de boutons : utiliser
admin.hasSubject—admin.subject is definedpasse le check Twig maisgetSubject()lève uneLogicExceptionen contexte liste (Sonata 4).
Pour les tests fins par admin (exports, hooks, sécurité), hériter de
tests/Admin/AbstractAdminTestCase.php : implémenter
adminServiceId(), utiliser $this->admin et
$this->actAs(['ROLE_TRESORIER']) pour poser un token en mémoire.
Checklist de revue d'un Admin
- Aucune occurrence de
$this->trans(— uniquement$this->getTranslator()->trans(). - Chaque
configureFormFieldsa des types explicites (pas de second argumentnull). - Chaque action custom déclarée dans
configureRoutes()a une vérification de rôle dans son handler. - Chaque
HiddenTypesensible estmapped => falseet restauré explicitement enprePersist/preUpdate. - Aucun appel direct à
getRootAlias()/andWhere()contreProxyQueryInterface. - Pas de mock de
DatagridMapperdans les tests (il estfinal). - Pas de logique financière inline — délégation à un service domaine.
- CHANGELOG technique + utilisateur mis à jour si l'Admin expose une nouvelle action visible.
Pour aller plus loin
src/Admin/KohinosAdmin.php— base commune (translator, security, perPage).src/Admin/DonAdmin.php— référence pour hidden field + STI + placeholder.src/Admin/AdherentAdmin.php,PrestataireAdmin.php,SolidoumeParamAdmin.php— exemples de batch actions et routes custom.config/packages/sonata_admin.yaml— pool, security handler, templates.doc/TROUBLESHOOTING.md§ Sonata Admin 4.x — erreurs fréquentes post-migration..claude/agents/sonata-admin-reviewer.md— agent d'audit dédié.- Conventions & règles dures · Tests & qualité · Invariants financiers