Skip to content

omnibase/admin

Administration backoffice for base-bundle: dynamic CRUD, dashboard, menus and field-based form generation

Floor
Prepared structure · Omnibase
Version
main
Verified for real
Its README does not say
Licence
MIT
composer require omnibase/admin:dev-main

The back office of the omnibase sites: CRUD controllers generated from an entity and its fields, a dashboard of widgets, a sidebar, the system pages (settings, API keys, opening hours, the trash, reports, site texts, redirections), with no dependency on EasyAdmin. It owns the Base\Admin\ namespace and runs on omnisense/omnibase 3 or 4, under Symfony (through omnibridge/symfony).

Three ideas shape it:

  • One field system. A CRUD yields Base\Field\*Field descriptors (omnisense/omnibase), each wired to a real Symfony form type (Base\Field\Type\*Type): the same fields work in the back office and in any form of the site.
  • A plain CRUD pipeline. AbstractCrudController's actions (index, detail, new, edit, delete, batchDelete, toggle) are ordinary controller methods a CRUD overrides; the routes are generated for every registered CRUD.
  • A website in the website. The back office opens as a transparentJS overlay over the page the visitor was on (X-Transparent-Nest); without transparentJS every link is an ordinary page load.

Use it for a site's administration. A site that only needs a page or two of its own under /admin can route them there itself: they are nested and get the sidebar (System pages).

How this manual is organised

Propose a change docs/index.md

Installation

Requirements

What composer.json requires: PHP 8.1 or later, symfony/config, dependency-injection, http-kernel and form 6, 7 or 8, omnibridge/symfony 1.x and omnisense/omnibase 3.0 or 4.0.

The bundle

composer require omnibase/admin
// config/bundles.php
return [
    // ...
    Base\BaseBundle::class => ['all' => true],
    Base\Admin\AdminBundle::class => ['all' => true],
];

AdminBundle::getPath() is the package's root: its templates/ is the @Admin namespace. Like BaseBundle, it aliases each of its controllers Base\Admin\Controller\X to App\Admin\Controller\X unless the application declares a class there: an application overrides a screen by writing that class.

The routes

The CRUDs' routes are generated by the loader of type base_admin (Base\Admin\Router\AdminRouteLoader); import it after the core's controllers:

# config/routes.yaml
base_controller:
    resource: "@BaseBundle/Controller"
    type: attribute
    prefix: /

# The back office's CRUDs (omnibase/admin), /admin.
base_admin_crud:
    resource: .
    type: base_admin

The prefix is /admin (AdminRouteRegistry's $urlPrefix). The loader adds the system pages, the dashboard's widget and layout endpoints, the trash, the revisions, the opening hours, and - when the dashboard controller has no #[Route] of its own - the route admin on the prefix. An application's routes imported after it keep their names over the loader's. Every route is in the Reference.

The dashboard controller

The back office's front door is the application's: a class extending Base\Admin\Controller\AbstractDashboardController (autoconfigured, tagged base.admin.dashboard_controller). The harness's (docker/app/src/Controller/Admin/DashboardController.php in omnisense/omnibase):

namespace App\Controller\Admin;

use Base\Admin\Config\MenuItem;
use Base\Admin\Controller\AbstractDashboardController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class DashboardController extends AbstractDashboardController
{
    #[Route('/admin', name: 'admin')]
    public function index(): Response
    {
        return parent::index();
    }

    public function configureWidgetItems(): iterable
    {
        yield MenuItem::section('Members and the site', 'fa-solid fa-users')->setSubItems([
            MenuItem::linkToCrud(\Base\Entity\User::class, 'Accounts', 'fa-solid fa-user'),
            MenuItem::linkToCrud(\Base\Entity\Layout\Setting::class, 'Settings', 'fa-solid fa-sliders'),
            MenuItem::linkToRoute('admin_trash', [], 'Trash', 'fa-solid fa-trash-can'),
        ]);
    }
}

See The dashboard and the menu.

Who enters

On every page of the back office (and of the profiler) the core's SecuritySubscriber asks isGranted('BACKEND'), which the core's AdminVoter grants to a signed-in ROLE_ADMIN. What each CRUD then lets an administrator do is in Who may write in a CRUD.

Contextual help

The help panel of the back office shows the manuals of omnibase/docs when it is installed.

Propose a change docs/installation.md

Writing a CRUD

A CRUD is a controller extending Base\Admin\Controller\AbstractCrudController. It implements CrudControllerInterface, which autoconfiguration tags base.admin.crud_controller and controller.service_arguments: registered as a service (the application's src/ is, by Symfony's default configuration), it gets its routes, its menu entry and its links from every other screen.

A complete CRUD

The bundle's own CRUD of the core's redirections (Base\Admin\Controller\Crud\RedirectionCrudController):

namespace Base\Admin\Controller\Crud;

use Base\Admin\Config\Crud;
use Base\Admin\Controller\AbstractCrudController;
use Base\Admin\Filter\Filters;
use Base\Entity\Layout\Redirection;
use Base\Field\BooleanField;
use Base\Field\DateTimeField;
use Base\Field\IdField;
use Base\Field\IntegerField;
use Base\Field\TextField;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;

class RedirectionCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return Redirection::class;
    }

    public static function getPreferredIcon(): ?string
    {
        return 'fa-solid fa-diamond-turn-right';
    }

    public function configureCrud(Crud $crud): Crud
    {
        return parent::configureCrud($crud)
            ->setDefaultSort(['hits' => 'DESC']);
    }

    public function configureFilters(Filters $filters): Filters
    {
        return $filters->add('status')->add('enabled');
    }

    public function configureFields(string $pageName): iterable
    {
        yield IdField::new('id')->onlyOnIndex();
        yield TextField::new('source', '@admin.redirection.source')->setColumns(6)
            ->setHelp('@admin.redirection.source_help');
        yield TextField::new('target', '@admin.redirection.target')->setColumns(6)
            ->setHelp('@admin.redirection.target_help');
        yield IntegerField::new('status', '@admin.redirection.status')->setColumns(4)
            ->setFormType(ChoiceType::class)->setFormTypeOptions([
                'choices' => ['redirection.status_301' => Redirection::PERMANENT, 'redirection.status_302' => Redirection::TEMPORARY],
                'translation_domain' => 'admin',
                'choice_translation_domain' => 'admin',
            ]);
        yield BooleanField::new('enabled', '@admin.redirection.enabled')->setColumns(4);
        yield IntegerField::new('hits', '@admin.redirection.hits')->hideOnForm();
        yield DateTimeField::new('lastHitAt', '@admin.redirection.last_hit_at')->hideOnForm();
        yield DateTimeField::new('createdAt', '@admin.redirection.created_at')->onlyOnDetail();
    }
}

The entity

getEntityFqcn() is guessed from the controller's name when it is not overridden: CrudController is taken off, and each of \Controller\Crud\, \Controller\Admin\Crud\, \Admin\Controller\Crud\, \Controller\Backoffice\Crud\ is replaced by \Entity\ until a class exists (App\Controller\Crud\Blog\ArticleCrudController manages App\Entity\Blog\Article). None found: a LogicException asks to override it. The reverse, AbstractCrudController::getCrudControllerFqcn($entityOrClass), finds the CRUD of an entity - the registered one first, an App\ class before a Base\ one, then the CRUD of the nearest parent class.

getPreferredIcon() gives the CRUD's icon in the menu and the dashboard.

configureCrud(Crud $crud)

The default sorts by id descending, 20 rows a page. Base\Admin\Config\Crud:

Method Effect
setEntityLabelInSingular($label), setEntityLabelInPlural($label) The entity's name on the pages (otherwise admin_entity_name(): its translation in entities, else its class name in words)
setPageTitle($pageName, $title), setHelp($pageName, $help) A page's title, its help text (Crud::PAGE_INDEX, PAGE_DETAIL, PAGE_EDIT, PAGE_NEW)
setDefaultSort(['field' => 'ASC'|'DESC', ...]) The list's order
setPaginatorPageSize(int) Rows per page
setSearchFields([...]) The properties the search box looks in; empty: every string or text column of the list's fields (LOWER(...) LIKE %query%)
showEntityActionsInlined(), showEntityActionsAsDropdown() A row's actions as buttons or in a menu
setFormOptions($new, $edit = null) The options of the new and edit forms
setEntityPermission($permission) The attribute every page of the CRUD requires (Who may write in a CRUD)

Labels are translation keys or TranslatableInterfaces (CRUD fields).

configureFields(string $pageName)

Yields Base\Field\*Field descriptors (omnisense/omnibase's): IdField, TextField, TextareaField, IntegerField, NumberField, MoneyField, BooleanField, DateField, DateTimeField, SelectField, AssociationField, CollectionField, ImageField, FileField, WysiwygField, EditorField, SlugField... (the classes of vendor/omnisense/omnibase/src/Field). $pageName is the page being built; onlyOnIndex(), onlyOnDetail(), hideOnForm()... say where a field appears, setColumns() its width, setFormType() and setFormTypeOptions() its form field. Base\Admin\Form\FieldFormBuilder makes each one the form field of its type. The rules for related records, enums, collections and required fields are in CRUD fields.

configureActions(Actions $actions)

The default (Actions::addDefaults()):

Page Actions
index new, edit, delete, detail
detail edit, delete, index
edit saveAndReturn, saveAndContinue, index
new saveAndReturn, saveAndAddAnother, saveAndContinue, index

and, for an entity with an address on the site, a link to it. Base\Admin\Config\Actions:

Method Effect
add($page, Action|string $action, $icon = null, $callable = null) Adds an action to a page
update($page, $name, callable $fn), remove($page, $name) Changes or removes one
disable(...$names) Removes them from every page; their routes answer 404
setPermission($name, $permission), setPermissions([...]) The attribute an action requires
openTo($role, ...$custom), allowAnyoneTo(...$names) Opens the writing actions to a role; lets any administrator run some (Who may write in a CRUD)

Base\Admin\Config\Action::new($name, $label = null, $icon = null) builds one: createAsGlobalAction() (on the page, not on a row), createAsBatchAction(), linkToCrudAction($method), linkToRoute($route, $parameters), linkToUrl($url), askConfirmation($question), displayIf(callable), setPermission(), renderAsButton(), renderAsLink(), targetBlank(), setCssClass(), setHtmlAttributes(). Its constants name the built-in ones (Action::NEW, EDIT, DELETE, DETAIL, INDEX, BATCH_DELETE, SAVE_AND_RETURN, SAVE_AND_CONTINUE, SAVE_AND_ADD_ANOTHER).

A CRUD's own actions: #[AdminAction]

A public method with Base\Admin\Attribute\AdminAction is one more route of the CRUD, named like the built-in ones (admin_crud_<slug>_<method>), and AdminActionSubscriber runs the CRUD's gate before it: the entity permission, the action's permission, a disabled action's 404 and, for a method that writes, the CSRF token.

use Base\Admin\Attribute\AdminAction;
use Base\Admin\Config\Action;
use Base\Admin\Config\Actions;
use Symfony\Component\HttpFoundation\Response;

public function configureActions(Actions $actions): Actions
{
    return parent::configureActions($actions)
        ->add(Actions::PAGE_INDEX, Action::new('refreshRates', 'Refresh the rates', 'fa-solid fa-rotate')
            ->createAsGlobalAction()
            ->linkToCrudAction('refreshRates')
            ->askConfirmation('Fetch today\'s rates now?'));
}

#[AdminAction('/refresh')]
public function refreshRates(): Response
{
    // gate and CSRF token already checked
    $this->rates->refresh();
    $this->addFlash('success', 'Rates refreshed.');

    return $this->redirectToIndex();
}
Argument Default Effect
path null: /<kebab-cased method> Relative to the CRUD's address; a path holding {entityId} makes it a record's action (the method takes string $entityId and loads it with $this->findEntity($entityId))
methods ['POST'] The HTTP methods of the route; one that takes no GET is drawn as a small form (its token, a button, the confirmation) instead of a link
csrf true Check the token (admin-action-<method>, posted as _token or an X-CSRF-Token header) before a writing method runs
requirements [] More route requirements

The marketplace's exchange_rates/refreshRates, the invoices' send, markPaid, credit and the complaints' handle, dismiss, reopen are such actions.

configureFilters(Filters $filters)

Filters::add('property') adds a filter whose kind is guessed from the mapping (boolean, numeric for integer, smallint, bigint, float, decimal, date for date, datetime, datetime_immutable, date_immutable, text otherwise); Filters::add(Filter::new('property', 'Label')->asChoice([...])) gives one explicitly: asBoolean(), asDate() (a from/to range), asNumeric() (=, >, <, ≠), asChoice($choices), asAssociation($entities, $labelCallback), or applyWith(callable) for a query of its own. A CRUD of a Thread gets a state filter unless it declares one.

A property added by its name alone is guessed from its column: a boolean, a number, or a date range for every moment (date, datetime, datetimetz and the core's utc_datetime, each mutable or immutable). The default state filter is labelled State (the forms domain) and offers the states by their value, with the core's enum words (published, draft...) in the reader's language.

Hooks

Method Default Override to
configureRecordNote(object $entity): ?string null Show a line about the record under its heading on the detail and edit pages (plain text, escaped)
isDeletable(object $entity): bool true Refuse deleting a record others rely on: the button disappears and the delete routes refuse it
createEntity(string $fqcn): object the constructor, its owner parameter (owner, author typed with the user's class) filled with the signed-in user Build a new record otherwise
persistEntity($em, $entity), updateEntity($em, $entity) persist() and flush(); flush() Do more on a save
removeEntity($em, $entity) remove() Change how a record goes (single and batch deletes both run it); deleteEntity() adds the flush
createIndexQueryBuilder(Crud $crud, array $sort) the repository's query builder, alias entity, sorted Narrow the list

The pages (index, detail, new, edit, delete, batchDelete, toggle, collectionEntries) are public methods too; an override calls the parent or renders with renderCrud(). findEntity($entityId) finds a record by its id or its address identifier, redirectToIndex() goes back to the list.

Routes and addresses

Every registered CRUD gets, under /admin/<slug>:

Route Path Methods
admin_crud_<slug>_index /admin/<slug> GET
admin_crud_<slug>_new /admin/<slug>/new GET, POST
admin_crud_<slug>_batchDelete /admin/<slug>/batch-delete POST
admin_crud_<slug>_detail /admin/<slug>/{entityId} GET
admin_crud_<slug>_edit /admin/<slug>/{entityId}/edit GET, POST
admin_crud_<slug>_delete /admin/<slug>/{entityId}/delete POST
admin_crud_<slug>_toggle /admin/<slug>/{entityId}/toggle PATCH
admin_crud_<slug>_collectionEntries /admin/<slug>/{entityId}/collection/{field} GET
admin_crud_<slug>_<method> the #[AdminAction]'s path its methods

The slug is the controller's namespace after Crud\ (or its short name), each segment kebab-cased and pluralised, a repeated segment collapsed, joined with / (Base\Routing\AdminRouteNames::slugify(), the core's): Crud\UserCrudController is users, Crud\User\GroupCrudController users/groups; in a route name / and - become _ (admin_crud_users_groups_index).

In PHP the address is built by Base\Admin\Router\AdminUrlGenerator, in Twig by admin_url():

$url = $this->adminUrlGenerator
    ->setController(UserCrudController::class)
    ->setAction('edit')
    ->setEntityId($user->getId())
    ->generateUrl();
<a href="{{ admin_url('Base\\Admin\\Controller\\Crud\\UserCrudController', 'edit', user.id) }}">...</a>

{entityId} is the record's readable identifier when it stores one (admin.url_identifier, CRUD fields); a GET by another identifier is redirected to the canonical one.

The CRUDs the bundle ships

Controller Entity (omnisense/omnibase) Slug
Crud\UserCrudController Base\Entity\User users
Crud\User\ConnectionCrudController Base\Entity\User\Connection users/connections
Crud\User\GroupCrudController Base\Entity\User\Group users/groups
Crud\User\PermissionCrudController Base\Entity\User\Permission users/permissions
Crud\ComplaintCrudController Base\Entity\User\Complaint (with handle, dismiss, reopen) complaints
Crud\TextOverrideCrudController Base\Entity\Layout\TextOverride text-overrides
Crud\RedirectionCrudController Base\Entity\Layout\Redirection redirections
Crud\Thread\TagCrudController, Crud\Thread\TaxonCrudController Base\Entity\Thread\Tag, Taxon threads/tags, threads/taxons
Crud\Layout\SettingCrudController, Crud\Layout\ImageCrudController Base\Entity\Layout\Setting, Image layouts/settings, layouts/images
Crud\Layout\Attribute\AdapterCrudController Base\Entity\Layout\Attribute\Adapter\Common\AbstractAdapter layouts/attributes/adapters
Crud\Layout\Widget\PageCrudController Base\Entity\Layout\Widget\Page layouts/widgets/pages

All under Base\Admin\Controller\; each is overridden by the application's class of the same name under App\Admin\Controller\. Their former names (Base\Controller\Backoffice\Crud\*) are kept as aliases for one release (src/Resources/former-names.php).

Propose a change docs/crud-controllers.md

The dashboard and the menu

The dashboard controller's hooks

Base\Admin\Controller\AbstractDashboardController::index() renders @Admin/dashboard.html.twig with the widgets the hooks give, through MenuBuilder (each item's visibility checked, the stored customisation applied):

Hook Default What it gives
configureWidgetItems() [] The dashboard's groups: sections (or submenus) with their links, and blocks. Without any, the dashboard falls back to one card per registered CRUD
configureDashboardBlockItems() welcome (size 5, no background) and analytics_card (size 3) The built-in blocks, kept whether or not configureWidgetItems() calls its parent
configureMenuBeforeItems() [] The sidebar's first entries
configureMenuItems() nothing when the dashboard has groups, else one entry per registered CRUD The sidebar's own entries (the system pages, as a rule)
configureMenuAfterItems() [] The sidebar's last entries
configureUserMenu() MenuBuilder::buildUserMenuDefault() The account menu
configureSidebarBlockItems() the analytics_widget block The block under the sidebar's icons

How the sidebar is assembled from these is in System pages. A super-administrator customises the dashboard in place (order, size, hidden, background); the layout is kept per scope (LayoutScope::DASHBOARD, SIDEBAR, CRUD) by LayoutStore and saved through admin_layout_save.

MenuItem

Base\Admin\Config\MenuItem's factories build Base\Admin\Config\Menu\MenuItems:

Factory Item
MenuItem::linkToCrud($entityFqcn, $label = null, $icon = null) The CRUD registered for that class, else for its nearest parent class
MenuItem::linkToRoute($route, $parameters = [], $label = null, $icon = null) A route
MenuItem::linkToUrl($label, $icon, $url) An address
MenuItem::linkToDashboard($label, $icon = null) The dashboard
MenuItem::linkToLogout($label, $icon = null), linkToExitImpersonation($label, $icon = null) Sign out; leave an impersonation
MenuItem::section($label = null, $icon = null) A group, its links given by setSubItems([...])
MenuItem::subMenu($label, $icon = null, $url = null) A submenu
MenuItem::block($blockName, $label = null, $icon = null, $instanceKey = null, $params = []) A widget of a widget type (below); $instanceKey keeps each instance's customisation apart

An item takes setCrudAction('new') (a "create" shortcut), setPermission($attribute) (shown only to whom it is granted), setBadge(), setLinkTarget(), setDescription(), setCssClass(), setSize(), setHeight(), setBackground(), setHidden().

public function configureWidgetItems(): iterable
{
    yield MenuItem::section('menu.section.blog', 'fa-solid fa-newspaper')->setSubItems([
        MenuItem::linkToCrud(Article::class, 'menu.item.articles', 'fa-solid fa-newspaper'),
        MenuItem::linkToCrud(Article::class, 'menu.item.articles', 'fa-solid fa-plus-circle')->setCrudAction('new'),
    ]);

    yield MenuItem::block('analytics_card', 'Last 30 days', 'fa-solid fa-chart-line', '30d', ['days' => 30]);
}

Widget types

A block names a widget type by its name. The bundle registers:

Name Class Parameters What it shows
welcome WelcomeWidgetType The greeting
analytics_card AnalyticsCardWidgetType days (14), series The site's traffic, day by day (Base\Service\Analytics), with the timeline's events; series the lines plotted (all but bots and AI by default)
entity_views EntityViewsWidgetType days (14), entityClass, entityId, series The page views of one record, or of every record of a class
counter CounterWidgetType mode (views or records), days (14), entityClass, entityId, countClass, scopeId, scopeClass, states, series One number and its trend: the views of a record, or how many records there are (narrowed to a parent and to states)
composite CompositeWidgetType panes: a list of {type, label, params} Two or more widgets of the registered types side by side in one card
admin_complaints ComplaintsWidgetType The open reports

All but composite and admin_complaints are in the palette a super-administrator adds widgets from (PaletteDashboardWidgetTypeInterface).

Writing a widget type

A service implementing Base\Admin\Widget\DashboardWidgetTypeInterface is a widget type (autoconfigured, tag base.admin.dashboard_widget_type):

namespace App\Admin\Widget;

use Base\Admin\Config\Menu\MenuItem;
use Base\Admin\Widget\DashboardWidgetTypeInterface;

final class OpenOrdersWidgetType implements DashboardWidgetTypeInterface
{
    public function __construct(private readonly OrderRepository $orders)
    {
    }

    public static function getName(): string
    {
        return 'open_orders';
    }

    public function getTemplate(): string
    {
        return 'admin/widget/open_orders.html.twig';
    }

    public function getTemplateVars(MenuItem $widget): array
    {
        return ['count' => $this->orders->countOpen($widget->getParams()['days'] ?? 7)];
    }
}

and the dashboard yields MenuItem::block('open_orders', 'Open orders', 'fa-solid fa-box'). Implementing PaletteDashboardWidgetTypeInterface instead (getDefaultLabel(), getDefaultIcon()) also puts it in the palette (tag base.admin.dashboard_widget_type.palette). In a template, dashboard_widget_type(name) gives the type.

The timeline the analytics widgets draw reads the services implementing the core's Base\Service\TimelineEventProviderInterface (tag base.admin.timeline_event_provider).

Propose a change docs/dashboard.md

CRUD fields

A CRUD controller yields its fields from configureFields() (Base\Field\*Field, in omnisense/omnibase); Base\Admin\Form\FieldFormBuilder turns each into a Symfony form field: the field's form type with its options, nothing in between.

AssociationType (omnisense/omnibase) embeds the related entity's own form in the record being edited. For a record that exists on its own - the account an order belongs to, the journal of a bank account, the venue of a concert - that is not what a back office wants: the whole account (email, password, roles) or the whole journal inside another record. It did not work either: the embedded form failed on what it cannot guess (roles), and put a PHP enum of the related record in a text input ("AccountType could not be converted to string", a 500 on /admin/reconciliation-rules/new).

So FieldFormBuilder builds an AssociationField as a picker - omnibase's SelectType on the related class, fed by the autocompletion, what SelectField::new('owner') gives - when the related record exists on its own, and keeps the embedded form for what the record owns:

The association The field is
many-to-one, many-to-many a picker (single or multiple, as the association says)
a user account (a class implementing Symfony's UserInterface), whatever the association a picker
one-to-many, one-to-one the embedded form (the lines of an entry, the address of a place)
->embed(), or ->setFields([...]) with the fields to embed the embedded form, whatever the association
->embed(false) a picker, whatever the association
yield AssociationField::new('customer')->setColumns(4);      // many-to-one: a picker
yield AssociationField::new('owners');                       // many-to-many: a multiple picker
yield AssociationField::new('customer')->setDisabled();      // shown, not changeable
yield AssociationField::new('lines');                        // one-to-many: the lines' forms, embedded
yield AssociationField::new('lines')->embed(false);          // ...or chosen among existing lines
yield AssociationField::new('address')->embed();             // the address's own form, asked for
yield AssociationField::new('customer')->autoload(false)->setFields([
    'email' => ['form_type' => EmailType::class],            // an embedded form of the fields named
]);

The related class is the field's class option when it is set, else the Doctrine association of the form's entity. A picker keeps the field's label, help, columns, required and disabled settings; the options that describe an embedded form (allowAdd(), autoload(), showCollapsed()...) are dropped.

AssociationType used by name in a form of your own ($builder->add('address', AssociationType::class)) always embeds: the rule above is the back office's, applied to an AssociationField.

In an embedded form, a column that holds a PHP enum is a select of its cases (see below), no longer a text input.

Lists and detail pages are unchanged: the field is displayed by crud/field/association.html.twig as before.

A PHP enum is a select of its cases

SelectField (omnibase's SelectType) finds its choices by itself when the property holds a PHP enum:

#[ORM\Column(length: 16, enumType: CommentState::class)]    // or simply a property typed CommentState
protected CommentState $state = CommentState::PENDING;

yield SelectField::new('state');                             // pending, approved, spam, trash
  • Where the enum is read: Doctrine's enumType: (which Doctrine also sets from a property typed with a backed enum), the type of the property on a model that is not an entity, or the field's own class: SelectField::new('states')->setClass(CommentState::class)->allowMultipleChoices(). An enumType: on a simple_array or json column is a multiple select.
  • What the record receives: the case (CommentState::SPAM), or a list of cases. A value that names no case is refused ("The selected choice is invalid"), and the record keeps what it had.
  • What the select holds: the backed value (spam); the case's name when the enum is not backed (NO_TRUMP).
  • The labels, in this order: what the enum says itself when it implements Symfony's TranslatableInterface; the key <short class name in snake case>.<value> in the enums domain (comment_state.spam in translations/enums.fr.yaml); the case's name made readable (NO_TRUMP: "No trump").

A field that brings its own choices keeps them, and what it stores is what they hold - the enum is not guessed over them:

yield SelectField::new('role')->setChoices([
    '@agenda.role.soloist' => 'soloist',      // label => stored value; a label that is a
    'Guest' => 'guest',                       // translation key is translated
]);

(The labels of such choices are now what is shown; the stored value was printed in their place.)

The list and the detail page say what the form says

A cell of the list, a line of the detail page and the form's select name a value with the same words:

  • a PHP enum's case by the labels above (admin_enum_label()), whether the column is a SelectField or a plain TextField;
  • a value among the field's own choices by its label, translated when it is a key (admin_field_choices() turns ['Label' => 'value'] round; groups are looked into, choices made by a closure are not);
  • an omnibase EnumType (->setEnumClass(ThreadState::class)) by trans_enum, as before.

The cell used to print the stored value with a capital - "Pending", "Other" - beside a form saying "À valider", "Autre". A field template of your own gets the same words from the two functions:

{{ admin_enum_label(field.value) ?? field.formattedValue }}

Before this, a SelectField on an enum stopped the form on "No choices, or autocomplete option, could be guessed without using data information".

A record chosen in any repository

SelectField::new('room')->setClass(Room::class) - or any picker, an AssociationField included - works whatever the repository of the class: one of omnibase's (Base\Database\Repository\ServiceEntityRepository, asked through cacheById()), or Doctrine's own (extends Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository, what make:entity writes), asked through find() and findBy() on the identifier. With a Doctrine repository the form used to die on submission: Undefined method "cacheById".

A collection of objects: allowObject()

CollectionField holds a list; each entry is built with the form type named by setEntryType(). By default the entries must be scalars: the type throws "Object data are not allowed in collection unless you mark it as so" when the list holds objects, because an entry whose type cannot take an object would try to print it. When the entry type has a data_class - one form per object - say so:

yield CollectionField::new('targets')
    ->setEntryType(TargetType::class)      // TargetType: data_class => SocialPostTarget::class
    ->allowAdd()
    ->allowDelete()
    ->allowObject()                        // the entries are objects, TargetType knows how to edit one
    ->hideOnIndex();
  • allowObject() only lifts that guard (the allow_object option of omnibase's CollectionType); the entry type is still the one that maps an object to its fields.
  • With allowAdd(), give the entry type an empty_data (or a constructor without required arguments): a new entry is created from nothing.
  • The property needs an adder and a remover (addTarget(), removeTarget()) or a setter; with Doctrine, cascade: ['persist'] and orphanRemoval: true on the one-to-many, so that an added entry is saved and a removed one deleted.
  • An AssociationField on a one-to-many does this by itself (its inner collection sets allow_object): CollectionField + allowObject() is for an entry type of your own.

The words of a CRUD page are translated once

configureCrud() gives its labels, page titles and helps as plain words, as keys that name their domain, or as TranslatableInterface:

return parent::configureCrud($crud)
    ->setEntityLabelInSingular('@agenda.admin.event.singular')
    ->setEntityLabelInPlural('@agenda.admin.event.plural')
    ->setPageTitle(Crud::PAGE_NEW, new TranslatableMessage('event.new', [], 'agenda'))
    ->setHelp(Crud::PAGE_INDEX, 'Les dates passées restent visibles.');     // plain words: as they are

The controller translates the keys and the translatables when it builds the page's configuration (AbstractCrudController::translateCrudLabels()), so every template prints words: the list's title printed the key itself (@agenda.admin.event.plural).

A menu entry on the application's own class

MenuItem::linkToCrud(\App\Entity\User::class, 'Utilisateurs') leads to the CRUD registered for that class, else to the one of the nearest parent class: omnibase registers its users' CRUD against Base\Entity\User, which the application's User extends. (With no fallback the entry was printed without an address, href="#".)

A record's address

A record is linked by the first readable identifier it stores, else by its id: /admin/articles/petit-cours, /admin/users/14.

# config/packages/admin.yaml
admin:
    url_identifier:
        fields: [slug, uuid]                  # the default, in order; []: always the id
        entities:
            App\Entity\User: [username]

A field counts only when it is a column of the entity: the address has to be looked up again, and the lookup queries columns. An entity whose getSlug() is computed - made from the title at each call, or read from a translation - is linked by its id: the address made of the computed slug answered 404 (/admin/scholars/publications/2/edit redirected to it). To give such an entity a readable address, store the slug (#[Slugify] on a column).

An address by id keeps working for every record, and a GET by an identifier that is not the canonical one is redirected to it.

A field is required when the record needs it

A field without ->setRequired(false) is required, and the back office does not send a form with a required field left empty: an optional picture, link or date marked required stopped every save. Base\Admin\Test\CrudFieldsTestCase (testing/, outside src/: omnisense/omnibase loads every class of a bundle's src/ when it boots, and this one extends PHPUnit's TestCase, which an application's console does not always have) checks a bundle's screens against its entities, without a kernel; each bundle extends it once:

final class CrudRequiredFieldsTest extends CrudFieldsTestCase
{
    protected static function sourceDirectory(): string
    {
        return __DIR__.'/../../src';
    }
}

On the new and edit forms of every CRUD controller under that directory:

  • only what the record needs is required: a column neither nullable nor defaulted (in the mapping, on the property, by the constructor), a join column that is not nullable, a NotBlank, NotNull or Count(min: 1) constraint, a setter that refuses null - and a thread's title, the field a slug follows, or what requiredByTheScreen() gives a reason for;
  • what the record needs is required, unless filledByTheScreen() says who fills it (a number given when the record is persisted);
  • a field left optional takes an empty value: an empty text, number or date sends null, so its setter accepts null, or the field says what empty is (setEmptyData());
  • one field per property (two fields on one property are one form field, the last), and a slug follows a field of the form (setTargetFieldName()).

A form the screen does not open (new or edit disabled) is not checked; skipped() names a controller that cannot be read in an installation (a package its entity needs is missing), services() what configureFields() reads that a stub does not give.

Propose a change docs/crud-fields.md

Who may write in a CRUD

A CRUD's list and detail pages are read by whoever enters the back office (or holds the CRUD's entity permission, see below). What writes is the super-admin's by default: new, edit, delete, the batch delete and the three "save" buttons require ROLE_SUPERADMIN (Base\Admin\Config\Actions::$defaultPermissions). A site's administrator (ROLE_ADMIN) reads everything and changes nothing, unless the CRUD says otherwise: its buttons are not drawn, and the address typed by hand answers 403 - "new" requires "ROLE_SUPERADMIN".

That default is right for what configures the site (users, settings, redirections) and wrong for the site's own contents: the posts of a blog, the dates of an agenda, a scholar's publications are written by the person whose site it is.

#[OpenToAdmins]: the administrators write here

use Base\Admin\Attribute\OpenToAdmins;

#[OpenToAdmins]
class PostCrudController extends AbstractCrudController
{
}

On the controller, the attribute opens the seven actions above to ROLE_ADMIN (held directly or through security.role_hierarchy). Two arguments:

#[OpenToAdmins(actions: ['publish', 'duplicate'])]   // the CRUD's own #[AdminAction] methods as well
#[OpenToAdmins(role: 'ROLE_STAFF')]                  // another role than ROLE_ADMIN
  • The default does not move. A CRUD without the attribute stays as it was; nothing is opened application-wide.

  • What the CRUD sets itself is kept. The attribute stands only where the blanket default would have applied: Actions::setPermission(), Action::setPermission() and allowAnyoneTo() in configureActions() win. An opened CRUD that keeps its deletions to the super-admin:

    #[OpenToAdmins]
    class InvoiceCrudController extends AbstractCrudController
    {
        public function configureActions(Actions $actions): Actions
        {
            return parent::configureActions($actions)->setPermission(Action::DELETE, 'ROLE_SUPERADMIN');
        }
    }
    
  • It is inherited. An application's CRUD extending a bundle's opened one is opened too; an attribute of its own replaces the parent's.

  • A CRUD's own actions (#[AdminAction]) have no permission unless one is given: naming them in actions: gives them the role, as Action::setPermission() would one by one.

  • Records, one by one. The permission is asked again about the record once it is loaded (edit, delete, a record's action): a voter given the record as its subject may grant an administrator some records only.

#[OpenToAdmins] is the one way a bundle opens its screens: it replaces the OpenToAdminsTrait some bundles carried a copy of ($this->openToAdmins(parent::configureActions($actions), 'publish') becomes #[OpenToAdmins(actions: ['publish'])], and the override of configureActions() goes when it did nothing else). What the attribute does is Actions::openTo($role, ...$custom), for a CRUD that decides in code.

Who may see the CRUD at all

Crud::setEntityPermission() in configureCrud() closes the whole CRUD - its pages, its menu entry, the links other screens draw to its records (CrudAccessChecker) - to whoever does not hold the permission; a voter's attribute works as well as a role:

return parent::configureCrud($crud)->setEntityPermission(ComplaintVoter::READ);

#[OpenToAdmins] does not change it: an administrator writes in the CRUDs they may see.

Tests

tests/Security/OpenToAdminsTest: the default (an administrator has no "new" button and no way to run it), an opened CRUD, another role, the CRUD's own actions, a permission the CRUD keeps, a CRUD that extends an opened one.

Propose a change docs/crud-access.md

System pages, nest, reports, site texts

What omnibase/admin gives every site so that none keeps a copy of its own.

The back office as a website-in-website

Base\Admin\EventSubscriber\NestHeaderSubscriber answers X-Transparent-Nest: overlay for every page of the back office:

  • any path under its prefix (/admin, /admin/...), whoever routed it - the CRUDs and the system pages of this bundle, and a screen an application or a bundle routes there itself (#[Route('/admin/outils/canva', name: 'app_admin_canva')]): nothing to add to the route;
  • a route named admin, admin_crud_* or admin_*, wherever it is routed.

A page of the site that should open the same way, outside /admin, asks for it:

#[Route('/cuisine', name: 'app_kitchen', defaults: ['_nest' => true])]

transparentjs opens such a page as a floating, dockable panel over the site when a link matches its nest patterns (Base.boot({nest: ['/admin*']}), omnisense/omnibase's assets/boot.js). An application deletes its App\EventSubscriber\NestHeaderSubscriber.

The sidebar, on every screen

The sidebar is the dashboard's whole menu, the same on the dashboard, the CRUD pages, the system pages and any screen of an application or a bundle:

  1. configureMenuBeforeItems();
  2. the dashboard's groups - each section (or submenu) of configureWidgetItems() with its links (CRUD, route, URL); its blocks and its "create" shortcuts (->setCrudAction('new')) stay on the dashboard;
  3. configureMenuItems() and configureMenuAfterItems(): the system pages, last. A link the groups already offer is not repeated; a group named like one of these sections adds its links to it.

An application declares its screens once, as the dashboard's groups, and keeps configureMenuItems() for the "System" section. (The sidebar used to show configureMenuItems() alone.) Left as it is, configureMenuItems() adds nothing when the dashboard has groups, and lists every registered CRUD when it has none.

A screen of its own - a controller routed outside the CRUD loader, whose template extends @Admin/layout.html.twig - needs nothing to get it:

#[Route('/admin/outils/canva', name: 'app_admin_canva')]   // under /admin: nested without asking
public function canva(): Response
{
    return $this->render('admin/tools/canva.html.twig', [...]);   // {% extends '@Admin/layout.html.twig' %}
}

The layout takes the admin_context the controller passed, or builds the menus itself (Twig admin_context(): MenuBuilder::buildDefault() for the sidebar, buildUserMenuDefault() for the account menu). A controller that seeds the context itself keeps what it set. The item whose URL is the longest prefix of the current path is the selected one, so a screen listed in the menu (MenuItem::linkToRoute('app_admin_canva', ...)) is highlighted on its page.

Settings and API keys

Base\Admin\Controller\SystemController serves /admin/settings (admin_settings), /admin/api-key (admin_apikey, ROLE_SUPERADMIN) and the sidebar's quick edit of the title and slogan (admin_settings_quick). The routes come from the base_admin loader (config/routes/base_admin.yaml, already imported by every site).

What the pages hold is declared by sections, services implementing Base\Admin\Settings\SettingsSectionInterface (autoconfigured, tag base.admin.settings_section):

Section Page Fields
Base\Admin\Settings\SiteSettingsSection (100) settings logos, title, slogan, meta, launch date, access, maintenance, security, mail
Base\Admin\Settings\BusinessSettingsSection (90) settings phone, address
Base\Admin\Settings\SpamKeySection (100) apikey api.spam.akismet
Base\Marketplace\Settings\PaymentKeysSection (50) apikey Stripe's three keys, when omnitrade's stripe gateway is there

An application adds its own in src/ (autowired and autoconfigured like any service):

namespace App\Admin;

use Base\Admin\Settings\SettingsSectionInterface;

final class SiteSection implements SettingsSectionInterface
{
    public function getPage(): string { return self::SETTINGS; }
    public function getFields(): array
    {
        return [
            'app.site.city' => ['required' => false, 'label' => 'Ville'],
            'app.site.photo_credit' => ['required' => false, 'label' => 'Crédit photo'],
        ];
    }
}

Fields are LayoutSettingListType's options plus roles (the role a field is shown to). On the API keys page a field is a revealable password field unless it names another form_type, and its value is stored secure. Sections come in their priority order (#[AsTaggedItem(priority: …)], an application's at 0); a path declared again by a later section keeps its place and takes the later options (a label changed).

An application that still routes its own App\Controller\Admin\SystemController under admin_settings/admin_apikey keeps it (its routes are imported after the loader's); deleting it hands the pages over to this one.

Reports (complaints)

Base\Entity\User\Complaint (omnisense/omnibase) gets its screen: Base\Admin\Controller\Crud\ComplaintCrudController (/admin/complaints, route admin_crud_complaints_index). Read only - a report comes from the site - with three actions: handled, dismissed, reopened (Complaint::settle(), by the signed-in staff member). Who sees a report is Base\Security\Voter\ComplaintVoter's call (COMPLAINT_READ, COMPLAINT_HANDLE): the staff above the person it is about.

The dashboard tile admin_complaints (Base\Admin\Widget\ComplaintsWidgetType):

// DashboardController::configureMenuItems() / configureWidgetItems()
yield MenuItem::linkToCrud(\Base\Entity\User\Complaint::class, 'Signalements', 'fa-solid fa-hand');
yield MenuItem::block('admin_complaints', 'Signalements');

Site texts

Base\Admin\Controller\Crud\TextOverrideCrudController (/admin/text-overrides): any text of the messages catalogue rewritten for one language (Base\Entity\Layout\TextOverride, omnisense/omnibase). The list of keys shows each text's current wording in the site's first enabled locale.

yield MenuItem::linkToCrud(\Base\Entity\Layout\TextOverride::class, 'Textes du site', 'fa-solid fa-font');

Opening hours

Base\Admin\Controller\HoursController serves /admin/hours (admin_hours, ROLE_ADMIN; routed by the base_admin loader): what omnisense/omnibase's OpeningHours reads everywhere - the footer, the day an order is for, the time slots, the page's JSON-LD.

  • The usual week: seven days, up to three slots each (Base\Entity\Hours\WeekDayHours); a day left empty is closed. Until a week is saved, the page shows the configured one (base.opening_hours.week).
  • Special days: closed, or open at other hours, for one day or a run of days (Base\Entity\Hours\SpecialDay), added and deleted here.
  • One place of a site that has several: /admin/hours?scope=store:12 (or path('admin_hours', {scope: opening_hours(store).scope})) edits that place's own week (ScopedWeek) and days off; "Follow the site's week" gives its week up.

Plain forms, a POST and a CSRF token each (admin_hours_week, admin_hours_special, admin_hours_special_delete); hours that make no sense (a closing before its opening, two slots overlapping) are refused with a message.

yield MenuItem::linkToRoute('admin_hours', [], 'Horaires', 'fa-solid fa-clock');

Redirections

Base\Admin\Controller\Crud\RedirectionCrudController (/admin/redirections): the old addresses of a site taken over and where each leads now (Base\Entity\Layout\Redirection, omnisense/omnibase - see its docs/40-commons/redirections.md). An old address is typed or pasted whole and kept as its path; the list is sorted by visitors carried, with the date each was last used.

yield MenuItem::linkToCrud(\Base\Entity\Layout\Redirection::class, 'Redirections', 'fa-solid fa-diamond-turn-right');

The business's phone and address

Base\Admin\Settings\BusinessSettingsSection (90) adds to the settings page base.settings.phone and base.settings.address.{street, postal_code, locality, region, country}: what omnisense/omnibase's local_business_jsonld() prints, ahead of base.local_business in the configuration.

Tests

vendor/bin/phpunit in this checkout, or from the omnibase harness: docker compose -f compose.yml -f compose.checkouts.yml run --rm omnibase test admin (tests/bootstrap.php registers the test namespace in a host's autoloader). The widget tests that boot a kernel need a host application's environment: the harness gives them one (its test environment, a fresh SQLite database), as does an application's own suite. They take the host's content types as they find them - the first Thread subtype the picker offers (HostContentType: an Article in glitchr, a forum Topic or forge Software in the harness) - and skip what needs rows when the database has none.

Propose a change docs/system-pages.md

Graphs in the back office

@glitchr/graphjs 1.0.0 - a layered directed graph that is dragged and zoomed: HTML nodes, SVG edges, no dependency - is vendored in this bundle (public/js/graph.js, public/js/layout.js, public/css/graph.css) and loaded by @Admin/layout.html.twig on every admin page, the way transparent.js is. A screen draws a graph without adding a script or a stylesheet.

Declared in the page

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

{% block content %}
<div class="card">
    <div data-graph data-graph-preset="pipeline" id="order-{{ order.id }}-steps" aria-label="{{ 'order.steps'|trans }}">
        <a href="{{ path('admin_crud_orders_detail', {entityId: order.id}) }}" data-graph-node="order" data-graph-state="success">{{ order }}</a>
        {% for shipment in order.shipments %}
            <span data-graph-node="shipment-{{ shipment.id }}" data-graph-from="order" data-graph-state="{{ shipment.delivered ? 'success' : 'running' }}">{{ shipment }}</span>
        {% endfor %}
    </div>
</div>
{% endblock %}

Every [data-graph] starts by itself when the page loads, and again after an in-admin navigation (the library listens to transparent:load). A node is any element with data-graph-node="id"; data-graph-from="a b" draws an edge from each. Presets: tree (top to bottom, right-angled edges) and pipeline (left to right, curved edges, a state on each node).

Given as data

window.Graph is the class, for a screen's own script ({% block body_javascript %}):

const graph = Graph.get(document.querySelector('#pipeline')) ?? new Graph(document.querySelector('#pipeline'), { preset: 'pipeline' });
graph.setData({
    nodes: [{ id: 'build', label: 'build', state: 'success' }, { id: 'test', label: 'tests', state: 'running', href: '/admin/jobs/12' }],
    edges: [{ from: 'build', to: 'test' }],
});
graph.setState('test', 'success');

graph.js is an ES module, so it runs after the page's classic scripts: a script that needs Graph at once is a module too (<script type="module">), or waits for DOMContentLoaded.

Look

Everything is a --graph-* custom property set on the [data-graph] element or above it (--graph-height, --graph-bg, --graph-accent, --graph-node-bg...). The graph follows the system's light or dark scheme; data-graph-theme="light" or "dark" on the element forces one.

The attributes, the API, the events (graph:select, graph:view, graph:layout) and the variables are in the library's own README and docs/. Updating the copy: public/js/README.md.

Propose a change docs/graphs.md

Configuration reference

admin:

Base\Admin\DependencyInjection\AdminConfiguration, dumped in the core's harness (bin/console config:dump-reference admin, this checkout mounted over vendor/omnibase/admin):

# Default configuration for extension with alias: "admin"
admin:

    # Readable identifiers used in admin URLs instead of the numeric id.
    url_identifier:

        # Identifier fields in priority order. Empty list = always use the numeric id.
        fields:

            # Defaults:
            - slug
            - uuid

        # Lowercase generated identifiers. Only safe where the database compares strings case-insensitively (MySQL _ci collation).
        lowercase:            false

        # Per-entity override, keyed by FQCN. Applies to subclasses too.
        entities:

            # Prototype
            class:                []

Option Type Default Effect
url_identifier.fields list of property names [slug, uuid] The identifiers a record's address uses instead of its id, in priority order: the first the entity stores as a column whose value is neither blank nor all digits. []: always the id
url_identifier.lowercase bool false Lowercase the identifiers in the addresses; safe only where the database compares strings case-insensitively (MySQL's _ci collations)
url_identifier.entities map of class => list {} Per-entity fields, by class (subclasses included)

They are given to the core's Base\Field\FieldValueResolver ($identifierFields, $identifierFieldsByEntity, $lowercaseIdentifiers), which both builds and resolves the addresses. See CRUD fields.

What the bundle registers

Autoconfiguration (AdminBundle::build()):

Interface or class Tag
Base\Admin\Controller\CrudControllerInterface base.admin.crud_controller, controller.service_arguments
Base\Admin\Controller\AbstractDashboardController base.admin.dashboard_controller
Base\Admin\Widget\DashboardWidgetTypeInterface base.admin.dashboard_widget_type
Base\Admin\Widget\PaletteDashboardWidgetTypeInterface base.admin.dashboard_widget_type.palette (as well)
Base\Admin\Settings\SettingsSectionInterface base.admin.settings_section
Base\Service\TimelineEventProviderInterface (the core's) base.admin.timeline_event_provider

AdminRoutePass collects the non-abstract services tagged base.admin.crud_controller into AdminRouteRegistry (and a service locator for CrudAccessChecker), and those tagged base.admin.dashboard_controller as the dashboard.

Services it gives the core: Base\Notifier\NotificationLinkerInterface is Base\Admin\Notifier\AdminNotificationLinker (the back-office page a notification is about, notification_admin_url() in Twig). Base\Field\FieldValueResolver, FieldFormBuilder, AdminRouteRegistry, AdminRouteLoader (tag routing.loader), AdminUrlGenerator, CrudAccessChecker (reset between requests), SecurityVoter (tag security.voter), MenuBuilder, LayoutStore, LayoutArranger and the system controllers are defined in config/services.php.

Propose a change docs/configuration.md

Reference

Routes

Under the prefix /admin, from the base_admin loader (bin/console debug:router in the core's harness). The CRUDs' routes are in Writing a CRUD.

Route Path Methods Controller
admin /admin GET the dashboard controller's index (when it has no #[Route] of its own)
admin_settings /admin/settings GET, POST SystemController::settings
admin_apikey /admin/api-key GET, POST SystemController::apiKey
admin_settings_quick /admin/settings/quick POST SystemController::settingsQuick
admin_layout_save /admin/layout/{scope} POST LayoutController::save
admin_layout_quick /admin/layout/{scope}/quick POST LayoutController::quickSave
admin_icon_search /admin/layout/icons GET LayoutController::icons
admin_analytics_breakdown /admin/analytics/breakdown/{range} GET AnalyticsController::breakdown
admin_dashboard_widget_types /admin/dashboard/widget-types GET DashboardWidgetController::types
admin_dashboard_widget_new /admin/dashboard/widget/{blockName}/new GET DashboardWidgetController::newInstance
admin_dashboard_widget_instances /admin/dashboard/widget/instances GET DashboardWidgetController::instances
admin_dashboard_widget_merge /admin/dashboard/widget/merge GET DashboardWidgetController::merge
admin_dashboard_widget_split /admin/dashboard/widget/split GET DashboardWidgetController::split
admin_dashboard_widget_restore /admin/dashboard/widget/restore POST DashboardWidgetController::restore
admin_revision_value /admin/revision/{id} GET RevisionController::value
admin_trash /admin/trash GET TrashController::index
admin_trash_restore /admin/trash/{id}/restore POST TrashController::restore
admin_trash_destroy /admin/trash/{id}/destroy POST TrashController::destroy
admin_trash_empty /admin/trash/empty POST TrashController::empty
admin_hours /admin/hours GET HoursController::index
admin_hours_week /admin/hours/week POST HoursController::week
admin_hours_special /admin/hours/special POST HoursController::addSpecialDay
admin_hours_special_delete /admin/hours/special/{id}/delete POST HoursController::deleteSpecialDay

The controllers are Base\Admin\Controller\'s. The trash lists the core's TrashBalls (what was deleted from a trasheable entity), restores one or destroys it; the revisions page gives an older value of a versioned field.

Twig

Function What it gives
admin_url(controller, action = 'index', entityId = null, parameters = {}) A CRUD page's address
admin_action_url(action, entity, controller) An action's address for a row: its URL callable, its static URL, else its CRUD action's route
admin_action_post(action, controller) Whether the action runs an #[AdminAction] route that takes no GET (drawn as a form posting its token)
admin_action_token_id(method) admin-action-<method>
admin_action_order(actions, stored = []) The actions in the order stored
admin_context() The AdminContext of the page, built from the default menus for a screen that passed none
admin_menu_current() The selected menu item (the longest prefix of the current path)
admin_crud_granted(controller) Whether the user may open that CRUD (its entity permission)
admin_entity_crud(entity) The CRUD that manages a record's class, or null
admin_entity_id(entity) Its address identifier
admin_entity_label(entity), admin_entity_avatar(entity), admin_entity_image(entity) Its label, avatar, image (FieldValueResolver)
admin_entity_icon(entityOrClass) Its CRUD's icon
admin_entity_name(class, plural = false) The entity's name: its translation in entities, else its class name in words
admin_display(...) A field's value formatted (FieldValueResolver::formatValue())
admin_enum_label(value, enumClass = null) An enum case's label
admin_field_choices(field) A field's choices (empty for choices made by a closure)
admin_site_icons() The site's own icons for the back office's tab (@Admin/_site_icons.html.twig)
admin_dev_version() In debug, a suffix of transparentJS's cache version that changes with uncommitted edits of the vendors
dashboard_widget_type(name) A widget type

Global: admin_active_users, on the back office's requests: the other administrators active within User::getOnlineDelay(), the most recent first.

Listeners

Event Listener Priority Effect
kernel.request ActiveAdminsSubscriber::onKernelRequest 4 Sets admin_active_users
kernel.controller AdminActionSubscriber::onKernelController 0 Runs the CRUD's gate before an #[AdminAction] method
kernel.response NestHeaderSubscriber::onKernelResponse 0 Adds X-Transparent-Nest to the back office's pages (path under /admin, route admin or admin_*) and to routes with defaults: {_nest: true}

The voter

Base\Admin\Security\SecurityVoter decides the back office's attributes (Base\Admin\Security\Permission):

Attribute Subject Granted when
EA_VIEW_MENU_ITEM a menu item it has no permission, or its permission is granted
EA_EXECUTE_ACTION an action (and the record) the same, with the action's permission on the record
EA_VIEW_FIELD a field the same, with the field's permission on its value
EA_ACCESS_ENTITY {permission, entity} the same
EA_EXIT_IMPERSONATION IS_IMPERSONATOR

A permission is any attribute: a role, or one of the core's permission tags (PermissionVoter, ARTICLE.PUBLISH).

Propose a change docs/reference.md