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: 361

Dependents: 0

Suggesters: 0

Stars: 3

Open Issues: 0

v1.20.4 2026-09-23 21:41 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

> ⚠️ **`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 centredadmin_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 belladmin_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 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.

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.

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