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\*Fielddescriptors (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
- Installation: requirements, the bundle, the routes, the dashboard controller.
- Writing a CRUD: the controller, its configuration
(
Crud,Actions,Action,Filters), its hooks,#[AdminAction], its routes and addresses, the CRUDs the bundle ships. - The dashboard and the menu: the dashboard's hooks,
MenuItem, the widget types, writing a widget type. - CRUD fields, Who may write in a CRUD, System pages, nest, reports, site texts, Graphs in the back office: the existing guides.
- Configuration reference:
admin:, the tags, the services it replaces. - Reference: the routes, the Twig functions and globals, the listeners, the voter.
Proposer une modification 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.
Proposer une modification 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).
Proposer une modification 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).
Proposer une modification 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.
A related record is chosen, not edited
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(). AnenumType:on asimple_arrayorjsoncolumn 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 theenumsdomain (comment_state.spamintranslations/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 aSelectFieldor a plainTextField; - 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)) bytrans_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 (theallow_objectoption of omnibase'sCollectionType); the entry type is still the one that maps an object to its fields.- With
allowAdd(), give the entry type anempty_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']andorphanRemoval: trueon the one-to-many, so that an added entry is saved and a removed one deleted. - An
AssociationFieldon a one-to-many does this by itself (its inner collection setsallow_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,NotNullorCount(min: 1)constraint, a setter that refuses null - and a thread's title, the field a slug follows, or whatrequiredByTheScreen()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.
Proposer une modification 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()andallowAnyoneTo()inconfigureActions()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 inactions:gives them the role, asAction::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.
Proposer une modification 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_*oradmin_*, 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:
configureMenuBeforeItems();- 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; configureMenuItems()andconfigureMenuAfterItems(): 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(orpath('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.
Proposer une modification 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.
Proposer une modification 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.
Proposer une modification 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).