Introduction
Welcome to 15-Minute Sylius. In this series we are tackling real Sylius customizations — no fluff, no 45-minute detours into theory. Just practical solutions to problems you will actually face when working with Sylius.
Today we are looking at one of the most common customizations and the one that is usually the base for all other business processes you will implement in your application: a new CRUD (Create, Read, Update, Delete) for a custom concept in your admin panel.
To achieve this we will use two powerful bundles from Sylius: Sylius Resource Bundle and Sylius Grid Bundle. If you have ever felt stuck on this, you will get unstuck. And if you are fairly new to Sylius, remember there is a previous episode where we install Sylius and do some basic customizations.
Creating the Brand Entity
Let's start with creating a new entity. The usual way would be to jump
into src/Entity/ and create the class there. But we can do
something a bit more sophisticated.
We will probably have multiple customizations in our application. Brand management will have its own entity, forms, menu items, and business processes. Let's keep everything together in a modular directory structure:
src/
Brand/
Entity/
Brand.php
The Brand class needs three properties: an auto-generated
id, a code (a unique identifier within the
application, not auto-generated), and a name. We generate
getters and setters, but remove the setId method
— the ID will be generated by the database.
// src/Brand/Entity/Brand.php
namespace App\Brand\Entity;
class Brand
{
private ?int $id = null;
private ?string $code = null;
private ?string $name = null;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(?string $code): void
{
$this->code = $code;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): void
{
$this->name = $name;
}
}
Note that the class cannot be
finalbecause it will be a Doctrine ORM entity — Doctrine needs to create proxy classes for lazy loading.
Also notice the fields are initialized with null. This is
important — Symfony forms require fields to have an initial value (even
null) when rendering the form.
ORM Mapping & Database Migration
Right now the class is just a plain PHP class. To make it a real entity we need ORM mapping:
// src/Brand/Entity/Brand.php
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'app_brand')]
class Brand
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'string')]
private ?string $code = null;
#[ORM\Column(type: 'string')]
private ?string $name = null;
// ... getters and setters
}
The table is named app_brand — using a prefix is a convention
to distinguish your custom tables from Sylius core tables.
Now let's generate a migration:
bin/console do:mi:diff
And we get: "No changes detected in your mapping information."
Gotcha: Doctrine only scans directories configured in
config/packages/doctrine.yaml. By default it only looks insrc/Entity/, but our entity lives insrc/Brand/Entity/.
The fix is to add a second mapping section in doctrine.yaml:
# config/packages/doctrine.yaml
doctrine:
orm:
mappings:
App:
type: attribute
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
brand:
type: attribute
dir: '%kernel.project_dir%/src/Brand/Entity'
prefix: 'App\Brand\Entity'
Now run the migration commands again:
bin/console do:mi:diff
bin/console do:mi:mi
The migration is generated with a CREATE TABLE app_brand
statement and the columns we defined. Run it and the table is created.
Registering a Sylius Resource
We have an entity and a database table, but we need to manage it in the admin panel. If you look at the Sylius admin, you will notice that practically everything you see — products, customers, orders — are resources.
The Sylius Resource Bundle and Grid Bundle are not Sylius-specific — they are generic Symfony bundles that you can use in any Symfony application to simplify CRUD panel creation.
To register our entity as a resource, add the #[AsResource]
attribute:
use Sylius\Resource\Metadata\AsResource;
#[AsResource]
class Brand
{
// ...
}
Let's check if it works:
bin/console debug:container | grep brand
Nothing happens. Why? Because we also need to tell the Resource Bundle where to look for resources:
# config/packages/sylius_resource.yaml
sylius_resource:
mapping:
paths:
- '%kernel.project_dir%/src/Entity'
- '%kernel.project_dir%/src/Brand/Entity'
Running debug:container again, we get an error:
"Brand entity must implement ResourceInterface."
use Sylius\Resource\Model\ResourceInterface;
#[AsResource]
class Brand implements ResourceInterface
{
// ...
}
ResourceInterfaceis a marker interface from the Resource Bundle. It tells Sylius which objects are resources and which are not.
Now debug:container | grep brand shows four
services automatically registered:
app.controller.brand— a controller with predefined CRUD actionsapp.factory.brand— creates new instances of Brandapp.repository.brand— fetches Brand entities from the databaseapp.manager.brand— manages persistence (Doctrine EntityManager wrapper)
The naming pattern is app.{service_type}.{resource_name}.
Configuring Resource Operations
The resource is registered but has no operations yet. Let's add
Create and Index operations with a
routePrefix so they live under the admin firewall:
use Sylius\Resource\Metadata\AsResource;
use Sylius\Resource\Metadata\Create;
use Sylius\Resource\Metadata\Index;
#[AsResource(
section: 'admin',
routePrefix: '/admin',
operations: [
new Create(),
new Index(),
],
)]
class Brand implements ResourceInterface
{
// ...
}
Verify the routes were created:
bin/console debug:router | grep brand
Two routes appear: app_admin_brand_create and
app_admin_brand_index, both prefixed with
/admin.
Routing & Templates
Let's visit /admin/brands in the browser. We get an error:
"Unable to find template index.html.twig".
By default the Resource Bundle looks for templates in your
templates/ directory. We could create our own, but Sylius Admin
Bundle already has shared CRUD templates we can reuse:
#[AsResource(
section: 'admin',
routePrefix: '/admin',
templatesDir: '@SyliusAdmin/shared/crud',
operations: [
new Create(),
new Index(),
],
)]
Refreshing the page gives us a new error — the Index operation expects a grid to be configured. A grid defines how the table of entities should look: which columns to show, which actions to offer. Time to create one.
Grid Bundle Configuration
The Grid Bundle provides a builder API for defining grids in PHP. Create
a grid class in a new Grid/ directory under Brand/:
// src/Brand/Grid/AdminBrandGrid.php
namespace App\Brand\Grid;
use App\Brand\Entity\Brand;
use Sylius\Bundle\GridBundle\Builder\GridBuilderInterface;
use Sylius\Bundle\GridBundle\Builder\Field\StringField;
use Sylius\Bundle\GridBundle\Builder\Action\CreateAction;
use Sylius\Bundle\GridBundle\Builder\ActionGroup\MainActionGroup;
use Sylius\Bundle\GridBundle\Grid\AbstractGrid;
use Sylius\Bundle\GridBundle\Grid\ResourceAwareGridInterface;
#[AsGrid]
class AdminBrandGrid extends AbstractGrid implements ResourceAwareGridInterface
{
public static function getName(): string
{
return 'app_admin_brand';
}
public function getResourceClass(): string
{
return Brand::class;
}
public function buildGrid(GridBuilderInterface $gridBuilder): void
{
$gridBuilder
->addField(StringField::create('name'))
->addField(StringField::create('code'))
->addActionGroup(
MainActionGroup::create(
CreateAction::create(),
)
)
;
}
}
Key points:
- The
#[AsGrid]attribute auto-registers the grid as a service getName()returnsapp_admin_brand— following the same naming conventionStringFielddefines table columns fornameandcodeMainActionGroupcontains buttons displayed above the table — here a "Create" button to add new brands
Now reference the grid in the Index operation:
new Index(grid: AdminBrandGrid::class),
Refreshing the page — no errors! We have an index page with columns for name and code, and a "Create" button. Clicking "Create" shows a form. After submitting, the new brand appears in the list.
Gotcha: If the create form throws an error about uninitialized properties, make sure your entity fields are initialized with
null(e.g.private ?string $name = null). Symfony forms need an initial value to render the form.
Completing the CRUD
We can create and list brands. Now let's add Update and Delete operations to complete the full CRUD:
use Sylius\Resource\Metadata\Update;
use Sylius\Resource\Metadata\Delete;
#[AsResource(
section: 'admin',
routePrefix: '/admin',
templatesDir: '@SyliusAdmin/shared/crud',
operations: [
new Create(),
new Update(),
new Delete(),
new Index(grid: AdminBrandGrid::class),
],
)]
And add the corresponding grid actions in the ItemActionGroup
— these buttons appear in every row of the table:
use Sylius\Bundle\GridBundle\Builder\Action\UpdateAction;
use Sylius\Bundle\GridBundle\Builder\Action\DeleteAction;
use Sylius\Bundle\GridBundle\Builder\ActionGroup\ItemActionGroup;
$gridBuilder
// ... fields and MainActionGroup ...
->addActionGroup(
ItemActionGroup::create(
UpdateAction::create(),
DeleteAction::create(),
)
)
;
After refreshing: Edit and Delete buttons appear next to each brand. The edit form works, and the delete action shows a confirmation modal before removing the entity.
Admin Menu Integration
So far we have been typing the URL manually. The last step is to add a Brands item to the admin sidebar menu, under the Catalog section.
Sylius builds the admin menu using events. We need an event listener for
sylius.menu.admin.main:
// src/Brand/Menu/AdminBrandMenuListener.php
namespace App\Brand\Menu;
use Sylius\Bundle\UiBundle\Menu\Event\MenuBuilderEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(event: 'sylius.menu.admin.main')]
class AdminBrandMenuListener
{
public function __invoke(MenuBuilderEvent $event): void
{
$menu = $event->getMenu();
$menu->getChild('catalog')
->addChild('brands', [
'route' => 'app_admin_brand_index',
])
->setLabel('Brands')
;
}
}
The listener grabs the existing catalog submenu (the same
section that contains Products, Taxons, etc.) and adds a
brands child that links to our index route.
After refreshing, "Brands" appears in the Catalog section of the sidebar. Clicking it takes you to the brand index page with full CRUD functionality.
Conclusion
In this episode we built a complete CRUD for a custom Brand entity in the Sylius admin panel using:
- A modular entity in
src/Brand/Entity/with proper ORM mapping - Sylius Resource Bundle to register the entity and get auto-generated services (controller, factory, repository, manager)
- Resource operations (Create, Index, Update, Delete) with shared admin templates
- Sylius Grid Bundle to define the index table with fields and action buttons
- An event listener to integrate the new section into the admin sidebar menu
Not so scary, was it? If you are building with Sylius and hit a wall, drop a comment — your feedback helps shape future episodes. There is also a repository with per-episode branches linked in the video description so you can follow along or compare your work.
Presented by Mateusz from Commerce Weavers. If you need help with complex e-commerce projects, you know where to find us.