Aller au contenu

omnibase/admin Référence de l’API

Étage
Structure préparée · Omnibase
Version
main
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnibase/admin:dev-main

Lue dans les fichiers PHP du paquet au commit 4462fb7a19e3 : ses espaces de noms, classes, interfaces et énumérations, leurs constantes, propriétés et méthodes publiques avec leur PHPDoc, et les options que prennent ses fabriques. Rien n’en est exécuté.

Base\Admin

Base\Admin\Attribute

  • AdminAction class

    Registers a public method of a CRUD controller as one more route of that CRUD, next to index/new/edit/...: AdminRouteLoader mounts it under the CRUD's own slug, named like the built-ins (admin_url(controller, method) finds it), and AdminActionSubscriber runs the CRUD's gate before it - the entity permission, the action's own permission, a disabled action's 404 and, for a method that writes, the CSRF token.

    6 methods · 4 properties · 1 constant

  • OpenToAdmins class

    A CRUD the site's administrators write in: on the controller, it opens the actions that write - new, edit, delete, batch delete and the three saves, which the back office reserves to the super-admin by default (Actions::$defaultPermissions) - to ROLE_ADMIN.

    2 methods · 2 properties

Base\Admin\Config

  • Action class

    One self-contained action definition: builder API and state in a single class (no Config/Dto duplication). getAsDto() returns $this so code written against the old two-class split keeps working.

    47 methods · 19 constants

  • Actions class

    Per-page action registry. One class holds the collection state directly (no ActionConfigDto behind it); built-in actions are created lazily with sensible defaults and can be reconfigured through the update() callable.

    18 methods · 1 property · 5 constants

  • Crud class

    Per-controller CRUD configuration: one class holding its own state (no CrudDto behind it). Only the options the controllers actually use; grows on demand.

    30 methods · 4 constants

  • MenuItem class

    Static factory facade: same call sites as before (MenuItem::linkToRoute(...), MenuItem::section(...), ...), now producing a single MenuItem model instead of eight wrapper classes.

    9 methods

Base\Admin\Config\Menu

  • MenuItem class

    One menu item model for every kind of entry (crud, route, url, section, submenu, dashboard, logout, exit-impersonation) — the kind is a plain type string instead of eight near-identical classes wrapping one DTO.

    57 methods · 9 constants

Base\Admin\Context

  • AdminContext class

    Request-scoped state of the admin: which controller/action is running, its Crud/Actions configuration and the resolved menus. Built by the controllers themselves (no factory/reflection dance) and read by Twig through the "admin" global.

    21 methods

Base\Admin\Controller

  • AbstractCrudController class

    Direct, linear CRUD pipeline: every page is an ordinary controller action calling overridable hooks - no event dispatch, no listener-side rendering.

    26 methods · 1 property

  • AbstractDashboardController class

    The admin's mount point: renders the dashboard and owns the menu configuration (before/main/after sections and the user menu).

    10 methods · 1 constant

  • AnalyticsController class

    Backs the dashboard analytics card's range picker (see dashboard.html.

    2 methods

  • CrudControllerInterface interface

    Marker interface: implementing it (via AbstractCrudController) tags the service base.admin.crud_controller, which registers its routes and menus.

    1 method

  • DashboardController class

    Zero-config default dashboard: mounted automatically at the registry's urlPrefix (see AdminRouteLoader::addDashboardRoute()) whenever the app hasn't wired its own. configureMenuItems() already defaults to MenuBuilder::buildDefault() (one section-less link per registered CRUD), so this class needs no body of its own to be usable out of the box.

  • DashboardWidgetController class

    Backs the dashboard's "+ Add widget" palette (types/newInstance), the drag-one-card-onto-another merge gesture (merge, also used to add a THIRD/FOURTH card into an already-merged composite), and pulling a composite back apart into its standalone cards again (split). Those four actions are GET, no CSRF - read-only, none of them persists anything (persistence still only ever happens through the existing batched admin_layout_save POST on "Done"). Each renders server-side Twig so the client never has to reinvent widget markup itself - it only ever inserts/replaces whatever HTML comes back.

    7 methods

  • HoursController class

    "Opening hours" (/admin/hours): the usual week, day by day, and the days off it - closed, or open at other hours, for one day or a run of days.

    5 methods · 1 constant

  • LayoutController class

    The superadmin-only customize-mode save endpoint: one POST per "Done" click (never per-drag - see LayoutStore's cache-invalidation note), scoped to sidebar or dashboard. Registered by AdminRouteLoader alongside the dashboard/CRUD routes - the app needs no routing config of its own.

    4 methods

  • RevisionController class

    Serves the raw previous value behind one entry of a field's history badge.

    2 methods

  • SystemController class

    The system pages of the back office: the settings (/admin/settings) and the API keys (/admin/api-key), the same machinery on every site (SettingBag, LayoutSettingListType). What they hold is declared by sections (Base\Admin\Settings\SettingsSectionInterface, tagged base.admin.settings_section): omnibase's own, each bundle's, the application's. Routed by AdminRouteLoader (admin_settings, admin_apikey, admin_settings_quick); an application that still routes a controller of its own under those names keeps it (its routes load last).

    4 methods

  • TrashController class

    The trash: what soft deletion put aside, and the two things you can do with it - put it back, or destroy it.

    5 methods

Base\Admin\Controller\Crud

  • ComplaintCrudController class

    Reports ("signalements", Base\Entity\User\Complaint): what a member reported about another one or about something, read and settled here. Who sees one is ComplaintVoter's call - the staff above the person it is about, never that person. Nobody writes one here: they come from the site (a forum, a game world, a comment); the staff marks one handled or dismissed, with an answer, or opens it again. The dashboard tile is ComplaintsWidgetType.

    9 methods

  • RedirectionCrudController class

    "Redirections": the addresses the site no longer answers, and where each leads now (Base\Entity\Layout\Redirection, glitchr/omnibase). An old address is typed or pasted whole - it is kept as its path -, the new one is a path of the site or another site's address; permanent (301) unless it is a detour. The list shows how many visitors each one carried, and when last: the old addresses still in use, and those that can go.

    5 methods

  • TextOverrideCrudController class

    "Textes du site": any text of the site's pages rewritten here - a heading, a button, a paragraph, in one of the site's languages. Pick the text by its key (the list shows its current wording), write the new one; delete it to bring the original back. {placeholders} stay as they are. The text wins over translations/ through glitchr/omnibase's OverridingTranslator.

    4 methods

  • UserCrudController class

    4 methods

Base\Admin\Controller\Crud\Layout

Base\Admin\Controller\Crud\Layout\Attribute

  • AdapterCrudController class

    Every attribute adapter - the definitions behind scopes, rules, actions and plain attributes ("scope-region", "rule-cart-total", a barcode standard...): their code, icon and label. Each kind is created from its own CRUD, which knows its settings; this list renames and re-icons them, and keeps those attributes still use.

    6 methods

Base\Admin\Controller\Crud\Layout\Widget

  • PageCrudController class

    Layout pages served at /page/{slug} (WidgetController::Page).

    4 methods

Base\Admin\Controller\Crud\Thread

  • TagCrudController class

    4 methods

  • TaxonCrudController class

    The taxonomy: categories that nest (a parent, children), each with a label, a description and keywords per language. Threads are filed under them.

    4 methods

Base\Admin\Controller\Crud\User

Base\Admin\DependencyInjection

Base\Admin\DependencyInjection\Compiler

  • AdminRoutePass class

    Collects every service tagged base.admin.crud_controller into the AdminRouteRegistry constructor argument, so routes and URLs resolve from a compiled list instead of runtime reflection - and into a service locator keyed by class for CrudAccessChecker, which reads a controller's entity permission without routing a request through it.

    1 method

Base\Admin\EventSubscriber

  • ActiveAdminsSubscriber class

    Exposes "admin_active_users" (a Twig global: every OTHER admin/ superadmin active within User::getOnlineDelay(), most recent first, excluding the current viewer) for the admin topbar's presence indicator - admin-panel requests only, not every page on the site.

    3 methods

  • AdminActionSubscriber class

    Runs a CRUD's gate in front of each of its #[AdminAction] methods, the way index()/edit()/... run it at their top - but before the method, so a controller cannot forget it: entity permission, the action's own permission, disabled action, CSRF token (see AbstractCrudController::guardAdminAction()).

    2 methods

  • NestHeaderSubscriber class

    Declares every admin page "nestable": transparentJS only mounts a page into its overlay when the response carries the X-Transparent-Nest header, so a plain visit (or a site without transparentJS) keeps getting the ordinary full page.

    3 methods · 1 constant

Base\Admin\Filter

  • Filter class

    One filter definition: which property, which comparison widget, and how it constrains the index QueryBuilder. Single class - the type string drives both the rendering (inline bar) and the WHERE clause.

    16 methods · 11 constants

  • Filters class

    Filter registry for one CRUD, filled in configureFilters().

    6 methods

Base\Admin\Form

  • FieldFormBuilder class

    Builds a plain Symfony form from a collection of field descriptors.

    5 methods

Base\Admin\Layout

  • LayoutArranger class

    Pure merge of a code-defined MenuItem tree with a persisted LayoutConfig: reorders, hides, and drops stale entries - never invents or mutates the underlying items beyond calling setHidden(). No dependencies beyond MenuItem, so this is trivially unit-testable.

    3 methods

  • LayoutConfig class

    One persisted layout: an ordered, hide/show-annotated tree of item keys.

    9 methods

  • LayoutScope class

    The customizable regions of the admin - each gets its own persisted layout config, stored under its own SettingBag path (see LayoutStore).

    2 methods · 3 constants

  • LayoutStore class

    Persists LayoutConfig via the existing SettingBag path/value store - no new entity. Two real gotchas, both handled here so callers don't have to think about them:

    3 methods

  • PageCustomization class

    The superadmin-authored customization stored for ONE admin page: its own entry (customized title/description) plus its top-right action row's order and per-action icons.

    2 methods

Base\Admin\Menu

  • MenuBuilder class

    Resolves menu items (URLs + selected state) and provides the default menu — dashboard link plus one entry per registered CRUD controller — shared by the dashboard and every CRUD page.

    8 methods

Base\Admin\Notifier

  • AdminNotificationLinker class

    A notification's target, seen from the back-office: the edit page of the CRUD that manages its class. Nothing when the class has no CRUD here, or one the current user may not open.

    2 methods

Base\Admin\Orm

  • Paginator class

    Thin pagination wrapper around Doctrine's Paginator with the counters the index template needs.

    11 methods

Base\Admin\Router

  • AdminRouteLoader class

    Generates the conventional CRUD routes for every registered controller:

    3 methods · 2 constants

  • AdminRouteRegistry class

    Maps CRUD controller FQCNs to their generated route names and URL slugs.

    9 methods · 1 constant

  • AdminUrlGenerator class

    Fluent builder for admin URLs on top of the generated per-controller routes (admin_crud_<slug>_<action>); no runtime dispatch, no query-string controller FQCNs.

    8 methods

Base\Admin\Security

  • CrudAccessChecker class

    Whether the current user may open a CRUD, asked from outside it: the menu, the dashboard's quick access, the links other screens draw to its records.

    4 methods

  • Permission class

    Admin permission attributes. Constant names and string values are kept identical to the historical EA_* set so existing isGranted() call sites and security configuration keep working after the switch.

    1 method · 5 constants

  • SecurityVoter class

    Votes on the five admin permissions by delegating each subject's own declared permission to Symfony Security. No context service needed: every check receives its subject explicitly.

    1 method

Base\Admin\Settings

  • BusinessSettingsSection class

    The business behind the site: its phone and its address. Read by glitchr/omnibase's LocalBusiness (the page's JSON-LD: Twig local_business_jsonld()), where what is typed here wins over the configuration (base.local_business); the phone is also the one omnibase's notifier signs its messages with (base.settings.phone).

    3 methods

  • SettingsSectionInterface interface

    A section of the back office's system pages (Base\Admin\Controller\ SystemController): the settings page (/admin/settings) or the API keys page (/admin/api-key). Each bundle - and the application - declares its own, as an autoconfigured service:

    2 methods · 2 constants

  • SettingsSections class

    The sections of the system pages, gathered from every service tagged base.admin.settings_section (SettingsSectionInterface), in priority order.

    2 methods

  • SiteSettingsSection class

    omnibase's own settings, the same on every site: the logos, the title and slogan, the meta tags, the launch date, access restrictions, maintenance, account security, the mail sender. Explicit labels (LayoutSettingListType would otherwise derive "Meta - Author" from the path).

    3 methods

  • SpamKeySection class

    omnibase's own API key: Akismet, which reads every comment, question and message a visitor leaves (Base\Service\SpamChecker); typed here it wins over the configuration.

    3 methods

Base\Admin\Test

  • CrudFieldsTestCase class

    What a bundle's back office screens ask, against what its records need - extended by each bundle's own test, which names its source directory:

    6 methods

Base\Admin\Twig

  • AdminTwigExtension class

    13 methods

  • DashboardWidgetTwigExtension class

    3 methods

  • DevVersionTwigExtension class

    Dev-only suffix for transparentJS's cache_version (see layout.html.twig's Transparent.ready call): deploy_version() folds in the app's and each vendor/glitchr repo's git HEAD, which is exactly right for deploys - but during live development the vendor edits are UNCOMMITTED, HEAD never moves, and every already-visited admin page kept replaying its SPA-cached pre-fix HTML/CSS indefinitely (reported live, repeatedly, as fixes "still broken" mid-iteration). In debug mode this appends the max mtime of this bundle's own hot sources, so ANY saved edit purges the client cache on the next navigation; in prod it contributes nothing at all.

    3 methods

  • EntityNameTwigExtension class

    The name a CRUD page gives its entity when configureCrud() set no label: `admin_entity_name(fqcn)` and `admin_entity_name(fqcn, true)` for the plural.

    4 methods

  • SiteIconTwigExtension class

    The site's own icon in the back office's tab: admin_site_icons(), printed by the layout and the sign-in page (@Admin/_site_icons.html.twig).

    3 methods · 1 constant

Base\Admin\Widget

  • AnalyticsCardWidgetType class

    The built-in "Trafic" dashboard card - also the reference implementation of DashboardWidgetTypeInterface (see AbstractDashboardController:: configureDashboardBlockItems(), which registers the default instance).

    6 methods

  • AnalyticsSeriesPalette class

    The single source of truth for the analytics widget's per-series identity (translation key + color) - shared by AnalyticsController::breakdown() (the range picker's JSON endpoint) and AnalyticsCardWidgetType (the initial server-rendered paint), so the two can never drift apart the way two hand-copied color maps eventually do.

    3 constants

  • ComplaintsWidgetType class

    The dashboard's tile for reports: how many wait, the last few the signed-in staff member may read (ComplaintVoter), each opening its page.

    4 methods

  • CompositeWidgetType class

    "Dual-width card" fusion: one shared .card holding two (or more) other registered widget types side by side, e.g.:

    3 methods

  • CounterWidgetType class

    The "Counter" palette entry: a single number + period-over-period trend for one picked entity (or "all instances of a class", summed) - no chart, unlike EntityViewsWidgetType ("Vues"), which this otherwise mirrors closely (same LinkableEntityRegistry-driven class/instance picker, same params shape). A genuinely separate widget type rather than "Vues with the canvas hidden": the whole point is a compact, at-a- glance KPI tile, and reusing the exact same getTemplateVars() shape would compute/hand over a full daily series this template would never use.

    6 methods · 3 constants

  • DashboardWidgetTypeInterface interface

    Self-registering dashboard widget type (mirrors CrudControllerInterface's own auto-tagging pattern - see AdminBundle::build()). Any base-bundle, base-bundle-admin, or app service implementing this becomes usable via MenuItem::block(self::getName(), ...) without touching dashboard.html.twig.

    3 methods

  • DashboardWidgetTypeRegistry class

    Indexes every tagged DashboardWidgetTypeInterface by name - the single lookup point dashboard.html.twig's widget loop uses to dispatch a block-type MenuItem to its renderer, replacing what used to be one hardcoded {% if widget.blockName == 'analytics_card' %} in the template.

    2 methods

  • EntityViewsWidgetType class

    Generalizes what used to be App\Admin\Widget\ArticleViewsWidgetType (Article-only) into a widget that can show traffic for ANY LinkableInterface entity this app has - discovered via LinkableEntityRegistry, not hardcoded to Article or any other single class. Lives in base-bundle-admin (not app-level) for exactly that reason: nothing in here references Article, Destination, User, or any other app-specific class by name - the package stays entity-agnostic, same standing rule as every other built-in widget type here.

    6 methods · 1 constant

  • LinkableEntityRegistry class

    What EntityViewsWidgetType's own settings picker (and any future "pick a trackable page" feature) chooses from: every Doctrine-mapped entity class this app happens to have that implements Base\Service\Model\LinkableInterface (a __toLink() a real request path can be tracked under, plus __toString() for a human-readable option label) - discovered generically off Doctrine's own metadata, not a hardcoded list. In this app that resolves to every Base\Entity\Thread subtype (Article, Destination, Gallery, Comment, ...) PLUS User side by side - the two class FAMILIES this was generalized to comply with (was Article-only) - but nothing here actually references either Thread or User by name: a third, unrelated LinkableInterface entity added later needs zero changes here to become pickable too.

    5 methods

  • PaletteDashboardWidgetTypeInterface interface

    Marks a DashboardWidgetTypeInterface as eligible for the "+ Add widget" palette. Purely additive - extending (not modifying) the base interface means no existing implementer breaks, and it's also what EXCLUDES a type from the palette with no separate opt-out flag: CompositeWidgetType implements only DashboardWidgetTypeInterface, never this one.

    2 methods

  • PaletteWidgetTypeRegistry class

    The palette-eligible subset of registered widget types - same shape as DashboardWidgetTypeRegistry but indexes only PaletteDashboardWidgetTypeInterface implementers, since not every widget type should be addable through the "+ Add widget" UI (e.g. CompositeWidgetType).

    3 methods

  • TimelineEventRegistry class

    Collects every tagged TimelineEventProviderInterface's events and formats them for the analytics chart - the single place both AnalyticsController (the range-picker's live fetch) and AnalyticsCardWidgetType (initial paint) go for this, so both always return the identical shape.

    2 methods

  • WelcomeWidgetType class

    The "Generic" widget in the "+ Add widget" palette - a free-form text block, editable in place (see welcome.html.twig's own data-widget-text, and layout.html.twig's input handler for it). getName() stays 'welcome' (the class/file keep their original name too) even though this is no longer just the dashboard's fixed greeting card - every already-stored widget (code-defined default instance AND any ad-hoc one a superadmin added) references that exact string as its blockName; changing it would silently orphan every existing one (see _block.html.twig's own "unknown blockName renders an empty shell" comment for what that'd look like).

    6 methods

Base\Admin

AdminBundle class

class AdminBundle extends AbstractBaseBundle AdminBundle.php:17

AdminBundle::__construct()

public function __construct()

AdminBundle::getPath()

public function getPath(): string

Modern bundle layout: the class lives in src/, the bundle root is the package root — so TwigBundle picks up ./templates as @Admin.

AdminBundle::build()

public function build(ContainerBuilder $container): void

Base\Admin\Attribute

AdminAction class

final class AdminAction AdminAction.php:39

Registers a public method of a CRUD controller as one more route of that CRUD, next to index/new/edit/...: AdminRouteLoader mounts it under the CRUD's own slug, named like the built-ins (admin_url(controller, method) finds it), and AdminActionSubscriber runs the CRUD's gate before it - the entity permission, the action's own permission, a disabled action's 404 and, for a method that writes, the CSRF token.

// 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();
}

A path holding {entityId} makes it a record's action: the method takes string $entityId and loads it with $this->findEntity($entityId) (the id, slug or uuid the admin links with). The Action is then an entity action (the default type) on the index/detail/edit pages.

A route that takes no GET (the default is POST alone) renders as a small form - its CSRF token, one submit button, the Action's confirmation question if any - instead of a link. The token id is tokenId($method): "admin-action-<method>", posted as _token (or an X-CSRF-Token header).

const TOKEN_PREFIX = 'admin-action-'
public readonly ?string $path

relative to the CRUD's own URL, "/refresh" or "/{entityId}/publish"; null derives "/<kebab-cased method>"

public readonly array $methods

HTTP methods the route accepts

public readonly bool $csrf

check the token before the method runs (writing methods only); false leaves it to the method

public readonly array $requirements

extra route requirements ({entityId} already has one)

AdminAction::__construct()

public function __construct(public readonly ?string $path = null, public readonly array $methods = ['POST'], public readonly bool $csrf = true, public readonly array $requirements = [])
$path

relative to the CRUD's own URL, "/refresh" or "/{entityId}/publish"; null derives "/<kebab-cased method>"

$methods

HTTP methods the route accepts

$csrf

check the token before the method runs (writing methods only); false leaves it to the method

$requirements

extra route requirements ({entityId} already has one)

AdminAction::of()

public static function of(string $controllerFqcn, string $methodName): ?self

The attribute on $controllerFqcn::$methodName, null for any other method (the built-in actions included).

AdminAction::tokenId()

public static function tokenId(string $methodName): string

AdminAction::getPath()

public function getPath(string $methodName): string

AdminAction::isEntityAction()

public function isEntityAction(): bool

AdminAction::acceptsGet()

public function acceptsGet(): bool

Whether a plain link can reach it - otherwise it renders as a form.

OpenToAdmins class

final class OpenToAdmins OpenToAdmins.php:28

A CRUD the site's administrators write in: on the controller, it opens the actions that write - new, edit, delete, batch delete and the three saves, which the back office reserves to the super-admin by default (Actions::$defaultPermissions) - to ROLE_ADMIN.

#[OpenToAdmins] class PostCrudController extends AbstractCrudController

#[OpenToAdmins(actions: ['publish', 'duplicate'])]    // and the screen's own #[AdminAction] methods
#[OpenToAdmins(role: 'ROLE_STAFF')]                   // another role than ROLE_ADMIN

The default does not change: a CRUD without the attribute stays the super-admin's to write. A permission the CRUD sets itself in configureActions() - Actions::setPermission(), Action::setPermission(), allowAnyoneTo() - is kept: the attribute only stands where the blanket default would have applied. A CRUD that extends an opened one is opened too, unless it carries an attribute of its own.

Who may see the CRUD at all is another matter: Crud::setEntityPermission().

public readonly string $role

the role that writes (held directly or through security.role_hierarchy)

public readonly array $actions

the CRUD's own actions (#[AdminAction] method names) opened to that role as well

OpenToAdmins::__construct()

public function __construct(public readonly string $role = UserRole::ADMIN, public readonly array $actions = [])
$role

the role that writes (held directly or through security.role_hierarchy)

$actions

the CRUD's own actions (#[AdminAction] method names) opened to that role as well

OpenToAdmins::of()

public static function of(string $controllerFqcn): ?self

The attribute of a CRUD controller: its own, else its nearest parent's; null when none says so.

Base\Admin\Config

Action class

class Action Action.php:12

One self-contained action definition: builder API and state in a single class (no Config/Dto duplication). getAsDto() returns $this so code written against the old two-class split keeps working.

const BATCH_DELETE = 'batchDelete'
const DELETE = 'delete'
const DETAIL = 'detail'
const EDIT = 'edit'
const INDEX = 'index'
const NEW = 'new'
const SAVE_AND_ADD_ANOTHER = 'saveAndAddAnother'
const SAVE_AND_CONTINUE = 'saveAndContinue'
const SAVE_AND_RETURN = 'saveAndReturn'
const HOME = 'home'

"Back to the dashboard" - the non-CRUD counterpart of INDEX: a system page (settings, API keys) has no entity list to return to, but it does have the same need for a way back out of the form it is showing.

const GOTO_PREV = 'prev'
const GOTO_SEE = 'see'
const GOTO_NEXT = 'next'
const SEPARATOR = 'separator'
const GROUP = 'group'
const GOTO = 'goto'
const TYPE_ENTITY = 'entity'
const TYPE_GLOBAL = 'global'
const TYPE_BATCH = 'batch'

Action::__toString()

public function __toString(): string

Action::new()

public static function new(string $name, TranslatableInterface|string|callable|bool|null $label = null, ?string $icon = null): static

Action::createAsGlobalAction()

public function createAsGlobalAction(): static

Action::createAsBatchAction()

public function createAsBatchAction(): static

Action::getType()

public function getType(): string

Action::isEntityAction()

public function isEntityAction(): bool

Action::isGlobalAction()

public function isGlobalAction(): bool

Action::isBatchAction()

public function isBatchAction(): bool

Action::getName()

public function getName(): string

Action::getLabel()

public function getLabel(): mixed

Action::setLabel()

public function setLabel(TranslatableInterface|string|callable|bool|null $label): static

Action::getIcon()

public function getIcon(): ?string

Action::setIcon()

public function setIcon(?string $icon): static

Action::getCssClass()

public function getCssClass(): string

Action::setCssClass()

public function setCssClass(string $cssClass): static

Action::addCssClass()

public function addCssClass(string $cssClass): static

Action::getHtmlElement()

public function getHtmlElement(): string

Action::setHtmlElement()

public function setHtmlElement(string $htmlElement): static

Action::getHtmlAttributes()

public function getHtmlAttributes(): array

Action::setHtmlAttributes()

public function setHtmlAttributes(array $attributes): static

Action::addHtmlAttributes()

public function addHtmlAttributes(array $attributes): static
public function renderAsLink(bool $renderAsLink = true): static

Action::renderAsButton()

public function renderAsButton(string $buttonType = 'submit'): static

Action::renderAsTooltip()

public function renderAsTooltip(): static

Action::targetBlank()

public function targetBlank(): static

Action::displayAsSeparator()

public function displayAsSeparator(): static

Action::getTemplatePath()

public function getTemplatePath(): ?string

Action::setTemplatePath()

public function setTemplatePath(string $templatePath): static

Action::getCrudActionName()

public function getCrudActionName(): ?string

Action::linkToCrudAction()

public function linkToCrudAction(string $crudActionName): static

Action::getRouteName()

public function getRouteName(): ?string

Action::getRouteParameters()

public function getRouteParameters(): mixed

Action::linkToRoute()

public function linkToRoute(string $routeName, array|callable $routeParameters = []): static

Action::getUrl()

public function getUrl(): mixed
$url

a URL, or a callable receiving the entity instance (or null) and returning the URL

Action::linkToUrl()

public function linkToUrl(string|callable $url): static

Action::getLinkUrl()

public function getLinkUrl(): ?string

Action::setLinkUrl()

public function setLinkUrl(?string $linkUrl): static

Runtime-resolved URL (set by the controller/renderer once the target entity is known).

Action::getTranslationParameters()

public function getTranslationParameters(): array

Action::setTranslationParameters()

public function setTranslationParameters(array $parameters): static

Action::getDisplayCallable()

public function getDisplayCallable(): ?callable

Action::displayIf()

public function displayIf(callable $callable): static

Action::isDisplayed()

public function isDisplayed(mixed $entity = null): bool

Action::getPermission()

public function getPermission(): ?string

Action::setPermission()

public function setPermission(string $permission): static

Action::getConfirmation()

public function getConfirmation(): TranslatableInterface|string|null

Action::askConfirmation()

public function askConfirmation(TranslatableInterface|string|null $question): static

A question asked before the action runs (the layout's confirm dialog, as for delete) - for an action rendered as a form, i.e. one whose #[AdminAction] route takes no GET.

Action::getAsDto()

public function getAsDto(): static

Kept for source compatibility with the former builder/DTO split: the action IS its own DTO.

Actions class

class Actions Actions.php:15

Per-page action registry. One class holds the collection state directly (no ActionConfigDto behind it); built-in actions are created lazily with sensible defaults and can be reconfigured through the update() callable.

const PAGE_INDEX = 'index'
const PAGE_DETAIL = 'detail'
const PAGE_EDIT = 'edit'
const PAGE_NEW = 'new'
const PAGE_SYSTEM = 'system'

The bespoke, non-entity form pages of the backoffice (site settings, API keys, ...): one page name for all of them, since they share a single template (@Admin/page/system.html.twig) and a single action set - unlike CRUD, where new/edit/index/detail each need their own.

public static array $defaultPermissions = [Action::NEW => UserRole::SUPERADMIN, Action::EDIT => UserRole::SUPERADMIN, Action::DELETE => UserRole::SUPERADMIN, Action::BATCH_DELETE => UserRole::SUPERAD…

Blanket default: every mutating action requires ROLE_SUPERADMIN+ - plain ROLE_ADMIN (the moderator tier) is backend-accessible but read-only by default. Reassignable app-wide; per-controller opt-out via allowAnyoneTo().

Actions::new()

public static function new(): static

Actions::add()

public function add(string $pageName, Action|string $actionNameOrObject, ?string $actionIcon = null, ?callable $callable = null): static

Actions::update()

public function update(string $pageName, string $actionName, callable $callable): static

Actions::remove()

public function remove(string $pageName, string $actionName): static

Actions::disable()

public function disable(string ...$actionNames): static

Actions::isDisabled()

public function isDisabled(string $pageName, string $actionName): bool

Actions::setPermission()

public function setPermission(string $actionName, ?string $permission): static

Actions::setPermissions()

public function setPermissions(array $permissions): static

Actions::getPermission()

public function getPermission(string $actionName): ?string

Actions::getEffectivePermission()

public function getEffectivePermission(string $actionName, ?Action $action = null): ?string

Explicit per-Action permission wins, then a per-Actions override (set via setPermission()/setPermissions() - an explicit null there means "opted out", NOT "fall through to the blanket default"), then the blanket default for that action name, if any.

Actions::openTo()

public function openTo(string $role, string ...$custom): static

Opens the actions that write - those the blanket $defaultPermissions reserves to the super-admin - and the CRUD's own $custom actions to $role, wherever no permission was set for them here: what #[OpenToAdmins] on a CRUD controller does (Base\Admin\Attribute\OpenToAdmins, applied once configureActions() has run).

Actions::allowAnyoneTo()

public function allowAnyoneTo(string ...$actionNames): static

Documented per-controller opt-out from the blanket $defaultPermissions, e.g. ->allowAnyoneTo(Action::EDIT) lets any backend-authenticated user (including a plain-ROLE_ADMIN moderator) run that action on this CRUD.

Actions::getAll()

public function getAll(string $pageName): array

Actions::getEntityActions()

public function getEntityActions(string $pageName): array

Actions::getGlobalActions()

public function getGlobalActions(string $pageName): array

Actions::getBatchActions()

public function getBatchActions(string $pageName): array

Actions::addDefaults()

public function addDefaults(): static

Registers the conventional set of actions for every page.

Actions::addSystemDefaults()

public function addSystemDefaults(): static

The conventional action row of a system page - deliberately the same three controls an entity EDIT page carries (see addDefaults()), in the same order: the committing save, a save that keeps you where you are, and the way back out. A form page in the backoffice should offer the same buttons in the same place whether or not there is an entity behind it; these were previously a single unlabelled submit at the bottom of the card instead.

Built here rather than through createBuiltInAction(): every CRUD built-in ends in ->linkToCrudAction(), which is meaningless without a CRUD controller behind the page, and SAVE_AND_RETURN's own label even flips to "Créer" for any page name that isn't 'edit'.

Crud class

class Crud Crud.php:12

Per-controller CRUD configuration: one class holding its own state (no CrudDto behind it). Only the options the controllers actually use; grows on demand.

const PAGE_INDEX = 'index'
const PAGE_DETAIL = 'detail'
const PAGE_EDIT = 'edit'
const PAGE_NEW = 'new'

Crud::new()

public static function new(): static

Crud::getEntityPermission()

public function getEntityPermission(): ?string

Crud::setEntityPermission()

public function setEntityPermission(?string $entityPermission): static

Crud::getEntityFqcn()

public function getEntityFqcn(): ?string

Crud::setEntityFqcn()

public function setEntityFqcn(?string $entityFqcn): static

Crud::getEntityLabelInSingular()

public function getEntityLabelInSingular(): TranslatableInterface|string|null

Crud::setEntityLabelInSingular()

public function setEntityLabelInSingular(TranslatableInterface|string|null $label): static

Crud::getEntityLabelInPlural()

public function getEntityLabelInPlural(): TranslatableInterface|string|null

Crud::setEntityLabelInPlural()

public function setEntityLabelInPlural(TranslatableInterface|string|null $label): static

Crud::getPageTitle()

public function getPageTitle(string $pageName): TranslatableInterface|string|null

Crud::setPageTitle()

public function setPageTitle(string $pageName, TranslatableInterface|string $title): static

Crud::getHelp()

public function getHelp(string $pageName): TranslatableInterface|string|null

Crud::setHelp()

public function setHelp(string $pageName, TranslatableInterface|string $helpMessage): static

Crud::getDefaultSort()

public function getDefaultSort(): array

Crud::setDefaultSort()

public function setDefaultSort(array $sortFieldsAndOrder): static

Crud::getPaginatorPageSize()

public function getPaginatorPageSize(): int

Crud::setPaginatorPageSize()

public function setPaginatorPageSize(int $maxResultsPerPage): static

Crud::getSearchFields()

public function getSearchFields(): array

Crud::setSearchFields()

public function setSearchFields(array $fieldNames): static

Crud::showEntityActionsInlined()

public function showEntityActionsInlined(bool $inlined = true): static

Crud::showEntityActionsAsDropdown()

public function showEntityActionsAsDropdown(bool $asDropdown = true): static

Crud::areEntityActionsInlined()

public function areEntityActionsInlined(): bool

Crud::getNewFormOptions()

public function getNewFormOptions(): array

Crud::getEditFormOptions()

public function getEditFormOptions(): array

Crud::setFormOptions()

public function setFormOptions(array $newFormOptions, ?array $editFormOptions = null): static

Crud::getCurrentAction()

public function getCurrentAction(): ?string

Crud::setCurrentAction()

public function setCurrentAction(?string $currentAction): static

Crud::getCurrentPage()

public function getCurrentPage(): ?string

Crud::setCurrentPage()

public function setCurrentPage(?string $currentPage): static

Crud::getAsDto()

public function getAsDto(): static

Kept for source compatibility with the former builder/DTO split.

MenuItem class

class MenuItem MenuItem.php:13

Static factory facade: same call sites as before (MenuItem::linkToRoute(...), MenuItem::section(...), ...), now producing a single MenuItem model instead of eight wrapper classes.

MenuItem::linkToCrud()

public static function linkToCrud(string $entityFqcn, TranslatableInterface|string|null $label = null, ?string $icon = null): Item

MenuItem::linkToDashboard()

public static function linkToDashboard(TranslatableInterface|string $label, ?string $icon = null): Item

MenuItem::linkToExitImpersonation()

public static function linkToExitImpersonation(TranslatableInterface|string $label, ?string $icon = null): Item

MenuItem::linkToLogout()

public static function linkToLogout(TranslatableInterface|string $label, ?string $icon = null): Item

MenuItem::linkToRoute()

public static function linkToRoute(string $routeName, array $routeParameters = [], TranslatableInterface|string|null $label = null, ?string $icon = null): Item

MenuItem::linkToUrl()

public static function linkToUrl(TranslatableInterface|string $label, ?string $icon, string $url): Item

MenuItem::section()

public static function section(TranslatableInterface|string|null $label = null, ?string $icon = null): Item

MenuItem::subMenu()

public static function subMenu(TranslatableInterface|string $label, ?string $icon = null, ?string $url = null): Item

MenuItem::block()

public static function block(string $blockName, TranslatableInterface|string|null $label = null, ?string $icon = null, ?string $instanceKey = null, array $params = []): Item

References a registered DashboardWidgetTypeInterface (by name) rather than a link - used for widgets that aren't navigation, like the analytics chart/stats. Auto-keyed under "core.<blockName>" so it's orderable/hideable like everything else.

$instanceKey lets the SAME widget type be used more than once with independent hide/order/size state - e.g. two analytics_card instances showing different windows via $params: MenuItem::block('analytics_card', 'dashboard.analytics_title', 'fa-solid fa-chart-line') MenuItem::block('analytics_card', 'Last 30 days', 'fa-solid fa-chart-line', '30d', ['days' => 30]) Left null, the key is byte-for-byte "core.<blockName>" exactly as before - every existing call site is unaffected.

Base\Admin\Config\Menu

MenuItem class

class MenuItem MenuItem.php:12

One menu item model for every kind of entry (crud, route, url, section, submenu, dashboard, logout, exit-impersonation) — the kind is a plain type string instead of eight near-identical classes wrapping one DTO.

const TYPE_CRUD = 'crud'
const TYPE_DASHBOARD = 'dashboard'
const TYPE_EXIT_IMPERSONATION = 'exit_impersonation'
const TYPE_LOGOUT = 'logout'
const TYPE_ROUTE = 'route'
const TYPE_URL = 'url'
const TYPE_SECTION = 'section'
const TYPE_SUBMENU = 'submenu'
const TYPE_BLOCK = 'block'

References a registered DashboardWidgetTypeInterface by name - no URL. See Base\Admin\Widget\DashboardWidgetTypeRegistry.

MenuItem::__construct()

public function __construct(string $type, TranslatableInterface|string|null $label = null, ?string $icon = null)

MenuItem::getType()

public function getType(): string

MenuItem::isSection()

public function isSection(): bool

MenuItem::isSubMenu()

public function isSubMenu(): bool

MenuItem::getLabel()

public function getLabel(): TranslatableInterface|string|null

MenuItem::setLabel()

public function setLabel(TranslatableInterface|string|null $label): static

MenuItem::isLabelCustomized()

public function isLabelCustomized(): bool

MenuItem::setLabelCustomized()

public function setLabelCustomized(bool $labelCustomized): static

MenuItem::getDescription()

public function getDescription(): ?string

MenuItem::setDescription()

public function setDescription(?string $description): static

MenuItem::getIcon()

public function getIcon(): ?string

MenuItem::setIcon()

public function setIcon(?string $icon): static

MenuItem::getCssClass()

public function getCssClass(): string

MenuItem::setCssClass()

public function setCssClass(string $cssClass): static

MenuItem::getPermission()

public function getPermission(): ?string

MenuItem::setPermission()

public function setPermission(?string $permission): static

MenuItem::getBadge()

public function getBadge(): ?string

MenuItem::setBadge()

public function setBadge(mixed $badge): static

MenuItem::getLinkTarget()

public function getLinkTarget(): ?string

MenuItem::setLinkTarget()

public function setLinkTarget(string $linkTarget): static

MenuItem::getEntityFqcn()

public function getEntityFqcn(): ?string

MenuItem::setEntityFqcn()

public function setEntityFqcn(?string $entityFqcn): static

MenuItem::getCrudActionName()

public function getCrudActionName(): ?string

MenuItem::setCrudAction()

public function setCrudAction(?string $crudActionName): static

MenuItem::getEntityId()

public function getEntityId(): mixed

MenuItem::setEntityId()

public function setEntityId(mixed $entityId): static

MenuItem::getRouteName()

public function getRouteName(): ?string

MenuItem::getRouteParameters()

public function getRouteParameters(): array

MenuItem::setRoute()

public function setRoute(string $routeName, array $routeParameters = []): static

MenuItem::getUrl()

public function getUrl(): ?string

MenuItem::setUrl()

public function setUrl(?string $url): static

MenuItem::getBlockName()

public function getBlockName(): ?string

MenuItem::setBlockName()

public function setBlockName(?string $blockName): static

MenuItem::getParams()

public function getParams(): array

MenuItem::setParams()

public function setParams(array $params): static

MenuItem::getSubItems()

public function getSubItems(): array

MenuItem::setSubItems()

public function setSubItems(array $subItems): static

MenuItem::hasSubItems()

public function hasSubItems(): bool

MenuItem::getLinkUrl()

public function getLinkUrl(): ?string

MenuItem::setLinkUrl()

public function setLinkUrl(?string $linkUrl): static

MenuItem::isSelected()

public function isSelected(): bool

MenuItem::setSelected()

public function setSelected(bool $selected): static

MenuItem::isExpanded()

public function isExpanded(): bool

MenuItem::setExpanded()

public function setExpanded(bool $expanded): static

MenuItem::getAsDto()

public function getAsDto(): static

Kept for source compatibility with the former builder/DTO split.

MenuItem::setKey()

public function setKey(?string $key): static

MenuItem::getKey()

public function getKey(): string

Stable identity for the persisted layout config (see the customizable dashboard/sidebar feature). An explicit key wins; otherwise a hash of the item's structural identity - deliberately NOT positional (inserting an item elsewhere mustn't renumber everyone else's stored config) and NOT the raw translated string (translation keys are locale-stable; routeParameters is included because e.g. a per-role sidebar shortcut differs from its siblings only by a query parameter).

MenuItem::isHidden()

public function isHidden(): bool

MenuItem::setHidden()

public function setHidden(bool $hidden): static

MenuItem::hasBackground()

public function hasBackground(): bool

MenuItem::setBackground()

public function setBackground(bool $background): static

MenuItem::getSize()

public function getSize(): int

MenuItem::setSize()

public function setSize(int $size): static

A generous absolute ceiling (matching Layout\LayoutConfig::MAX_COLUMNS - not imported here, this class doesn't depend on the Layout namespace), not the real business limit: the dashboard's actual configured column count is dynamic and enforced separately, in LayoutConfig::sanitizeItems() (stored values) and LayoutArranger::apply() (code-defined defaults) - both of which know the CURRENT column count, which this model class has no way to.

MenuItem::getHeight()

public function getHeight(): ?int

MenuItem::setHeight()

public function setHeight(?int $height): static

null clears back to auto. A generous absolute ceiling/floor, same reasoning as setSize().

MenuItem::isAdHoc()

public function isAdHoc(): bool

MenuItem::setAdHoc()

public function setAdHoc(bool $adHoc): static

Base\Admin\Context

AdminContext class

class AdminContext AdminContext.php:16

Request-scoped state of the admin: which controller/action is running, its Crud/Actions configuration and the resolved menus. Built by the controllers themselves (no factory/reflection dance) and read by Twig through the "admin" global.

AdminContext::getRequest()

public function getRequest(): ?Request

AdminContext::setRequest()

public function setRequest(?Request $request): static

AdminContext::getReferrer()

public function getReferrer(): ?string

AdminContext::getDashboardControllerFqcn()

public function getDashboardControllerFqcn(): ?string

AdminContext::setDashboardControllerFqcn()

public function setDashboardControllerFqcn(?string $fqcn): static

AdminContext::getCrudControllerFqcn()

public function getCrudControllerFqcn(): ?string

AdminContext::setCrudControllerFqcn()

public function setCrudControllerFqcn(?string $fqcn): static

AdminContext::getCrud()

public function getCrud(): ?Crud

AdminContext::setCrud()

public function setCrud(?Crud $crud): static

AdminContext::getActions()

public function getActions(): ?Actions

AdminContext::setActions()

public function setActions(?Actions $actions): static

AdminContext::getMainMenu()

public function getMainMenu(): array

AdminContext::setMainMenu()

public function setMainMenu(array $mainMenu): static

AdminContext::getUserMenu()

public function getUserMenu(): array

AdminContext::setUserMenu()

public function setUserMenu(array $userMenu): static

AdminContext::getEntity()

public function getEntity(): mixed

AdminContext::setEntity()

public function setEntity(mixed $entity): static

AdminContext::getEntities()

public function getEntities(): iterable

AdminContext::setEntities()

public function setEntities(iterable $entities): static

AdminContext::getAssets()

public function getAssets(): array

AdminContext::setAssets()

public function setAssets(array $assets): static

Base\Admin\Controller

AbstractCrudController class

abstract class AbstractCrudController extends AbstractController implements CrudControllerInterface AbstractCrudController.php:40

Direct, linear CRUD pipeline: every page is an ordinary controller action calling overridable hooks - no event dispatch, no listener-side rendering.

Hook names (configureFields/configureActions/configureCrud/ createIndexQueryBuilder/...) intentionally match the historical API so existing controllers port without rewriting their bodies.

public static array $crudNamespaceCandidates = ['\Controller\Crud\\', '\Controller\Admin\Crud\\', '\Admin\Controller\Crud\\', '\Controller\Backoffice\Crud\\']

Convention: App\Controller\Admin\Crud\Xxx\YyyCrudController maps to App\Entity\Xxx\Yyy. Override for non-conventional locations.

\Admin\Controller\Crud\: this bundle's screens of the core's entities (Base\Admin\Controller\Crud\User\GroupCrudController for Base\Entity\User\Group).

AbstractCrudController::setAdminServices()

public function setAdminServices(EntityManagerInterface $entityManager, FieldFormBuilder $fieldFormBuilder, FieldValueResolver $fieldValueResolver, AdminUrlGenerator $adminUrlGenerator, AdminContext $adminContext, \Base\Admin\Menu\MenuBuilder $menuBuilder, \Base\Admin\Layout\LayoutStore $layoutStore, \Base\Admin\Router\AdminRouteRegistry $routeRegistry, \Base\Admin\Layout\PageCustomization $pageCustomization): void

AbstractCrudController::setLabelTranslator()

public function setLabelTranslator(?TranslatorInterface $translator = null): void

AbstractCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

AbstractCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

AbstractCrudController::getCrudControllerFqcn()

public static function getCrudControllerFqcn(object|string|null $entity): ?string

Convention-based reverse lookup: entity FQCN (or instance) to its CRUD controller FQCN. Mirrors getEntityFqcn(): each namespace candidate is substituted for "\Entity\", trying the App\ variant before Base\.

AbstractCrudController::configureFields()

public function configureFields(string $pageName): iterable

AbstractCrudController::configureActions()

public function configureActions(Actions $actions): Actions

AbstractCrudController::configureCrud()

public function configureCrud(Crud $crud): Crud

AbstractCrudController::configureFilters()

public function configureFilters(\Base\Admin\Filter\Filters $filters): \Base\Admin\Filter\Filters

AbstractCrudController::configureRecordNote()

public function configureRecordNote(object $entity): ?string

A line about THIS record under its heading on the detail and edit pages - what the list cannot say at a glance: a payment method's gateway reachable or not, when an order last moved. Plain text, escaped; null (the default) shows nothing.

AbstractCrudController::isDeletable()

public function isDeletable(object $entity): bool

Whether this record may be deleted now - false for one others still rely on (a payment method orders were paid with, a feature products carry). The delete button disappears from every page and the delete routes refuse it, so the rule holds whatever the button shows.

AbstractCrudController::index()

public function index(Request $request): Response

AbstractCrudController::detail()

public function detail(Request $request, string $entityId): Response

AbstractCrudController::new()

public function new(Request $request): Response

AbstractCrudController::edit()

public function edit(Request $request, string $entityId): Response

AbstractCrudController::collectionEntries()

public function collectionEntries(Request $request, string $entityId, string $field): Response

One slice of an embedded collection, rendered on demand.

Collections are capped (CollectionType::$max_entries) because every entry is a full sub-form whose relations get hydrated - measured at ~3 queries each, so an unbounded collection is an unbounded query count. The cap bounds the initial page; this renders the rest when the operator actually asks for it.

The form is rebuilt with the collection WINDOWED to the requested slice, so this request pays for that slice only. array_slice preserves keys in CollectionType, so a lazily fetched entry keeps its real index and still binds to the right slot when the parent form is submitted.

AbstractCrudController::delete()

public function delete(Request $request, string $entityId): Response

AbstractCrudController::batchDelete()

public function batchDelete(Request $request): Response

AbstractCrudController::toggle()

public function toggle(Request $request, string $entityId): Response

Backs crud/field/boolean.html.twig's inline switch - the whole reason this exists rather than routing a toggle through the normal edit form. Deliberately narrow: only properties that are ACTUALLY configured as a BooleanField with the switch option on, on THIS crud's own field list, can be flipped - a client can't toggle an arbitrary property just by guessing its name, and this can't be used to touch fields the controller never chose to expose as a switch in the first place. Requires edit permission, same CSRF token family as the rest of this controller's mutations.

AbstractCrudController::createEntity()

public function createEntity(string $entityFqcn): object

AbstractCrudController::persistEntity()

public function persistEntity(EntityManagerInterface $entityManager, object $entity): void

AbstractCrudController::updateEntity()

public function updateEntity(EntityManagerInterface $entityManager, object $entity): void

AbstractCrudController::deleteEntity()

public function deleteEntity(EntityManagerInterface $entityManager, object $entity): void

AbstractCrudController::removeEntity()

public function removeEntity(EntityManagerInterface $entityManager, object $entity): void

deleteEntity() without its flush - batchDelete() removes each record through here and flushes the lot once. Override this one, not deleteEntity(), to change how a record goes: both paths run it.

AbstractCrudController::createIndexQueryBuilder()

public function createIndexQueryBuilder(Crud $crud, array $sort): QueryBuilder

AbstractCrudController::getEntityPermission()

public function getEntityPermission(): ?string

The entity permission configureCrud() declares (Crud::setEntityPermission()), read for the index - the page a menu item or a quick access card opens. The menu, the dashboard and the links to related records ask it through CrudAccessChecker to hide what denyAccessUnlessGrantedToRun() would refuse; the AdminContext is left alone.

AbstractDashboardController class

abstract class AbstractDashboardController extends AbstractController AbstractDashboardController.php:21

The admin's mount point: renders the dashboard and owns the menu configuration (before/main/after sections and the user menu).

const TRANSLATION_DASHBOARD = 'admin'

AbstractDashboardController::setAdminServices()

public function setAdminServices(AdminContext $adminContext, AdminUrlGenerator $adminUrlGenerator, AdminRouteRegistry $routeRegistry, MenuBuilder $menuBuilder, TranslatorInterface $translator): void

AbstractDashboardController::index()

public function index(): Response

AbstractDashboardController::configureMenuBeforeItems()

public function configureMenuBeforeItems(): iterable

AbstractDashboardController::configureMenuItems()

public function configureMenuItems(): iterable

The sidebar's own entries (the system pages, as a rule), shown after the dashboard's groups (see resolveMenu()). By default nothing when the dashboard has groups - they are the sidebar's navigation -, else one entry per registered CRUD.

AbstractDashboardController::configureMenuAfterItems()

public function configureMenuAfterItems(): iterable

AbstractDashboardController::configureUserMenu()

public function configureUserMenu(): iterable

AbstractDashboardController::configureWidgetItems()

public function configureWidgetItems(): iterable

The dashboard's central navigation: sections (label + big icon) each carrying sub-items (regular MenuItem crud/route links, plus optional create shortcuts via ->setCrudAction('new')) - the historical configureWidgetItems() card grid. Empty by default: without groups the dashboard falls back to the flat one-card-per-CRUD quick-access listing.

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'), ]);

MenuItem::block() also works here - not just in configureDashboardBlockItems() - so an app can add MORE block-type widgets alongside its link-list groups: another instance of a built-in type (e.g. a second analytics_card with different params), or an app-defined DashboardWidgetTypeInterface implementation (register it as a normal autoconfigured service, no Twig changes needed - see Base\Admin\Widget\DashboardWidgetTypeRegistry). The $instanceKey argument keeps each instance's hide/order/size state independent:

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

AbstractDashboardController::configureDashboardBlockItems()

public function configureDashboardBlockItems(): iterable

Non-overridable-in-spirit hook (unlike configureWidgetItems()/ configureMenuAfterItems() above, this one isn't meant to be reassigned by app subclasses): supplies the dashboard's built-in block-type widgets (currently just the analytics chart) independently of whatever the app yields from configureWidgetItems(). Both funnel through the same resolve(..., LayoutScope::DASHBOARD) call, so a block is just as orderable/hideable as an app-defined widget group - but an app whose configureWidgetItems() override doesn't call parent:: still gets the built-in blocks, because they never went through that override point to begin with.

AbstractDashboardController::configureSidebarBlockItems()

public function configureSidebarBlockItems(): iterable

Same non-overridable-in-spirit contract as configureDashboardBlockItems(), for the sidebar: the analytics stats block, rendered as a dropdown triggered from the sidebar's bottom icon row (see layout.html.twig).

AbstractDashboardController::getMenuItems()

public function getMenuItems(): array

The dashboard's whole menu, reusable by every other admin page (CRUD, system pages, an application's own screens extending @Admin/layout.html.twig) so the sidebar is the same everywhere - see MenuBuilder::buildDefault().

AnalyticsController class

class AnalyticsController extends AbstractController AnalyticsController.php:19

Backs the dashboard analytics card's range picker (see dashboard.html.

twig's analytics_card block + admin-charts.js) - a live re-fetch instead of a full page reload every time the picker changes. Read-only/GET, no CSRF needed (matches the existing gm_show-style "just data" controllers elsewhere in this app, not LayoutController's own POST+CSRF shape).

AnalyticsController::__construct()

public function __construct(readonly Analytics $analytics, readonly TranslatorInterface $translator, readonly TimelineEventRegistry $timelineEvents)

AnalyticsController::breakdown()

public function breakdown(string $range): JsonResponse

CrudControllerInterface interface

interface CrudControllerInterface CrudControllerInterface.php:9

Marker interface: implementing it (via AbstractCrudController) tags the service base.admin.crud_controller, which registers its routes and menus.

CrudControllerInterface::getEntityFqcn()

public static function getEntityFqcn(): string

DashboardController class

class DashboardController extends AbstractDashboardController DashboardController.php:20

Zero-config default dashboard: mounted automatically at the registry's urlPrefix (see AdminRouteLoader::addDashboardRoute()) whenever the app hasn't wired its own. configureMenuItems() already defaults to MenuBuilder::buildDefault() (one section-less link per registered CRUD), so this class needs no body of its own to be usable out of the box.

Apps that want a customized dashboard (grouped sections, extra widgets, ...) define their own App\Admin\Controller\DashboardController extending this class - AdminBundle aliases the whole Base\Admin\Controller namespace onto App\Admin\Controller, the same App-wins convention used for entities and repositories, so the app's version is simply what "Base\Admin\Controller\DashboardController" resolves to the moment it exists - no route or service wiring required on top.

DashboardWidgetController class

class DashboardWidgetController extends AbstractController DashboardWidgetController.php:37

Backs the dashboard's "+ Add widget" palette (types/newInstance), the drag-one-card-onto-another merge gesture (merge, also used to add a THIRD/FOURTH card into an already-merged composite), and pulling a composite back apart into its standalone cards again (split). Those four actions are GET, no CSRF - read-only, none of them persists anything (persistence still only ever happens through the existing batched admin_layout_save POST on "Done"). Each renders server-side Twig so the client never has to reinvent widget markup itself - it only ever inserts/replaces whatever HTML comes back.

restore() is the one exception: it's POST + CSRF-checked (same 'admin-layout' token as LayoutController::save()) because, unlike the four above, it writes straight to the stored LayoutConfig itself - clearing a deleted code-defined widget's flag can't wait for the next batched "Done" save, since deleted_widgets (see AbstractDashboardController::index()) is itself read from that same stored config on every page load.

DashboardWidgetController::__construct()

public function __construct(protected readonly PaletteWidgetTypeRegistry $paletteRegistry, protected readonly TranslatorInterface $translator, protected readonly LayoutStore $layoutStore, protected readonly LinkableEntityRegistry $entities)

DashboardWidgetController::instances()

public function instances(Request $request): JsonResponse

The instance picker's options for ONE class, on demand.

The two entity-picking widgets (Counter, "Vues") used to receive every instance of EVERY pickable class up front, serialised into a data-instances-by-class attribute. Measured on beta: 9.2s to render one empty Counter, a 106KB fragment, 42.5KB of it that single attribute - roughly a thousand entities hydrated to fill a <select> that shows one class at a time, and it grows with the site. The picker now asks for the class it actually needs.

GET + no CSRF like the other read-only routes here. $class is untrusted, so it is checked against getPickableClasses() rather than handed to the ORM - findInstances() guards too, but an arbitrary FQCN should not reach it in the first place.

DashboardWidgetController::types()

public function types(): JsonResponse

DashboardWidgetController::merge()

public function merge(Request $request): Response

Drag-to-merge: the client sends the full list of panes the resulting composite should have - either [target, dragged] for a brand new 2-pane fusion, or [...existing composite's own panes, dragged] when dropping a THIRD/FOURTH card onto an already-merged one (the client decides which case it is; this endpoint doesn't care, it just renders whatever pane list it's given, capped at MAX_PANES).

DashboardWidgetController::split()

public function split(Request $request): Response

The inverse of merge(): the client sends a composite's own data-widget-params (its 'panes' array, unchanged), this renders each pane back out as its own standalone widget fragment - block-type panes through the same _block.html.twig wrapper newInstance() uses, group-type (link-list) panes through _group.html.twig - concatenated into one response. The client removes the composite node and inserts this in its place; nothing is persisted here either, same as every other action in this controller.

DashboardWidgetController::newInstance()

public function newInstance(string $blockName, Request $request): Response

DashboardWidgetController::restore()

public function restore(Request $request): JsonResponse

Un-deletes a code-defined dashboard widget (see AbstractDashboardController::index()'s deleted_widgets and dashboard.html.twig's "restore" picker). Dashboard-scope only - the sidebar has no equivalent picker, nothing else needs this yet.

Writes straight to the stored LayoutConfig and returns success/ failure only; the client reloads the page on success rather than this endpoint trying to render the restored widget's markup itself (it has no code-defined MenuItem to render from here - only AbstractDashboardController::index(), one layer up, ever sees configureWidgetItems() - a full reload naturally goes through that same path again, now with the flag cleared).

HoursController class

class HoursController extends AbstractController HoursController.php:31

"Opening hours" (/admin/hours): the usual week, day by day, and the days off it - closed, or open at other hours, for one day or a run of days.

What glitchr/omnibase's OpeningHours reads everywhere: the footer, the day an order is for, the time slots, the page's JSON-LD.

The site's own hours; with ?scope=store:12, one place's (its own week, or the site's until it has one; its own days off beside the site's).

The page is read with GET; every change is a POST with its CSRF token. Routed by AdminRouteLoader (admin_hours, admin_hours_week, admin_hours_special, admin_hours_special_delete).

const SLOTS = 3

Slots a day may have: a morning, an afternoon, an evening.

HoursController::__construct()

public function __construct(readonly OpeningHours $hours, readonly WeekDayHoursRepository $weekDays, readonly ScopedWeekRepository $scopedWeeks, readonly SpecialDayRepository $specialDays, readonly EntityManagerInterface $entityManager)

HoursController::index()

public function index(Request $request): Response

HoursController::week()

public function week(Request $request): RedirectResponse

The usual week: week[<ISO day>][<n>][open|close], empty rows left out.

HoursController::addSpecialDay()

public function addSpecialDay(Request $request): RedirectResponse

A day off, or a run of days: closed, or open at other hours.

HoursController::deleteSpecialDay()

public function deleteSpecialDay(Request $request, int $id): RedirectResponse

LayoutController class

class LayoutController extends AbstractController LayoutController.php:19

The superadmin-only customize-mode save endpoint: one POST per "Done" click (never per-drag - see LayoutStore's cache-invalidation note), scoped to sidebar or dashboard. Registered by AdminRouteLoader alongside the dashboard/CRUD routes - the app needs no routing config of its own.

LayoutController::__construct()

public function __construct(protected readonly LayoutStore $layoutStore, protected readonly ?\Base\Service\LocalizerInterface $localizer = null, protected readonly ?\Base\Service\IconProvider $iconProvider = null)

LayoutController::icons()

public function icons(Request $request): JsonResponse

Searchable icon list for the page-icon picker popover (see layout.html.twig's data-page-icon handler) - backed by base-bundle's own cached FontAwesome metadata (IconProvider), NOT the select2 autocomplete endpoint, whose obfuscated-token contract exists for the form widget and has nothing this simple popover needs. Flat, deduped, solid-style entries first (the admin's own icon set is all but exclusively fa-solid), capped small - this is a picker, not a browser.

LayoutController::save()

public function save(Request $request, string $scope): JsonResponse

LayoutController::quickSave()

public function quickSave(Request $request, string $scope): JsonResponse

Title/text edits used to only ever persist via the full save() above - batched behind "Termine", same as position/size/hide/etc - which meant a text edit looked committed (the DOM updates immediately, contentEditable) but was silently lost if you left customize mode any other way (reported live). Narrowly scoped to just 'label'/ 'text' rather than a general per-field PATCH: those are the only two edits that visually commit themselves as you type, so they're the only ones a user would reasonably expect to already be saved - position/resize/hide/background still batch via save() above, unchanged, since dragging/resizing has no equivalent "looks done" moment mid-gesture.

Read-modify-write against the CURRENTLY PERSISTED config (not whatever's live in the DOM) - deliberately does NOT touch any other item's still-pending position/size/etc, and re-validates the whole result through LayoutConfig::fromArray() rather than hand-rolling the same length-capping/type-coercion fromArray() already does.

RevisionController class

class RevisionController extends AbstractController RevisionController.php:25

Serves the raw previous value behind one entry of a field's history badge.

Read-only/GET, no CSRF, same shape as AnalyticsController - it hands back data the caller is already allowed to read and edit, and restoring is not what this does: the value goes into the form input, and nothing is written until the editor saves the form like any other change.

Values are fetched rather than embedded in the page on purpose. An EditorJS article body runs to tens of kilobytes, and with five revisions per field the badge alone would have added several hundred KB of data attributes to every edit page - for values almost nobody clicks.

RevisionController::__construct()

public function __construct(readonly EntityManagerInterface $entityManager)

RevisionController::value()

public function value(Request $request, int $id): JsonResponse

SystemController class

class SystemController extends AbstractController SystemController.php:35

The system pages of the back office: the settings (/admin/settings) and the API keys (/admin/api-key), the same machinery on every site (SettingBag, LayoutSettingListType). What they hold is declared by sections (Base\Admin\Settings\SettingsSectionInterface, tagged base.admin.settings_section): omnibase's own, each bundle's, the application's. Routed by AdminRouteLoader (admin_settings, admin_apikey, admin_settings_quick); an application that still routes a controller of its own under those names keeps it (its routes load last).

SystemController::__construct()

public function __construct(protected readonly SettingBagInterface $settingBag, protected readonly SettingRepository $settingRepository, protected readonly AdminContext $adminContext, protected readonly MenuBuilder $menuBuilder, protected readonly TranslatorInterface $translator, protected readonly PageCustomization $pageCustomization, protected readonly SettingsSections $sections)

SystemController::settings()

public function settings(Request $request): Response

SystemController::apiKey()

public function apiKey(Request $request): Response

SystemController::settingsQuick()

public function settingsQuick(Request $request): JsonResponse

TrashController class

class TrashController extends AbstractController TrashController.php:25

The trash: what soft deletion put aside, and the two things you can do with it - put it back, or destroy it.

Listing is GET; both writes are POST + CSRF, because unlike the read-only endpoints elsewhere in this bundle these change the database, and one of them does so beyond recovery.

TrashController::__construct()

public function __construct(readonly TrashManager $trashManager, readonly EntityManagerInterface $entityManager, readonly TranslatorInterface $translator, readonly AdminContext $adminContext, readonly MenuBuilder $menuBuilder)

TrashController::index()

public function index(Request $request): Response

TrashController::restore()

public function restore(Request $request, int $id): RedirectResponse

TrashController::destroy()

public function destroy(Request $request, int $id): RedirectResponse

TrashController::empty()

public function empty(Request $request): RedirectResponse

Destroy everything whose grace period has run out - the same thing the cron does, offered here so an admin need not wait for it.

Deliberately NOT "destroy everything in the trash": the grace period is the promise soft deletion makes, and a button that voids it for entries put there a minute ago would make the trash a slower hard delete.

Base\Admin\Controller\Crud

ComplaintCrudController class

class ComplaintCrudController extends AbstractCrudController ComplaintCrudController.php:28

Reports ("signalements", Base\Entity\User\Complaint): what a member reported about another one or about something, read and settled here. Who sees one is ComplaintVoter's call - the staff above the person it is about, never that person. Nobody writes one here: they come from the site (a forum, a game world, a comment); the staff marks one handled or dismissed, with an answer, or opens it again. The dashboard tile is ComplaintsWidgetType.

ComplaintCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

ComplaintCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

ComplaintCrudController::configureCrud()

public function configureCrud(Crud $crud): Crud

ComplaintCrudController::configureFilters()

public function configureFilters(Filters $filters): Filters

ComplaintCrudController::configureFields()

public function configureFields(string $pageName): iterable

ComplaintCrudController::configureActions()

public function configureActions(Actions $actions): Actions

ComplaintCrudController::handle()

public function handle(Request $request, string $entityId): Response

ComplaintCrudController::dismiss()

public function dismiss(Request $request, string $entityId): Response

ComplaintCrudController::reopen()

public function reopen(Request $request, string $entityId): Response

RedirectionCrudController class

class RedirectionCrudController extends AbstractCrudController RedirectionCrudController.php:24

"Redirections": the addresses the site no longer answers, and where each leads now (Base\Entity\Layout\Redirection, glitchr/omnibase). An old address is typed or pasted whole - it is kept as its path -, the new one is a path of the site or another site's address; permanent (301) unless it is a detour. The list shows how many visitors each one carried, and when last: the old addresses still in use, and those that can go.

RedirectionCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

RedirectionCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

RedirectionCrudController::configureCrud()

public function configureCrud(Crud $crud): Crud

RedirectionCrudController::configureFilters()

public function configureFilters(Filters $filters): Filters

RedirectionCrudController::configureFields()

public function configureFields(string $pageName): iterable

TextOverrideCrudController class

class TextOverrideCrudController extends AbstractCrudController TextOverrideCrudController.php:24

"Textes du site": any text of the site's pages rewritten here - a heading, a button, a paragraph, in one of the site's languages. Pick the text by its key (the list shows its current wording), write the new one; delete it to bring the original back. {placeholders} stay as they are. The text wins over translations/ through glitchr/omnibase's OverridingTranslator.

Moved here from the apps' App\Controller\Admin\Crud\TextOverrideCrudController.

TextOverrideCrudController::setTextServices()

public function setTextServices(TranslatorInterface $translator, array $locales = []): void

TextOverrideCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

TextOverrideCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

TextOverrideCrudController::configureFields()

public function configureFields(string $pageName): iterable

UserCrudController class

class UserCrudController extends AbstractCrudController UserCrudController.php:18

UserCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

UserCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

UserCrudController::configureFilters()

public function configureFilters(Filters $filters): Filters

UserCrudController::configureFields()

public function configureFields(string $pageName): iterable

Base\Admin\Controller\Crud\Layout

ImageCrudController class

class ImageCrudController extends AbstractCrudController ImageCrudController.php:11

ImageCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

ImageCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

ImageCrudController::configureFields()

public function configureFields(string $pageName): iterable

SettingCrudController class

class SettingCrudController extends AbstractCrudController SettingCrudController.php:14

SettingCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

SettingCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

SettingCrudController::createEntity()

public function createEntity(string $entityFqcn): object

SettingCrudController::configureFilters()

public function configureFilters(Filters $filters): Filters

SettingCrudController::configureFields()

public function configureFields(string $pageName): iterable

Base\Admin\Controller\Crud\Layout\Attribute

AdapterCrudController class

class AdapterCrudController extends AbstractCrudController AdapterCrudController.php:22

Every attribute adapter - the definitions behind scopes, rules, actions and plain attributes ("scope-region", "rule-cart-total", a barcode standard...): their code, icon and label. Each kind is created from its own CRUD, which knows its settings; this list renames and re-icons them, and keeps those attributes still use.

AdapterCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

AdapterCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

AdapterCrudController::configureActions()

public function configureActions(Actions $actions): Actions

Nothing is created here: each kind has its own CRUD, which knows its settings.

AdapterCrudController::isDeletable()

public function isDeletable(object $entity): bool

AdapterCrudController::configureFilters()

public function configureFilters(Filters $filters): Filters

AdapterCrudController::configureFields()

public function configureFields(string $pageName): iterable

Base\Admin\Controller\Crud\Layout\Widget

PageCrudController class

class PageCrudController extends AbstractCrudController PageCrudController.php:23

Layout pages served at /page/{slug} (WidgetController::Page).

Also what makes that page render for administrators at all: its template links to this CRUD through crudify(), which had no controller to resolve for a Page and handed null to AdminUrlGenerator::setController().

The content is HTML (WysiwygType), like the pages already stored.

PageCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

PageCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

PageCrudController::configureFilters()

public function configureFilters(Filters $filters): Filters

PageCrudController::configureFields()

public function configureFields(string $pageName): iterable

Base\Admin\Controller\Crud\Thread

TagCrudController class

class TagCrudController extends AbstractCrudController TagCrudController.php:14

TagCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

TagCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

TagCrudController::configureFilters()

public function configureFilters(Filters $filters): Filters

TagCrudController::configureFields()

public function configureFields(string $pageName): iterable

TaxonCrudController class

class TaxonCrudController extends AbstractCrudController TaxonCrudController.php:18

The taxonomy: categories that nest (a parent, children), each with a label, a description and keywords per language. Threads are filed under them.

TaxonCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

TaxonCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

TaxonCrudController::configureFilters()

public function configureFilters(Filters $filters): Filters

TaxonCrudController::configureFields()

public function configureFields(string $pageName): iterable

Base\Admin\Controller\Crud\User

ConnectionCrudController class

class ConnectionCrudController extends AbstractCrudController ConnectionCrudController.php:24

Read-only login audit log (who signed in/failed to, from where, when) - an admin never creates or edits a Connection by hand, entries are written automatically by ConnectionSubscriber on every login attempt.

ConnectionCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

ConnectionCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

ConnectionCrudController::configureActions()

public function configureActions(Actions $actions): Actions

ConnectionCrudController::configureFilters()

public function configureFilters(Filters $filters): Filters

ConnectionCrudController::configureFields()

public function configureFields(string $pageName): iterable

GroupCrudController class

class GroupCrudController extends AbstractCrudController GroupCrudController.php:16

GroupCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

GroupCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

GroupCrudController::createEntity()

public function createEntity(string $entityFqcn): object

GroupCrudController::configureFields()

public function configureFields(string $pageName): iterable

PermissionCrudController class

class PermissionCrudController extends AbstractCrudController PermissionCrudController.php:14

PermissionCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

PermissionCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

PermissionCrudController::configureFields()

public function configureFields(string $pageName): iterable

Base\Admin\DependencyInjection

AdminConfiguration class

class AdminConfiguration extends AbstractBaseConfiguration AdminConfiguration.php:10

AdminConfiguration::getConfigTreeBuilder()

public function getConfigTreeBuilder(): TreeBuilder

AdminExtension class

class AdminExtension extends AbstractBaseExtension AdminExtension.php:15

AdminExtension::getConfiguration()

public function getConfiguration(array $config, ContainerBuilder $container): AdminConfiguration

AdminExtension::load()

public function load(array $configs, ContainerBuilder $container): void

Base\Admin\DependencyInjection\Compiler

AdminRoutePass class

class AdminRoutePass implements CompilerPassInterface AdminRoutePass.php:19

Collects every service tagged base.admin.crud_controller into the AdminRouteRegistry constructor argument, so routes and URLs resolve from a compiled list instead of runtime reflection - and into a service locator keyed by class for CrudAccessChecker, which reads a controller's entity permission without routing a request through it.

AdminRoutePass::process()

public function process(ContainerBuilder $container): void

Base\Admin\EventSubscriber

ActiveAdminsSubscriber class

class ActiveAdminsSubscriber implements EventSubscriberInterface ActiveAdminsSubscriber.php:24

Exposes "admin_active_users" (a Twig global: every OTHER admin/ superadmin active within User::getOnlineDelay(), most recent first, excluding the current viewer) for the admin topbar's presence indicator - admin-panel requests only, not every page on the site.

Deliberately separate from the older, currently-inert Base\Subscriber\ AnalyticsSubscriber (public-site "online users" + Google Analytics puller, gated on a narrower/legacy admin check) rather than reviving or depending on it - this is scoped to exactly what the admin topbar needs.

ActiveAdminsSubscriber::__construct()

public function __construct(readonly UserRepository $userRepository, readonly Environment $twig, readonly Security $security)

ActiveAdminsSubscriber::getSubscribedEvents()

public static function getSubscribedEvents(): array

ActiveAdminsSubscriber::onKernelRequest()

public function onKernelRequest(RequestEvent $event): void

AdminActionSubscriber class

class AdminActionSubscriber implements EventSubscriberInterface AdminActionSubscriber.php:18

Runs a CRUD's gate in front of each of its #[AdminAction] methods, the way index()/edit()/... run it at their top - but before the method, so a controller cannot forget it: entity permission, the action's own permission, disabled action, CSRF token (see AbstractCrudController::guardAdminAction()).

AdminActionSubscriber::getSubscribedEvents()

public static function getSubscribedEvents(): array

AdminActionSubscriber::onKernelController()

public function onKernelController(ControllerEvent $event): void

NestHeaderSubscriber class

class NestHeaderSubscriber implements EventSubscriberInterface NestHeaderSubscriber.php:26

Declares every admin page "nestable": transparentJS only mounts a page into its overlay when the response carries the X-Transparent-Nest header, so a plain visit (or a site without transparentJS) keeps getting the ordinary full page.

An admin page is one whose path is under the back office's prefix (/admin): the CRUDs and the system pages routed by this bundle, and any screen an application or a bundle routes there itself (#[Route('/admin/outils/canva')]) - it needs nothing more. A route named admin, admin_* is one too, wherever it is routed.

Any other route opts in with defaults: ['_nest' => true] (a screen of the site that opens over the page the same way): the one subscriber for every site - none keeps a copy of its own.

const HEADER = 'X-Transparent-Nest'

NestHeaderSubscriber::__construct()

public function __construct(?AdminRouteRegistry $registry = null)

NestHeaderSubscriber::getSubscribedEvents()

public static function getSubscribedEvents(): array

NestHeaderSubscriber::onKernelResponse()

public function onKernelResponse(ResponseEvent $event): void

Base\Admin\Filter

Filter class

class Filter Filter.php:13

One filter definition: which property, which comparison widget, and how it constrains the index QueryBuilder. Single class - the type string drives both the rendering (inline bar) and the WHERE clause.

const TYPE_TEXT = 'text'
const TYPE_BOOLEAN = 'boolean'
const TYPE_CHOICE = 'choice'
const TYPE_DATE = 'date'
const TYPE_NUMERIC = 'numeric'
const TYPE_ASSOCIATION = 'association'
const COMPARISON_LIKE = 'like'
const COMPARISON_EQ = 'eq'
const COMPARISON_NEQ = 'neq'
const COMPARISON_GT = 'gt'
const COMPARISON_LT = 'lt'

Filter::new()

public static function new(string $property, TranslatableInterface|string|null $label = null): static

Filter::getProperty()

public function getProperty(): string

Filter::getLabel()

public function getLabel(): TranslatableInterface|string|null

Filter::setLabel()

public function setLabel(TranslatableInterface|string|null $label): static

Filter::getType()

public function getType(): string

Filter::setType()

public function setType(string $type): static

Filter::asBoolean()

public function asBoolean(): static

Filter::asDate()

public function asDate(): static

Filter::asNumeric()

public function asNumeric(): static

Filter::asChoice()

public function asChoice(array $choices): static
$choices

value => label

Filter::asAssociation()

public function asAssociation(iterable $entities, ?callable $labelCallback = null): static

Filter by a to-one association (Destination, Author, Tag, ...) - a dropdown of the related entity's real rows rather than a free-text guess at an id. $entities is typically $repository->findAll() (or a narrower query - findBy(), a custom finder, ...); each becomes one choice, keyed by id, labeled with $labelCallback($entity) or the entity's own __toString() if none is given. The WHERE clause itself (entity.<property> = :id) is identical to a plain equality filter - Doctrine resolves a scalar compared against a to-one association to its join column automatically - so this is really "asChoice(), but the choices come from a repository instead of a hand-written array".

Filter::getChoices()

public function getChoices(): array

Filter::applyWith()

public function applyWith(callable $applyCallable): static

Filter::getComparisons()

public function getComparisons(): array

The comparison operators this filter's widget should offer, keyed by operator, valued with a short display symbol. Empty = no chooser (boolean/choice/association/date have a fixed semantic).

Filter::apply()

public function apply(QueryBuilder $queryBuilder, string $alias, mixed $value): void
$value

the raw request value (already checked non-empty)

Filter::isActive()

public static function isActive(mixed $value): bool

A filter value counts as "set" when it constrains anything.

Filters class

class Filters Filters.php:10

Filter registry for one CRUD, filled in configureFilters().

add() accepts a property name (type guessed later from Doctrine metadata) or a fully configured Filter.

Filters::new()

public static function new(): static

Filters::add()

public function add(Filter|string $propertyOrFilter): static

Filters::remove()

public function remove(string $property): static

Filters::get()

public function get(string $property): ?Filter

Filters::getAll()

public function getAll(): array

Filters::isEmpty()

public function isEmpty(): bool

Base\Admin\Form

FieldFormBuilder class

class FieldFormBuilder FieldFormBuilder.php:30

Builds a plain Symfony form from a collection of field descriptors.

This is the whole Field -> FormType bridge: each descriptor names a real Symfony FormTypeInterface FQCN (a Base\Field\Type\* or a Symfony core type) plus its options; nothing else happens in between. The same field list a CRUD controller yields in configureFields() can therefore be used to build a form anywhere - inside the admin or in any regular controller.

One exception: an AssociationField that points to a record existing on its own (a many-to-one, a many-to-many, a user account) is built as a picker (SelectType on the related class), not as that record's embedded form - see addField() and getPickedClass().

FieldFormBuilder::__construct()

public function __construct(protected FormFactoryInterface $formFactory, protected ?ManagerRegistry $doctrine = null)

FieldFormBuilder::createFormBuilder()

public function createFormBuilder(mixed $data, iterable $fields, string $page = FieldDescriptor::PAGE_NEW, array $formOptions = []): FormBuilderInterface

FieldFormBuilder::createForm()

public function createForm(mixed $data, iterable $fields, string $page = FieldDescriptor::PAGE_NEW, array $formOptions = []): FormInterface

FieldFormBuilder::addField()

public function addField(FormBuilderInterface $builder, FieldDescriptor $descriptor): void

FieldFormBuilder::getFormOptions()

public function getFormOptions(FieldDescriptor $descriptor): array

Maps the descriptor's generic settings onto Symfony form options; explicit formTypeOptions always win.

Base\Admin\Layout

LayoutArranger class

class LayoutArranger LayoutArranger.php:13

Pure merge of a code-defined MenuItem tree with a persisted LayoutConfig: reorders, hides, and drops stale entries - never invents or mutates the underlying items beyond calling setHidden(). No dependencies beyond MenuItem, so this is trivially unit-testable.

LayoutArranger::apply()

public function apply(array $items, LayoutConfig $config, ?string $locale = null, ?string $defaultLocale = null): array
$locale

current request locale - drives which of an entry's per-locale title/description overrides applies (see LayoutConfig's 'intl' key). null keeps the historical locale-agnostic behavior (plain label/description only).

$defaultLocale

the app's default locale - plain label/description are THAT locale's values, 'intl' holds the rest.

LayoutArranger::deletedItems()

public function deletedItems(array $items, LayoutConfig $config): array

The code-defined items a superadmin has deleted from this level (apply() intentionally omits these for good, see the deleted-flag branch above) - the "restore a widget" picker's data source. Reuses the same byKey matching apply() does, so the returned MenuItem is the real code-defined one (correct label/icon/key), not a reconstruction from the stored entry.

Returns

in code order

LayoutArranger::capture()

public function capture(array $items): LayoutConfig

Snapshots the current code-defined order as a starting config, so the customize UI's JS never has to invent keys - it always starts from what capture() already produced server-side.

LayoutConfig class

class LayoutConfig LayoutConfig.php:36

One persisted layout: an ordered, hide/show-annotated tree of item keys.

Shape: {version, items: [{key, visible, size, height, deleted, children: [...]}]}, plus five OPTIONAL keys (blockName, label, icon, params, and label ALONE without the other three - see below).

blockName+label+icon+params together mean an ad-hoc/palette-added widget - a self-contained widget definition with no code-defined counterpart to re-skin. An item without any of the five is otherwise byte-identical to the pre-widening 4-key shape (every pre-existing stored config, and every sidebar-scope item, which never carries any of them at all).

label CAN also appear alone, with no blockName/icon/params: an in-place title edit on a matched CODE-DEFINED item, block-type or section-type alike (see LayoutArranger::applyLevel()'s own matching half - label applies to any matched code-defined item, not just a blockName-carrying one). icon/params only ever mean something for a blockName-dispatched (block-type) widget's own rendering, so they stay gated to entries that actually carry a blockName.

deleted only ever matters for a code-defined item (see LayoutArranger's own comment on why an ad-hoc one needs no such flag), but is always present/false rather than one more OPTIONAL key, since unlike the others it isn't tied to whether this entry has its own self-contained widget definition.

fromArray() is the validation boundary for data coming back out of SettingBag (itself reachable and hand-editable via the generic settings CRUD) - it must never throw or produce something LayoutArranger can't safely consume, no matter how malformed the input.

LayoutConfig::new()

public static function new(): static

LayoutConfig::fromArray()

public static function fromArray(mixed $data): static

LayoutConfig::getVersion()

public function getVersion(): int

LayoutConfig::getColumns()

public function getColumns(): int

LayoutConfig::setColumns()

public function setColumns(int $columns): static

LayoutConfig::getItems()

public function getItems(): array

LayoutConfig::setItems()

public function setItems(array $items): static

LayoutConfig::restoreItem()

public function restoreItem(string $key): static

Clears the deleted flag on the top-level entry with this key, if present - the restore-widget action's whole job (see DashboardWidgetController::restore()). A no-op if the key isn't stored at all (nothing to restore) or wasn't deleted in the first place (idempotent, matches a superadmin double-clicking restore).

LayoutConfig::toArray()

public function toArray(): array

LayoutScope class

class LayoutScope LayoutScope.php:9

The customizable regions of the admin - each gets its own persisted layout config, stored under its own SettingBag path (see LayoutStore).

const SIDEBAR = 'sidebar'
const DASHBOARD = 'dashboard'
const CRUD = 'crud'

Per-CRUD-page customization (title, description), keyed by the CRUD's URL slug rather than by a sidebar menu item.

It needs its own scope because the sidebar-backed mechanism cannot reach these pages at all: an in-place title edit persists onto the page's own MENU ITEM (data-menu-key), and most CRUD pages have no menu item - the sidebar only lists Settings, API keys and a few pre-filtered Users links, so on /admin/articles nothing is marked selected, _current resolves to null and the whole editable-header block is skipped. Keying on the slug gives every CRUD page a stable identity to hang customization on whether or not it is in the menu.

LayoutScope::all()

public static function all(): array

LayoutScope::isValid()

public static function isValid(string $scope): bool

LayoutStore class

class LayoutStore LayoutStore.php:21

Persists LayoutConfig via the existing SettingBag path/value store - no new entity. Two real gotchas, both handled here so callers don't have to think about them:

1. SettingBag::set()/get() resolve to the CURRENT REQUEST locale when none is passed explicitly - saving from /fr/admin and reading from /en/admin would silently diverge. Always pin the default locale. 2. SettingBag::set() invalidates the WHOLE compiled settings snapshot on every call, so the customize UI must batch one save on "Done", never one write per drag - that's a client-side contract, not enforced here.

LayoutStore::__construct()

public function __construct(protected readonly SettingBagInterface $settingBag)

LayoutStore::get()

public function get(string $scope): LayoutConfig

LayoutStore::save()

public function save(string $scope, LayoutConfig $config): void

PageCustomization class

class PageCustomization PageCustomization.php:17

The superadmin-authored customization stored for ONE admin page: its own entry (customized title/description) plus its top-right action row's order and per-action icons.

All of it lives in the CRUD scope, keyed by a stable page key - a CRUD's URL slug for an entity page, "system/<page>" for a bespoke one (settings, API keys). Both kinds need the same thing, an identity that is NOT a sidebar menu item (see LayoutScope::CRUD's own doc comment), so they share one resolver instead of each walking the stored config themselves.

PageCustomization::__construct()

public function __construct(protected readonly LayoutStore $layoutStore)

PageCustomization::resolve()

public function resolve(string $pageKey): array

Base\Admin\Menu

MenuBuilder class

class MenuBuilder MenuBuilder.php:25

Resolves menu items (URLs + selected state) and provides the default menu — dashboard link plus one entry per registered CRUD controller — shared by the dashboard and every CRUD page.

MenuBuilder::__construct()

public function __construct(protected readonly AdminRouteRegistry $registry, protected readonly AdminUrlGenerator $adminUrlGenerator, protected readonly UrlGeneratorInterface $urlGenerator, protected readonly RequestStack $requestStack, protected readonly TranslatorInterface $translator, protected readonly AuthorizationCheckerInterface $authorizationChecker, protected readonly LayoutStore $layoutStore, protected readonly LayoutArranger $layoutArranger, protected readonly ?\Base\Service\LocalizerInterface $localizer = null, protected readonly iterable $dashboardControllers = [], protected readonly ?CrudAccessChecker $crudAccessChecker = null, protected readonly ?CsrfTokenManagerInterface $csrfTokenManager = null)

MenuBuilder::isCrudGranted()

public function isCrudGranted(string $controllerFqcn): bool

Whether a link to this CRUD controller may be shown to the current user: the entity permission its configureCrud() declares, the same one the controller enforces once reached (see CrudAccessChecker).

The dashboard's quick access and the CRUD pages' own cross-links ask here too, so every listing agrees with the menu.

MenuBuilder::buildDefault()

public function buildDefault(): array

The app's own curated menu (dashboard link + configureMenuItems()'s sections/entries) when a DashboardController is registered - the same menu the dashboard itself shows - falling back to a generic unsectioned "one entry per registered CRUD" listing only when no app DashboardController exists yet (e.g. mid-migration).

MenuBuilder::buildUserMenuDefault()

public function buildUserMenuDefault(mixed $user): array

Profile link (if the user object exposes a getId()) + sign out - the historical "usually better to call the parent method" default.

MenuBuilder::groupIntoSections()

public function groupIntoSections(array $items): array

Nests each section's directly-following items under it as subItems, turning configureMenuItems()'s flat "section, item, item, section, item" authoring convention (the same flat/marker style EasyAdmin uses, kept deliberately unchanged so it stays familiar) into the tree filterGranted()/resolveUrl()/markSelected()/LayoutArranger already all recurse into via getSubItems() - none of those needed a single change to support this.

The practical effect: a section and its members become one DOM subtree in the sidebar (an outer <li> containing a nested data-sortable <ul>), so dragging the section by its own handle carries its members along for free, while members are still independently reorderable/hideable within their section. Items before the first section, and TYPE_BLOCK items (rendered separately in the sidebar footer, never part of the scrollable menu list), stay top-level and ungrouped.

MenuBuilder::getColumns()

public function getColumns(string $layoutScope): int

How many columns the given scope's grid is currently configured for (dashboard.html.twig needs this outside resolve()'s own return value, to set the --widget-columns CSS custom property and the resize control's own upper bound).

MenuBuilder::deletedItems()

public function deletedItems(iterable $items, string $layoutScope): array

Code-defined items the superadmin has deleted from the given scope - the "restore a widget" picker's data source (see dashboard.html.twig).

Same filterGranted() pass as resolve() so a permission-gated item never shows up as "deleted" just because the current user can't see it in the first place.

MenuBuilder::resolve()

public function resolve(iterable $items, ?string $layoutScope = null): array

Base\Admin\Notifier

AdminNotificationLinker class

final class AdminNotificationLinker implements NotificationLinkerInterface AdminNotificationLinker.php:15

A notification's target, seen from the back-office: the edit page of the CRUD that manages its class. Nothing when the class has no CRUD here, or one the current user may not open.

AdminNotificationLinker::__construct()

public function __construct(readonly AdminRouteRegistry $registry, readonly AdminUrlGenerator $urlGenerator, readonly ?\Doctrine\ORM\EntityManagerInterface $entityManager = null, readonly ?\Base\Admin\Security\CrudAccessChecker $crudAccessChecker = null)

AdminNotificationLinker::adminUrl()

public function adminUrl(Notification $notification): ?string

Base\Admin\Orm

Paginator class

class Paginator implements IteratorAggregate, Countable Paginator.php:12

Thin pagination wrapper around Doctrine's Paginator with the counters the index template needs.

Paginator::__construct()

public function __construct(QueryBuilder $queryBuilder, int $page = 1, int $pageSize = 20)

Paginator::getIterator()

public function getIterator(): \Traversable

Paginator::count()

public function count(): int

Paginator::getPage()

public function getPage(): int

Paginator::getPageSize()

public function getPageSize(): int

Paginator::getTotal()

public function getTotal(): int

Paginator::getPageCount()

public function getPageCount(): int

Paginator::hasPreviousPage()

public function hasPreviousPage(): bool

Paginator::hasNextPage()

public function hasNextPage(): bool

Paginator::getPreviousPage()

public function getPreviousPage(): int

Paginator::getNextPage()

public function getNextPage(): int

Base\Admin\Router

AdminRouteLoader class

class AdminRouteLoader extends Loader AdminRouteLoader.php:30

Generates the conventional CRUD routes for every registered controller:

{prefix}/{slug} admin_crud_{slug}_index GET {prefix}/{slug}/new admin_crud_{slug}_new GET|POST {prefix}/{slug}/batch-delete admin_crud_{slug}_batchDelete POST {prefix}/{slug}/{entityId} admin_crud_{slug}_detail GET {prefix}/{slug}/{entityId}/edit admin_crud_{slug}_edit GET|POST {prefix}/{slug}/{entityId}/delete admin_crud_{slug}_delete POST {prefix}/{slug}/{entityId}/toggle admin_crud_{slug}_toggle PATCH {prefix}/{slug}/{entityId}/collection/{field} admin_crud_{slug}_collectionEntries GET

plus one route per #[AdminAction] method a controller declares, named the same way (admin_crud_{slug}_{method}) and added before the {entityId} routes - see Base\Admin\Attribute\AdminAction.

Loaded with: $routes->import('.', 'base_admin') in the app's routing config.

const TYPE = 'base_admin'
const BUILT_IN_ACTIONS = ['index', 'new', 'batchDelete', 'detail', 'edit', 'delete', 'toggle', 'collectionEntries']

The routes load() declares itself - an #[AdminAction] cannot replace one.

AdminRouteLoader::__construct()

public function __construct(protected readonly AdminRouteRegistry $registry)

AdminRouteLoader::supports()

public function supports(mixed $resource, ?string $type = null): bool

AdminRouteLoader::load()

public function load(mixed $resource, ?string $type = null): RouteCollection

AdminRouteRegistry class

class AdminRouteRegistry AdminRouteRegistry.php:11

Maps CRUD controller FQCNs to their generated route names and URL slugs.

Fed by the compiler pass with every service tagged base.admin.crud_controller.

const ROUTE_PREFIX = 'admin_crud_'

AdminRouteRegistry::current()

public static function current(): ?self

AdminRouteRegistry::__construct()

public function __construct(array $controllerFqcns = [], array $dashboardControllerFqcns = [], protected readonly string $urlPrefix = '/admin')
$dashboardControllerFqcns

every concrete (non-abstract) class tagged base.admin.dashboard_controller - normally just Base\Admin\Controller\DashboardController (the package's zero-config default) plus, optionally, one app-defined dashboard controller

AdminRouteRegistry::getDashboardControllerFqcn()

public function getDashboardControllerFqcn(): ?string

The dashboard controller the admin should actually mount at getUrlPrefix() - an App\ override if one is registered, otherwise the package's own zero-config default. Null only if the app has somehow disabled dashboard controller autoconfiguration entirely.

AdminRouteRegistry::getControllers()

public function getControllers(): array
Returns

controller FQCN => slug

AdminRouteRegistry::getUrlPrefix()

public function getUrlPrefix(): string

AdminRouteRegistry::getSlug()

public function getSlug(string $controllerFqcn): string

AdminRouteRegistry::getControllerForEntity()

public function getControllerForEntity(string|object $entity): ?string

The registered CRUD controller that manages a given entity, or null if nothing does.

This is what lets a related entity rendered in a field become a link to its own admin page without every field having to name a controller by hand - the association/select templates auto-resolve through here and only fall back to an explicit crudControllerFqcn option when the mapping is ambiguous or absent.

Walks the PARENT CHAIN, not just the exact class, for two reasons: a Doctrine proxy's class is a generated subclass of the real entity, and an inheritance tree (Thread -> Article/Comment/...) is often managed by one controller registered against the base class. The exact class always wins over an ancestor so a subclass with its own CRUD keeps it.

AdminRouteRegistry::getRouteName()

public function getRouteName(string $controllerFqcn, string $action = 'index'): string

AdminRouteRegistry::slugify()

public static function slugify(string $controllerFqcn): string

Controller FQCN => a REST-ish, hierarchical, pluralised URL slug:

Crud\Article\ArticleCrudController => "articles" Crud\Article\CommentCrudController => "articles/comments" Crud\Article\CommentReplyCrudController => "articles/comment-replies" Crud\UserCrudController => "users"

Previously this flattened the namespace with "-" and left it singular ("article-article", "article-comment"), which read as an internal class path rather than a URL. Each namespace segment after "Crud\" is now kebab-cased and pluralised, then joined with "/" so the grouping shows up as real nesting.

The consecutive-duplicate collapse is what stops "Article\Article" (a group whose own main entity repeats the group name) becoming "articles/articles"; it is applied on the pluralised segments so "Article\Article" and any future "Foo\Foo" behave the same way.

NOTE: the multi-segment result means one entity's collection path is a PREFIX of another's ("/admin/articles" vs "/admin/articles/comments"), which the loader has to order deepest-first - see AdminRouteLoader::load().

AdminUrlGenerator class

class AdminUrlGenerator implements AdminUrlGeneratorInterface AdminUrlGenerator.php:13

Fluent builder for admin URLs on top of the generated per-controller routes (admin_crud_<slug>_<action>); no runtime dispatch, no query-string controller FQCNs.

AdminUrlGenerator::__construct()

public function __construct(protected readonly UrlGeneratorInterface $urlGenerator, protected readonly AdminRouteRegistry $routes)

AdminUrlGenerator::setController()

public function setController(string $controllerFqcn): static

AdminUrlGenerator::setAction()

public function setAction(string $action): static

AdminUrlGenerator::setEntityId()

public function setEntityId(mixed $entityId): static

AdminUrlGenerator::set()

public function set(string $paramName, mixed $paramValue): static

AdminUrlGenerator::unset()

public function unset(string $paramName): static

AdminUrlGenerator::unsetAll()

public function unsetAll(): static

AdminUrlGenerator::generateUrl()

public function generateUrl(int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string

Base\Admin\Security

CrudAccessChecker class

class CrudAccessChecker implements ResetInterface CrudAccessChecker.php:22

Whether the current user may open a CRUD, asked from outside it: the menu, the dashboard's quick access, the links other screens draw to its records.

Same answer AbstractCrudController::denyAccessUnlessGrantedToRun() gives once a request reaches the controller - the entity permission its configureCrud() declares (Crud::setEntityPermission()) - so declaring it there once is enough: no menu item has to repeat the role.

The controllers sit behind a service locator (AdminRoutePass): only the ones actually asked about are built, and each answer is kept until the end of the request (kernel.reset), the menu being built on every page.

CrudAccessChecker::__construct()

public function __construct(protected readonly ContainerInterface $controllers, protected readonly AuthorizationCheckerInterface $authorizationChecker)

CrudAccessChecker::getEntityPermission()

public function getEntityPermission(string $controllerFqcn): ?string

Null for a controller that declares none, or that this locator does not know (nothing to enforce then, same as the controller itself).

CrudAccessChecker::isGranted()

public function isGranted(string $controllerFqcn): bool

CrudAccessChecker::reset()

public function reset(): void

Permission class

class Permission Permission.php:10

Admin permission attributes. Constant names and string values are kept identical to the historical EA_* set so existing isGranted() call sites and security configuration keep working after the switch.

const EA_ACCESS_ENTITY = 'EA_ACCESS_ENTITY'
const EA_EXECUTE_ACTION = 'EA_EXECUTE_ACTION'
const EA_VIEW_MENU_ITEM = 'EA_VIEW_MENU_ITEM'
const EA_VIEW_FIELD = 'EA_VIEW_FIELD'
const EA_EXIT_IMPERSONATION = 'EA_EXIT_IMPERSONATION'

Permission::exists()

public static function exists(?string $permissionName): bool

SecurityVoter class

class SecurityVoter extends Voter SecurityVoter.php:19

Votes on the five admin permissions by delegating each subject's own declared permission to Symfony Security. No context service needed: every check receives its subject explicitly.

SecurityVoter::__construct()

public function __construct(protected readonly AuthorizationCheckerInterface $authorizationChecker)

Base\Admin\Settings

BusinessSettingsSection class

final class BusinessSettingsSection implements SettingsSectionInterface BusinessSettingsSection.php:17

The business behind the site: its phone and its address. Read by glitchr/omnibase's LocalBusiness (the page's JSON-LD: Twig local_business_jsonld()), where what is typed here wins over the configuration (base.local_business); the phone is also the one omnibase's notifier signs its messages with (base.settings.phone).

BusinessSettingsSection::__construct()

public function __construct(readonly TranslatorInterface $translator)

BusinessSettingsSection::getPage()

public function getPage(): string

BusinessSettingsSection::getFields()

public function getFields(): array

SettingsSectionInterface interface

interface SettingsSectionInterface SettingsSectionInterface.php:29

A section of the back office's system pages (Base\Admin\Controller\ SystemController): the settings page (/admin/settings) or the API keys page (/admin/api-key). Each bundle - and the application - declares its own, as an autoconfigured service:

final class PadletKeySection implements SettingsSectionInterface { public function getPage(): string { return self::API_KEYS; } public function getFields(): array { return ['api.padlet.key' => ['required' => false, 'label' => 'Padlet — clé API']]; } }

Fields are LayoutSettingListType's: a setting path => its options ('label', 'form_type', 'translatable', 'required', ...), plus 'roles' - the role a field is shown to. On the API keys page a field is a revealable password field unless it says otherwise, and its value is stored secure. Sections come in their priority order (#[AsTaggedItem(priority: ...)]): omnibase/admin's own at 100, then the bundles', then the application's.

const SETTINGS = 'settings'
const API_KEYS = 'apikey'

SettingsSectionInterface::getPage()

public function getPage(): string

self::SETTINGS or self::API_KEYS.

SettingsSectionInterface::getFields()

public function getFields(): array
Returns

setting path => field options

SettingsSections class

final class SettingsSections SettingsSections.php:9

The sections of the system pages, gathered from every service tagged base.admin.settings_section (SettingsSectionInterface), in priority order.

SettingsSections::__construct()

public function __construct(readonly iterable $sections = [])

SettingsSections::fields()

public function fields(string $page): array

Every field of a page, in the sections' order; a path declared again further down (the application's section, at a lower priority) keeps its place and takes the later options - a label changed, a field made required.

SiteSettingsSection class

final class SiteSettingsSection implements SettingsSectionInterface SiteSettingsSection.php:21

omnibase's own settings, the same on every site: the logos, the title and slogan, the meta tags, the launch date, access restrictions, maintenance, account security, the mail sender. Explicit labels (LayoutSettingListType would otherwise derive "Meta - Author" from the path).

SiteSettingsSection::__construct()

public function __construct(readonly TranslatorInterface $translator)

SiteSettingsSection::getPage()

public function getPage(): string

SiteSettingsSection::getFields()

public function getFields(): array

SpamKeySection class

final class SpamKeySection implements SettingsSectionInterface SpamKeySection.php:13

omnibase's own API key: Akismet, which reads every comment, question and message a visitor leaves (Base\Service\SpamChecker); typed here it wins over the configuration.

SpamKeySection::__construct()

public function __construct(readonly TranslatorInterface $translator)

SpamKeySection::getPage()

public function getPage(): string

SpamKeySection::getFields()

public function getFields(): array

Base\Admin\Test

CrudFieldsTestCase class

abstract class CrudFieldsTestCase extends TestCase CrudFieldsTestCase.php:46

What a bundle's back office screens ask, against what its records need - extended by each bundle's own test, which names its source directory:

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

A field without setRequired(false) is required, and the browser does not send a form with a required field left empty: the links, the frames, the picture of a classroom card stopped every card. So, on the new and edit forms of every CRUD controller under that directory:

  • a field is marked required only when the record cannot do without it: a column that is not nullable and has no default (none in the mapping, the property or the constructor), a join column that is not nullable, a NotBlank, NotNull or Count(min: 1) constraint - or the field a required slug is made from, a thread's title (what names it), or when the bundle says why its screen asks more (requiredByTheScreen());
  • what the record cannot do without is marked required, else the field left empty reaches the database or a setter that refuses it;
  • a column with a default may be either: the form shows that default;
  • a field left optional takes an empty value: what an empty text, number or date sends is null, so its setter accepts null, or the field names what empty means (setEmptyData());
  • one field per property (two fields on one property are one form field, the last), and a slug follows the field it is made from.

A form the screen does not open (new or edit disabled) is not checked. Read without a kernel or a database: the controllers are built without their constructor (stubs for the services configureFields() reads), the entities' mapping from their attributes (their own, their parents', their traits', and their translation class's - Name + Intl). A browser-level required is what is checked: the hidden inputs behind an editor, a checkbox, a multiple choice and a collection never carry one.

CrudFieldsTestCase::testEveryScreenIsRead()

public function testEveryScreenIsRead(): void

CrudFieldsTestCase::testOnlyWhatTheRecordNeedsIsRequired()

public function testOnlyWhatTheRecordNeedsIsRequired(): void

CrudFieldsTestCase::testWhatTheRecordNeedsIsRequired()

public function testWhatTheRecordNeedsIsRequired(): void

CrudFieldsTestCase::testAnOptionalFieldTakesAnEmptyValue()

public function testAnOptionalFieldTakesAnEmptyValue(): void

CrudFieldsTestCase::testOneFieldPerProperty()

public function testOneFieldPerProperty(): void

CrudFieldsTestCase::testTheSlugFollowsAField()

public function testTheSlugFollowsAField(): void

Base\Admin\Twig

AdminTwigExtension class

class AdminTwigExtension extends AbstractExtension AdminTwigExtension.php:14

AdminTwigExtension::__construct()

public function __construct(protected readonly AdminUrlGenerator $adminUrlGenerator, protected readonly FieldValueResolver $fieldValueResolver, protected readonly ?\Base\Admin\Router\AdminRouteRegistry $routeRegistry = null, protected readonly ?\Base\Admin\Context\AdminContext $adminContext = null, protected readonly ?\Base\Admin\Security\CrudAccessChecker $crudAccessChecker = null, protected readonly ?\Symfony\Component\Routing\Generator\UrlGeneratorInterface $router = null, protected readonly ?\Closure $menuBuilder = null, protected readonly ?\Symfony\Bundle\SecurityBundle\Security $security = null)

AdminTwigExtension::adminContext()

public function adminContext(): ?\Base\Admin\Context\AdminContext

The back office's context with its menus, for @Admin/layout.html.twig: the sidebar (the dashboard's whole menu, MenuBuilder::buildDefault()) and the account menu, built here when the page's controller did not.

A screen of an application or a bundle that extends the layout from a plain controller - routed outside the CRUD loader, rendering without admin_context - had an empty sidebar; the CRUD and system pages, and the screens that seed the context themselves, are unchanged.

AdminTwigExtension::adminMenuCurrent()

public function adminMenuCurrent(): ?\Base\Admin\Config\Menu\MenuItem

The menu item matching the page being rendered, or null.

A function rather than a template variable because the templates that need it are included with only - and because three of them would otherwise each repeat the same two-level walk over mainMenu. Selection itself is decided once, by MenuBuilder::markSelected() (longest matching path prefix), so a detail or edit page resolves to the CRUD's own item rather than to nothing.

AdminTwigExtension::adminEntityIcon()

public function adminEntityIcon(object|string|null $entityFqcn): ?string

Accepts a class-string OR an entity instance. Templates iterating a collection hold objects, not class names, and the string-only signature turned that into a TypeError (a 500 on every index rendering an icon chip) rather than a missing icon. Sibling helpers here already take mixed $entity, so this now matches them.

AdminTwigExtension::getFunctions()

public function getFunctions(): array

AdminTwigExtension::adminFieldChoices()

public function adminFieldChoices(mixed $field): array

The choices a field brings itself - SelectField::setChoices(['Label' => 'value']), Symfony's shape, groups nested - turned round for a list or a detail cell: what the record stores => its label, which the template translates as the form does ('@agenda.role.soloist'). The cell printed the stored value with a capital ("Other") where the form's select said "Autre".

Choices made by a closure are the form's alone: an empty map.

AdminTwigExtension::adminEnumLabel()

public function adminEnumLabel(Environment $twig, mixed $value, ?string $enumClass = null): ?string

The words a list or a detail page prints for a PHP enum's case: the ones its select shows in the form (Base\Form\Common\NativeEnum::label(): what the enum says itself when it is translatable, else its key in the "enums" domain, else its name made readable). The cell printed the stored value with a capital - "Pending" beside a form saying "En attente".

$value is a case, or what a column stores of one when $enumClass names the enum. Null for anything else: the template keeps its own label (a field's choices, an omnibase EnumType through trans_enum).

AdminTwigExtension::adminActionOrder()

public function adminActionOrder(iterable $actions, array $stored = []): array

The final left-to-right order of a page's action buttons: the order declared in code, with the superadmin's stored order applied on top.

A PERMUTATION OF THE SLOTS the stored actions already occupy, not a "stored first, unstored last" sort. Stored children are how EVERY per-action customization is persisted (icon, visibility, order alike - see LayoutController::quickSave()), so a page where the superadmin has only ever re-iconed ONE button has exactly one child stored, and a naive sort read that as "this button is first, everything else is unranked" - re-iconing or hiding a single button visibly reshuffled the whole row (found live: hiding "Enregistrer et continuer" on Settings moved it to the front). Restricting the permutation to the slots those actions already hold means a partial record can only reorder the buttons it actually mentions, and an action added in code later keeps its declared position instead of being pushed to the end.

$actions

the page's actions, in code order

$stored

action name => stored position

Returns

action name => final position

AdminTwigExtension::adminEntityCrud()

public function adminEntityCrud(mixed $entity): ?string

The CRUD controller managing a related entity, so a field template can link it to its own admin page without the field naming a controller.

Null when nothing manages that class - the templates then render plain text, exactly as before.

AdminTwigExtension::adminCrudGranted()

public function adminCrudGranted(?string $controllerFqcn): bool

Whether the current user may open this CRUD (its entity permission, see CrudAccessChecker) - the field templates link a related record only then, and print it as plain text otherwise. admin_entity_crud() itself stays a pure lookup: which CRUD manages a class decides how the value is displayed, not only whether it links.

AdminTwigExtension::adminActionPost()

public function adminActionPost(Action $action, string $controllerFqcn): bool

Whether the action runs an #[AdminAction] route that takes no GET: the templates render it as a form posting its CSRF token (@Admin/crud/_action_form.html.twig) rather than as a link the route would refuse. False for every built-in action and for any action pointing at a URL or a route of its own.

AdminTwigExtension::adminActionUrl()

public function adminActionUrl(Action $action, object $entity, string $controllerFqcn): ?string

An entity-row action's href: a per-entity callable url (set via Action::linkToUrl(fn($entity) => ...), e.g. the "view on the live site" action) takes priority, then a pre-set static linkUrl, and only then the normal admin_url(controller, crudActionName) route - this is the one place all three of those possibilities are resolved together, since the index/detail templates only ever render one href per row.

AdminTwigExtension::adminUrl()

public function adminUrl(string $controllerFqcn, string $action = 'index', mixed $entityId = null, array $parameters = []): string

DashboardWidgetTwigExtension class

class DashboardWidgetTwigExtension extends AbstractExtension DashboardWidgetTwigExtension.php:10

DashboardWidgetTwigExtension::__construct()

public function __construct(protected readonly DashboardWidgetTypeRegistry $registry)

DashboardWidgetTwigExtension::getFunctions()

public function getFunctions(): array

DashboardWidgetTwigExtension::getWidgetType()

public function getWidgetType(string $name): ?DashboardWidgetTypeInterface

DevVersionTwigExtension class

final class DevVersionTwigExtension extends AbstractExtension DevVersionTwigExtension.php:19

Dev-only suffix for transparentJS's cache_version (see layout.html.twig's Transparent.ready call): deploy_version() folds in the app's and each vendor/glitchr repo's git HEAD, which is exactly right for deploys - but during live development the vendor edits are UNCOMMITTED, HEAD never moves, and every already-visited admin page kept replaying its SPA-cached pre-fix HTML/CSS indefinitely (reported live, repeatedly, as fixes "still broken" mid-iteration). In debug mode this appends the max mtime of this bundle's own hot sources, so ANY saved edit purges the client cache on the next navigation; in prod it contributes nothing at all.

DevVersionTwigExtension::__construct()

public function __construct(readonly bool $debug)

DevVersionTwigExtension::getFunctions()

public function getFunctions(): array

DevVersionTwigExtension::getVersion()

public function getVersion(): string

EntityNameTwigExtension class

class EntityNameTwigExtension extends AbstractExtension EntityNameTwigExtension.php:20

The name a CRUD page gives its entity when configureCrud() set no label: admin_entity_name(fqcn) and admin_entity_name(fqcn, true) for the plural.

The entity's own translation first - the "entities" domain every omnibase bundle and application already fills ("user._plural", "marketplace.order. _singular"...), the one the fields and the flash messages read - and, for an entity nobody named, its class name in plain words ("Wardrobe item"), never the bare class ("WardrobeItem").

EntityNameTwigExtension::__construct()

public function __construct(protected readonly ?TranslatorInterface $translator = null)

EntityNameTwigExtension::getFunctions()

public function getFunctions(): array

EntityNameTwigExtension::entityName()

public function entityName(?string $entityFqcn, bool $plural = false): string

EntityNameTwigExtension::humanize()

public static function humanize(string $text): string

Symfony's own |humanize (FormRenderer::humanize()), so the name reads like every other humanized label of the back office.

SiteIconTwigExtension class

class SiteIconTwigExtension extends AbstractExtension SiteIconTwigExtension.php:21

The site's own icon in the back office's tab: admin_site_icons(), printed by the layout and the sign-in page (@Admin/_site_icons.html.twig).

The back office's pages linked no icon, so the browser asked for /favicon.ico, which most sites do not serve (their icons live under public/assets/ico/, linked by their own base template): a 404 on every screen of /admin and a blank tab. The icons are read where the sites keep them - public/assets/ico/ (favicon.svg, favicon-32x32.png or favicon-32.png, the 16 px one, apple-touch-icon.png, favicon.ico), then public/favicon.ico. A site with none gets an empty data: icon, so that the browser asks for nothing.

const DIRECTORY = 'assets/ico'

SiteIconTwigExtension::__construct()

public function __construct(readonly string $publicDir)

SiteIconTwigExtension::getFunctions()

public function getFunctions(): array

SiteIconTwigExtension::siteIcons()

public function siteIcons(): array

The links to print, in order: their href is a path under public/, for asset(), or a data: URI when the site serves no icon.

Base\Admin\Widget

AnalyticsCardWidgetType class

final class AnalyticsCardWidgetType implements PaletteDashboardWidgetTypeInterface AnalyticsCardWidgetType.php:16

The built-in "Trafic" dashboard card - also the reference implementation of DashboardWidgetTypeInterface (see AbstractDashboardController:: configureDashboardBlockItems(), which registers the default instance).

A second instance with different $params (e.g. ['days' => 30]) shows a genuinely different initial window with zero Twig changes required.

AnalyticsCardWidgetType::__construct()

public function __construct(readonly Analytics $analytics, readonly TimelineEventRegistry $timelineEvents, readonly TranslatorInterface $translator)

AnalyticsCardWidgetType::getName()

public static function getName(): string

AnalyticsCardWidgetType::getTemplate()

public function getTemplate(): string

AnalyticsCardWidgetType::getDefaultLabel()

public function getDefaultLabel(): string

AnalyticsCardWidgetType::getDefaultIcon()

public function getDefaultIcon(): ?string

AnalyticsCardWidgetType::getTemplateVars()

public function getTemplateVars(MenuItem $widget): array

AnalyticsSeriesPalette class

final class AnalyticsSeriesPalette AnalyticsSeriesPalette.php:27

The single source of truth for the analytics widget's per-series identity (translation key + color) - shared by AnalyticsController::breakdown() (the range picker's JSON endpoint) and AnalyticsCardWidgetType (the initial server-rendered paint), so the two can never drift apart the way two hand-copied color maps eventually do.

Order and colors are both load-bearing, not decoration: this is slots 1-6 of the design system's validated 8-hue categorical ramp, kept in THIS sequence - node scripts/validate_palette.js (dataviz skill) confirms it passes the adjacent-pair CVD/contrast checks in both light and dark for this exact order, not for any color individually.

Slot 6 (green, uniqueViews) was appended 2026-09-14 and the six-slot ramp re-validated: worst adjacent CVD dE 9.1 light / 8.4 dark, normal- vision dE 19.6 / 19.3, all dark steps >= 3:1. The light-mode contrast WARN (slots 3-5 below 3:1) predates it; the card's legend and stat labels are the relief it requires. Reordering the keys, swapping a color, or inserting a new series between two existing ones invalidates that guarantee and needs re-validating, not just re-eyeballing.

const SERIES = ['pageViewsHuman' => ['label' => 'analytics.label.page_views_human', 'color' => '#2a78d6', 'colorDark' => '#3987e5'], 'pageViewsBot' => ['label' => 'analytic…
const SOURCE_KEYS = ['pageViewsHuman', 'pageViewsBot', 'pageViewsAi']

The subset of SERIES that are traffic SOURCES - the three that partition every page view between them (a view is human, bot or AI, never two of those). uniqueVisitors/uniqueUsers are deliberately not here: they count people rather than views, so they are their own stats and are never summed into a page-view total.

This is what makes "page views" answerable per-source at all, and it lives here rather than being re-listed per widget because three separate copies had already started drifting - EntityViewsWidgetType kept its own, the legend handler in layout.html.twig kept a third as a lookup object.

const DEFAULT_VISIBLE = ['pageViewsHuman', 'uniqueVisitors', 'uniqueUsers', 'uniqueViews']

What a widget shows before anyone has touched its legend: everything EXCEPT bot and AI traffic.

"Vues" used to mean Analytics' combined pageViews column, which is human + bot + AI, so every view count on the dashboard silently counted crawlers. That is not a rounding error - on beta today bots are the MAJORITY of traffic (1632 bot against 1352 human), and on production 443 bot against 947 human - so the headline number was reporting roughly double the real audience on one host and nearly half again on the other.

Human-only is therefore the default rather than a filter to opt into. Bot and AI are not removed, just not counted until asked for: both still have their own legend entry, one click re-shows the line, and the total then re-adds it (see recomputeStatsFromChart in layout.html.twig) - which is the property that makes this safe, since the number is always exactly the sum of the lines you can see.

ComplaintsWidgetType class

final class ComplaintsWidgetType implements DashboardWidgetTypeInterface ComplaintsWidgetType.php:17

The dashboard's tile for reports: how many wait, the last few the signed-in staff member may read (ComplaintVoter), each opening its page.

yield MenuItem::block('admin_complaints', 'Signalements');

ComplaintsWidgetType::__construct()

public function __construct(readonly ComplaintRepository $complaints, readonly AuthorizationCheckerInterface $authorization)

ComplaintsWidgetType::getName()

public static function getName(): string

ComplaintsWidgetType::getTemplate()

public function getTemplate(): string

ComplaintsWidgetType::getTemplateVars()

public function getTemplateVars(MenuItem $widget): array

CompositeWidgetType class

final class CompositeWidgetType implements DashboardWidgetTypeInterface CompositeWidgetType.php:30

"Dual-width card" fusion: one shared .card holding two (or more) other registered widget types side by side, e.g.:

MenuItem::block('composite', 'Overview', null, null, ['panes' => [ ['type' => 'analytics_card', 'label' => 'This week', 'params' => ['days' => 7]], ['type' => 'analytics_card', 'label' => 'This month', 'params' => ['days' => 30]], ]]);

Deliberately implements DashboardWidgetTypeInterface ONLY (not PaletteDashboardWidgetTypeInterface) - that's what excludes it from the "+ Add widget" palette, no separate opt-out flag needed. The composite ITSELF is still fully hide/resize/reorder-able as one unit through the existing customize UI either way.

Two ways a pane gets into params.panes[]: a 'type' key (a code-defined block widget, e.g. analytics_card, wired by an app developer via configureWidgetItems() as shown above) or a 'subItems' key (a plain link-list card, produced end-to-end by the superadmin dragging one dashboard card onto another - see DashboardWidgetController::merge()). Both shapes render through the same @Admin/widget/composite.html.twig.

CompositeWidgetType::getName()

public static function getName(): string

CompositeWidgetType::getTemplate()

public function getTemplate(): string

CompositeWidgetType::getTemplateVars()

public function getTemplateVars(MenuItem $widget): array

CounterWidgetType class

final class CounterWidgetType implements PaletteDashboardWidgetTypeInterface CounterWidgetType.php:40

The "Counter" palette entry: a single number + period-over-period trend for one picked entity (or "all instances of a class", summed) - no chart, unlike EntityViewsWidgetType ("Vues"), which this otherwise mirrors closely (same LinkableEntityRegistry-driven class/instance picker, same params shape). A genuinely separate widget type rather than "Vues with the canvas hidden": the whole point is a compact, at-a- glance KPI tile, and reusing the exact same getTemplateVars() shape would compute/hand over a full daily series this template would never use.

Counts one of two different things, chosen per instance:

MODE_VIEWS page views of the picked entity, over $days - the original behaviour. MODE_RECORDS how many records exist, optionally narrowed to a parent thread and to a set of states ("articles in Europe that are still drafts"). Nothing to do with traffic; it reads the table, not the analytics rollups.

The two modes deliberately use SEPARATE parameters for their pickers (entityClass/entityId against countClass/scopeId) rather than reinterpreting one pair. Switching modes back and forth then cannot silently repoint the other mode's selection at something that means something else there, and a widget configured both ways keeps both answers.

const ALL_INSTANCES = EntityViewsWidgetType::ALL_INSTANCES
const MODE_VIEWS = 'views'
const MODE_RECORDS = 'records'

CounterWidgetType::__construct()

public function __construct(readonly Analytics $analytics, readonly LinkableEntityRegistry $entities, readonly TranslatorInterface $translator, readonly EntityManagerInterface $entityManager, readonly AdminRouteRegistry $routes, readonly UrlGeneratorInterface $urlGenerator, readonly ?\Base\Admin\Security\CrudAccessChecker $crudAccessChecker = null)

CounterWidgetType::getName()

public static function getName(): string

CounterWidgetType::getTemplate()

public function getTemplate(): string

CounterWidgetType::getDefaultLabel()

public function getDefaultLabel(): string

CounterWidgetType::getDefaultIcon()

public function getDefaultIcon(): ?string

CounterWidgetType::getTemplateVars()

public function getTemplateVars(MenuItem $widget): array

DashboardWidgetTypeInterface interface

interface DashboardWidgetTypeInterface DashboardWidgetTypeInterface.php:13

Self-registering dashboard widget type (mirrors CrudControllerInterface's own auto-tagging pattern - see AdminBundle::build()). Any base-bundle, base-bundle-admin, or app service implementing this becomes usable via MenuItem::block(self::getName(), ...) without touching dashboard.html.twig.

DashboardWidgetTypeInterface::getName()

public static function getName(): string

Matches the blockName passed to MenuItem::block().

DashboardWidgetTypeInterface::getTemplate()

public function getTemplate(): string

DashboardWidgetTypeInterface::getTemplateVars()

public function getTemplateVars(MenuItem $widget): array
Returns

vars for the template - merged with 'widget' (the MenuItem instance itself) by the caller

DashboardWidgetTypeRegistry class

class DashboardWidgetTypeRegistry DashboardWidgetTypeRegistry.php:11

Indexes every tagged DashboardWidgetTypeInterface by name - the single lookup point dashboard.html.twig's widget loop uses to dispatch a block-type MenuItem to its renderer, replacing what used to be one hardcoded {% if widget.blockName == 'analytics_card' %} in the template.

DashboardWidgetTypeRegistry::__construct()

public function __construct(iterable $types)

DashboardWidgetTypeRegistry::get()

public function get(string $name): ?DashboardWidgetTypeInterface

EntityViewsWidgetType class

final class EntityViewsWidgetType implements PaletteDashboardWidgetTypeInterface EntityViewsWidgetType.php:24

Generalizes what used to be App\Admin\Widget\ArticleViewsWidgetType (Article-only) into a widget that can show traffic for ANY LinkableInterface entity this app has - discovered via LinkableEntityRegistry, not hardcoded to Article or any other single class. Lives in base-bundle-admin (not app-level) for exactly that reason: nothing in here references Article, Destination, User, or any other app-specific class by name - the package stays entity-agnostic, same standing rule as every other built-in widget type here.

Same underlying Base\Service\Analytics page-view rollup as the site-wide Trafic widget, just scoped to one entity's own __toLink() path (or, with the "all instances of this class" option, every instance's own path summed together).

const ALL_INSTANCES = 'all'

params.entityId's "every instance of entityClass, summed" sentinel - a string (never a real id, which is always an int) so is_int() below still cleanly tells this apart from "nothing picked yet" (null) with no separate flag to keep in sync.

EntityViewsWidgetType::__construct()

public function __construct(readonly Analytics $analytics, readonly LinkableEntityRegistry $entities, readonly TranslatorInterface $translator, readonly TimelineEventRegistry $timelineEvents)

EntityViewsWidgetType::getName()

public static function getName(): string

EntityViewsWidgetType::getTemplate()

public function getTemplate(): string

EntityViewsWidgetType::getDefaultLabel()

public function getDefaultLabel(): string

EntityViewsWidgetType::getDefaultIcon()

public function getDefaultIcon(): ?string

EntityViewsWidgetType::getTemplateVars()

public function getTemplateVars(MenuItem $widget): array

LinkableEntityRegistry class

final class LinkableEntityRegistry LinkableEntityRegistry.php:27

What EntityViewsWidgetType's own settings picker (and any future "pick a trackable page" feature) chooses from: every Doctrine-mapped entity class this app happens to have that implements Base\Service\Model\LinkableInterface (a __toLink() a real request path can be tracked under, plus __toString() for a human-readable option label) - discovered generically off Doctrine's own metadata, not a hardcoded list. In this app that resolves to every Base\Entity\Thread subtype (Article, Destination, Gallery, Comment, ...) PLUS User side by side - the two class FAMILIES this was generalized to comply with (was Article-only) - but nothing here actually references either Thread or User by name: a third, unrelated LinkableInterface entity added later needs zero changes here to become pickable too.

Thread itself is never offered (it has no LinkableInterface of its own - only its concrete subtypes each implement __toLink()/__toString() individually), so this naturally lists only the concrete, meaningful types an admin would actually want traffic for.

LinkableEntityRegistry::__construct()

public function __construct(readonly EntityManagerInterface $em)

LinkableEntityRegistry::getPickableClasses()

public function getPickableClasses(): array
Returns

entity FQCN => humanized short class name (e.g. "Article", "Comment reply"), sorted alphabetically by that label - this package stays entity-agnostic (no per-class translation catalog to keep in sync), matching the same auto-humanized-label convention every field/column in this admin already falls back to.

LinkableEntityRegistry::findInstances()

public function findInstances(string $class, int $limit = 200): array

Up to $limit instances of $class, newest id first - the option list for the picker's second (instance) select. Silently empty for a class that isn't actually pickable (untrusted input boundary, same defensive posture as DashboardWidgetController's own sanitizers) rather than throwing.

LinkableEntityRegistry::allPaths()

public function allPaths(string $class): array

Every $class instance's own __toLink(), for the "all $class, summed" aggregate mode - Thread subtypes are narrowed to PUBLISH state (a draft/deleted page has no real traffic worth summing in, mirrors ArticleViewsWidgetType::ALL_ARTICLES's prior behavior exactly); a non-Thread class (User) has no such state to filter on and lists every instance.

LinkableEntityRegistry::find()

public function find(string $class, int $id): ?LinkableInterface

PaletteDashboardWidgetTypeInterface interface

interface PaletteDashboardWidgetTypeInterface extends DashboardWidgetTypeInterface PaletteDashboardWidgetTypeInterface.php:12

Marks a DashboardWidgetTypeInterface as eligible for the "+ Add widget" palette. Purely additive - extending (not modifying) the base interface means no existing implementer breaks, and it's also what EXCLUDES a type from the palette with no separate opt-out flag: CompositeWidgetType implements only DashboardWidgetTypeInterface, never this one.

PaletteDashboardWidgetTypeInterface::getDefaultLabel()

public function getDefaultLabel(): string

Translation key or literal label shown in the palette, and used as the default label for a freshly added instance.

PaletteDashboardWidgetTypeInterface::getDefaultIcon()

public function getDefaultIcon(): ?string

PaletteWidgetTypeRegistry class

class PaletteWidgetTypeRegistry PaletteWidgetTypeRegistry.php:11

The palette-eligible subset of registered widget types - same shape as DashboardWidgetTypeRegistry but indexes only PaletteDashboardWidgetTypeInterface implementers, since not every widget type should be addable through the "+ Add widget" UI (e.g. CompositeWidgetType).

PaletteWidgetTypeRegistry::__construct()

public function __construct(iterable $types)

PaletteWidgetTypeRegistry::get()

public function get(string $name): ?PaletteDashboardWidgetTypeInterface

PaletteWidgetTypeRegistry::all()

public function all(): array

TimelineEventRegistry class

class TimelineEventRegistry TimelineEventRegistry.php:13

Collects every tagged TimelineEventProviderInterface's events and formats them for the analytics chart - the single place both AnalyticsController (the range-picker's live fetch) and AnalyticsCardWidgetType (initial paint) go for this, so both always return the identical shape.

TimelineEventRegistry::__construct()

public function __construct(readonly iterable $providers)

TimelineEventRegistry::getFormattedEvents()

public function getFormattedEvents(array $series, array $labels, string $dateFormat): array

$series/$labels/$dateFormat are the SAME values already computed for the chart itself, so bounds and formatting stay in lockstep with what's on screen. Chart.js's CategoryScale can only anchor an annotation to a value that IS one of the chart's own x-axis category ticks - an event whose formatted date matches none of $labels is dropped here rather than rendered as a broken/invisible annotation.

WelcomeWidgetType class

final class WelcomeWidgetType implements PaletteDashboardWidgetTypeInterface WelcomeWidgetType.php:27

The "Generic" widget in the "+ Add widget" palette - a free-form text block, editable in place (see welcome.html.twig's own data-widget-text, and layout.html.twig's input handler for it). getName() stays 'welcome' (the class/file keep their original name too) even though this is no longer just the dashboard's fixed greeting card - every already-stored widget (code-defined default instance AND any ad-hoc one a superadmin added) references that exact string as its blockName; changing it would silently orphan every existing one (see _block.html.twig's own "unknown blockName renders an empty shell" comment for what that'd look like).

Only the user-facing label changed (getDefaultLabel()), not the internal identifier.

params.text holds the custom text once edited; falls back to the original "Bienvenue dans votre administration..." greeting (translated) for the one default instance that's never been touched, so the existing live dashboard doesn't visibly change until a superadmin actually edits it or adds a new instance.

WelcomeWidgetType::__construct()

public function __construct(readonly TranslatorInterface $translator)

WelcomeWidgetType::getName()

public static function getName(): string

WelcomeWidgetType::getTemplate()

public function getTemplate(): string

WelcomeWidgetType::getDefaultLabel()

public function getDefaultLabel(): string

WelcomeWidgetType::getDefaultIcon()

public function getDefaultIcon(): ?string

WelcomeWidgetType::getTemplateVars()

public function getTemplateVars(MenuItem $widget): array