Skip to main content
Version: Next

UI components and Stimulus

The back-office ships its reusable UI as part of the default-twig bundle. The component PHP classes, their Twig templates, the Stimulus controllers and the asset build all live inside templates/backOffice/default-twig/. Nothing is declared in XML and nothing is registered globally. You drop a class in the right folder and the bundle picks it up.

This is the back-office counterpart of the Flexy front-office components. The patterns are the same (Symfony UX TwigComponent + Stimulus). What differs is the surface they target, the admin on Bootstrap 5, and the fact that the back-office layer is TwigComponent-only (see the note below).

Themes are bundles

BackOfficeDefaultTwigBundle registers its own component namespace, Twig paths and Stimulus app. A module never edits the core to add a back-office component. It ships its own bundle, or reuses the ones documented here through Twig.

A concrete component first

Every list screen in the admin renders through a single component, BoDataTable. Its class is a plain final class with public properties, tagged with #[AsTwigComponent]:

// templates/backOffice/default-twig/src/UiComponents/DataTable/DataTable.php
namespace BackOfficeDefaultTwigBundle\UiComponents\DataTable;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent(name: 'BoDataTable', template: '@BackOfficeDefaultTwig/components/DataTable/DataTable.html.twig')]
final class DataTable
{
public string $id = '';

public string $caption = '';

/** @var list<Column> */
public array $columns = [];

/** @var list<array<string, mixed>> */
public array $rows = [];

public string $emptyMessage = '';

public ?string $sortField = null;

public string $sortDirection = 'asc';

// … sort params, hook injection points, etc.
}

You render it from any Twig template with the component() function, the same syntax the Flexy front office uses. The second argument is a hash whose keys map one-to-one to the public properties:

{# templates/backOffice/default-twig/<domain>/list.html.twig #}
{{ component('BoDataTable', {
id: 'currencies',
columns: [
column('name', 'Name'|trans, 'start', 'name'),
column('code', 'ISO 4217'|trans, 'start', 'code'),
column_toggle('visible', 'Visible'|trans, 'toggle_visible_url'),
column_actions('_actions', 'Actions'|trans),
],
rows: rows,
emptyMessage: 'No currency defined yet.'|trans,
sortField: sort_field,
}) }}

The columns prop is a list of Column value objects. You rarely build them by hand: the bundle registers Twig helper functions (column(), column_text(), column_html(), column_toggle(), column_badge(), column_actions(), column_radio(), row_action() in src/Twig/DataTableExtension.php) that return the right Column/RowAction. Each Column carries a ColumnKind (an enum) telling the template how to render its cell:

// templates/backOffice/default-twig/src/UiComponents/DataTable/Column.php
namespace BackOfficeDefaultTwigBundle\UiComponents\DataTable;

final readonly class Column
{
public function __construct(
public string $key,
public string $label,
public ColumnKind $kind = ColumnKind::TEXT,
public string $cellAlign = 'start',
public array $options = [],
public ?string $sortKey = null,
) {
}
}
// templates/backOffice/default-twig/src/UiComponents/DataTable/ColumnKind.php
enum ColumnKind: string
{
case TEXT = 'text';
case HTML = 'html';
case TOGGLE = 'toggle';
case BADGE = 'badge';
case ACTIONS = 'actions';
case RADIO = 'radio';
}

Each kind has its own cell template under components/DataTable/cells/ (text.html.twig, html.html.twig, toggle.html.twig, badge.html.twig, actions.html.twig, radio.html.twig). The ACTIONS kind renders a list of RowAction value objects (edit / delete / view / custom), each optionally gated by a Symfony voter attribute.

Sortable columns and list sort state

Set Column::$sortKey to make a header clickable. Pair it with the ListSort value object (ListSort::fromRequest(...)), which reads the order / direction query parameters, validates them against an allow-list, and hands the result both to your Propel query and to the BoDataTable sortField / sortDirection props.

The component catalog

All components live in src/UiComponents/<Name>/<Name>.php, paired with a Twig template in components/<Name>/<Name>.html.twig. Every one is a final class tagged #[AsTwigComponent(name: 'Bo…', template: '@BackOfficeDefaultTwig/…')].

Component nameClassPurpose
BoDataTableUiComponents\DataTable\DataTableList table: typed columns, sortable headers, row actions, module hook injection points
BoPaginationUiComponents\Pagination\PaginationPage links for a paginated list (windowed, with ellipsis)
BoCreateDialogUiComponents\CreateDialog\CreateDialogBootstrap modal wrapping a Symfony FormView for "create" forms
BoConfirmDialogUiComponents\ConfirmDialog\ConfirmDialogModal confirming a destructive action (delete), posts to formAction with a CSRF token
BoWarningDialogUiComponents\WarningDialog\WarningDialogInformational / blocking modal with a single OK button
BoFetchDialogUiComponents\FetchDialog\FetchDialogModal whose body is fetched on demand from a data-fetch-url
BoDashboardUiComponents\Dashboard\DashboardHome dashboard: KPI cards + charts, period-aware
BoLanguageSwitcherUiComponents\LanguageSwitcher\LanguageSwitcher"Edit in <language>" switcher on i18n edit screens
BoSaveModeToolbarUiComponents\SaveModeToolbar\SaveModeToolbar"Save" / "Save and close" / "Close" toolbar on edit screens

A few notes on the more involved ones:

  • BoCreateDialog takes a Symfony FormView as its form prop, plus formAction, submitLabel, size, and an optional fieldsTemplate to override the default field rendering.
  • BoConfirmDialog posts to its formAction (default method="post") and includes a CSRF token by default (token: true). It exposes hook / hookContext props so modules can inject content via the back-office hook system.
  • BoDashboard is the only component with constructor dependencies: it injects DashboardStatsProvider and the RequestStack, reads the period query parameter, and computes the stats for the current locale. The autowiring is automatic (see below), and you still render it as {{ component('BoDashboard') }}.
Component classes are auto-discovered

The bundle loads BackOfficeDefaultTwigBundle\ from src/ with autowire() and autoconfigure() enabled (see BackOfficeDefaultTwigBundle::loadExtension()). Because Symfony UX TwigComponent registers the #[AsTwigComponent] attribute for autoconfiguration, every class under src/UiComponents/ becomes a component with no extra service declaration: no XML, no services.yaml entry. Add a new final class with the attribute, add its template under components/, and you are done.

No LiveComponents in the back-office

Unlike the Flexy front office, the back-office bundle uses only #[AsTwigComponent]. There is no #[AsLiveComponent], no LiveProp, no LiveAction in src/UiComponents/. Interactivity comes from Stimulus controllers, Bootstrap 5 JS and HTMX rather than from server-rendered live re-rendering. If you need the LiveComponent pattern (reactive props, server round-trips), see the front-office reference linked at the end.

Stimulus controllers

Client-side behavior lives in assets/controllers/. Each file is one Stimulus controller; the filename maps to the controller identifier used in data-controller:

templates/backOffice/default-twig/assets/controllers/
├── bo-sortable_controller.js → data-controller="bo-sortable"
├── bo-inline-edit_controller.js → data-controller="bo-inline-edit"
├── bo-date-range_controller.js → data-controller="bo-date-range"
├── bo-chart_controller.js → data-controller="bo-chart"
├── bootstrap-bridge_controller.js → data-controller="bootstrap-bridge"
└── … (50 controllers)

Back-office controllers are prefixed bo- and named in kebab-case. A controller is a standard Hotwired Stimulus class:

// templates/backOffice/default-twig/assets/controllers/confirm-modal_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
static values = {
message: { type: String, default: 'Are you sure?' },
};

confirm(event) {
if (!window.confirm(this.messageValue)) {
event.preventDefault();
event.stopImmediatePropagation();
}
}
}

Wire it in Twig the usual Stimulus way:

<button data-controller="confirm-modal"
data-action="click->confirm-modal#confirm"
data-confirm-modal-message-value="{{ 'Delete this item?'|trans }}">
{{ 'Delete'|trans }}
</button>

The bootstrap-bridge controller

bootstrap-bridge_controller.js initializes Bootstrap 5 tooltips and popovers for any element inside its scope, replacing the historical jQuery $.tooltip() pattern. It is typically attached to <body>:

<body data-controller="bootstrap-bridge">
<button data-bs-toggle="tooltip" title="{{ 'Help'|trans }}">…</button>
</body>

It imports Tooltip and Popover from bootstrap and disposes them on disconnect(), so the bridge plays nicely with dynamically inserted DOM.

Controllers are registered explicitly

The Stimulus app is started in assets/bootstrap.js, which imports every controller from assets/controllers/ and registers it under its identifier. AssetMapper serves these files as-is, with no build-time discovery: a new controller must be imported and registered in bootstrap.js.

Building the assets

The bundle serves its assets through Symfony AssetMapper: no Node.js, no bundler. The entry point is assets/app.js (which imports bootstrap.js, Bootstrap and HTMX), loaded as an ES module, and the third-party libraries are committed as ES modules under assets/vendor/. Only the SCSS is compiled, by symfonycasts/sass-bundle, which downloads a standalone dart-sass binary on first use:

# build the stylesheet (bin/install runs it on a fresh install)
ddev exec php bin/console sass:build

# rebuild on change during development
ddev exec php bin/console sass:build --watch
Rebuild after editing SCSS

A SCSS change is only visible after sass:build (or with a running sass:build --watch). JavaScript changes need no build. After editing a Twig template, clear the cache: ddev exec php Thelia cache:clear -e dev.

Learn more