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\Modelnamespace 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
setmethod to actually set the property. Consider using static analysis to disallow anyone from callingsetType/setStatedirectly.
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
AbstractResourceTypefrom 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
BrandFormTypeto avoid confusion with theBrandTypeenum.
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:
- Winzou State Machine – the traditional/legacy way, still awesome, still works, much simpler
- 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 toworkflowtype where an entity can be in multiple states simultaneously)marking_store– defines the method/property used:setType/typesupports– the entity class (App\Entity\Brand)places– we can define them as an Enum (!php/enum App\Brand\Model\BrandType), Symfony will automatically know aboutregular,premiumandsupremeinitial_marking–BrandType::Regular- Transitions:
mark_premium: regular -> premiummark_supreme: regular OR premium -> supremedegradate: 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 supremedegradate– 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
codefield (withUniqueEntityvalidation) because the code should be unique within our system - Translations in
messages.yamlto 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:
- State machine theory – a graph of states and transitions
- Adding a type/state property to a custom entity using PHP Enums
- Creating a custom form type to exclude the state from direct editing
- Configuring a Symfony Workflow
(
type: state_machine) with places, transitions, and enum-based states - Adding transition actions on both the Resource
(route) and Grid (button) using
ApplyStateMachineTransition - 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.