jul6art / admin-bundle
Symfony admin backoffice bundle
Requires
- php: ^8.5
- symfony/config: ^7.4 || ^8.0
- symfony/dependency-injection: ^7.4 || ^8.0
- symfony/http-kernel: ^7.4 || ^8.0
- symfony/security-bundle: ^7.4 || ^8.0
- symfony/security-core: ^7.4 || ^8.0
- symfony/translation-contracts: ^3.4
- symfony/yaml: ^7.4 || ^8.0
Requires (Dev)
- doctrine/doctrine-bundle: ^3.3
- doctrine/orm: ^3.5
- friendsofphp/php-cs-fixer: ^3.68
- jul6art/core-bundle: ^2.10
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.1
- phpstan/phpstan-doctrine: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^13.0
- rector/rector: ^2.0
- symfony/asset: ^7.4 || ^8.0
- symfony/flex: ^2.4
- symfony/form: ^7.4 || ^8.0
- symfony/framework-bundle: ^7.4 || ^8.0
- symfony/phpunit-bridge: ^7.4 || ^8.0
- symfony/routing: ^7.4 || ^8.0
- symfony/security-csrf: ^7.4 || ^8.0
- symfony/translation: ^7.4 || ^8.0
- symfony/twig-bundle: ^7.4 || ^8.0
- symfony/validator: ^7.4 || ^8.0
- symfony/var-dumper: ^7.4 || ^8.0
- twig/twig: ^3.28
Suggests
- doctrine/orm: Le trait `AppearancePreferencesTrait` et le contrôleur qui l'enregistre.
- jul6art/core-bundle: Required by the performance dashboard (Controller\PerformanceController): it provides the profiler store this screen renders.
- jul6art/datatable-bundle: Les tableaux du back-office, et les contrôleurs Stimulus `ui--modal` / `ui--tooltip` que la coquille suppose enregistrés.
- symfony/asset: `asset()` pour le logo et le favicon ; sans lui, les chemins sont rendus tels quels.
- symfony/form: Le formulaire d'apparence (`AppearanceType`).
- twig/twig: La coquille, les pages d'authentification et l'écran d'apparence. Sans Twig, il reste le contrat de navigation, les enums et le trait d'entité.
Provides
None
Conflicts
None
Replaces
None
- dev-master / 1.x-dev
- v1.20.4
- v1.20.3
- v1.20.2
- v1.20.1
- v1.20.0
- v1.19.1
- v1.19.0
- v1.18.0
- v1.17.0
- v1.16.1
- v1.16.0
- v1.15.0
- v1.14.5
- v1.14.4
- v1.14.3
- v1.14.2
- v1.14.1
- v1.14.0
- v1.13.0
- v1.12.0
- v1.11.1
- v1.11.0
- v1.10.1
- v1.10.0
- v1.9.0
- v1.8.1
- v1.8.0
- v1.7.0
- v1.6.1
- v1.6.0
- v1.5.0
- v1.4.2
- v1.4.1
- v1.4.0
- v1.3.1
- v1.3.0
- v1.2.1
- v1.2.0
- v1.1.1
- v1.1.0
- v1.0.2
- v1.0.1
- v1.0.0
This package is auto-updated.
Last update: 2026-09-23 21:42:34 UTC
README
Symfony admin backoffice bundle
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 needssecurity.authorization_checkerto 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
> ⚠️ **`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.
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_centersits in the shell's own container (flex-1, centred frommdup, allowed to shrink). Put the field in it, not aw-fullwrapper: 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, thenadmin_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 itstitle; 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 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.
permissiongoes toisGranted(), 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.featuregoes 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.
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'scontent. A class used only in the bundle's templates is otherwise purged from the production stylesheet — and only from that one.
Eleven Stimulus controllers ship with it (appearance, theme, dropdown, collapsible, tabs,
sidebar-section, toast, locale-switcher, cookie-consent, sidebar, password). Register
them under ui--<name>, which is what the templates address. Four more come with the keyboard
bricks below, under core-- and form--.
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-labelis 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.
⚠️
Escno 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+Breplaces it. A form that writes its own Cancel link gives itdata-shortcut="{{ keyboard_shortcut('form.back') }}";cancel_shortcut: falsetakes 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+Bin 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+Bmoves 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+Enterbecause 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::$labelKeyis 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 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.
⚠️ 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
