Search by

jul6art / admin-bundle

jul6art

Symfony admin backoffice bundle

Package info

github.com/jul6art/admin-bundle

Type:symfony-bundle

pkg:composer/jul6art/admin-bundle

Statistics

Installs: 444

Dependents: 0

Suggesters: 0

Stars: 3

Open Issues: 0

v1.29.0 2026-10-14 20:34 UTC

README

logo dev in the hood

Symfony admin backoffice bundle

License Version

The shell of a back office: a sidebar layout, a theme, per-user appearance, the sign-in pages, and a navigation contract the application fills.

Everything that makes a back office look like a back office, and nothing that makes it yours.

Requirements

  • PHP ^8.5
  • Symfony ^7.4 || ^8.0
  • symfony/security-bundle — a hard requirement, not a suggestion: the navigation builder needs security.authorization_checker to compile, and a package the container needs to compile is not a development dependency.

Suggested, and what each unlocks:

Package Without it
twig/twig no shell, no auth pages, no appearance screen — only the navigation contract, the enums and the entity trait
symfony/form no AppearanceType
doctrine/orm no appearance controller (its compiler pass removes it when no entity manager is registered)
symfony/asset logo and favicon paths render as-is instead of going through asset()
jul6art/datatable-bundle no tables, and the ui--modal / ui--tooltip controllers the shell assumes are registered

Installation

composer require jul6art/admin-bundle
// config/bundles.php — Flex does this for you
Jul6Art\AdminBundle\AdminBundle::class => ['all' => true],

Configuration

# config/packages/admin.yaml
admin:
    enabled: true

    # ⚠️ The single most important key. See "The base template" below.
    base_template: 'base.html.twig'

    branding:
        name: 'Acme Admin'
        logo: 'img/logo.png'        # asset paths, passed through asset()
        favicon: 'img/favicon.ico'
        home_route: admin_dashboard
        # Logo width in pixels on the authentication pages and in e-mails.
        # `~` — or leaving the key out — keeps the historical fixed height (h-12) and lets the
        # width follow. Both spellings work: a node whose default is null accepts an explicit null.
        logo_width: ~
        # `false` drops the name written under the logo (auth pages) and beside it (sidebar):
        # the wordmark case, where the logo already says the name. The logo then takes the room.
        show_name: true

### Performance dashboard (optional)

When `jul6art/core-bundle` is installed, the bundle also ships the screen that renders its
per-request profiler: slowest routes, N+1 suspects, CSV/JSON export, and a button to clear the
store. Without the core bundle the controller is **removed from the container** — the shell alone
does not require the profiler.

```yaml
# config/routes/admin.yaml — the application decides the URL and the firewall around it
admin_performance:
    resource: '@AdminBundle/Controller/PerformanceController.php'
    type: attribute
    prefix: /admin

# config/packages/admin.yaml
when@dev:
    admin:
        routes:
            performance: admin_performance_dashboard   # empty elsewhere ⇒ no link in the menu

⚠️ The route names must keep the admin_performance_ prefix: that is what core.performance.ignored_route_prefix excludes from collection. Rename them and the dashboard starts measuring its own page, adding a record on every visit to what it displays.

⚠️ Pages shipped by this bundle extend admin.layout_template (default @Admin/layout.html.twig). An application whose own pages go through its own layout — the one exposing window.jwtToken, an extra top bar — points that key at it, and makes that layout extend @Admin/layout.html.twig.

# An empty route name HIDES its link rather than breaking the render — which is what makes
# the multi-area case below work.
routes:
    login: admin_security_login
    logout: admin_security_logout
    register: admin_security_register      # empty closes public sign-up
    reset_password_request: admin_reset_password_request
    profile: ''
    change_password: admin_account_password_edit
    appearance: admin_account_appearance_edit
    privacy: ''

mercure:
    hub_url: '%env(MERCURE_PUBLIC_URL)%'
    token_route: admin_mercure_token

# The product's user guide, linked from the account menu and from a help icon in the top
# bar — both open a new tab. A route OR an url, never both; empty (the default) shows nothing.
user_guide:
    route: public_guide_index     # or: url: '/docs'

The guide's label is `nav.user_guide`, read from the application's `messages` catalogue like
`nav.logout`: every consumer that fills `user_guide` defines that key in each of its languages.

> ⚠️ **`routes` is a GLOBAL table — one route per entry, for the whole application.**
>
> An application with several areas, where the same screen exists twice — `/admin/account/appearance`
> and `/organization/account/appearance` — cannot name both here. Leave that entry **empty** and let
> each layout add its own link:
>
> ```twig
> {% block admin_account_menu_extra %}
>     {{ include('@Admin/partials/_menu_link.html.twig', {
>         route: 'app_organization_account_appearance_edit',
>         icon: 'fa-solid fa-palette',
>         label: 'nav.appearance'|trans,
>     }) }}
> {% endblock %}
> ```
>
> A single value sends everyone to the same place, and the audience of the other area to a 403 —
> the page works, the *link* is wrong, and nothing in a controller test looks at links.

### The base template

`@Admin/layout.html.twig` extends whatever `base_template` names, through Twig's dynamic
inheritance. The default is the bundle's own base, which loads **no assets at all** — a bundle
cannot choose between `encore_entry_link_tags()` and `importmap()` for its consumer.

So an application points `base_template` at its own base, and makes that one extend
`@Admin/base.html.twig`:

```twig
{# templates/base.html.twig #}
{% extends '@Admin/base.html.twig' %}

{% block stylesheets %}{{ encore_entry_link_tags('app') }}{% endblock %}
{% block javascripts %}{{ encore_entry_script_tags('app') }}{% endblock %}

⚠️ Forget the key and an admin page bypasses your base entirely — it renders with no stylesheet, and nothing points at the cause. This was found by adopting the bundle in a real application, not by writing it.

fixed-html belongs to the layout (1.23.1)

@Admin/layout.html.twig puts class="fixed-html" on <html>, through the html_class block; the base does not. Projects translate the class into max-height: 100vh; overflow-y: hidden, which is right for the back-office shell — its columns scroll on their own — and wrong anywhere else.

⚠️ Until 1.23.0 the base set it on every page. A page extending the base outside the layout — a customer portal, a public checkout page — grew past the window and the wheel did nothing. Projects worked around it page by page by rewriting html_attr; the page someone forgot stayed stuck. Those overrides can go: the base now writes the dark class and the appearance attributes on its own, and class only when it carries one.

A page of your own that scrolls inside its own columns opts in the way the layout does:

{% block html_class %}fixed-html{% endblock %}

Usage

The shell, and its blocks

{% extends '@Admin/layout.html.twig' %}

{% block content %}…{% endblock %}
Block What goes in
admin_sidebar_brand the sidebar header, logo included
admin_sidebar_nav the menu — by default, the providers'
admin_sidebar_footer the account block at the bottom
admin_topbar_left left of the top bar, after the mobile toggle — an impersonation banner
admin_topbar_center centre — the global search field (see below); the shell's container centres it
admin_topbar_locale right, FIRST — the language picker
admin_topbar_actions right, after the locale and BEFORE the account menu — a notification bell
admin_account_menu_extra extra entries in the account menu
admin_body_end after <main> — a global JS variable, a floating widget
container_class the content's max width
content the page

The top bar is one norm, written by the shell (1.15)

Every back-office on this theme shows the same bar, because the shell writes its layout and a project only fills slots:

  • the search is centred — admin_topbar_center sits in the shell's own container (flex-1, centred from md up, allowed to shrink). Put the field in it, not a w-full wrapper: a wrapper that claims the whole width pushes the field to the left and crushes the account button;
  • the language picker is left of the bell — admin_topbar_locale, then admin_topbar_actions, then the account menu. Before 1.15 a single slot held both, and two products ordered them oppositely;
  • the account name stays on one line — up to 14rem, cut beyond it (never wrapped), the full name in its title; the button does not shrink.

Projects that put the language picker in admin_topbar_actions still render — move it to admin_topbar_locale to follow the norm.

The page title follows the page (1.29)

Down a long form, the name of the record being edited used to scroll away with the first fields. Once the page's <h1> has left the screen, the shell now copies its text into the top bar, after the left slot; it leaves as soon as the <h1> comes back. Nothing to write in the pages — a short page never loses its <h1>, so nothing ever shows there. Register the controller as ui--page-title:

// assets/controllers/ui/page-title_controller.js
export { default } from '@jul6art/admin-bundle/controllers/page-title_controller';
  • It copies the first <h1> of <main>. A page whose <h1> does not carry the right name marks another element: data-ui--page-title-target="source".
  • It never moves the bar. The copy is laid OVER the free space, up to the first thing of the centre slot (the search field, its magnifier on a phone) or to its end when it is empty — cut with an ellipsis, in full in its title, hidden when it neither fits whole nor has 6rem. In the flow, it shrank the centre slot and slid the search field sideways each time it came and went.
  • For the eyes only: aria-hidden, the <h1> is already read. The fade is dropped under reduced motion.

Without the registration, the shell renders as before: the empty label stays invisible.

The global search field

{% block admin_topbar_center %}
    {{ include('@Admin/partials/_global_search.html.twig', {
        url: path('app_search_query'),        # answers ?q=<term> — see below
        labels: { customer: 'search.group.customer'|trans({}, 'search') },
        empty: 'search.empty'|trans({}, 'search'),
        more: 'search.more'|trans({ '%total%': '%total%' }, 'search'),
        placeholder: 'search.placeholder'|trans({}, 'search'),
        label: 'search.label'|trans({}, 'search'),
        close: 'search.close'|trans({}, 'search'),
    }) }}
{% endblock %}

Register the controller as search--global:

// assets/controllers/search/global_controller.js
export { default } from '@jul6art/admin-bundle/controllers/global-search_controller';

The engine is the bundle's (since 1.19, with Doctrine ORM): Jul6Art\AdminBundle\Search\GlobalSearch searches from two characters (on the server), with the comparison of api-bundle's OrSearchFilter (LOWER(field) LIKE LOWER('%term%'), so a panel and its list count alike — on text columns only: it casts no number, and LOWER() on an integer is a type error on PostgreSQL), five rows per family ordered case-insensitively, a COUNT only when a family is saturated, label and URL only — and keeps nothing. What is searched, and for whom, is the project's: implement GlobalSearchSourceInterface and alias it.

final readonly class SearchSource implements GlobalSearchSourceInterface
{
    public function families(): iterable              // only what the CURRENT actor may open
    {
        if ($this->security->isGranted('customer:read')) {
            yield new SearchFamily('customer', Customer::class, ['name', 'vatNumber'], 'app_customer_show',
                listRoute: 'app_customer_index');   // "see all" → ?search=<term>, if the list reads it
        }
    }

    public function scope(SearchFamily $family, QueryBuilder $builder): void
    {
        $builder->andWhere('e.organization = :tenant')->setParameter('tenant', $this->tenant());
    }
}

⚠️ Write the tenant in scope(), even behind a Doctrine tenant filter: such filters are often off for a platform account, and a search that relied on one reads every tenant for exactly the account nobody tests.

The route stays the project's too — its path and its access decision — and is three lines:

#[Route('/app/search', name: 'app_search_query', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function query(Request $request, GlobalSearch $search): JsonResponse
{
    return $search->respond((string) $request->query->get('q', ''));
}

respond() answers a JSON object keyed by family — { total, url, results: [{label, url}] }, the SearchGroup / SearchResult value objects — or []. Without a source the engine searches nothing. All strings reach the browser already rendered: the Stimulus controller translates nothing.

The controller debounces (250 ms), searches from two characters (enforce it on the server too), aborts the request in flight, escapes every label, and opens on / unless a field has the focus — pass shortcut: false to the partial (since 1.17) where the product's keyboard policy refuses it. Below md the field would be crushed to a few pixels next to the icons: a magnifier replaces it and opens a full-width row under the header, closed by Escape, the × or a tap outside.

The menu

One provider per module, so a module removed takes its menu with it:

final class UserNavigation implements NavigationProviderInterface
{
    public function sections(): iterable
    {
        yield new NavSection('access', 'nav.access', 'fa-solid fa-shield-halved', [
            new NavItem('admin_dashboard', 'nav.dashboard', 'fa-solid fa-house'),
            new NavItem('admin_user_index', 'nav.users', 'fa-solid fa-users', permission: 'user:read'),
            new NavItem('admin_report_index', 'nav.reports', 'fa-solid fa-chart-pie', feature: 'reporting'),
        ], priority: 100);
    }
}

Declaring the class as a service is enough — AdminBundle autoconfigures the tag.

The gate belongs next to the link. A menu entry whose guard drifts from its route's guard produces a visible link that answers 403 — an interface bug no controller test sees, because the controller is right.

  • permission goes to isGranted(), so it accepts a permission code, a role, anything a voter answers. ⚠️ A code no voter recognises is granted, not refused: Symfony's default strategy returns true when every voter abstains. Cover the menu with a test that walks it.
  • feature goes to a {@see FeatureVisibilityInterface} the application implements. ⚠️ With no checker registered, a feature-gated item is hidden. Deliberately: the other direction turns every paid module into a free one, and the suite stays green.
  • A section whose items all disappear disappears too — a group header opening onto nothing advertises a module the account cannot reach.

A count beside an entry (1.25) — unread messages, pending requests:

new NavItem('admin_message_index', 'nav.messages', 'fa-solid fa-inbox',
    permission: 'contact_message:list',
    badge: $this->messages->countUnread(),         // null or 0: no pill
    badgeLabelKey: 'nav.messages_unread',          // default 'nav.badge', receives %count%
),

The pill shows the number alone; a screen reader hears the sentence badgeLabelKey translates (in the item's labelDomain) — "3", read after "Messages", does not say three what.

⚠️ The provider computes the count each time the menu is built — on every page of the back office. Keep it to one cheap query (a COUNT on an indexed column); a live count belongs to the screen that shows the messages.

An entry that opens a new tab (1.27) — the public site of a back office, a guide: a page read beside the form one is filling, not instead of it:

new NavItem('site_home', 'nav.site', 'fa-solid fa-globe',
    newTab: true,                                  // target="_blank" rel="noopener noreferrer"
    newTabLabelKey: 'nav.new_tab',                 // the default; what a screen reader hears
),

An arrow follows the label (.admin-nav-new-tab), and the label is followed, for a screen reader, by the sentence newTabLabelKey translates in the item's labelDomain — the project adds that key to its catalogue.

An application that already has its menu in Twig overrides admin_sidebar_nav and keeps it. Both paths are supported; the contract is for projects starting from scratch.

Appearance

Four things on the User, and one line in the layout does the rest:

#[ORM\Entity]
class User implements AppearanceAwareInterface, AdminUserInterface
{
    use AppearancePreferencesTrait;   // the five appearance_* columns

    #[ORM\Column(length: 10, options: ['default' => 'light'])]
    private string $theme = 'light';

    public function getColorMode(): ColorMode { return ColorMode::fromStorage($this->theme); }
    public function setColorMode(ColorMode $m): static { $this->theme = $m->value; return $this; }

    public function getDisplayName(): string { return $this->firstName.' '.$this->lastName; }
    public function getInitials(): string { /* … */ }
    public function getAvatarPath(): ?string { return $this->avatarPath; }
}

A trait and not an embeddable: an embeddable shipped by a bundle needs a Doctrine mapping entry for the bundle's namespace, and in this ecosystem those are switched off — one of them would map a vendor User and create a second user table. A trait needs nothing, and the columns are named appearance_* explicitly, which is what a columnPrefix: false embeddable produced: an application migrating from one to the other has no schema change.

The colour mode stays out of the trait on purpose: almost every application already has a column for it under its own name. Those two methods are the wiring, not a redundancy.

Then import the screen where it belongs in your URL map:

# config/routes/admin.yaml
admin_account_appearance:
    resource: '@AdminBundle/Controller/AppearanceController.php'
    type: attribute
    prefix: /admin

The theme

/* assets/styles/app.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@import '@jul6art/admin-bundle/styles/tokens.css';      /* accents, density, contrast, motion */
@import '@jul6art/admin-bundle/styles/components.css';  /* .panel, .btn-*, .badge-*, .form-* */
// tailwind.config.js
module.exports = {
    presets: [require('./vendor/jul6art/admin-bundle/assets/tailwind/preset.js')],
    content: ['./templates/**/*.html.twig', './assets/**/*.js', /* + the bundle's assets */],
};

⚠️ The bundle's assets/ must be in Tailwind's content. A class used only in the bundle's templates is otherwise purged from the production stylesheet — and only from that one.

Twelve Stimulus controllers ship with it (appearance, theme, dropdown, collapsible, tabs, sidebar-section, toast, locale-switcher, cookie-consent, sidebar, password, page-title). Register them under ui--<name>, which is what the templates address. Four more come with the keyboard bricks below, under core-- and form--.

The decimal input (form--decimal, 1.21)

The ui-bundle's decimal types (CustomMoneyType, CustomUnitType) attach form--decimal to their input and ship no controller. This one formats the value the French way ("3 600,00") at rest, gives back the plain value ("3600.00") on focus and on submit, and switches a type="number" input (html5: true) to text — a number input cannot hold a comma and the browser would empty it.

// assets/controllers/form/decimal_controller.js — the path gives the identifier `form--decimal`
export { default } from '@jul6art/admin-bundle/controllers/decimal_controller';

⚠️ Without this file, nothing fails: the identifier resolves to nothing, and every price and quantity field shows "89.00". DecimalController.parse() is static and public, for controllers that read a possibly-formatted value (line totals).

A short collection (form--collection, ordering 1.26)

Adds, removes and — since 1.26 — reorders the rows of a CollectionType in place: the links of a footer, the steps of a checklist. Declare the collection allow_add, allow_delete and by_reference: false, and render the prototype and the rows with the same template.

{% macro link_row(row) %}
    <div data-form--collection-target="item">
        {{ form_row(row.label) }}
        {{ form_widget(row.position, {attr: {'data-form--collection-target': 'position'}}) }}
        <button type="button" data-action="form--collection#up">Up</button>
        <button type="button" data-action="form--collection#down">Down</button>
        <button type="button" data-action="form--collection#remove">Remove</button>
    </div>
{% endmacro %}

<div data-controller="form--collection" data-form--collection-prototype-value="{{ _self.link_row(form.links.vars.prototype)|e('html_attr') }}">
    <div data-form--collection-target="container">
        {% for row in form.links %}{{ _self.link_row(row) }}{% endfor %}
    </div>
    <button type="button" data-action="form--collection#add">Add</button>
</div>
// assets/controllers/form/collection_controller.js
export { default } from '@jul6art/admin-bundle/controllers/collection_controller';

⚠️ Moving a row does not reorder what Symfony receives: a field keeps its name, and its name carries its index. The order travels in each row's hidden position field (an IntegerType or HiddenType of the entry), which the controller renumbers after every move, addition and removal. Sort by it when you save; a row without one moves on screen only.

Two buttons rather than a drag handle: a keyboard and a screen reader reach them like any other button, and the focus stays on the button just pressed, which moved with its row.

Keyboard shortcuts

Fast data entry for whoever types a hundred records in a row: open a creation screen, land on the right field, save, start the next one — without reaching for the mouse.

{# your base template #}
{% block body_attr %} data-controller="core--keyboard"{% endblock %}
{% block head %}{{ include('@Admin/partials/_keyboard_meta.html.twig') }}{% endblock %}
{# an index screen: the shortcut goes on the button that already passed the permission check #}
<a href="{{ path('app_customer_new') }}" class="btn-primary"
   data-shortcut="{{ keyboard_shortcut('global.new') }}"
   data-shortcut-label="{{ 'customer.list.create'|trans }}">

{# an entry form #}
{{ form_start(form, { attr: { 'data-controller': 'form--autofocus form--submit-shortcut' } }) }}
Key Effect
n clicks the visible element whose data-shortcut is the combo in force for global.new (n by default) — typically the "New X" button. Render it with keyboard_shortcut('global.new'), never a literal "n": an organisation's override would otherwise never reach the button
? opens the cheat-sheet, listing what the current page offers
Ctrl + B back to the list: clicks the Cancel link of _form_actions (form.back, overridable)
Esc closes the cheat-sheet — and nothing else since 1.20
Ctrl/⌘ + Enter submits the form
Ctrl/⌘ + Shift + Enter submits it through the data-secondary button ("save and add another")

Four Stimulus controllers ship with it. Register them under the identifiers the templates address: core--keyboard, form--autofocus, form--submit-shortcut, form--shortcut-capture.

⚠️ data-shortcut-label is what puts an entry in the cheat-sheet. A combo without one still fires, and ? does not mention it — a shortcut nobody can discover.

⚠️ A button your template withholds is a shortcut that does not exist. The router clicks a node; it knows nothing about roles. Which is exactly why this is an attribute rather than a registry — the is_granted() that decides whether to render the button has already run.

⚠️ Esc no longer goes back (1.20). It only did after arriving through a shortcut, could not be overridden, and shares its key with every Select2, modal and picker: one press too many left a half-filled form. Ctrl+B replaces it. A form that writes its own Cancel link gives it data-shortcut="{{ keyboard_shortcut('form.back') }}"; cancel_shortcut: false takes it off the partial's.

Four rules keep a shortcut from firing where it would do harm (1.20.1):

  • a keystroke a control already handled (preventDefault()) is not routed — Ctrl+B in a Trix editor makes text bold, it does not leave the page;
  • from a rich editor (contenteditable), a shortcut whose target is a link does nothing;
  • an open modal (aria-modal, <dialog open>, datatable-bundle's [data-backdrop]) or the cheat-sheet confines the shortcuts to itself;
  • on macOS, Ctrl + a letter in a text field keeps its system meaning (Ctrl+B moves the caret back) — outside a field, and with Shift or Alt, shortcuts fire as anywhere.

The cheat-sheet lists a combo the page offers under the page's own label, and leaves out the generic global row for it.

Add your own actions by configuration; the four above belong to the shell, everything else belongs to a product:

admin:
    keyboard:
        actions:
            erp.lines.add: { default: 'l', label: 'keyboard.action.erp_lines_add' }

The cancel link of _form_actions answers form.back by default. A product may hand it another of its actions instead, or none:

{{ include('@Admin/partials/_form_actions.html.twig', { …,
    cancel_shortcut: 'app.leave',                        # declared under admin.keyboard.actions
    cancel_shortcut_label: 'keyboard.action.app_leave'|trans({}, 'keyboard'),
    # cancel_shortcut: false,                            # no shortcut on this link
}) }}

The link carries the combo in force, so an override reaches it.

Letting people change their own combos is optional. Implement KeyboardShortcutStoreInterface and alias it; the resolver picks it up, and a settings screen can read keyboard_actions() and render each field with form--shortcut-capture.

⚠️ Not implementing it is a supported state, and it degrades toward the factory shortcuts work — never toward no shortcuts. This is deliberately the opposite of the datatable bundle's preference port, whose absence removes the feature silently: a back-office that stopped answering Ctrl+Enter because nobody wired a database would be indistinguishable from a broken one.

The cheat-sheet and the capture widget read their labels from the browser catalogue, so hand Translation\DeclaredTranslationKeys to your JavaScript translation guard — otherwise it reports every one of them as dead and the next tidy-up deletes them.

⚠️ The action labels are NOT among them. KeyboardAction::$labelKey is read by a settings screen, server-side, in that screen's own domain — putting it in the browser catalogue would bless a dead entry as alive.

The inbox (1.25)

A mail-client screen for messages the application receives — contact requests, support tickets, enquiries: the folders on top, the list on the left, the open message on the right; on a phone, one pane at a time, with a way back. The bundle knows no entity: a message is anything implementing Contract\InboxMessageInterface (id, sender name and e-mail, a plain-text excerpt, the date received, read or not, and an optional status label).

The page is rendered by the server: one URL per folder, one per open message, links everywhere. It works without JavaScript; the controller only helps.

{# templates/message/index.html.twig — the list, and the open message when there is one #}
{% extends '@Admin/layout.html.twig' %}

{% block content %}
    {% embed '@Admin/inbox/_layout.html.twig' with { inbox: {
        folders: [
            { label: 'message.folder.inbox'|trans({}, 'contact'), url: path('admin_message_index'), count: unread, active: folder == 'inbox' },
            { label: 'message.folder.archived'|trans({}, 'contact'), url: path('admin_message_index', { folder: 'archived' }), count: 0, active: folder == 'archived' },
        ],
        messages: messages,                    # InboxMessageInterface[], in list order
        current: message ?? null,              # the open one, or null
        message_route: 'admin_message_show',   # takes `id`
        message_route_params: { folder: folder },
        translation_domain: 'contact',         # where getInboxStatusLabel() keys live
    } } %}
        {% block inbox_actions %}
            {% if is_granted('contact_message:reply', message) %}
                <a href="#reply-body" class="btn-primary" data-shortcut="{{ keyboard_shortcut('inbox.reply') }}"
                   data-shortcut-label="{{ 'message.reply'|trans({}, 'contact') }}">…</a>
            {% endif %}
        {% endblock %}
        {% block inbox_reader %}
            <div class="whitespace-pre-line">{{ message.body }}</div>
            {# the thread of replies, the reply form (textarea id="reply-body")… #}
        {% endblock %}
    {% endembed %}
{% endblock %}
Block What goes in it
inbox_actions the open message's buttons (reply, archive, delete…), in its header
inbox_reader its content — the text, the replies, the form. The bundle renders the sender, the e-mail and the date above it
inbox_attr more attributes on the root — a second controller, typically

Register the controller as ui--inbox:

// assets/controllers/ui/inbox_controller.js
export { default } from '@jul6art/admin-bundle/controllers/inbox_controller';

It turns every date into "3 hours ago" in the page's language (Intl.RelativeTimeFormat, the absolute date moving to title), keeps the open message in sight in a long list, and focuses a field a link points to (<a href="#reply-body">). Its refresh() action reloads the list and the folders in place, from the current URL.

⚠️ The bundle owns no real-time feed. When a message arrives, the application's own channel (Mercure, polling…) calls ui--inbox#refresh — through inbox_attr, or a small controller of its own. Without one, the list is as fresh as the last page load.

A long folder, slice by slice (1.28) — a folder is never rendered whole. The application renders a slice and passes more_url, the address of the next, older one; the template turns it into a link under the list, which works without JavaScript. With it, ui--inbox follows the link as soon as it scrolls into view, APPENDS the slice and replaces the link by the slice's own — an infinite scroll whose link stays the keyboard's way (pressed, it moves the focus to the first message added). A screen reader hears inbox.loaded (%count%); the link reads inbox.more.

{% embed '@Admin/inbox/_layout.html.twig' with { inbox: {
    …,
    more_url: more ? path('admin_message_index', {before: last.id}) : null,
} } %}

⚠️ The cursor is the application's, and it should not be an offset. Paginate on the ordering key — (receivedAt, id) before the last message shown: a message that arrives between two slices then shifts nothing, where an OFFSET repeats one message and skips another.

The panes fit the screen (1.28.1): the list and the open message scroll each in their own pane, never the page — ui--inbox measures what stands above and below them (--admin-inbox-top, 16rem without JavaScript).

⚠️ refresh() keeps the loaded slices. The fresh first page replaces the HEAD of the list, down to its own oldest message; what was loaded below stays. A message opened far down should be rendered with the list down to it (and its neighbours), or it is no longer in the list it was opened from.

Keyboard: inbox.previous, inbox.next, inbox.reply, inbox.archive are the application's to declare, not the bundle's — a consumer without an inbox has no business seeing them in its cheat-sheet:

admin:
    keyboard:
        actions:
            inbox.previous: { default: 'k', label: 'keyboard.action.inbox_previous' }
            inbox.next:     { default: 'j', label: 'keyboard.action.inbox_next' }
            inbox.reply:    { default: 'r', label: 'keyboard.action.inbox_reply' }
            inbox.archive:  { default: 'e', label: 'keyboard.action.inbox_archive' }

The previous/next links of the reader take their combo from the first two as soon as they are declared; undeclared, they stay plain links. The last two are yours to put on your own buttons in inbox_actions, as above — a button a permission withholds is a shortcut that does not exist.

The template's own words are in the application's inbox domain (the bundle ships no translations): inbox.folders, inbox.list, inbox.empty, inbox.select, inbox.unread, inbox.back, inbox.previous, inbox.next. All of them are rendered server-side — none belongs in the browser catalogue.

⚠️ The excerpt is plain text, and escaped. getInboxExcerpt() is what the list shows under the sender; strip the markup before returning it, or the list shows the tags.

The sign-in pages

@Admin/security/{login,register,reset_password_request,reset_password,check_email}.html.twig. They carry the branding and honour admin.routes — closing public sign-up is emptying one key, not overriding a template. The register and reset templates expect a form from the application: the bundle does not decide what an account is made of, it draws the screen.

They all extend @Admin/security/_card.html.twig. A project dresses the five at once by overriding that ONE file and extending the original through @!Admin — never by copying the pages:

{# templates/bundles/AdminBundle/security/_card.html.twig #}
{% extends '@!Admin/security/_card.html.twig' %}

{% block auth_topbar %}{{ include('_partials/_locale_switcher.html.twig') }}{% endblock %}
{% block auth_aside %}{{ include('security/_aside.html.twig') }}{% endblock %}
Block What goes in
auth_aside (1.24) beside the card: an illustrated half, a brand, a link back to the public site. Its elements are DIRECT children of .auth-shell — the project places them (absolute on a wide screen, in the flow on a phone, order for what comes after the card). Empty by default
auth_topbar above the card, right-aligned — a language picker
auth_title, auth_subtitle, auth_body, auth_footer the card itself, filled by each page

⚠️ The password-reset flow must never reveal whether an address exists. Both cases lead to the same confirmation page with the same text — a different message turns the form into an account-enumeration oracle. The shipped templates already read that way; keep them that way.

Quality assurance

composer qa            # cs-check + rector-check + phpstan (level max) + phpunit

Run 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.

License

The Admin bundle is open-sourced software licensed under the MIT license.

© 2026 jul6art