Symfony bundle providing a dynamic block system for pages and content entities, managed through EasyAdmin with drag-and-drop reordering.
- Dynamic block system with per-kind forms and templates
- Media uploads per block via VichUploader (auto-configured)
- Multi-file upload for kinds that opt in (
slider,articleout of the box) - select several files at once instead of adding them one by one - Drag-and-drop position ordering for blocks and media
- One-click duplication of a block or a media row in EasyAdmin, including its files
- Live preview of a newly picked image in EasyAdmin, before saving
- Site-wide media roles (favicon, apple-touch-icon, og-image, logo) retrievable anywhere via
site_media() - Media Library in EasyAdmin: browse every
Mediaregardless of how it's attached, and see where it's used - AJAX kind-switcher in EasyAdmin
- Extensible: register your own block kinds via a service tag
- Automatic CSS injection: bundles declare their stylesheets via a service tag, rendered by
bundle_stylesheets()in Twig - Reusable drag-and-drop sortable script for any EasyAdmin
CollectionField
- PHP >= 8.0
- Doctrine ORM
- EasyAdmin
- VichUploader Bundle
- Ghostscript (
gsbinary) installed on the server — required for automatic PDF thumbnail generation (see PDF thumbnails). Optional otherwise: without it, PDF uploads still work, but no.webpthumbnail is generated.
composer require c975l/ui-bundlephp bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrateAdd one entry to importmap.php (one-time, at installation):
'@c975l/ui-bundle/controllers.js' => [
'path' => './vendor/c975l/ui-bundle/assets/controllers.js',
],Add two lines to assets/bootstrap.js (or assets/stimulus_bootstrap.js):
import { startStimulusApp } from '@symfony/stimulus-bundle';
import { register as registerc975lUi } from '@c975l/ui-bundle/controllers.js';
const app = startStimulusApp();
registerc975lUi(app);Blocks are managed through EasyAdmin at /management, provided by c975l/config-bundle. Its dashboard does not load your site's main app AssetMapper entry — that would drag your front-end stylesheet (and unused front-end controllers) into the back-office and break EasyAdmin's own Bootstrap/AdminLTE styling. Instead, it loads a dedicated entry, @c975l/ui-bundle/admin.js.
block, eaSortable and the Trix editor integration are back-office-only, so they live in controllers-admin.js (separate from controllers.js, which only holds front-end controllers). The bundle ships a ready-to-use entrypoint for them — no file to create in your app.
Add one entry to importmap.php (one-time, at installation), pointing directly at the bundle's file:
'@c975l/ui-bundle/admin.js' => [
'path' => './vendor/c975l/ui-bundle/assets/admin.js',
'entrypoint' => true,
],That's it — eaSortable, block, and Trix are then available on every /management page.
Blocks are linked to their owner via a ManyToMany join table. The Block entity itself has no FK back to any specific owner — this keeps UiBundle fully decoupled from your domain entities. Each owner entity defines its own join table, and the BlockOrphanListener (auto-registered by the bundle) removes detached blocks on flush.
use c975L\UiBundle\Contract\HasBlocksInterface;
use c975L\UiBundle\Entity\Block;
use c975L\UiBundle\Entity\Trait\HasBlocksTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
class Page implements HasBlocksInterface
{
use HasBlocksTrait;
#[ORM\ManyToMany(targetEntity: Block::class, cascade: ['persist', 'remove'])]
#[ORM\JoinTable(name: 'site_page_block')]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $blocks;
public function __construct()
{
$this->blocks = new ArrayCollection();
}
}Key points:
- Use
ManyToMany(notOneToMany) —Blockhas no FK back to the owner. - Name the join table explicitly (e.g.
site_page_block) to avoid collisions. cascade: ['persist', 'remove']ensures blocks are saved and deleted with the owner.- Do not add
orphanRemoval— theBlockOrphanListenerhandles that automatically when you callremoveBlock().
After adding the mapping, generate and run the migration to create the join table:
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrateWhen you call $page->removeBlock($block), the trait queues the block in a pendingBlockRemovals list instead of immediately removing it. The BlockOrphanListener (Doctrine preFlush listener) then calls $em->remove($block) for each queued block before the flush completes. This ensures blocks are properly deleted from the database even though the relationship is ManyToMany.
Add a CollectionField using BlockType as entry type. The AJAX kind-switcher and drag-and-drop are handled automatically by the Stimulus controllers registered via @c975l/ui-bundle/controllers.js — no manual configureAssets call is needed:
use c975L\UiBundle\Form\BlockType;
class PageCrudController extends AbstractCrudController
{
public function configureFields(string $pageName): iterable
{
return [
// ...
CollectionField::new('blocks')
->setLabel(t('label.blocks', [], 'ui'))
->setEntryType(BlockType::class)
->allowAdd()
->allowDelete()
->setFormTypeOption('by_reference', false)
->hideOnIndex(),
];
}
}Drag-and-drop reordering is handled automatically by the eaSortable Stimulus controller registered via @c975l/ui-bundle/controllers.js. No configureAssets call is needed.
Requirement: each collection item must contain a hidden position field whose name ends with [position]. The script detects it automatically.
Expose a hidden position field in your collection entry type and order the collection by position on the entity side — the grip handle and drag behaviour are added automatically.
The bundle ships the following kinds out of the box (see config/services.yaml for the exact service definitions):
| Kind | Category | Form type | Template |
|---|---|---|---|
alert |
Elements | AlertType |
blocks/Alert.html.twig |
article |
Elements | ArticleType |
blocks/Article.html.twig |
audio |
Media | AudioType |
blocks/Audio.html.twig |
banner_title |
Media | BannerTitleType |
blocks/BannerTitle.html.twig |
button |
Elements | ButtonType |
blocks/Button.html.twig |
card |
Elements | CardType |
blocks/Card.html.twig |
collection |
Page sections | CollectionType |
blocks/Collection.html.twig |
cta_band |
Page sections | CtaBandType |
blocks/CtaBand.html.twig |
document_download |
Elements | DocumentDownloadType |
blocks/DocumentDownload.html.twig |
expertise_banner |
Page sections | ExpertiseBannerType |
blocks/ExpertiseBanner.html.twig |
feature_bar |
Page sections | FeatureBarType |
blocks/FeatureBar.html.twig |
form |
Forms | FormPickerType |
components/Form/FormBlock.html.twig |
hero |
Page sections | HeroType |
blocks/Hero.html.twig |
image |
Media | ImageType |
blocks/Image.html.twig |
image_compare |
Media | ImageCompareType |
blocks/ImageCompare.html.twig |
portfolio_grid |
Page sections | PortfolioGridType |
blocks/PortfolioGrid.html.twig |
process_steps |
Page sections | ProcessStepsType |
blocks/ProcessSteps.html.twig |
progress_bar |
Elements | ProgressBarType |
blocks/ProgressBar.html.twig |
rich_snippet |
SEO | RichSnippetType |
blocks/RichSnippet.html.twig |
section_cards |
Page sections | SectionCardsType |
blocks/SectionCards.html.twig |
slider |
Media | SliderType |
blocks/Slider.html.twig |
text_readmore |
Text | ReadmoreType |
blocks/TextReadmore.html.twig |
text_section |
Text | TextSectionType |
blocks/TextSection.html.twig |
video |
Media | VideoType |
blocks/Video.html.twig |
video_iframe |
Media | VideoIframeType |
blocks/VideoIframe.html.twig |
Maintenance note: update this table whenever a kind is added, renamed, or removed in
config/services.yaml.
Every "Page sections" kind above (hero, feature_bar, section_cards, expertise_banner, process_steps, portfolio_grid, cta_band, collection) has an optional Anchor field, letting an editor build a one-page nav (a menu_link block - see c975L/SiteBundle's README - pointing straight at a section of the same page).
- Typing an anchor (e.g.
Services) slugifies it (services). Leaving it empty falls back to slugifying the block's own title. - The final HTML
idrendered on the section is always{slug}-{block.id}(e.g.services-42) - the trailing block id is added at render time, not stored, so two blocks of the same kind on the same page (or the same title reused elsewhere) never collide. - In
SiteBundle's Menu admin, amenu_linkblock's target select lists every page's anchored sections alongside its pages/routes (Home → Services), decoded byMenuExtension::getMenuLinkUrl()into/home#services-42. - Every
url-style field onbutton,card,cta_band,heroandportfolio_grid(e.g.primaryUrl,ctaUrl,linkUrl) is a plainTextType, not Symfony'sUrlType— so an editor can point one straight at an in-page anchor (#services-42) or a relative path, not just an absolute URL.
Implemented by c975L\UiBundle\Service\BlockAnchorSlugger (the slug logic) and c975L\UiBundle\Form\Block\HasAnchorFieldTrait (the reusable field + FormEvents::SUBMIT listener). To add the same anchor field to a new "section" kind, in any bundle (own or third-party) that requires c975l/ui-bundle:
use c975L\UiBundle\Form\Block\HasAnchorFieldTrait;
use c975L\UiBundle\Service\BlockAnchorSlugger;
class MySectionType extends AbstractType
{
use HasAnchorFieldTrait;
public function __construct(private readonly BlockAnchorSlugger $anchorSlugger)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$this->addAnchorField($builder, $this->anchorSlugger); // 2nd arg: title field name, defaults to "title"
// ...your own fields...
}
}Then, in the kind's template ({'block' => $block} + $block->getData() is what render_block() passes it - see "Registering a custom block kind" below), compose the final id and pass it to your section's outer tag:
<section{% if anchor %} id="{{ anchor }}-{{ block.id }}"{% endif %}>...</section>No services.yaml entry is needed for BlockAnchorSlugger itself: it's autowired like any other service, from any bundle whose services.yaml scans its own src/ (the convention already used by every c975L bundle) - the same way SocialBundle's SocialLinkEntryType already reuses UiBundle\Form\IconPickerType across bundles.
Run bin/console c975l:ui:block:create (requires symfony/maker-bundle in require-dev) to scaffold the FormType, template and test below for a new kind - it prints the services.yaml snippet to add once it's done. The rest of this section describes what that snippet means and the manual steps if you'd rather write them yourself.
This command runs in a consuming app (it needs a booted kernel with symfony/maker-bundle installed) and generates into that app's own App\ namespace - it's meant for a one-off kind specific to that app, not for adding to UiBundle's own built-in catalog above. A new built-in kind (living in c975L\UiBundle\Form\Block\, wired into UiBundle's own config/services.yaml, with translations in all 3 translations/ui.*.xlf and a fixture entry in BlockFixtureProvider) is still written by hand, matching the existing kinds' conventions.
Declare a service with the ui.block tag in your bundle's services.yaml:
services:
ui.block.booking:
class: stdClass
tags:
- name: ui.block
kind: booking
label: Booking
description: A short reservation form # optional, shown under the label in the kind picker
category: Reservations
form: App\Form\Block\BookingType
template: '@App/blocks/booking.html.twig'
pickable: true # required - see below
priority: 80 # optional, defaults to 0 - see below
cacheable: true # required - see below
contexts: booking # optional, comma-separated, defaults to none - see belowCreate the form type to define the data sub-fields, and the Twig template to render the block on the front end. The form data is stored as JSON in the Block::$data column.
pickable and cacheable don't have a functional default in practice - declare both explicitly on every kind, so the behavior is readable in services.yaml without having to check BlockRegistryPass's fallback logic.
Set pickable: false for a singleton kind: one meant to be managed through its own dedicated EasyAdmin entry (see c975L/SocialBundle's SocialLinksCrudController for an example) and rendered wherever needed via BlockRepository::findOneByKind(), rather than attached per-page. This hides it from the generic per-page block picker (BlockRegistry::groupedByCategory()), so editors can't accidentally create independent, separately-filled copies of it on individual pages. Regular, repeatable kinds (card, text_section, contact_form...) should use true.
priority controls the kind's position within its category in the picker (higher shows first, same convention as the ui.stylesheet/ui.script tags); kinds sharing the same priority (default 0) fall back to alphabetical order.
The originating bundle of a kind is derived automatically from its template's @c975LXxx/... Twig namespace (no tag attribute to fill in) and exposed via BlockRegistry::getBundle()/groupedByBundle() - the latter mirrors groupedByCategory() but groups by bundle instead, for building a showcase page per bundle rather than the kind-picker's functional grouping.
contexts restricts a kind to one or more named contexts (e.g. menu) instead of it being offered everywhere BlockType is used — useful for a kind that only makes sense on one entity, not on every blocks collection in the app (e.g. SiteBundle's menu_link, restricted to contexts: menu so it doesn't show up in a Page's own block picker). Leave it unset (the default) for a kind meant to be usable anywhere, like the built-in kinds above. To make a BlockType field filter by context, pass the context form option:
CollectionField::new('blocks')
->setEntryType(BlockType::class)
->setFormTypeOptions(['context' => 'menu'])
// ...A CollectionField that doesn't set context (the default, null) sees every pickable kind regardless of its declared contexts — existing integrations keep working unchanged until they opt in.
media_required: true rejects saving a block of that kind when it has no attached media at all (enforced by RequiredMediaValidator on the Block entity itself, not by the form) — use it for a kind whose media isn't optional decoration but the whole point of the block (e.g. banner_title's background image). Defaults to false; only meaningful alongside media_types.
media_multi_upload: true adds a "select several files at once" input next to the usual one-file-per-row media collection, for a kind where editors routinely add many files at a time (e.g. slider, article) instead of clicking "Add" repeatedly. Each selected file becomes its own media entry, appended after the existing ones. Defaults to false; only meaningful alongside media_types.
There is no EasyAdmin block gallery anymore - it was removed entirely, not just unlinked. Its preview variants needed inline scripts for interactivity (slider, image_compare...), and a hash/nonce-based CSP (e.g. nelmio_security's csp.hash config) can never authorize a script trapped inside an <iframe srcdoc="..."> attribute string - that class of CSP tooling scans a response's literal <script>/<style> elements, and content inside a srcdoc string is invisible to that scan. Not a bug in the gallery's own templates, a structural incompatibility.
The sidebar's "Links" section (see c975L\UiBundle\Management\MenuProvider::getLinks()) instead links out to https://975l.com/pages/blocks, the c975L ecosystem's own canonical showcase of every bundle's block kinds - rendered inline in a normal page, no iframe, no CSP conflict. This is a fixed link shipped by the bundle itself, the same for every consuming app.
The fixture/showcase machinery the old gallery used (BlockFixtureProviderInterface, GalleryShowcaseProviderInterface, BlockFixtureMediaAttacher...) wasn't removed - it's what powers that /blocks page, and is available to any consuming app wanting to build its own equivalent showcase page (a plain controller/template, not an EasyAdmin/iframe one).
Kinds whose media_types (see above) start with image/, video/ or audio/ automatically get a placeholder attached (two images for image_compare, two images plus a video for slider, one otherwise) - no fixture provider needs to handle media itself. This is done by c975L\UiBundle\Service\BlockFixtureMediaAttacher, a public service that any consuming app can reuse for its own showcase page - see e.g. 975l.com's public /blocks, which calls attach(Block $block, string $kind) the same way instead of maintaining its own app-specific media mapping. Images are drawn in rotation from a small pool of 5 (BlockFixtureMediaAttacher::PLACEHOLDER_IMAGES) rather than a single repeated one, so consecutive kinds on the same page don't all show the same photo - call reset() at the start of a request/loop building several blocks so the rotation restarts at the same photo every time. These photos are Laurent Marquet's own work, used here with his permission. The placeholder video (public/videos/gallery-video.mp4) is AI-generated - Video generated by Leonardo.Ai https://leonardo.ai. The placeholder audio clip (public/audio/gallery-audio.mp3) is also AI-generated - Music generated by Mubert https://mubert.com/render. A kind with no fixture at all should show a "no example yet" placeholder instead of crashing, same as the old gallery did.
Implement BlockFixtureProviderInterface (auto-discovered the same way as BundleWhatsNewProviderInterface - no tag needed, just register the service). Each kind maps to one or more named variants - use '' as the only key when a single example is enough, or several labelled keys to show every visual style side by side (see alert's info/success/warning/danger or button's primary/secondary/success/danger/link in UiBundle's own BlockFixtureProvider):
use c975L\UiBundle\Contract\BlockFixtureProviderInterface;
class BookingBlockFixtureProvider implements BlockFixtureProviderInterface
{
public function getFixtures(): array
{
return [
'booking' => [
'' => ['title' => 'Réserver une table'],
],
];
}
}A kind with no registered fixture (empty array, or not returned at all) simply shows the "no example yet" placeholder - the gallery never breaks when a bundle hasn't caught up yet.
Some content is worth showing in the gallery but doesn't fit BlockFixtureProviderInterface, typically because rendering it through the real block/kind never reflects the fixture's own data anyway (e.g. SocialBundle's social_links_display always renders the site-wide singleton regardless of its own data, and share_buttons() isn't a block kind at all) or because the real template only renders something once resolved against live data this provider can't fabricate (e.g. a kind resolved against a real Page/route, or against an external source CollectionSourceProviderInterface supplies). For these, implement GalleryShowcaseProviderInterface instead (same auto-discovery, no tag needed) and render the underlying component/Twig function directly with made-up sample data, bypassing whatever real lookup it would otherwise do:
use c975L\UiBundle\Contract\GalleryShowcaseProviderInterface;
use Twig\Environment;
class BookingGalleryShowcaseProvider implements GalleryShowcaseProviderInterface
{
public function __construct(private Environment $twig) {}
public function getShowcases(): array
{
return [
'Booking widget' => [
'description' => 'Available layouts for the standalone booking widget.',
// "kind" ties this to "booking"'s own category and suppresses its own regular preview
// card (which would otherwise show up empty right next to this one) - use null if there's
// no real block kind at all (e.g. share_buttons()). "category" overrides the category
// directly instead (no suppression) - for a kind-less showcase that still belongs next
// to a related one, e.g. reusing a sibling kind's own category key.
'kind' => 'booking',
'variants' => [
'Compact' => $this->twig->render('@App/booking/widget.html.twig', ['layout' => 'compact']),
'Full' => $this->twig->render('@App/booking/widget.html.twig', ['layout' => 'full']),
],
],
];
}
}Each variant is already-rendered HTML (a plain string) rather than a Block - the gallery wraps it in the same isolated <iframe> a block preview gets. 'wide' => true originally rendered a showcase's card wider, for a component whose real styles only apply above a CSS breakpoint (e.g. share_buttons() hides itself entirely below 768px). The gallery now renders every item full-width by default, so this flag is currently a no-op there - it's kept in the interface so an existing provider that sets it (e.g. SocialBundle's share_buttons()) doesn't break. See UiBundle's own BlockFixtureProvider's class comment, and SocialBundle's GalleryShowcaseProvider for a real example.
A generic, shared "form definition" system (Entity\Form/Entity\FormField, tables site_form/site_form_field) - any bundle can manage its own named row (e.g. ContactFormBundle's "contact") in one place instead of keeping a private fields table, and an editor can also build a form entirely through the admin, with no bundle/code involved at all.
Controller\Management\FormCrudController(menu entry under SiteBundle's "Forms", if installed) lists/creates/edits everyForm. Each row'sfieldscollection is a drag-and-dropCollectionFieldofFormFieldTypeentries (text/textarea/email/checkbox), reordered the same wayslider/articlemedia already are (seeassets/js/ea-sortable.js). A field's programmaticname(the HTML input name, the notification email key) is derived automatically from itslabelbyService\FormFieldNameron save, scoped unique within the owningForm.FormField::$restricted/Form::$restricted: a field or a whole form seeded by its owning bundle (e.g. register'semail/plainPasswordfields, or ContactFormBundle's"contact"form itself) keeps its core identity locked (field type + deletion, or the form's ownname) while staying reorderable/relabellable - enforced server-side, not just hidden by CSS.- The
formblock kind (Form\Block\FormPickerType, templatecomponents/Form/FormBlock.html.twig) embeds anyFormby name, anywhere a block can go. Rendering and submission handling is done byController\FormController(routesui_form_submit/ui_form_fragment), not the block itself. Contract\FormActionInterface/Registry\FormActionRegistrylet a bundle process aForm's submission (tagged provider, onegetKey()per implementation) without UiBundle knowing what that action actually does -Form::$actionstores which key handles a given form,Form::$actionConfig(raw JSON, editable straight fromFormCrudController) is free-shape config read only by that action.Service\SendEmailFormAction(keysend_email) is the built-in provider: it lets a form built purely through the admin still notify someone by email, configured viaactionConfig'sto/from/replyTo/subject/template/senderEmailField/offerReceiveCopy(all optional - unset ones fall back to the site-wideemail-*config keys/a default template). It's backed by a genericService\EmailService(Model\EmailSendRequestin, bool out,getLastError()/consumeDebugPreview()for theROLE_SUPER_ADMIN+email-debugpreview instead of a silent real send). ImplementContract\DebugPreviewCapableInterfaceon your own action to get the same debug-preview behavior.- Protection, shared with every other c975L public form (contact/register/reset): a rotating honeypot + submission-timing check (
Service\FormBotProtection, merges what used to be separate SiteBundle/ContactFormBundle implementations), site-wide GDPR checkbox and reCAPTCHA v3 (Service\ReCaptchaFactory/Form\Extension\Recaptcha3TypeExtension, a no-op unlesskarser/karser-recaptcha3-bundleis registered), and an optional shared rate limiter (Service\RateLimiterGuard, configurelimiter.ui_forminconfig/packages/rate_limiter.yamlto enable it - unconfigured, nothing is rate-limited). Service\FormPrefillHelperlets app code pre-fill (and lock) aForm's field(s) from session right before redirecting a visitor to it (e.g. a listing page's "Contact us about this" link setting thesubjectfield) - no query string needed, cleared automatically once the submission succeeds.
Two independent, optional features sharing one display name (hardcoded "Donovan", see AiRephraseExtension::assistantName()), both disabled by default and config-driven through ConfigBundle (c975l:config:load-all loads their default rows from config/configs.json). This bundle makes no assumption about what's behind either feature - bring your own backend. No default endpoint, no default API key, no bundled AI provider: every value below starts null/false, and each feature stays entirely inert until a consuming app fills the rest in.
The sidebar link reads "Donovan (AI Agent)" - the "(AI Agent)" half is translated (label.ai_assistant_menu_suffix), composed once in MenuProvider::getLinks() rather than left as a translation key, since MenuBuilder only ever calls trans() on the whole label as one string.
A free-text question box (AiAssistantController::index()/ask()). ROLE_SUPER_ADMIN only - stricter than the rest of the page ("site-role-admin", see below): this calls a backend that's typically a shared/mutualized resource paid for by whoever operates it (e.g. Laurent's own 975l.com, across every one of his client sites), so who can spend against it is deliberately kept narrow rather than opened to every editor, even though answering "which block should I use" would otherwise suit editors best. The dashboard section of the AI Assistant page is hidden entirely for a viewer without that role, not just disabled.
| Config slug | Purpose |
|---|---|
ui-ai-assistant-dashboard-enabled |
Master switch - while false, the page's dashboard section shows setup steps and a link to Config instead of the question box |
ui-ai-assistant-dashboard-endpoint |
Plain HTTP URL the question is POSTed to ({"question": "..."}) |
ui-ai-assistant-dashboard-token (sensitive) |
Bearer token sent with the request |
The actual call is made by AiAssistantClient, the default implementation of Contract\AiAssistantClientInterface::ask(string $question): ?array{answer: string, sources: array{label: string, url: string}[]}. It only knows how to POST to a URL and parse {"answer": "...", "sources": [{"label": "...", "url": "..."}]} back - it has no idea what's on the other end, what data backs the answer, or whether that endpoint mutualizes questions across several sites. sources is always present (defaults to [] if a backend omits it) - a plain {label, url} pair per citation, never a bare identifier a frontend would have to resolve into a URL itself, since this bundle makes no assumption about what URL scheme a backend's citations resolve to. Override the service (standard Symfony service decoration/alias) to swap in a different transport - e.g. a purely local implementation with no network call at all.
Self-hosting your own backend instead of a shared/mutualized one. Nothing here requires 975l.com specifically - any consuming app can point ui-ai-assistant-dashboard-endpoint/-token at a controller of its own, as long as it honors the contract above. Minimal shape:
- A route accepting
POSTwithAuthorization: Bearer <token>(check it yourself, no Symfony Security expected -AiAssistantClientsends a plain bearer header) and aquestionfield (JSON or form-encoded, your choice), returning the{answer, sources}shape above. - To actually answer "which block should I use", the endpoint needs context about your block system: build a prompt from
BlockRegistry::all()(kind,getLabel(),getDescription(),getCategory()per entry) so the LLM only ever cites real kinds instead of hallucinating one - thesourcesyou return should point wherever your own block gallery/showcase lives (see "Gallery Showcase Providers" below), not a fixed URL this bundle can't know. - Same provider choice as the rephrase feature below (Anthropic native, or any OpenAI-compatible API like Euria/Infomaniak) - nothing here forces reusing
AiRephraseClient's config namespace, a self-hosted endpoint is free to read its own config slugs for its own key/provider/model. - This is exactly what 975l.com's own
AiHelpController/AiHelpServicedo (not shipped in this bundle - it's app-specific, not a bundle concern) for every one of Laurent's client sites, so they don't each need their own key: a working reference if you want to see the shape end-to-end, but self-hosting yours doesn't require reading it.
Since this is several files, not one: php bin/console c975l:ui:donovan-qa:create (needs symfony/maker-bundle, dev-only, see "Registering a custom block kind" above for the same dev-dependency note) scaffolds a working skeleton matching the shape above - a controller (App\Controller\Api\DonovanQaController, route POST /api/donovan-qa/ask), an LLM client (App\Service\DonovanQaLlmClient, same Anthropic/Euria dispatch as AiRephraseClient), a block-context builder (App\Service\DonovanQaContextBuilder, wraps BlockRegistry), a status/setup-guide Twig extension + template override for this same page (so a "Donovan (Q&A)" section appears here too, matching the two sections above), and a test skeleton. It prints the config/configs.json snippet for the 6 needed config slugs (donovan-qa-llm-enabled, donovan-qa-llm-provider/-api-key/-model/-base-uri, donovan-qa-authorized-tokens) to paste rather than writing it - same reasoning as c975l:ui:block:create not touching services.yaml: creating/loading app-level config entries is app-specific (ConfigCrudController disables manual Config creation on purpose, so an app needs its own loadDefaultConfig()-calling command if it doesn't have one yet, see 975l.com's own AppConfigLoadCommand for a 15-line model). sources[].url in the generated context builder is a placeholder ('') - fill in wherever your own block gallery/showcase lives, this bundle can't guess it.
A "rephrase" action calling the editor's own AI provider directly - no intermediary, and the rephrased content itself is never persisted or logged, the request/response round-trip is otherwise stateless (AiAssistantController::rephrase() / AiRephraseClient). "site-role-admin" (ROLE_ADMIN by default) - lower than the dashboard assistant's ROLE_SUPER_ADMIN since this spends the site's own key/budget, not a shared one, but still above a plain editor. The only thing that outlives the request is an aggregate token count (see below) - a number reveals nothing about what was rephrased. The button only appears when the feature isEnabled() - no permanently-visible-but-disabled state, matching the dashboard assistant's own page. Works on plain text: rich formatting (bold, links, lists...) is not preserved across a rephrase, a deliberate scope limit rather than an oversight.
The result is never a straight replacement - ai-rephrase.js appends it after the original, separated by \n\n---\n\n, so both stay directly editable in the same field. An editor keeps, deletes or merges either side by hand, rather than losing the original the moment a rephrase comes back or needing a separate "apply" step.
Two independent selects are sent with the request: a style (AiRephraseClient::getStyles() - neutral/professional/friendly/concise/persuasive/simple/enthusiastic/expanded) and a length (AiRephraseClient::getLengths() - same/shorter/longer, defaulting to same). Both index a closed const map on the server side - an unexpected/tampered value falls back to its default rather than being forwarded to the LLM, so neither can be used to inject arbitrary prompt instructions.
Every Trix-edited field gets it automatically (block_theme.html.twig's trix_editor_widget, no per-field wiring needed). A plain textarea does not, by default: the same block also renders technical/structured values dashboard-wide (e.g. ConfigBundle's Config CRUD renders its json-kind values through a plain textarea too), so showing the button unconditionally there would be actively wrong. A plain-text content field opts in explicitly:
TextareaField::new('summary')
->setFormTypeOption('attr', ['data-ai-rephrase' => 'true']),(see SiteBundle's PageCrudController::configureFields(), summarySocialNetwork, for a real example)
A third, field-independent spot: once enabled, the AI Assistant page itself shows a free-standing textarea wired to the same _ai_rephrase.html.twig partial and ai-rephrase.js controller - useful for rephrasing arbitrary text that isn't tied to any CMS field at all (a draft, something pasted in from elsewhere...).
| Config slug | Purpose |
|---|---|
ui-ai-assistant-rephrase-provider |
anthropic, openai or euria |
ui-ai-assistant-rephrase-api-key (sensitive) |
The editor's own key for that provider - billed to whoever owns it |
ui-ai-assistant-rephrase-base-uri |
Required only for euria (or any other OpenAI-compatible API): its base URI, since Euria/Infomaniak AI Tools exposes an OpenAI-compatible API under a different host |
ui-ai-assistant-rephrase-model |
Optional for anthropic/openai (each falls back to a reasonable default), mandatory for euria - its catalog isn't static enough to hardcode a default, isEnabled() stays false without it. Recommended: mistralai/Mistral-Small-4-119B-2603 |
anthropic and openai are called with their native APIs; any other value is treated as an OpenAI-compatible API using ui-ai-assistant-rephrase-base-uri as its host - this is how Euria is supported without provider-specific code.
A style selector (neutral/professional/friendly/concise) sits next to the button, backed by AiRephraseClient::rephrase(string $text, string $style = 'neutral') - each style maps to a fixed prompt fragment server-side (AiRephraseClient::STYLES), a closed list the request's raw style value is only ever used to index, never interpolated into the prompt itself, so a tampered value can't inject arbitrary instructions. getStyles(): array is the whole surface a custom UI needs to build its own selector.
Every provider response already includes its own token usage - AiRephraseClient reads it and hands it to AiUsageTracker, which rolls it up into one AiUsage row per calendar month (inputTokens/outputTokens/requestCount), not one row per request: a per-request log would tie a token count to a timestamp close enough to correlate with a specific edit, which the "nothing is persisted" promise above is precisely there to avoid. AiUsageTracker::getCurrentMonth() is the read side - shown on the AI Assistant page when the rephrase feature is enabled.
A failed call (bad/revoked key, provider outage...) is recorded on the same row instead of silently swallowed, and cleared automatically on the next successful call. While a failure is recorded, AiAlertProvider (implementing ConfigBundle's AlertProviderInterface) surfaces a dashboard-wide warning - so a broken key doesn't go unnoticed until an editor happens to report it. No proactive key validation on save: an invalid key is a normal runtime failure (a cheap, immediate 401 - no tokens billed, nothing resembling abuse to a provider), not something worth a dedicated "test connection" flow - the rephrase button itself just surfaces a plain error message on failure, same reasoning.
AiAlertProvider also surfaces two low-key info alerts (not warnings - being off is the normal, intended state for a site not using either feature) whenever the dashboard assistant or the rephrase feature isn't fully configured yet (missing/false *-enabled, endpoint, token, provider or key) - a discovery nudge on top of the Config screen's own label/description, for an app operator actively rolling either feature out across several sites.
Every one of these three alerts links to the AI Assistant page itself (management_ui_ai_assistant_index), not straight to the Config screen: the page is the actual "what do I do" landing spot. Each missing setup step links directly to that config row's edit page (AiAssistantController::configLinks(), one AdminUrlGenerator lookup per slug via ConfigRepository) rather than the raw config list - and for the rephrase key specifically, a short "where to get one" note per provider (Anthropic/OpenAI/Euria), same three-part structure for each (site → what to click → billing note), since the dashboard's own endpoint/token aren't self-service at all - they're whatever the backend's operator hands out, so that step just says to ask them instead.
Both keys are the consuming app's own, entered as sensitive config (encrypted at rest by ConfigBundle's VaultEncryptor) - this bundle has no billing relationship with any provider. An app centralizing the dashboard endpoint across several of its own sites (one shared backend, one shared token) is responsible for its own rate limiting on that backend - a leaked token can otherwise be called from anywhere, same as any bearer-token API.
BlockExtension::renderBlock() (called by the render_block() Twig function, itself used by the <twig:c975LUi:Blocks:Block> component) caches each block's rendered HTML in cache.app (via TagAwareCacheInterface), keyed by block_render_{id}_{locale} with an infinite TTL - no re-render, no DB round trip for the block's own data, on every subsequent hit across every visitor. BlockCacheInvalidationListener (src/Listener/) invalidates it automatically: it listens to postUpdate/preRemove on both Block and Media (an image/audio/video swap doesn't touch the parent Block's own fields, so it has to be watched too) and calls $cache->invalidateTags(['block_{id}']). This fires for any origin - EasyAdmin, an importer, another bundle - since it's a Doctrine listener on the entity class itself, not tied to a specific controller.
Locale is part of the cache key because a kind's template can render different content per app.request.locale even though Block::$data didn't change (e.g. SiteBundle's legal_model, which includes a different legal-text template per locale).
Set cacheable: false on a kind whenever its rendered output isn't a pure function of (Block::$id, Block::$data, locale) - i.e. whenever caching it under its own block id could serve stale or wrong-visitor content:
- Embeds a per-request form (CSRF token, session state):
ContactFormBundle'scontact_formis the current example. A cached form would hand every visitor the same CSRF token. - Reads another Block's data: SocialBundle's
social_links_displayis a data-less "pointer" kind that always renders the site-widesocial_linkssingleton found viaBlockRepository::findOneByKind(). Caching it under its own id would never see updates to the singleton it points at. - Queries unrelated entities live: BookBundle's
book_series/book_books/book_to_be_published/book_serie_stripslistBook/Serie/Striprecords viaBookBlockExtension's Twig functions - entitiesBlockCacheInvalidationListenerdoesn't watch, so a newly published book wouldn't ever invalidate the cache.
When in doubt, default to cacheable: false: the cost is one avoidable render per hit, not a correctness bug.
A kind that reads live data BlockCacheInvalidationListener doesn't watch would normally have to fall back to cacheable: false (see above) - implement BlockCacheTagProviderInterface instead (auto-discovered the same way as BlockFixtureProviderInterface, no tag needed) to keep it cacheable while adding your own extra cache tag(s) on top of the default block_{id}/blocks_all ones, then invalidate that tag yourself wherever the outside data actually changes:
use c975L\UiBundle\Contract\BlockCacheTagProviderInterface;
use c975L\UiBundle\Entity\Block;
class ArticlesSliderCacheTagProvider implements BlockCacheTagProviderInterface
{
public function getCacheTagResolvers(): array
{
return [
'articles_slider' => fn (Block $block): array => ['articles_slider_' . $block->getData()['pageId']],
];
}
}SiteBundle's own ArticlesSliderCacheTagProvider is a real example: articles_slider resolves another Page's own article blocks live at render time, so its listener tags the render with articles_slider_{pageId} and invalidates that tag whenever one of that page's articles changes.
Block templates are thin adapters around a set of Symfony UX Twig components living in templates/components/, callable directly in your own templates as <twig:c975LUi:Group:Name .../>.
| Component | Purpose |
|---|---|
<twig:c975LUi:Alert:Alert> |
Bootstrap-style alert box |
<twig:c975LUi:Article:Article> |
Single article (title/content/media) |
<twig:c975LUi:Article:Articles> |
Loops Article over a collection |
<twig:c975LUi:Audio:Audio> |
HTML5 audio player |
<twig:c975LUi:Blocks:Block> |
Renders one Block entity via its registered kind template |
<twig:c975LUi:Blocks:Blocks> |
Loops Block over a collection, auto-wraps consecutive card blocks in a .cards flex row |
<twig:c975LUi:Button:Button> |
Styled button/link |
<twig:c975LUi:Card:Card> |
Bootstrap card |
<twig:c975LUi:Card:Cards> |
Loops Card over an externally-supplied collection (no Block involved) |
<twig:c975LUi:Collection:Grid> |
Section title, followed by a grid of already-rendered items (see the collection block below) |
<twig:c975LUi:Cta:Band> |
Centered call-to-action panel (title/text/button) |
<twig:c975LUi:Expertise:Banner> |
Dark panel with text and a list of tags |
<twig:c975LUi:Feature:Bar> |
Row of short arguments (title + caption) |
<twig:c975LUi:General:RichSnippet> |
JSON-LD structured data snippet |
<twig:c975LUi:Hero:Hero> |
Header banner with title, subtitle, CTA buttons and image |
<twig:c975LUi:Image:Icon> |
Small icon image |
<twig:c975LUi:Image:Image> |
Responsive image |
<twig:c975LUi:Image:Link> |
Image wrapped in a link |
<twig:c975LUi:Pagination:Pagination> |
Pagination links |
<twig:c975LUi:Portfolio:Grid> |
Grid of project cards sourced from a block's own medias |
<twig:c975LUi:Process:Steps> |
Section title followed by numbered steps |
<twig:c975LUi:Progress:Bar> |
Progress bar |
<twig:c975LUi:Section:Cards> |
Section title followed by a grid of cards (icon/title/text) |
<twig:c975LUi:Slider:Slider> |
Image/media slider |
<twig:c975LUi:Text:Readmore> |
Collapsible "read more" text block |
<twig:c975LUi:Text:Section> |
Text section with optional image |
<twig:c975LUi:Video:Iframe> |
Embedded video iframe (YouTube etc.) |
<twig:c975LUi:Video:Video> |
HTML5 video player |
Props match the Twig variables used inside each template — see templates/components/<Group>/<Name>.html.twig for the exact list.
Maintenance note: update this table whenever a component is added, renamed, or removed in
templates/components/.
Two independent ways to get a row of image + title + description + button cards, matching two different sources:
1. Entered by hand in EasyAdmin — several card blocks. The card block kind carries its own
vich_uploader-managed image (media_types: 'image/*', same mechanism as article — the media
belongs to that one block, no pairing with anything else) plus optional url/target/buttonLabel
fields. When a media and/or a url is set, blocks/Card.html.twig renders an image + button teaser
instead of the plain content box. There is no dedicated kind for manually entered cards: a
"collection of cards" editors fill in by hand is just several card blocks placed next to each other —
<twig:c975LUi:Blocks:Blocks> automatically wraps consecutive card-kind blocks in a .cards flex row
(see templates/components/Blocks/Blocks.html.twig), the same way several article blocks form a list
of articles. (For a grid pulled live from another bundle's own entities instead, see the collection
kind below.)
2. Any bundle calling <twig:c975LUi:Card:Cards> directly — no Block entity involved. The
component doesn't know or care where items comes from; each entry must expose:
| Key | Required | Notes |
|---|---|---|
id |
no | HTML id of the .card element |
title |
no | Card header |
description |
no | Plain text, shown under the image |
image |
no | Full URL or path resolvable by asset() |
url |
no | Link target for the image and the button |
target |
no | '' (default, same window) or _blank |
buttonLabel |
no | Defaults to url when empty |
{# e.g. from BookBundle, mapping its own query result #}
<twig:c975LUi:Card:Cards items="{{ books|map(book => {
id: 'book-' ~ book.slug,
title: book.title,
description: book.summary,
image: book.coverUrl,
url: path('book_show', {slug: book.slug}),
}) }}" />The collection kind lets an editor drop a section on a page that always shows the latest N items
from another bundle's own entities (books, products, projects...) — unlike card, no item data is
entered on the block itself, only which source to pull from (source), how many to show (limit) and
the surrounding section heading/link. Each item is resolved live at render time and rendered through
collection_item, a card-based kind reserved for this use (never offered in the block picker - see
pickable: false in config/services.yaml), so it looks the same as a manually placed card.
Not cacheable (cacheable: false in config/services.yaml) — its content depends on another bundle's
own entities, which BlockCacheInvalidationListener has no way to invalidate on.
A bundle exposes its entities to this block by implementing CollectionSourceProviderInterface
(auto-discovered the same way as BlockFixtureProviderInterface — no tag needed):
use c975L\UiBundle\Contract\CollectionSourceProviderInterface;
use c975L\UiBundle\Model\CollectionItem;
class BookCollectionSourceProvider implements CollectionSourceProviderInterface
{
public function __construct(private BookRepository $books) {}
public function getSources(): array
{
return [
'book.collection.books' => [
'label' => 'Books',
'items' => function (?int $limit): iterable {
foreach ($this->books->findLatest($limit) as $book) {
yield new CollectionItem(
title: $book->getTitle(),
description: $book->getSummary(),
imageUrl: $book->getCoverUrl(),
url: $book->getUrl(),
);
}
},
],
];
}
}imageUrl is an already-resolved URL string, not a Media/entity reference — each provider is
responsible for resolving its own image storage before handing it back. A source removed since a
collection block was configured (e.g. its owning bundle uninstalled) doesn't break the page it's
still referenced from, it just renders an empty grid.
CollectionItem also takes buttonLabel (defaults to the raw url when empty) and buttonIcon (a
c975L\UiBundle\Image\Icon component src, e.g. an icon path from social_link_icon()) — both flow
straight into the transient collection_item Block's own teaser button, so a collection item's
call-to-action reads the same as a manually placed card's.
The collection block's own Presentation field (variant) switches every item's markup at once,
without an app-level template override: '' (default) renders each item as a card, 'portfolio'
reuses portfolio_grid's own markup/CSS instead (see CollectionItem.html.twig).
A source can optionally expose a 3rd key, detail, alongside label/items:
'items' => function (?int $limit): iterable { /* ... */ },
'detail' => fn (string $slug): ?array => $this->books->findOneBySlug($slug)?->toDetailData(),callable(string $slug): ?array — given an item's own slug, return a plain array of template variables
(by convention always including title, used for that URL's <title>), or null if the slug doesn't
resolve to anything (the caller falls through to a 404). This lets every item in the source get its own
detail URL — /pages/{page}/{slug} for a Page carrying this collection block — with no Page/Block
row persisted per item: the data is rebuilt from the source on every request. Pair it with the
collection block's own detailPage field (the slug of a real, separate Page whose own blocks
render as this item's detail view, a collectionItem Twig global carrying the current item's data to
whichever of them needs it) — see SiteBundle's README ("Item detail pages", under "Collection entries")
for the full recipe and PageController::resolveCollectionDetail() for the resolution logic itself.
To also make each item's own title a link to its detail page, CollectionItem needs the same slug
the detail callable expects:
yield new CollectionItem(
title: $book->getTitle(),
// ...
slug: $book->getSlug(),
);CollectionExtension::renderItems() builds that link itself (/pages/{currentPage}/{item->slug}) once
both the block's detailPage and the item's slug are set — nothing to do on the template side. Either
one missing (no detailPage configured, or a source whose items carry no slug) just renders the title
as plain text, same as today.
<twig:c975LUi:Video:Iframe> (the video_iframe block) auto-rewrites YouTube URLs to
youtube-nocookie.com, and defers creating the real <iframe> client-side until cookie consent
is given — the block's own HTML never changes with consent state, so it stays cacheable.
It has no composer dependency on any consent-banner bundle. Instead it reacts to an optional, documented contract, checked at connect time:
- a
[data-controller~="cookieConsent"]element present somewhere on the page (if absent, it fails open and renders the iframe immediately — a site with no consent banner isn't blocked by this), - a
window.CookieConsentglobal exposingvanilla-cookieconsentv3's API (acceptedCategory('content'),acceptCategory('content')), - its
cc:onConsent/cc:onChangeDOM events, so the placeholder upgrades to the real iframe live, without a page reload, as soon as consent is given.
c975l/site-bundle's <twig:c975LSite:General:CookieConsent/> is a ready-made provider of this
contract (see its own README) — but any consuming app's own banner satisfying the same contract
works just as well.
When a .pdf file is uploaded through VichUploader on any entity (no interface required), the bundle automatically generates a .webp thumbnail of the first page next to it (document.pdf → document.webp - the extension is replaced, not appended), via Ghostscript + Imagine/GD.
- Requires Ghostscript (
gs) installed on the server. If missing, the thumbnail generation silently fails — the PDF upload itself is unaffected. - Skipped for private files — entities implementing
VichPrivateFileInterface(e.g. a paid download in a shop) are not thumbnailed, since there's no public preview use case for them. - Thumbnail width defaults to
400px, or reusesgetImageWidth()if the entity also implementsVichImageResizableInterface.
No configuration needed — handled by VichPdfThumbnailListener, auto-registered like the rest of the bundle's services.
A Media row isn't necessarily attached to a Block — it can instead hold one of a fixed set of site-wide graphics, identified by a role:
use c975L\UiBundle\Entity\Media;
Media::ROLE_FAVICON; // 'favicon'
Media::ROLE_APPLE_TOUCH_ICON; // 'apple-touch-icon'
Media::ROLE_OG_IMAGE; // 'og-image'
Media::ROLE_LOGO; // 'logo'role is unique per value, so there is at most one Media for each. Create/replace one the same way as any other Media (e.g. from your own app's settings form or a fixture), setting setRole(Media::ROLE_FAVICON) — UiMediaNamer then stores it under a fixed, predictable filename at the root of public/ instead of the usual per-block path.
Retrieve it anywhere in Twig with the site_media() function, which returns null if none was uploaded yet:
{% set favicon = site_media('favicon') %}
{% if favicon %}
<link rel="icon" href="{{ vich_uploader_asset(favicon) }}">
{% endif %}A Media row can be attached in several ways depending on the consuming bundle: to a Block, directly to another entity (e.g. a Page's og-image), or as a site-wide role. MediaCrudController provides a single EasyAdmin gallery browsing every Media regardless of how it's attached, with a click-through to edit its metadata (alt, caption, credits, CSS classes...). Site-wide role graphics stay read-only there — they keep being managed wherever the consuming bundle handles roles (e.g. SiteGraphicCrudController in c975L/SiteBundle). Creating a new Media directly from this library (with no Block yet) is reserved to ROLE_SUPER_ADMIN — regular admins keep adding media the normal way, through a block's own form.
UiBundle does not register a menu entry for it: c975l/config-bundle (which owns the menu registration mechanism, see below) already depends on c975l/ui-bundle, so the reverse would be a circular dependency. A bundle that depends on both - e.g. c975L/SiteBundle - should add an entry pointing to MediaCrudController::class in its own MenuProviderInterface implementation.
Media::$url/Media::$description back the per-project link and text of the portfolio_grid kind (see MediaUploadType's portfolio_grid context) - a project card's title reuses the existing $label field.
Attaching more than one Media to a hero block switches it from a single static image to a pure-CSS crossfade slideshow cycling through all of them (no JS, disabled under prefers-reduced-motion) - see .hero__media--slideshow in sass/_page-sections.scss. A single attached media keeps the plain static image.
UiBundle only knows about Media/Block; it has no visibility into which entity of a consuming bundle owns that Block, or holds a direct reference to a Media (like a Page's og-image). Each bundle that knows this can contribute that information:
- Implement
MediaUsageProviderInterfacein your bundle - no tag needed, it's auto-discovered likeBundleWhatsNewProviderInterface. - Return, for a given batch of already-loaded
Mediarows, the places they're used.
use c975L\UiBundle\Contract\MediaUsageProviderInterface;
use c975L\UiBundle\Entity\Media;
class MyMediaUsageProvider implements MediaUsageProviderInterface
{
public function getUsages(array $medias): array
{
// [mediaId => [['label' => string, 'url' => ?string], ...], ...]
return [...];
}
}url can be null for a purely descriptive entry (no admin page to link to). Every registered provider's results are merged and shown in the "Used in" field of the Media Library's edit page.
UiBundle provides a mechanism for bundles to declare their stylesheets automatically, without requiring manual @import or <link> additions in each application.
- Each bundle that provides CSS implements
BundleStylesheetProviderInterfaceand registers itself with theui.stylesheetservice tag. - UiBundle collects all tagged providers at compile time (ordered by
priority, highest first). - The
bundle_stylesheets()Twig function returns the resolved list of URLs, ready for use in a layout template.
In kernel.debug, bundle_stylesheets() returns each bundle's stylesheet separately, for instant reload on every CSS edit. Outside debug (prod), it instead returns a single URL pointing to public/bundles/build/site.css, a concatenation of every registered local stylesheet built by StylesheetCacheWarmer (auto-registered, runs on bin/console cache:warmup / on first request after a cache clear - like any optional Symfony cache warmer). CDN stylesheets (absolute URLs) are excluded from that file and keep being linked on their own in both cases.
Create a provider class in your bundle:
use c975L\UiBundle\Contract\BundleStylesheetProviderInterface;
class StylesheetProvider implements BundleStylesheetProviderInterface
{
public function getStylesheets(): array
{
return [
'bundles/mybundle/css/styles.min.css', // local public asset
'https://cdn.example.com/lib/styles.min.css', // CDN URL, passed through as-is
];
}
}Register it with the tag in config/services.yaml:
services:
MyBundle\Service\StylesheetProvider:
tags:
- { name: 'ui.stylesheet', priority: 10 }The priority attribute is optional (default 0). Higher priority providers are injected first — use a high value (e.g. 100) for reset/base styles that must load before others.
Call bundle_stylesheets() in the stylesheets block of your layout:
{% block stylesheets %}
{% for stylesheet in bundle_stylesheets() %}
<link rel="stylesheet" href="{{ stylesheet }}">
{% endfor %}
{% endblock %}Local paths are resolved to absolute versioned URLs via Symfony's asset package. CDN URLs (starting with http) are returned as-is.
Same idea as above, but for CSS that should only load on the EasyAdmin dashboard (e.g. /management), not on the public site. Keep using plain style="..." attributes for one-off, low-value cases (a single margin: 0 isn't worth an extra HTTP request) — reach for this mechanism when a management template accumulates real, reusable CSS (see templates/management/media_index.html.twig for an example).
- Each bundle that provides management-only CSS implements
BundleStylesheetManagementProviderInterfaceand registers itself with theui.management_stylesheetservice tag. - UiBundle collects all tagged providers at compile time (ordered by
priority, highest first) intoStylesheetManagementRegistry. - The EasyAdmin
DashboardController::configureAssets()method (in the app or in the bundle that owns the dashboard, e.g.c975l/config-bundle) injectsStylesheetManagementRegistryand callsaddCssFile()for each entry.
Create a provider class in your bundle:
use c975L\UiBundle\Contract\BundleStylesheetManagementProviderInterface;
class StylesheetProvider implements BundleStylesheetManagementProviderInterface
{
public function getManagementStylesheets(): array
{
return [
'bundles/mybundle/css/management.min.css',
];
}
}Register it with the tag in config/services.yaml:
services:
MyBundle\Service\StylesheetProvider:
tags:
- { name: 'ui.management_stylesheet', priority: 10 }The priority attribute is optional (default 0). Higher priority providers are injected first — use a high value (e.g. 100) for reset/base styles that must load before others.
use c975L\UiBundle\Registry\StylesheetManagementRegistry;
public function __construct(
private readonly StylesheetManagementRegistry $stylesheetManagementRegistry,
) {}
public function configureAssets(): Assets
{
$assets = Assets::new();
foreach ($this->stylesheetManagementRegistry->all() as $stylesheet) {
$assets->addCssFile($stylesheet);
}
return $assets;
}Unlike the JS admin mechanism (BundleScriptAdminProviderInterface), no AssetMapper/importmap entry is needed — addCssFile() resolves plain public paths via Symfony's asset package, same as getStylesheets() above.
Tip
If this project helps you save development time:
- star it on GitHub — helps others find it
- open an issue to share how you use it — genuinely useful feedback
And if you'd like to support the work directly, the Sponsor button at the top of the GitHub page is there for that. Thank you!