← Tous les cours / Symfony Workflow Cours
00 — WORKFLOW VS STATEMACHINE

Workflow vs StateMachine — le choix fondamental

Workflow et StateMachine partagent la même API — la différence est que StateMachine limite à un seul état à la fois.

Quand tu modélises un cycle de vie — une commande qui passe de draft à confirmée à expédiée, ou un document avec plusieurs états parallèles — tu as deux outils dans le composant Workflow de Symfony : Workflow et StateMachine. Même API, comportement fondamentalement différent.

Workflow (Petri net)
Un objet peut se trouver dans plusieurs places simultanément. Le Marking stocke un tableau : ['reviewed' => 1, 'featured' => 1]. Idéal pour les processus parallèles (ex. : un article en cours de relecture ET de validation légale).
StateMachine (automate fini)
Un objet ne peut se trouver que dans une seule place à la fois. Le Marking stocke une string : 'published'. Idéal pour les cycles de vie séquentiels simples (ex. : commande → paiement → expédition).

Le Marking

Le Marking est l'objet qui représente l'état courant. Sa structure reflète le type :

// Workflow — plusieurs places possibles
$marking->getPlaces(); // ['draft' => 1, 'featured' => 1]
$marking->has('draft');   // true
$marking->has('published'); // false

// StateMachine — une seule place
$marking->getPlaces(); // ['published' => 1]

MethodMarkingStore — le pont vers l'objet

MethodMarkingStore lit et écrit l'état sur l'entité en appelant ses accesseurs :

use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore;

// Pour un Workflow (places multiples) :
new MethodMarkingStore(singleState: false, property: 'marking')
// → appelle $entity->getMarking() / setMarking(array $places)
// → la propriété doit stocker un tableau JSON

// Pour une StateMachine (place unique) :
new MethodMarkingStore(singleState: true, property: 'state')
// → appelle $entity->getState() / setState(string $state)
// → la propriété stocke une string (ou un Backed Enum)

Quand choisir l'un ou l'autre

CritèreWorkflowStateMachine
États simultanés ? Oui (plusieurs places) Non (une seule place)
Modèle formel Réseau de Petri Automate fini déterministe
Stockage en DB JSON ou tableau sérialisé VARCHAR / ENUM string
Cas d'usage typique Documents, processus parallèles Commandes, tickets, statuts
Complexité Plus expressif, plus complexe à déboguer Simple, lisible, debuggable
💡 Règle pratique : commence toujours par une StateMachine. Passe à un Workflow seulement quand tu as besoin de vrais états parallèles — pas parce que le cycle de vie est "complexe". Un workflow avec 10 places séquentielles est toujours une StateMachine.

Explore la différence ci-dessous :

Marking::getPlaces() →
01 — CONFIGURATION YAML

Configuration YAML — définir le graphe

La configuration YAML définit l'intégralité du graphe — places, transitions, guards, metadata, marking_store.

Toute la configuration d'un workflow vit dans config/packages/workflow.yaml. Ce fichier définit le graphe complet : places, transitions, marking store, events à dispatcher, et métadonnées. Symfony génère un service nommé workflow.<nom> (ou state_machine.<nom>).

Exemple complet — BlogPost

# config/packages/workflow.yaml
framework:
    workflows:
        blog_publishing:
            type: 'workflow'          # ou 'state_machine'
            marking_store:
                type: 'method'
                property: 'marking'    # $blogPost->getMarking() / setMarking()
            supports:
                - App\Entity\BlogPost
            initial_marking: 'draft'
            places:
                draft:
                    metadata: { color: '#ff6b6b' }
                review:
                    metadata: { color: '#f6ad55' }
                published:
                    metadata: { color: '#68d391' }
                rejected:
                    metadata: { color: '#fc6d6d' }
            transitions:
                to_review:
                    from: 'draft'
                    to:   'review'
                    metadata: { label: 'Soumettre à relecture' }
                publish:
                    from: 'review'
                    to:   'published'
                    guard: "is_granted('ROLE_EDITOR')"
                reject:
                    from: 'review'
                    to:   'rejected'
                redraft:
                    from: ['rejected', 'review']
                    to:   'draft'
            events_to_dispatch: ~  # null = tous ; [] = aucun sauf GUARD
            audit_trail:
                enabled: true

Injection du service généré

Symfony crée automatiquement le service workflow.blog_publishing. Deux façons de l'injecter :

use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\Workflow\WorkflowInterface;

// Option 1 : #[Target] — recommandé, explicite
public function __construct(
    #[Target('blog_publishing')]
    private readonly WorkflowInterface $workflow,
) {}

// Option 2 : par nom de paramètre (autowiring nommé)
public function __construct(
    private readonly WorkflowInterface $blogPublishingWorkflow,
) {}
Nommage du service
Pour un type workflow : workflow.blog_publishing
Pour un type state_machine : state_machine.blog_publishing
Dans les deux cas, le service implémente WorkflowInterface. #[Target('blog_publishing')] résout automatiquement le bon service.

events_to_dispatch — contrôler les événements

Par défaut (null), tous les événements sont dispatchés. Pour optimiser les performances :

events_to_dispatch:  # Lister uniquement les événements dont tu as besoin
    - workflow.guard
    - workflow.transition
    - workflow.completed
💡 Optimisation : chaque événement dispatché a un coût. Sur des workflows à haute fréquence (processus de paiement, états de tâches), restreindre events_to_dispatch aux seuls événements écoutés réduit significativement l'overhead. workflow.guard est toujours dispatché, même avec [].
02 — WORKFLOWINTERFACE

WorkflowInterface — can, apply, getEnabledTransitions

L'API de WorkflowInterface se résume à trois opérations : vérifier, appliquer, explorer.

WorkflowInterface est l'unique point d'entrée pour interagir avec un workflow depuis le code applicatif. Elle expose trois opérations : vérifier (can), appliquer (apply), et explorer (getEnabledTransitions).

L'interface complète

interface WorkflowInterface
{
    public function getMarking(object $subject): Marking;

    // Vérifier si une transition est possible (sans appliquer)
    public function can(object $subject, string $transitionName): bool;

    // Appliquer une transition — retourne le nouveau Marking
    public function apply(object $subject, string $transitionName, array $context = []): Marking;

    // Toutes les transitions accessibles depuis l'état courant
    public function getEnabledTransitions(object $subject): array;

    // Une transition spécifique accessible (ou null)
    public function getEnabledTransition(object $subject, string $name): ?Transition;

    // Liste détaillée des blocages pour une transition
    public function buildTransitionBlockerList(object $subject, string $transitionName): TransitionBlockerList;

    public function getName(): string;
    public function getDefinition(): Definition;
    public function getMarkingStore(): MarkingStoreInterface;
    public function getMetadataStore(): MetadataStoreInterface;
}

Utilisation typique dans un service

final class BlogPostPublisher
{
    public function __construct(
        #[Target('blog_publishing')]
        private readonly WorkflowInterface $workflow,
    ) {}

    public function submitForReview(BlogPost $post): void
    {
        // Vérification avant application
        if (!$this->workflow->can($post, 'to_review')) {
            throw new \LogicException('Cannot submit for review from current state.');
        }

        // Application avec contexte optionnel
        $this->workflow->apply($post, 'to_review', [
            'user'   => $this->security->getUser(),
            'reason' => 'Ready for editorial review',
        ]);
    }

    /** @return Transition[] */
    public function getAvailableActions(BlogPost $post): array
    {
        return $this->workflow->getEnabledTransitions($post);
    }
}

NotEnabledTransitionException et UndefinedTransitionException

apply() peut lancer deux exceptions distinctes :

use Symfony\Component\Workflow\Exception\NotEnabledTransitionException;
use Symfony\Component\Workflow\Exception\UndefinedTransitionException;

try {
    $this->workflow->apply($post, 'publish');
} catch (NotEnabledTransitionException $e) {
    // La transition existe mais est bloquée (guard, mauvais état)
    $blockers = $e->getTransitionBlockerList();
    foreach ($blockers as $blocker) {
        $this->logger->warning($blocker->getMessage());
    }
} catch (UndefinedTransitionException $e) {
    // La transition n'existe pas dans ce workflow
}
$context dans apply()
Le tableau $context est propagé à tous les événements via TransitionEvent::getContext(). C'est le moyen de passer des métadonnées aux listeners sans modifier l'entité (utilisateur courant, raison, timestamp, etc.).
🔑 Préfère can() avant apply() quand la transition peut échouer légitimement (interface utilisateur). Appelle apply() directement quand l'état est garanti par le contexte applicatif — et gère l'exception sinon.
03 — GUARDS

Guards — bloquer une transition

Un guard évalue si une transition est autorisée — GuardEvent::setBlocked(true) empêche apply().

Un guard est le gardien d'une transition. Il s'exécute avant toute application et peut bloquer la transition en appelant GuardEvent::setBlocked(true). Si bloqué, apply() lance une NotEnabledTransitionException.

GuardEvent

use Symfony\Component\Workflow\Event\GuardEvent;

final class BlogPostWorkflowGuard
{
    public function onPublish(GuardEvent $event): void
    {
        /** @var BlogPost $post */
        $post = $event->getSubject();

        if (!$post->hasReviewer()) {
            $event->setBlocked(true, 'A reviewer must be assigned before publishing.');
            return;
        }

        if (!$this->security->isGranted('ROLE_EDITOR')) {
            $event->setBlocked(true, 'Only editors can publish.');
        }
    }
}

Enregistrement du listener

Le nommage des événements de guard suit une convention à trois niveaux de précision :

# Niveau 1 : tous les guards de tous les workflows
'workflow.guard'

# Niveau 2 : tous les guards du workflow blog_publishing
'workflow.blog_publishing.guard'

# Niveau 3 : guard de la transition 'publish' dans blog_publishing
'workflow.blog_publishing.guard.publish'
# config/services.yaml
App\Workflow\BlogPostWorkflowGuard:
    tags:
        - name: kernel.event_listener
          event: 'workflow.blog_publishing.guard.publish'
          method: 'onPublish'

#[AsGuard] — l'attribut Symfony 6.4+

Depuis Symfony 6.4, #[AsGuard] remplace la déclaration de tag manuelle :

use Symfony\Component\Workflow\Attribute\AsGuard;

final class PublishGuard
{
    #[AsGuard(transition: 'publish', workflow: 'blog_publishing')]
    public function __invoke(GuardEvent $event): void
    {
        $post = $event->getSubject();
        if (!$post->hasReviewer()) {
            $event->setBlocked(true, 'Reviewer required.');
        }
    }
}

Guard en YAML — expression language

La clé guard: dans la configuration supporte les expressions Symfony :

transitions:
    publish:
        from: 'review'
        to:   'published'
        guard: "is_granted('ROLE_EDITOR') and subject.hasReviewer()"
💡 Guard YAML vs listener : le guard YAML est pratique pour les conditions simples basées sur les rôles (is_granted()). Pour une logique complexe (injections de services, conditions multiples, messages d'erreur personnalisés), utilise un listener ou #[AsGuard].
TransitionBlockerList
buildTransitionBlockerList() retourne un objet itérable de TransitionBlocker. Chaque blocker a un getMessage() et un getCode() (utiles pour les API REST qui doivent expliquer pourquoi une action est impossible).
04 — LES 7 ÉVÉNEMENTS

Les 7 événements du workflow

Sept événements jalonnent chaque transition — de GUARD (avant) à ANNOUNCE (après) — chacun avec un rôle précis.

Sept événements balisent chaque application de transition. Comprendre leur ordre et leur rôle permet de choisir le bon point d'interception pour chaque besoin.

Les 7 événements dans l'ordre d'exécution

#Constante WorkflowEventsNom de l'événementClasseRôle
1 GUARD workflow.guard GuardEvent Bloquer ou autoriser — avant tout
2 LEAVE workflow.leave LeaveEvent La place courante est quittée
3 TRANSITION workflow.transition TransitionEvent La transition s'applique
4 ENTER workflow.enter EnterEvent La nouvelle place est entrée (Marking pas encore mis à jour)
5 ENTERED workflow.entered EnteredEvent La nouvelle place est active (Marking mis à jour)
6 COMPLETED workflow.completed CompletedEvent Transition terminée avec succès
7 ANNOUNCE workflow.announce AnnounceEvent Annonce des transitions suivantes possibles

Convention de nommage des listeners

Chaque événement peut être écouté à trois niveaux de granularité. Exemple pour ENTERED :

// Tous les workflows, toutes les places
'workflow.entered'

// Seulement le workflow blog_publishing
'workflow.blog_publishing.entered'

// Seulement l'entrée dans la place 'published' de blog_publishing
'workflow.blog_publishing.entered.published'

Cas d'usage recommandés

ÉvénementCas d'usage typique
GUARD Contrôle d'accès, validation métier avant transition
LEAVE Nettoyage de l'état courant, logging de sortie
TRANSITION Audit trail, enrichissement du contexte
ENTER Initialisation de la nouvelle place (avant persistence)
ENTERED Envoi de notifications, mise à jour de champs dérivés (après persistence)
COMPLETED Déclenchement d'actions post-transition (emails, jobs)
ANNOUNCE Pré-évaluation des guards pour les transitions suivantes (UI)
HasContextTrait
Tous les événements incluent HasContextTrait, qui expose getContext(): array. Le contexte passé à apply($subject, $name, $context) est disponible dans chaque listener — c'est le canal de communication entre l'appelant et les listeners.

Exemple : audit trail sur COMPLETED

use Symfony\Component\Workflow\Event\CompletedEvent;

final class WorkflowAuditListener
{
    public function onCompleted(CompletedEvent $event): void
    {
        $this->auditLog->record(
            subject:    $event->getSubject(),
            transition: $event->getTransition()->getName(),
            context:    $event->getContext(),
        );
    }
}
💡 ENTER vs ENTERED : la distinction est subtile mais importante. Dans ENTER, le Marking de l'objet n'est pas encore mis à jour — l'objet est encore dans l'ancienne place en base. Dans ENTERED, le Marking est mis à jour. Si tu dois déclencher une notification après que l'état soit persisté, utilise ENTERED (ou mieux : COMPLETED avec $event->getMarking()).
05 — MARKINGSTORE

MarkingStore et persistance

Le MarkingStore est le pont entre le Workflow et la persistance — MethodMarkingStore délègue aux accesseurs de l'objet.

Le MarkingStore est le composant qui lit et écrit l'état du workflow sur l'objet métier. MethodMarkingStore est l'implémentation fournie par Symfony — elle délègue à des accesseurs (get/set) de l'objet.

MethodMarkingStore en détail

use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore;

// Constructeur
new MethodMarkingStore(
    singleState: false,  // true = StateMachine, false = Workflow
    property:    'marking' // nom de la propriété sur l'entité
);

Avec singleState: false (Workflow), le store appelle :

Avec singleState: true (StateMachine), le store appelle :

Implémentation Doctrine recommandée

Pour une StateMachine (state = string)

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Order
{
    #[ORM\Column(type: 'string', length: 50)]
    private string $state = 'new';

    public function getState(): string { return $this->state; }
    public function setState(string $state): void { $this->state = $state; }
}

Pour un Workflow (marking = tableau JSON)

#[ORM\Entity]
class BlogPost
{
    #[ORM\Column(type: 'json', nullable: true)]
    private ?array $marking = null;

    public function getMarking(): ?array { return $this->marking; }
    public function setMarking(?array $marking): void { $this->marking = $marking; }
}

Custom MarkingStore

Si tu as des besoins particuliers (stockage en Redis, log d'historique, champ calculé), implémente MarkingStoreInterface :

use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface;
use Symfony\Component\Workflow\Marking;

final class AuditedMarkingStore implements MarkingStoreInterface
{
    public function getMarking(object $subject): Marking
    {
        return new Marking($subject->getState() ? [$subject->getState() => 1] : []);
    }

    public function setMarking(object $subject, Marking $marking, array $context = []): void
    {
        $places = array_keys($marking->getPlaces());
        $subject->setState($places[0] ?? null);
        $this->audit->log($subject, $context);
    }
}
🔑 Enregistrement du custom store : déclare le service puis référence-le dans la config YAML : marking_store: { service: 'App\Workflow\AuditedMarkingStore' }.
06 — METADATA

Metadata — enrichir le graphe

Les métadonnées enrichissent le graphe avec des informations métier — couleurs, labels, descriptions — sans modifier le comportement.

Les métadonnées du workflow sont des données attachées au graphe lui-même — pas à l'objet métier. Elles servent à enrichir l'interface utilisateur (couleurs, labels) et à stocker des configurations sans polluer l'entité.

Déclaration en YAML

framework:
    workflows:
        blog_publishing:
            metadata:
                title: 'Publication des articles'
            places:
                draft:
                    metadata: { label: 'Brouillon', color: '#ff6b6b' }
                published:
                    metadata: { label: 'Publié', color: '#68d391' }
            transitions:
                to_review:
                    from: 'draft'
                    to:   'review'
                    metadata: { label: 'Soumettre', icon: '✉️' }

MetadataStoreInterface

use Symfony\Component\Workflow\WorkflowInterface;

$metaStore = $workflow->getMetadataStore();

// Metadata du workflow entier
$title = $metaStore->getWorkflowMetadata()['title'] ?? null;

// Metadata d'une place
$color = $metaStore->getPlaceMetadata('draft')['color'] ?? null;

// Metadata d'une transition (objet Transition requis)
$transition = $workflow->getEnabledTransition($subject, 'to_review');
if ($transition) {
    $label = $metaStore->getTransitionMetadata($transition)['label'] ?? null;
}

// Raccourci avec getMetadata(key, subject)
// subject = null → workflow, string → place, Transition → transition
$color = $metaStore->getMetadata('color', 'draft');

Utilisation en Twig

Symfony expose une extension Twig pour accéder aux métadonnées directement dans les templates :

{# Accès aux métadonnées d'une place #}
{% set meta = workflow_metadata(post, 'color', place) %}
<span style="background: {{ meta }}">{{ place }}</span>

{# Transitions accessibles avec leurs métadonnées #}
{% for transition in workflow_transitions(post, 'blog_publishing') %}
    {% set label = workflow_metadata(post, 'label', transition) %}
    <button>{{ label ?? transition.name }}</button>
{% endfor %}
💡 Bonne pratique : utilise les métadonnées pour tout ce qui concerne la présentation du workflow (couleurs, labels, icônes, descriptions). Ne mets pas de logique métier dans les métadonnées. La logique métier appartient aux guards et listeners.
07 — REGISTRY

Registry — multi-workflow

Le Registry permet d'accéder à n'importe quel workflow pour un objet donné — utile quand un objet participe à plusieurs workflows.

Le Registry est le service central qui référence tous les workflows de l'application. Il permet d'obtenir le bon workflow pour un objet donné, sans injecter chaque workflow individuellement.

Injection du Registry

use Symfony\Component\Workflow\Registry;

final class ContentManager
{
    public function __construct(
        private readonly Registry $workflows,
    ) {}
}

API du Registry

// Obtenir un workflow spécifique pour un objet
$workflow = $this->workflows->get($post, 'blog_publishing');

// Obtenir le premier workflow qui supporte l'objet (si un seul workflow)
$workflow = $this->workflows->get($post);

// Vérifier si un workflow supporte l'objet
if ($this->workflows->has($post, 'moderation')) {
    // ...
}

// Obtenir tous les workflows supportant l'objet
$allWorkflows = $this->workflows->all($post);
foreach ($allWorkflows as $workflow) {
    echo $workflow->getName();
}

Cas d'usage : objet avec plusieurs workflows

Un même objet peut participer à plusieurs workflows indépendants :

# config/packages/workflow.yaml
framework:
    workflows:
        blog_publishing:
            supports: [App\Entity\BlogPost]
            # ...
        blog_moderation:
            supports: [App\Entity\BlogPost]
            # ...
final class BlogPostService
{
    public function moderateAndPublish(BlogPost $post): void
    {
        $publishing  = $this->workflows->get($post, 'blog_publishing');
        $moderation  = $this->workflows->get($post, 'blog_moderation');

        if ($moderation->can($post, 'approve')) {
            $moderation->apply($post, 'approve');
        }

        if ($publishing->can($post, 'publish')) {
            $publishing->apply($post, 'publish');
        }
    }
}
Registry vs injection directe
Préfère l'injection directe via #[Target] dans la grande majorité des cas : plus explicite, plus testable, meilleure lisibilité. Utilise le Registry uniquement quand tu dois gérer dynamiquement plusieurs workflows pour un objet (dispatchers génériques, admin tools, interfaces de modération polyvalentes).
🔑 get() sans nom peut jeter : si l'objet est supporté par plusieurs workflows et que tu appelles get($subject) sans préciser le nom, Symfony lève une InvalidArgumentException. Toujours spécifier le nom quand plusieurs workflows coexistent pour le même type d'objet.
08 — VISUALISATION & DEBUG

Visualisation et debug

workflow:dump génère une représentation graphique du workflow — indispensable pour la documentation et le debug.

Le composant Workflow propose des outils de visualisation et de debug qui transforment la configuration YAML en diagramme et donnent de la visibilité sur l'état courant en Twig.

workflow:dump

La commande workflow:dump génère une représentation du graphe dans trois formats :

# Format DOT (Graphviz) — défaut
php bin/console workflow:dump blog_publishing | dot -Tpng -o workflow.png

# Format PlantUML
php bin/console workflow:dump blog_publishing --dump-format=puml

# Format Mermaid
php bin/console workflow:dump blog_publishing --dump-format=mermaid

# Avec les métadonnées et les listeners
php bin/console workflow:dump blog_publishing --with-metadata --with-listeners

Extension Twig — les 5 fonctions

FonctionRetourUsage
workflow_can(subject, name, workflow?) bool Tester si une transition est possible
workflow_transitions(subject, workflow?) Transition[] Toutes les transitions accessibles
workflow_marked_places(subject, workflow?) string[] Places actives (Marking courant)
workflow_has_marked_place(subject, place, workflow?) bool Tester si une place est active
workflow_metadata(subject, key, placeOrTransition) mixed Lire les métadonnées du graphe

Afficher l'état courant dans un template

{# Afficher les places actives #}
{% for place in workflow_marked_places(post) %}
    <span class="badge b-ok">{{ place }}</span>
{% endfor %}

{# Boutons d'action — une transition par bouton accessible #}
<div>
{% for transition in workflow_transitions(post, 'blog_publishing') %}
    <form method="post" action="/blog/{{ post.id }}/transition">
        <input type="hidden" name="transition" value="{{ transition.name }}">
        <button type="submit">
            {{ workflow_metadata(post, 'label', transition) ?? transition.name }}
        </button>
    </form>
{% else %}
    <p>Aucune transition disponible.</p>
{% endfor %}
</div>

Explore le workflow BlogPost ci-dessous :

État courant :
09 — SYNTHÈSE

Synthèse — guide de choix

Workflow vs StateMachine, les 7 événements, les patterns de production.

Le composant Workflow de Symfony est puissant mais nécessite des choix clairs. Ce guide de synthèse récapitule les décisions clés et les patterns de production.

Guide de choix : Workflow vs StateMachine

QuestionWorkflowStateMachine
Plusieurs places simultanées ? Oui — c'est le cas Non — une seule place
Stockage DB JSON array VARCHAR / Backed Enum
Complexité de debug Plus complexe Simple, lisible
Cas d'usage typique Documents multi-validation Commandes, tickets, réservations
Recommandation par défaut Oui — commence ici

Les 7 événements — récapitulatif

#ÉvénementCas d'usage
1GUARDBloquer une transition (accès, validation)
2LEAVENettoyage de l'état précédent
3TRANSITIONAudit trail pendant la transition
4ENTERInit de la nouvelle place (avant persist)
5ENTEREDNotifications (après persist)
6COMPLETEDJobs asynchrones post-transition
7ANNOUNCEPré-évaluation des guards (UI)

Anti-patterns courants

🚫 Logique métier dans les guards YAML.
Les guards YAML avec is_granted() sont pratiques mais limités. Dès que la condition implique un service ou plusieurs critères, passe à #[AsGuard] ou un listener.
🚫 Workflow là où StateMachine suffit.
Un cycle de vie linéaire avec 10 étapes séquentielles est toujours une StateMachine. Ne choisis Workflow que si tu as vraiment besoin de places simultanées.
🚫 Registry::get() sans nom avec plusieurs workflows.
Si l'entité est supportée par plusieurs workflows, get($subject) sans nom lance une exception. Toujours être explicite sur le nom du workflow.
🚫 apply() sans can() dans le code de production.
Appelle toujours can() avant apply() dans les contrôleurs et services exposés à l'utilisateur. Les exceptions de workflow sont des erreurs de programmation, pas des erreurs utilisateur.

Checklist de déploiement