15-Minute Sylius

Episode 4: State Machine Callbacks & Emails

SyliusState MachinesSymfony WorkflowSylius MailerEvent Listeners

Introduction

Welcome to episode 4 of 15-Minute Sylius. In the previous episode we introduced the state machine concept — a graph of states and transitions that models business processes. We built a brand type workflow with three states (regular, premium, supreme) and added transition buttons in the admin grid.

It looks cool, but right now it doesn't do anything valuable. Clicking "Mark Premium" changes a value in the database — that's it. In a real application, state transitions should trigger side effects: sending emails, updating related entities, firing webhooks, logging audit trails.

In this episode we'll make the state machine useful by:

  • Reacting to transitions with Symfony Workflow event listeners
  • Sending notification emails via the Sylius Mailer Bundle
  • Replacing plain string fields with custom Twig grid fields (Bootstrap badges)

Winzou vs Symfony Workflow

Between episodes 3 and 4, a Winzou State Machine configuration was added to the repository as an alternative to the Symfony Workflow approach we've been using. This is the more traditional/legacy way of defining state machines in Sylius.

Winzou is arguably easier to read at first glance, but it's also less powerful than Symfony Workflow. The choice is up to you — both work. Throughout this series, all recordings use Symfony Workflow, but the repository includes both implementations so you can compare.

Tip: When doing any state machine customization, check the repository for both Winzou and Symfony Workflow implementations side by side. Pick whichever fits your team's preferences.


Workflow Event Listeners

Symfony Workflow dispatches events at every stage of a transition. We can create event listeners that react to these events using a specific naming pattern:

workflow.completed                                    # any transition on any workflow
workflow.<workflow_name>.completed                    # any transition on a specific workflow
workflow.<workflow_name>.completed.<transition_name>  # a specific transition on a specific workflow

For our use case, we want to listen to the completion of the mark_premium transition on the app_brand workflow:

workflow.app_brand.completed.mark_premium

The completed event fires after the transition has been applied — the entity is already in its new state. This is the right moment to send a notification email.

Note: There are other events too — guard (before checking if transition is allowed), leave, transition, enter, entered, and announce. We'll explore guards in a future episode.


Adding an Email Property to Brand

Before we can send a notification, we need someone to notify. Our Brand entity currently has no contact information. Let's add an email property so we can reach the brand's contacts when their status changes.

Three things need updating:

  1. Entity — add an email property with getter/setter and a Doctrine column
  2. Grid — add a new string field to display the email in the admin list
  3. Form type — add an email field to the create/update form

After the migration and a quick test — we set emails on our test brands — we're ready to go.


Creating the Event Listener

Create a new listener class in a Listener directory under the Brand module:

// src/Brand/Listener/BrandTransitionListener.php
namespace App\Brand\Listener;

use App\Entity\Brand;
use Sylius\Component\Mailer\Sender\SenderInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Workflow\Event\CompletedEvent;

#[AsEventListener(
    event: 'workflow.app_brand.completed.mark_premium',
    method: 'handleMarkPremium',
)]
class BrandTransitionListener
{
    public function __construct(
        private SenderInterface $sender,
    ) {
    }

    public function handleMarkPremium(CompletedEvent $event): void
    {
        /** @var Brand $brand */
        $brand = $event->getSubject();

        $this->sender->send(
            'mark_premium_congratulations',
            [$brand->getEmail()],
            ['brand' => $brand],
        );
    }
}

Key points:

  • The #[AsEventListener] attribute wires the listener to the specific workflow event — no manual service configuration needed
  • $event->getSubject() returns the entity the transition was applied to (our Brand)
  • We inject SenderInterface from the Sylius Mailer component to send the email

Design choice: You can put one listener per transition, or group multiple transition handlers in a single class. Follow clean code principles — think about separation of responsibilities and keep it readable.


Sylius Mailer Bundle

The Sylius Mailer Bundle provides a simple way to define and send transactional emails. You define email "codes" in configuration and pair them with Twig templates.

Step 1: Configure the email code

# config/packages/sylius_mailer.yaml
sylius_mailer:
    emails:
        mark_premium_congratulations:
            subject: "Congratulations! Your brand is now Premium"
            template: "email/mark_premium_congratulations.html.twig"

Step 2: Create the email template

Look at existing Sylius email templates in the vendor for reference. Each template extends a base layout and defines two blocks: subject and body.

{# templates/email/mark_premium_congratulations.html.twig #}
{% extends '@SyliusMailer/Email/layout.html.twig' %}

{% block subject %}
    Congratulations! Your brand is now Premium
{% endblock %}

{% block body %}
    <p>Hello,</p>
    <p>
        Great news! The brand <strong>{{ brand.name }}</strong>
        has been marked as <strong>Premium</strong>.
    </p>
    <p>Thank you for your partnership!</p>
{% endblock %}

Gotcha: The block is called body, NOT content. Using the wrong block name will cause the email to render empty or throw an exception. This is a common mistake.

Step 3: The sender->send() method

The SenderInterface::send() method takes three arguments:

  1. Email code — the key from sylius_mailer.emails (e.g., 'mark_premium_congratulations')
  2. Recipients — an array of email addresses
  3. Data — an associative array of variables passed to the Twig template
$this->sender->send(
    'mark_premium_congratulations',       // email code
    [$brand->getEmail()],                 // recipients
    ['brand' => $brand],                  // template data
);

Debugging Common Mistakes

After wiring everything together, the first test... didn't work. Here's what went wrong:

Mistake 1: Wrong Twig block name

The email template used {% block content %} instead of {% block body %}. The Sylius Mailer base layout expects body. Using the wrong name means the email either renders empty or throws an exception.

Mistake 2: Wrong state machine component setting

The sylius_resource.yaml configuration had the state machine component set to winzou (from the between-episode repo work) instead of symfony:

# config/packages/sylius_resource.yaml
sylius_resource:
    settings:
        # WRONG: state_machine_component: winzou
        state_machine_component: symfony  # Must match the workflow type you're using

Because the listener was wired to Symfony Workflow events, but Sylius was dispatching Winzou events, the listener never fired.

Gotcha: If your workflow event listeners are not firing, check sylius_resource.settings.state_machine_component. It must match the type of state machine you're using (symfony or winzou).

Verifying with MailHog

If you're using the Sylius Docker configuration (from episode 1), MailHog is already running. After fixing both issues:

  1. Degrade the test brand back to regular
  2. Click "Mark Premium"
  3. Check MailHog — the congratulations email appears with the brand name

The same pattern applies to mark_supreme and degradate transitions — each needs its own email code, template, and listener method (or a separate listener class). This is left as a homework exercise, with the full implementation available in the repository.


Custom Twig Grid Field

Our grid currently displays the brand type as a plain string — regular, premium, supreme. It works, but it's boring. Sylius uses Bootstrap badges for states elsewhere (e.g., product review statuses), and we can do the same.

Switch from string field to Twig field

In the grid definition, replace the string field for type with a Twig field that points to a custom template:

use Sylius\Bundle\GridBundle\Builder\Field\TwigField;

// In AdminBrandGrid::buildGrid()
$gridBuilder
    ->addField(
        TwigField::create('type', 'admin/grid/field/type.html.twig')
            ->setLabel('Type')
    )
;

Create the Twig template

The template maps each type to a Bootstrap badge color:

{# templates/admin/grid/field/type.html.twig #}
{% set state_map = {
    'regular': 'secondary',
    'premium': 'primary',
    'supreme': 'warning',
} %}

{% set color = state_map[data] | default('secondary') %}

<span class="badge bg-{{ color }}">{{ data }}</span>

After refreshing the admin panel, brand types now appear as color-coded badges: grey for regular, blue for premium, yellow for supreme. A small visual improvement that makes the grid much easier to scan.

Tip: Twig grid fields are powerful — they accept any data from the grid row and let you render it with full Twig template capabilities. Use them whenever you need more than a plain string display.


Summary

In this episode we covered:

  1. Reacting to state machine transitions using Symfony Workflow event listeners
  2. Sending notification emails with the Sylius Mailer Bundle — email codes, Twig templates, and the sender service
  3. Debugging common mistakes — wrong Twig block names and mismatched state machine component settings
  4. Custom Twig grid fields — replacing plain strings with Bootstrap badges for a polished admin UI

With episodes 1–4, we've now covered all the technical fundamentals of Sylius that aren't strictly e-commerce: Resource & Grid Bundle (CRUD), state machines, event listeners, and the mailer. These are separated bundles that together form the Sylius Stack.

In the next episodes, we'll use these building blocks to customize Sylius e-commerce business processes — checkout, payments, shipments, product reviews, and more.


Presented by Mateusz from Commerce Weavers. If you need help with complex e-commerce projects, you know where to find us.