15-Minute Sylius

Episode 3: State Machines

Sylius State Machines Symfony Workflow PHP Enums

Introduction

Hello everyone and welcome to 15-Minute Sylius. This is the third episode in this series and just a reminder – in this series we are tackling down the really simple and quick wins, quick customizations at the beginning of your adventure in Sylius that you may take. And I'm trying to show you that every customization that you can make at the very beginning is really simple and it can be really efficient.

I hope that in this episode you will also see something that is really exciting because we will be talking about the topic that I really like a lot and this is like the fundament of what Sylius is about.

And I know that I already told you that the fundament is Sylius Resource and Grid bundle that we touched in the last episode. In this episode we will be touching state machines.


What is a State Machine?

If you have never heard about the state machine concept, it's pretty simple. The deterministic finite state machine (or deterministic finite state acceptor) is a quintuple defined as:

  • Σ (sigma) – the input alphabet
  • S – a finite, non-empty set of states
  • s₀ – an initial state, an element of S
  • δ (delta) – the state transition function: δ : S × Σ → S
  • F – the set of final states, a possibly empty subset of S

This is the state machine if you are a mathematician. But if you are a programmer or a developer, don't bother about it. I strongly encourage you to go into the mathematician model because it is fascinating and this is something that seems like real science, not what we are doing here.

But what we need to focus on is that a state machine is basically a graph of states and transitions between them.

stateDiagram-v2
    direction LR
    [*] --> S0 : initial state
    S0 --> S1 : transition A
    S0 --> S2 : transition B
    S1 --> S2 : transition C
    S2 --> [*]

State Machines in Sylius: Product Reviews Example

Let's imagine that we have some concept like product reviews. If we see that we already have some product reviews in our Sylius application created probably from the fixtures, these product reviews have some states.

This is a really simple state transition graph: we have a review that is in the state new. It can be either accepted or rejected. So if I click "Accept", it will be changed to "Accepted". If I click "Reject", it will be changed to "Rejected".

stateDiagram-v2
    direction LR
    [*] --> new
    new --> accepted : accept
    new --> rejected : reject

This product review state machine is trivial, but there are much more complicated state machines in Sylius. Like the whole checkout system is a state machine process, modeled with some states, some transitions between the different states of the order.

We can take a look at it in the code:

# CoreBundle/Resources/config/app/workflow/sylius_order_checkout.yaml
framework:
    workflows:
        !php/const Sylius\Component\Core\OrderCheckoutTransitions::GRAPH:
            type: state_machine
            marking_store:
                type: method
                property: checkoutState
            supports:
                - Sylius\Component\Core\Model\OrderInterface
            initial_marking: !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_CART
            places:
                - !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_CART
                - !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_ADDRESSED
                - !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_SHIPPING_SELECTED
                - !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_SHIPPING_SKIPPED
                - !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_PAYMENT_SELECTED
                - !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_PAYMENT_SKIPPED
                - !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_COMPLETED
            transitions:
                !php/const Sylius\Component\Core\OrderCheckoutTransitions::TRANSITION_ADDRESS:
                    from:
                        - !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_CART
                        - !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_ADDRESSED
                        # ... more states
                    to: !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_ADDRESSED
stateDiagram-v2
    [*] --> cart
    cart --> addressed : address
    addressed --> shipping_selected : select_shipping
    addressed --> shipping_skipped : skip_shipping
    shipping_selected --> payment_selected : select_payment
    shipping_skipped --> payment_selected : select_payment
    shipping_selected --> payment_skipped : skip_payment
    shipping_skipped --> payment_skipped : skip_payment
    payment_selected --> completed : complete
    payment_skipped --> completed : complete
    completed --> [*]

We have the name of the state machine, the set of states, and the transitions. From which state we can go to which state – we can always have only one final state after the transition. And there are a bunch of callbacks (services that react to what we are doing), in this specific example, in the checkout process.


Adding a "Type" Property to Our Brand Entity

Okay, so as we already know the theory, let's jump into the code and see how we can use this concept in our new entity Brand (created in the last episode in src/Entity/Brand). It has some really simple properties like identifier, name, and code.

Let's think about the concept that our brands will have some specific types:

  • Regular – just a brand, no special relationship with the provider
  • Premium – popular brands we can market as premium, maybe with specific discounts or offers for specific customer groups
  • Supreme – the top tier

Step 1: Add a simple type property

I started very simple. I put some new property named type, with a default value of regular, and it has getters and setters.

Step 2: Use a PHP Enum instead of a plain string

But if we are talking about types or states, it would be nice to use some enumeration feature. We're already living in the PHP world in the 21st century, and we finally have enums in PHP. This is exactly what we want – a specific set of states and no other states would be applicable.

// src/Brand/Model/BrandType.php
namespace App\Brand\Model;

enum BrandType: string
{
    case Regular = 'regular';
    case Premium = 'premium';
    case Supreme = 'supreme';
}

The enum is in App\Brand\Model namespace because it's not an entity – there's no reason to put it into the Entity namespace.

Step 3: Use the enum in the entity column

// src/Entity/Brand.php
namespace App\Entity;

use App\Brand\Model\BrandType;
use Doctrine\ORM\Mapping as ORM;

class Brand
{
    // ...

    #[ORM\Column(type: 'string', enumType: BrandType::class)]
    private BrandType $type = BrandType::Regular;

    public function getType(): BrandType
    {
        return $this->type;
    }

    public function setType(BrandType $type): void
    {
        $this->type = $type;
    }
}

It's obviously still a string from the database point of view, but it's an enum type in PHP. It has a default value both in the database and in the code.

Important: The state machine concept is built around the idea that we don't want to set the type/state of the entity directly, because that means no control over how it's set. We need transitions to move from one state to another. But from the code perspective we still need the setter because the tools we'll use (both Winzou and Symfony Workflow) use the set method to actually set the property. Consider using static analysis to disallow anyone from calling setType/setState directly.


Showing the Type in the Grid

Add a new field to the grid – a string field called type with label "Type". Because the type is an enum (not a plain string), we need to tell the grid to render it with the name:

type.name

This uses expression language – Grid will automatically know what to call to display the value. After refreshing the admin panel, the test brand shows regular (the default type).


Creating a Custom Form Type

We don't want the type/state to be editable in the create/update form – we only want to manage name and code. So we need a custom form type.

// src/Brand/Form/Type/BrandType.php (aliased as BrandFormType)
namespace App\Brand\Form\Type;

use Sylius\Bundle\ResourceBundle\Form\Type\AbstractResourceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;

class BrandType extends AbstractResourceType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class)
            ->add('code', TextType::class)
        ;
    }

    public function getBlockPrefix(): string
    {
        return 'app_brand';
    }
}

We extend AbstractResourceType from the Resource Bundle – the default form type for resources. It automatically injects the data class and some validation groups.

Register the form type as a service

# config/services.yaml
services:
    App\Brand\Form\Type\BrandType:
        arguments:
            $dataClass: '%app.model.brand.class%'
            $validationGroups: ['sylius']

We use the parameter %app.model.brand.class% that is automatically registered for our resources (instead of the FQCN of Brand directly).

Configure the form type on the entity resource

// src/Entity/Brand.php
use App\Brand\Form\Type\BrandType as BrandFormType;

#[AsResource(
    section: 'admin',
    formType: BrandFormType::class,
    // ...
    operations: [
        new Create(),
        new Update(),
        new Delete(),
        new Index(grid: AdminBrandGrid::class),
    ],
)]
class Brand implements ResourceInterface
{
    // ...
}

We alias the form type as BrandFormType to avoid confusion with the BrandType enum.

Now the form only shows name and code fields.


Defining the State Machine (Symfony Workflow)

As I mentioned at the beginning, we can use one of two components:

  1. Winzou State Machine – the traditional/legacy way, still awesome, still works, much simpler
  2. Symfony Workflow – a little bit more modern, more flexibility regarding configuration

We will use Symfony Workflow. You also need to tell Sylius to use it:

# config/packages/sylius_resource.yaml
sylius_resource:
    settings:
        state_machine_component: symfony

The workflow configuration

# config/packages/workflow.yaml
framework:
    workflows:
        app_brand:
            type: state_machine
            marking_store:
                type: method
                property: type
            supports:
                - App\Entity\Brand
            initial_marking: !php/enum App\Brand\Model\BrandType::Regular
            places: !php/enum App\Brand\Model\BrandType
            transitions:
                mark_premium:
                    from: !php/enum App\Brand\Model\BrandType::Regular
                    to: !php/enum App\Brand\Model\BrandType::Premium
                mark_supreme:
                    from:
                        - !php/enum App\Brand\Model\BrandType::Regular
                        - !php/enum App\Brand\Model\BrandType::Premium
                    to: !php/enum App\Brand\Model\BrandType::Supreme
                degradate:
                    from:
                        - !php/enum App\Brand\Model\BrandType::Premium
                        - !php/enum App\Brand\Model\BrandType::Supreme
                    to: !php/enum App\Brand\Model\BrandType::Regular

Key configuration points:

  • type: state_machine – the entity is always in exactly one state (as opposed to workflow type where an entity can be in multiple states simultaneously)
  • marking_store – defines the method/property used: setType / type
  • supports – the entity class (App\Entity\Brand)
  • places – we can define them as an Enum (!php/enum App\Brand\Model\BrandType), Symfony will automatically know about regular, premium and supreme
  • initial_markingBrandType::Regular
  • Transitions:
    • mark_premium: regular -> premium
    • mark_supreme: regular OR premium -> supreme
    • degradate: premium OR supreme -> regular
stateDiagram-v2
    [*] --> regular
    regular --> premium : mark_premium
    regular --> supreme : mark_supreme
    premium --> supreme : mark_supreme
    premium --> regular : degradate
    supreme --> regular : degradate

Verify it works

After refreshing the page (no errors = good sign), check the Symfony Profiler under the "Workflow" section. Among the Sylius workflows, you should see app_brand. The graph shows exactly the transitions we configured.


Adding Transition Buttons (Apply State Machine Transition Action)

The state machine concept is tightly connected with the Sylius Resource Bundle because Sylius uses it extensively for modeling business processes – not only checkout but also order processing, payments, shipments, reviews.

There are predefined resource actions we can use to create state machine transition buttons.

On the Resource: ApplyStateMachineTransition

// src/Entity/Brand.php
use Sylius\Resource\Metadata\ApplyStateMachineTransition;

#[AsResource(
    // ...
    operations: [
        new Create(),
        new Update(),
        new Delete(),
        new Index(grid: AdminBrandGrid::class),
        new ApplyStateMachineTransition(
            stateMachineTransition: 'mark_premium',
            // stateMachineGraph: 'app_brand', // optional, auto-detected
        ),
    ],
)]
class Brand implements ResourceInterface
{
    // ...
}

This creates a route (visible with debug:router) that takes an id parameter (resource.id) and applies the transition.

On the Grid: Update Transition Action

In the Grid definition, add an item action for the transition:

use Sylius\Bundle\GridBundle\Builder\Action\UpdateAction;

// Inside your Grid's buildGrid() method:
$gridBuilder
    ->addActionGroup(
        ItemActionGroup::create(
            UpdateAction::create('mark_premium')
                ->setOptions([
                    'link' => [
                        'route' => 'app_admin_brand_mark_premium',
                        'parameters' => ['id' => 'resource.id'],
                    ],
                    'graph' => 'app_brand',
                    'transition' => 'mark_premium',
                    'show_disabled' => false,  // hides button when transition is unavailable
                    'class' => 'btn-purple',   // Bootstrap styling
                    'icon' => 'tabler:star',   // Tabler icon set (used in Sylius admin)
                ]),
            UpdateAction::create(),
            DeleteAction::create(),
        )
    )
;

After refreshing the admin panel:

  • A new button appears (star icon, purple) next to each brand
  • Clicking it transitions the brand from regular to premium
  • The button becomes disabled (or hidden with show_disabled: false) when the transition is not available (e.g. the brand is already premium)

Challenge: Add the Remaining Transitions

The mark_premium action is done. Now as a challenge, you should create the same kind of actions for:

  • mark_supreme – transition from regular/premium to supreme
  • degradate – transition from premium/supreme back to regular

The process is the same – add an ApplyStateMachineTransition operation on the resource, and an action in the grid for each transition.

stateDiagram-v2
    direction LR
    [*] --> regular
    regular --> premium : mark_premium
    regular --> supreme : mark_supreme
    premium --> supreme : mark_supreme
    premium --> regular : degradate
    supreme --> regular : degradate

Bonus: Improvements from Episode 2

On the episode 2 branch of the repository, some improvements were added:

  • A unique constraint on the code field (with UniqueEntity validation) because the code should be unique within our system
  • Translations in messages.yaml to make labels look nicer in the admin

Q&A from the Community

Why no Maker Bundle commands?

There are make:entity and make:grid commands from the Symfony Maker Bundle. You should absolutely use them! More automation means less time on trivial things and more focus on business value and complex scenarios.

But in this video series, understanding the process behind the automation helps use it more efficiently. If something doesn't work, you're still able to do it yourself.

Why no AI?

Same philosophy. We could make it probably much quicker, but we are not here to do things quick. We are here to do things right and understand the processes before automating them.

What about Testing / TDD?

Testing will definitely be touched in next episodes. The host is a self-described "test freak" who cannot live without tests. There's also a talk about Behavior-Driven Development (in Polish) that will be linked in the description.

Why attributes for configuration?

Using attributes (for ORM mapping, Resource Bundle config) is the current Symfony standard. While the host admits being a "dinosaur" who's not a huge fan of attributes, they acknowledge the benefits:

  • Faster bootstrapping
  • Consistency with framework standards
  • Easier onboarding for other developers

Using framework standards builds a better foundation for well-written, maintainable applications.


Summary

In this episode we covered:

  1. State machine theory – a graph of states and transitions
  2. Adding a type/state property to a custom entity using PHP Enums
  3. Creating a custom form type to exclude the state from direct editing
  4. Configuring a Symfony Workflow (type: state_machine) with places, transitions, and enum-based states
  5. Adding transition actions on both the Resource (route) and Grid (button) using ApplyStateMachineTransition
  6. Styling and UX – icons, colors, and hiding disabled transitions

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