Symfony UI bundle
- PHP ^8.5
- Symfony ^7.4 || ^8.0
composer require jul6art/ui-bundleThen register it in config/bundles.php (Flex does this for you):
Jul6Art\UiBundle\UiBundle::class => ['all' => true],# config/packages/ui.yaml
ui:
# Leaves the bundle installed and inert when false.
enabled: trueui.enabled is also exposed as a container parameter.
Fifteen form types and the Twig theme that renders them. Extracted from an application that runs all of it.
# config/packages/twig.yaml
twig:
form_themes:
- '@Ui/form/input_group_addon.html.twig'
⚠️ This is the step that fails silently. The types set view variables; the theme is what turns them into markup. Skip it and every field still renders — as a plain input, with the icon gone, nothing in any log and no test failing. Assert on rendered HTML somewhere in the project, not onview.vars.
The shipped markup is Tailwind-flavoured, because a form theme is markup and a bundle shipping
one has to pick a vocabulary. A project on another framework registers its own theme after this
one and redeclares input_group_addon_widget; Symfony takes the last definition.
$builder
->add('email', CustomEmailType::class)
->add('phone', CustomPhoneType::class)
->add('website', CustomUrlType::class)
->add('q', CustomSearchType::class); // magnifier on the leftCustomAddressType, CustomCityType, CustomEmailType, CustomKeyType,
CustomLicensePlateType, CustomPasswordType, CustomPhoneType, CustomSearchType,
CustomSiretType, CustomUrlType, CustomVatNumberType, CustomZipCodeType. Each builds on the
right HTML input — EmailType, TelType, UrlType, SearchType — so browser-side keyboard and
validation come for free.
Every add-on is decoration. Nothing here validates a SIRET, a VAT number or a phone number: validation belongs on the entity, where an import and an API write go through it too.
The types ask for a logical name (email), never for markup. Font Awesome 6 ships as the default,
so a project already using it configures nothing:
# config/packages/ui.yaml
ui:
icons:
email: '<svg class="icon"><use href="#mail"/></svg>' # override one
phone: '' # remove oneOverriding one key keeps the other eleven — the bundle re-merges the defaults, because a prototype config node otherwise replaces the whole map and eleven add-ons vanish at once.
The available names: address, city, email, key, license_plate, password, phone,
search, siret, url, vat_number, zip_code.
$builder
->add('total', CustomMoneyType::class, ['currency' => 'CHF', 'scale' => 2])
->add('duration', CustomUnitType::class, ['unit' => 'h'])
->add('responseTimeHours', CustomCountType::class, ['unit' => 'h']);CustomMoneyType builds on NumberType, not Symfony's MoneyType: MoneyType divides by 100 and
stores integer cents, while this keeps the scalar a decimal column maps to.
A configured currency shows its symbol; one without shows its ISO code as text. That fallback
is correct for a good third of the world's currencies (CHF, PLN, SEK…) and is deliberately not
approximated — a euro sign beside a Swiss-franc amount is a reporting error, not a cosmetic one.
Add symbols with ui.currency_icons, keyed by ISO code.
CustomUnitType with an empty unit renders no add-on at all, so a field whose unit comes from
data degrades to a plain number input rather than an empty box.
CustomCountType is the WHOLE-NUMBER one, and it exists because the other two cannot do the
job. CustomUnitType is parented on NumberType and submits a float: on a property typed
?int — an SLA in hours, a service interval in months — that hands a float to an int setter and
raises a TypeError in strict mode, which is a 500 on a perfectly ordinary entry. The workaround
people reach for next, InputGroupAddOnType directly, is parented on TextType and submits a
string, which fails the same way for the same reason. So "12 h" in the box had three options and
all three were wrong.
It attaches no decimal controller: that controller formats a scale, and a whole number has
none — IntegerType already sets inputmode="numeric" for the keypad.
⚠️ The two DECIMAL types attach aform--decimalStimulus controller that this bundle does not ship. Exposing Stimulus controllers would mean choosing AssetMapper or Encore for every consumer. Write the controller in the project — thousands separator, decimal comma, keystroke filtering — readingdata-form--decimal-decimals-value. Without it the field is a plain, unformatted number input, which works but is not what the type promises. Same forform--password, which the reveal button in the theme is wired to.The attachment appends to
data-controllerrather than replacing it, so a field that already carries a project controller keeps it.
$builder->add('file', TabularFileType::class);TabularFileType is a plain FileType with an accept=".csv,.xlsx" hint on the picker — nothing
more. It carries no mimeTypes constraint: a browser sends application/vnd.ms-excel for a
real binary .xls and for a .csv saved out of Excel, so a MIME allow list strict enough to
admit the second admits the first too. jul6art/dataflow-bundle settled this by reading the
file's own bytes once it reaches the server (SpreadsheetSignature, and each reader's
supports()); a constraint here would be a second, weaker gate that disagrees with that one on
the exact files that matter. The accept attribute is a convenience for the file picker, not a
security boundary — validate by content, downstream, as that bundle does.
An explicit accept (or any other attr) passed to the field is kept and takes precedence.
$builder->add('technician', AutocompleteEntityType::class, [
'class' => User::class,
'choice_label' => 'fullName',
'autocomplete_url' => '/api/users?roles=ROLE_TECHNICIAN&isActive=true',
'text_key' => 'fullName', // the API field shown in the results (default: name)
'query_builder' => static fn (UserRepository $r) => $r->activeTechniciansQuery(),
// 'search_key' => 'search', // the term's query key (default: search)
// 'secondary_key' => 'sku', // appended in parentheses to each result
// 'depends_on' => '#work_order_customer', 'depends_param' => 'customer',
]);The page carries the current value and nothing else; the list comes from the API as the user
types, through the Select2 controller of jul6art/datatable-bundle (ui--select2). A plain
EntityType with a Select2 URL only looks lazy: Symfony still loads and writes every row the
query builder allows — 801 options on one picker, growing with the data.
⚠️ Thequery_builderstill decides what may be posted. It no longer lists, but the posted id is resolved THROUGH it (BoundedLazyChoiceLoader, anIN (:ids)added to that query): a row that exists but that the query excludes — an inactive account, another customer's site — is refused exactly as before. A barefindBy(['id' => $ids])would accept it. Keep the URL and the query builder saying the same thing: a URL wider than the query offers rows the form refuses.
placeholder defaults to '': that empty option is what the Select2 clear button restores
(datatable-bundle ≥ 2.4.4 offers no button without one). Requires doctrine/orm and an entity with
a single identifier.
$builder->add('reference', InputGroupAddOnType::class, [
'right_addon' => '<i class="fa-solid fa-hashtag"></i>',
'right_type' => 'button', // icon | button | text — only `button` is focusable
'right_clickable' => true,
]);Les datatables ont déménagé.
AbstractDataTableConfigProvideretAdminDataTableConfigvivent désormais dansjul6art/datatable-bundledepuis la v2.0.0 de ce bundle. Elles n'avaient de sens qu'au-dessus d'une collection API Platform, dontui-bundlene dépend pas — et ce bundle-ci reste utile à une application qui n'expose aucune API. Le remplacement est un changement de namespace :-use Jul6Art\UiBundle\DataTable\AbstractDataTableConfigProvider; +use Jul6Art\DatatableBundle\DataTable\AbstractDataTableConfigProvider;La configuration passe de
ui.datatable.tenant_*àdatatable.tenant.*.
composer qa # cs-check + rector-check + phpstan (level max) + phpunitRun composer qa, not the single tool you have in mind: the CI's "Coding standards" job runs
Rector too, and its lowest deps job installs the minimum of every constraint — which is where
this ecosystem has repeatedly found what a local run could not.
extra.symfony.require states which Symfony line this bundle targets; the CI enforces it with
SYMFONY_REQUIRE on both the highest and the lowest job. A local composer install may still
resolve a newer Symfony, which broadens what you exercise rather than narrowing it — but it means
the toolchain can propose something that only makes sense on one branch. rector.php skips one
such rule already, with the reason written next to it.
Whatever you do, keep the code free of classes that exist on only one of the declared branches.
A bundle promising ^7.4 || ^8.0 has to hold both.
The UI bundle is open-sourced software licensed under the MIT license.
© 2026 jul6art
