15-Minute Sylius

Episode 2: Resource & Grid Bundle (CRUD)

SyliusResource BundleGrid BundleCRUDDoctrine ORM

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 final because 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 in src/Entity/, but our entity lives in src/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
{
    // ...
}

ResourceInterface is 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 actions
  • app.factory.brand — creates new instances of Brand
  • app.repository.brand — fetches Brand entities from the database
  • app.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() returns app_admin_brand — following the same naming convention
  • StringField defines table columns for name and code
  • MainActionGroup contains 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:

  1. A modular entity in src/Brand/Entity/ with proper ORM mapping
  2. Sylius Resource Bundle to register the entity and get auto-generated services (controller, factory, repository, manager)
  3. Resource operations (Create, Index, Update, Delete) with shared admin templates
  4. Sylius Grid Bundle to define the index table with fields and action buttons
  5. 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.