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é.
AdminBundleclass3 methods
AdminActionclassRegisters 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
OpenToAdminsclassA 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
ActionclassOne 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
ActionsclassPer-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
CrudclassPer-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
MenuItemclassStatic factory facade: same call sites as before (MenuItem::linkToRoute(...), MenuItem::section(...), ...), now producing a single MenuItem model instead of eight wrapper classes.
9 methods
MenuItemclassOne 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
AdminContextclassRequest-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
AbstractCrudControllerclassDirect, linear CRUD pipeline: every page is an ordinary controller action calling overridable hooks - no event dispatch, no listener-side rendering.
26 methods · 1 property
AbstractDashboardControllerclassThe admin's mount point: renders the dashboard and owns the menu configuration (before/main/after sections and the user menu).
10 methods · 1 constant
AnalyticsControllerclassBacks the dashboard analytics card's range picker (see dashboard.html.
2 methods
CrudControllerInterfaceinterfaceMarker interface: implementing it (via AbstractCrudController) tags the service base.admin.crud_controller, which registers its routes and menus.
1 method
DashboardControllerclassZero-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.
DashboardWidgetControllerclassBacks 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
HoursControllerclass"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
LayoutControllerclassThe 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
RevisionControllerclassServes the raw previous value behind one entry of a field's history badge.
2 methods
SystemControllerclassThe 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
TrashControllerclassThe trash: what soft deletion put aside, and the two things you can do with it - put it back, or destroy it.
5 methods
ComplaintCrudControllerclassReports ("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
RedirectionCrudControllerclass"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
TextOverrideCrudControllerclass"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
UserCrudControllerclass4 methods
Base\Admin\Controller\Crud\Layout
ImageCrudControllerclass3 methods
SettingCrudControllerclass5 methods
Base\Admin\Controller\Crud\Layout\Attribute
AdapterCrudControllerclassEvery 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
PageCrudControllerclassLayout pages served at /page/{slug} (WidgetController::Page).
4 methods
Base\Admin\Controller\Crud\Thread
TagCrudControllerclass4 methods
TaxonCrudControllerclassThe 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
ConnectionCrudControllerclassRead-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.
5 methods
GroupCrudControllerclass4 methods
PermissionCrudControllerclass3 methods
Base\Admin\DependencyInjection
AdminConfigurationclass1 method
AdminExtensionclass2 methods
Base\Admin\DependencyInjection\Compiler
AdminRoutePassclassCollects 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
ActiveAdminsSubscriberclassExposes "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
AdminActionSubscriberclassRuns 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
NestHeaderSubscriberclassDeclares 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
FilterclassOne 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
FiltersclassFilter registry for one CRUD, filled in configureFilters().
6 methods
FieldFormBuilderclassBuilds a plain Symfony form from a collection of field descriptors.
5 methods
LayoutArrangerclassPure 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
LayoutConfigclassOne persisted layout: an ordered, hide/show-annotated tree of item keys.
9 methods
LayoutScopeclassThe customizable regions of the admin - each gets its own persisted layout config, stored under its own SettingBag path (see LayoutStore).
2 methods · 3 constants
LayoutStoreclassPersists 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
PageCustomizationclassThe 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
MenuBuilderclassResolves 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
AdminNotificationLinkerclassA 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
PaginatorclassThin pagination wrapper around Doctrine's Paginator with the counters the index template needs.
11 methods
AdminRouteLoaderclassGenerates the conventional CRUD routes for every registered controller:
3 methods · 2 constants
AdminRouteRegistryclassMaps CRUD controller FQCNs to their generated route names and URL slugs.
9 methods · 1 constant
AdminUrlGeneratorclassFluent 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
CrudAccessCheckerclassWhether 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
PermissionclassAdmin 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
SecurityVoterclassVotes 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
BusinessSettingsSectionclassThe 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
SettingsSectionInterfaceinterfaceA 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
SettingsSectionsclassThe sections of the system pages, gathered from every service tagged base.admin.settings_section (SettingsSectionInterface), in priority order.
2 methods
SiteSettingsSectionclassomnibase'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
SpamKeySectionclassomnibase'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
CrudFieldsTestCaseclassWhat 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
AdminTwigExtensionclass13 methods
DashboardWidgetTwigExtensionclass3 methods
DevVersionTwigExtensionclassDev-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
EntityNameTwigExtensionclassThe 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
SiteIconTwigExtensionclassThe 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
AnalyticsCardWidgetTypeclassThe built-in "Trafic" dashboard card - also the reference implementation of DashboardWidgetTypeInterface (see AbstractDashboardController:: configureDashboardBlockItems(), which registers the default instance).
6 methods
AnalyticsSeriesPaletteclassThe 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
ComplaintsWidgetTypeclassThe 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
CompositeWidgetTypeclass"Dual-width card" fusion: one shared .card holding two (or more) other registered widget types side by side, e.g.:
3 methods
CounterWidgetTypeclassThe "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
DashboardWidgetTypeInterfaceinterfaceSelf-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
DashboardWidgetTypeRegistryclassIndexes 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
EntityViewsWidgetTypeclassGeneralizes 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
LinkableEntityRegistryclassWhat 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
PaletteDashboardWidgetTypeInterfaceinterfaceMarks 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
PaletteWidgetTypeRegistryclassThe 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
TimelineEventRegistryclassCollects 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
WelcomeWidgetTypeclassThe "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(): stringModern 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): voidBase\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 $pathrelative to the CRUD's own URL, "/refresh" or "/{entityId}/publish"; null derives "/<kebab-cased method>"
public readonly array $methodsHTTP methods the route accepts
public readonly bool $csrfcheck the token before the method runs (writing methods only); false leaves it to the method
public readonly array $requirementsextra 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 = [])$pathrelative to the CRUD's own URL, "/refresh" or "/{entityId}/publish"; null derives "/<kebab-cased method>"
$methodsHTTP methods the route accepts
$csrfcheck the token before the method runs (writing methods only); false leaves it to the method
$requirementsextra route requirements ({entityId} already has one)
AdminAction::of()
public static function of(string $controllerFqcn, string $methodName): ?selfThe attribute on $controllerFqcn::$methodName, null for any other method (the built-in actions included).
AdminAction::tokenId()
public static function tokenId(string $methodName): stringAdminAction::getPath()
public function getPath(string $methodName): stringAdminAction::isEntityAction()
public function isEntityAction(): boolAdminAction::acceptsGet()
public function acceptsGet(): boolWhether 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_ADMINThe 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 $rolethe role that writes (held directly or through security.role_hierarchy)
public readonly array $actionsthe 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 = [])$rolethe role that writes (held directly or through security.role_hierarchy)
$actionsthe CRUD's own actions (#[AdminAction] method names) opened to that role as well
OpenToAdmins::of()
public static function of(string $controllerFqcn): ?selfThe 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(): stringAction::new()
public static function new(string $name, TranslatableInterface|string|callable|bool|null $label = null, ?string $icon = null): staticAction::createAsGlobalAction()
public function createAsGlobalAction(): staticAction::createAsBatchAction()
public function createAsBatchAction(): staticAction::getType()
public function getType(): stringAction::isEntityAction()
public function isEntityAction(): boolAction::isGlobalAction()
public function isGlobalAction(): boolAction::isBatchAction()
public function isBatchAction(): boolAction::getName()
public function getName(): stringAction::getLabel()
public function getLabel(): mixedAction::setLabel()
public function setLabel(TranslatableInterface|string|callable|bool|null $label): staticAction::getIcon()
public function getIcon(): ?stringAction::setIcon()
public function setIcon(?string $icon): staticAction::getCssClass()
public function getCssClass(): stringAction::setCssClass()
public function setCssClass(string $cssClass): staticAction::addCssClass()
public function addCssClass(string $cssClass): staticAction::getHtmlElement()
public function getHtmlElement(): stringAction::setHtmlElement()
public function setHtmlElement(string $htmlElement): staticAction::getHtmlAttributes()
public function getHtmlAttributes(): arrayAction::setHtmlAttributes()
public function setHtmlAttributes(array $attributes): staticAction::addHtmlAttributes()
public function addHtmlAttributes(array $attributes): staticAction::renderAsLink()
public function renderAsLink(bool $renderAsLink = true): staticAction::renderAsButton()
public function renderAsButton(string $buttonType = 'submit'): staticAction::renderAsTooltip()
public function renderAsTooltip(): staticAction::targetBlank()
public function targetBlank(): staticAction::displayAsSeparator()
public function displayAsSeparator(): staticAction::getTemplatePath()
public function getTemplatePath(): ?stringAction::setTemplatePath()
public function setTemplatePath(string $templatePath): staticAction::getCrudActionName()
public function getCrudActionName(): ?stringAction::linkToCrudAction()
public function linkToCrudAction(string $crudActionName): staticAction::getRouteName()
public function getRouteName(): ?stringAction::getRouteParameters()
public function getRouteParameters(): mixedAction::linkToRoute()
public function linkToRoute(string $routeName, array|callable $routeParameters = []): staticAction::getUrl()
public function getUrl(): mixed$urla URL, or a callable receiving the entity instance (or null) and returning the URL
Action::linkToUrl()
public function linkToUrl(string|callable $url): staticAction::getLinkUrl()
public function getLinkUrl(): ?stringAction::setLinkUrl()
public function setLinkUrl(?string $linkUrl): staticRuntime-resolved URL (set by the controller/renderer once the target entity is known).
Action::getTranslationParameters()
public function getTranslationParameters(): arrayAction::setTranslationParameters()
public function setTranslationParameters(array $parameters): staticAction::getDisplayCallable()
public function getDisplayCallable(): ?callableAction::displayIf()
public function displayIf(callable $callable): staticAction::isDisplayed()
public function isDisplayed(mixed $entity = null): boolAction::getPermission()
public function getPermission(): ?stringAction::setPermission()
public function setPermission(string $permission): staticAction::getConfirmation()
public function getConfirmation(): TranslatableInterface|string|nullAction::askConfirmation()
public function askConfirmation(TranslatableInterface|string|null $question): staticA 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(): staticKept 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(): staticActions::add()
public function add(string $pageName, Action|string $actionNameOrObject, ?string $actionIcon = null, ?callable $callable = null): staticActions::update()
public function update(string $pageName, string $actionName, callable $callable): staticActions::remove()
public function remove(string $pageName, string $actionName): staticActions::disable()
public function disable(string ...$actionNames): staticActions::isDisabled()
public function isDisabled(string $pageName, string $actionName): boolActions::setPermission()
public function setPermission(string $actionName, ?string $permission): staticActions::setPermissions()
public function setPermissions(array $permissions): staticActions::getPermission()
public function getPermission(string $actionName): ?stringActions::getEffectivePermission()
public function getEffectivePermission(string $actionName, ?Action $action = null): ?stringExplicit 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): staticOpens 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): staticDocumented 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): arrayActions::getEntityActions()
public function getEntityActions(string $pageName): arrayActions::getGlobalActions()
public function getGlobalActions(string $pageName): arrayActions::getBatchActions()
public function getBatchActions(string $pageName): arrayActions::addDefaults()
public function addDefaults(): staticRegisters the conventional set of actions for every page.
Actions::addSystemDefaults()
public function addSystemDefaults(): staticThe 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(): staticCrud::getEntityPermission()
public function getEntityPermission(): ?stringCrud::setEntityPermission()
public function setEntityPermission(?string $entityPermission): staticCrud::getEntityFqcn()
public function getEntityFqcn(): ?stringCrud::setEntityFqcn()
public function setEntityFqcn(?string $entityFqcn): staticCrud::getEntityLabelInSingular()
public function getEntityLabelInSingular(): TranslatableInterface|string|nullCrud::setEntityLabelInSingular()
public function setEntityLabelInSingular(TranslatableInterface|string|null $label): staticCrud::getEntityLabelInPlural()
public function getEntityLabelInPlural(): TranslatableInterface|string|nullCrud::setEntityLabelInPlural()
public function setEntityLabelInPlural(TranslatableInterface|string|null $label): staticCrud::getPageTitle()
public function getPageTitle(string $pageName): TranslatableInterface|string|nullCrud::setPageTitle()
public function setPageTitle(string $pageName, TranslatableInterface|string $title): staticCrud::getHelp()
public function getHelp(string $pageName): TranslatableInterface|string|nullCrud::setHelp()
public function setHelp(string $pageName, TranslatableInterface|string $helpMessage): staticCrud::getDefaultSort()
public function getDefaultSort(): arrayCrud::setDefaultSort()
public function setDefaultSort(array $sortFieldsAndOrder): staticCrud::getPaginatorPageSize()
public function getPaginatorPageSize(): intCrud::setPaginatorPageSize()
public function setPaginatorPageSize(int $maxResultsPerPage): staticCrud::getSearchFields()
public function getSearchFields(): arrayCrud::setSearchFields()
public function setSearchFields(array $fieldNames): staticCrud::showEntityActionsInlined()
public function showEntityActionsInlined(bool $inlined = true): staticCrud::showEntityActionsAsDropdown()
public function showEntityActionsAsDropdown(bool $asDropdown = true): staticCrud::areEntityActionsInlined()
public function areEntityActionsInlined(): boolCrud::getNewFormOptions()
public function getNewFormOptions(): arrayCrud::getEditFormOptions()
public function getEditFormOptions(): arrayCrud::setFormOptions()
public function setFormOptions(array $newFormOptions, ?array $editFormOptions = null): staticCrud::getCurrentAction()
public function getCurrentAction(): ?stringCrud::setCurrentAction()
public function setCurrentAction(?string $currentAction): staticCrud::getCurrentPage()
public function getCurrentPage(): ?stringCrud::setCurrentPage()
public function setCurrentPage(?string $currentPage): staticCrud::getAsDto()
public function getAsDto(): staticKept 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): ItemMenuItem::linkToDashboard()
public static function linkToDashboard(TranslatableInterface|string $label, ?string $icon = null): ItemMenuItem::linkToExitImpersonation()
public static function linkToExitImpersonation(TranslatableInterface|string $label, ?string $icon = null): ItemMenuItem::linkToLogout()
public static function linkToLogout(TranslatableInterface|string $label, ?string $icon = null): ItemMenuItem::linkToRoute()
public static function linkToRoute(string $routeName, array $routeParameters = [], TranslatableInterface|string|null $label = null, ?string $icon = null): ItemMenuItem::linkToUrl()
public static function linkToUrl(TranslatableInterface|string $label, ?string $icon, string $url): ItemMenuItem::section()
public static function section(TranslatableInterface|string|null $label = null, ?string $icon = null): ItemMenuItem::subMenu()
public static function subMenu(TranslatableInterface|string $label, ?string $icon = null, ?string $url = null): ItemMenuItem::block()
public static function block(string $blockName, TranslatableInterface|string|null $label = null, ?string $icon = null, ?string $instanceKey = null, array $params = []): ItemReferences 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.
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(): stringMenuItem::isSection()
public function isSection(): boolMenuItem::isSubMenu()
public function isSubMenu(): boolMenuItem::getLabel()
public function getLabel(): TranslatableInterface|string|nullMenuItem::setLabel()
public function setLabel(TranslatableInterface|string|null $label): staticMenuItem::isLabelCustomized()
public function isLabelCustomized(): boolMenuItem::setLabelCustomized()
public function setLabelCustomized(bool $labelCustomized): staticMenuItem::getDescription()
public function getDescription(): ?stringMenuItem::setDescription()
public function setDescription(?string $description): staticMenuItem::getIcon()
public function getIcon(): ?stringMenuItem::setIcon()
public function setIcon(?string $icon): staticMenuItem::getCssClass()
public function getCssClass(): stringMenuItem::setCssClass()
public function setCssClass(string $cssClass): staticMenuItem::getPermission()
public function getPermission(): ?stringMenuItem::setPermission()
public function setPermission(?string $permission): staticMenuItem::getBadge()
public function getBadge(): ?stringMenuItem::setBadge()
public function setBadge(mixed $badge): staticMenuItem::getLinkTarget()
public function getLinkTarget(): ?stringMenuItem::setLinkTarget()
public function setLinkTarget(string $linkTarget): staticMenuItem::getEntityFqcn()
public function getEntityFqcn(): ?stringMenuItem::setEntityFqcn()
public function setEntityFqcn(?string $entityFqcn): staticMenuItem::getCrudActionName()
public function getCrudActionName(): ?stringMenuItem::setCrudAction()
public function setCrudAction(?string $crudActionName): staticMenuItem::getEntityId()
public function getEntityId(): mixedMenuItem::setEntityId()
public function setEntityId(mixed $entityId): staticMenuItem::getRouteName()
public function getRouteName(): ?stringMenuItem::getRouteParameters()
public function getRouteParameters(): arrayMenuItem::setRoute()
public function setRoute(string $routeName, array $routeParameters = []): staticMenuItem::getUrl()
public function getUrl(): ?stringMenuItem::setUrl()
public function setUrl(?string $url): staticMenuItem::getBlockName()
public function getBlockName(): ?stringMenuItem::setBlockName()
public function setBlockName(?string $blockName): staticMenuItem::getParams()
public function getParams(): arrayMenuItem::setParams()
public function setParams(array $params): staticMenuItem::getSubItems()
public function getSubItems(): arrayMenuItem::setSubItems()
public function setSubItems(array $subItems): staticMenuItem::hasSubItems()
public function hasSubItems(): boolMenuItem::getLinkUrl()
public function getLinkUrl(): ?stringMenuItem::setLinkUrl()
public function setLinkUrl(?string $linkUrl): staticMenuItem::isSelected()
public function isSelected(): boolMenuItem::setSelected()
public function setSelected(bool $selected): staticMenuItem::isExpanded()
public function isExpanded(): boolMenuItem::setExpanded()
public function setExpanded(bool $expanded): staticMenuItem::getAsDto()
public function getAsDto(): staticKept for source compatibility with the former builder/DTO split.
MenuItem::setKey()
public function setKey(?string $key): staticMenuItem::getKey()
public function getKey(): stringStable 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(): boolMenuItem::setHidden()
public function setHidden(bool $hidden): staticMenuItem::hasBackground()
public function hasBackground(): boolMenuItem::setBackground()
public function setBackground(bool $background): staticMenuItem::getSize()
public function getSize(): intMenuItem::setSize()
public function setSize(int $size): staticA 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(): ?intMenuItem::setHeight()
public function setHeight(?int $height): staticnull clears back to auto. A generous absolute ceiling/floor, same reasoning as setSize().
MenuItem::isAdHoc()
public function isAdHoc(): boolMenuItem::setAdHoc()
public function setAdHoc(bool $adHoc): staticBase\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(): ?RequestAdminContext::setRequest()
public function setRequest(?Request $request): staticAdminContext::getReferrer()
public function getReferrer(): ?stringAdminContext::getDashboardControllerFqcn()
public function getDashboardControllerFqcn(): ?stringAdminContext::setDashboardControllerFqcn()
public function setDashboardControllerFqcn(?string $fqcn): staticAdminContext::getCrudControllerFqcn()
public function getCrudControllerFqcn(): ?stringAdminContext::setCrudControllerFqcn()
public function setCrudControllerFqcn(?string $fqcn): staticAdminContext::getCrud()
public function getCrud(): ?CrudAdminContext::setCrud()
public function setCrud(?Crud $crud): staticAdminContext::getActions()
public function getActions(): ?ActionsAdminContext::setActions()
public function setActions(?Actions $actions): staticAdminContext::getMainMenu()
public function getMainMenu(): arrayAdminContext::setMainMenu()
public function setMainMenu(array $mainMenu): staticAdminContext::getUserMenu()
public function getUserMenu(): arrayAdminContext::setUserMenu()
public function setUserMenu(array $userMenu): staticAdminContext::getEntity()
public function getEntity(): mixedAdminContext::setEntity()
public function setEntity(mixed $entity): staticAdminContext::getEntities()
public function getEntities(): iterableAdminContext::setEntities()
public function setEntities(iterable $entities): staticAdminContext::getAssets()
public function getAssets(): arrayAdminContext::setAssets()
public function setAssets(array $assets): staticBase\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): voidAbstractCrudController::setLabelTranslator()
public function setLabelTranslator(?TranslatorInterface $translator = null): voidAbstractCrudController::getEntityFqcn()
public static function getEntityFqcn(): stringAbstractCrudController::getPreferredIcon()
public static function getPreferredIcon(): ?stringAbstractCrudController::getCrudControllerFqcn()
public static function getCrudControllerFqcn(object|string|null $entity): ?stringConvention-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): iterableAbstractCrudController::configureActions()
public function configureActions(Actions $actions): ActionsAbstractCrudController::configureCrud()
public function configureCrud(Crud $crud): CrudAbstractCrudController::configureFilters()
public function configureFilters(\Base\Admin\Filter\Filters $filters): \Base\Admin\Filter\FiltersAbstractCrudController::configureRecordNote()
public function configureRecordNote(object $entity): ?stringA 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): boolWhether 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): ResponseAbstractCrudController::detail()
public function detail(Request $request, string $entityId): ResponseAbstractCrudController::new()
public function new(Request $request): ResponseAbstractCrudController::edit()
public function edit(Request $request, string $entityId): ResponseAbstractCrudController::collectionEntries()
public function collectionEntries(Request $request, string $entityId, string $field): ResponseOne 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): ResponseAbstractCrudController::batchDelete()
public function batchDelete(Request $request): ResponseAbstractCrudController::toggle()
public function toggle(Request $request, string $entityId): ResponseBacks 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): objectAbstractCrudController::persistEntity()
public function persistEntity(EntityManagerInterface $entityManager, object $entity): voidAbstractCrudController::updateEntity()
public function updateEntity(EntityManagerInterface $entityManager, object $entity): voidAbstractCrudController::deleteEntity()
public function deleteEntity(EntityManagerInterface $entityManager, object $entity): voidAbstractCrudController::removeEntity()
public function removeEntity(EntityManagerInterface $entityManager, object $entity): voiddeleteEntity() 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): QueryBuilderAbstractCrudController::getEntityPermission()
public function getEntityPermission(): ?stringThe 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): voidAbstractDashboardController::index()
public function index(): ResponseAbstractDashboardController::configureMenuBeforeItems()
public function configureMenuBeforeItems(): iterableAbstractDashboardController::configureMenuItems()
public function configureMenuItems(): iterableThe 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(): iterableAbstractDashboardController::configureUserMenu()
public function configureUserMenu(): iterableAbstractDashboardController::configureWidgetItems()
public function configureWidgetItems(): iterableThe 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(): iterableNon-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(): iterableSame 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(): arrayThe 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): JsonResponseCrudControllerInterface 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(): stringDashboardController 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): JsonResponseThe 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(): JsonResponseDashboardWidgetController::merge()
public function merge(Request $request): ResponseDrag-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): ResponseThe 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): ResponseDashboardWidgetController::restore()
public function restore(Request $request): JsonResponseUn-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 = 3Slots 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): ResponseHoursController::week()
public function week(Request $request): RedirectResponseThe usual week: week[<ISO day>][<n>][open|close], empty rows left out.
HoursController::addSpecialDay()
public function addSpecialDay(Request $request): RedirectResponseA day off, or a run of days: closed, or open at other hours.
HoursController::deleteSpecialDay()
public function deleteSpecialDay(Request $request, int $id): RedirectResponseLayoutController 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): JsonResponseSearchable 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): JsonResponseLayoutController::quickSave()
public function quickSave(Request $request, string $scope): JsonResponseTitle/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): JsonResponseSystemController 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): ResponseSystemController::apiKey()
public function apiKey(Request $request): ResponseSystemController::settingsQuick()
public function settingsQuick(Request $request): JsonResponseTrashController 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): ResponseTrashController::restore()
public function restore(Request $request, int $id): RedirectResponseTrashController::destroy()
public function destroy(Request $request, int $id): RedirectResponseTrashController::empty()
public function empty(Request $request): RedirectResponseDestroy 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(): stringComplaintCrudController::getPreferredIcon()
public static function getPreferredIcon(): ?stringComplaintCrudController::configureCrud()
public function configureCrud(Crud $crud): CrudComplaintCrudController::configureFilters()
public function configureFilters(Filters $filters): FiltersComplaintCrudController::configureFields()
public function configureFields(string $pageName): iterableComplaintCrudController::configureActions()
public function configureActions(Actions $actions): ActionsComplaintCrudController::handle()
public function handle(Request $request, string $entityId): ResponseComplaintCrudController::dismiss()
public function dismiss(Request $request, string $entityId): ResponseComplaintCrudController::reopen()
public function reopen(Request $request, string $entityId): ResponseRedirectionCrudController 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(): stringRedirectionCrudController::getPreferredIcon()
public static function getPreferredIcon(): ?stringRedirectionCrudController::configureCrud()
public function configureCrud(Crud $crud): CrudRedirectionCrudController::configureFilters()
public function configureFilters(Filters $filters): FiltersRedirectionCrudController::configureFields()
public function configureFields(string $pageName): iterableTextOverrideCrudController 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 = []): voidTextOverrideCrudController::getEntityFqcn()
public static function getEntityFqcn(): stringTextOverrideCrudController::getPreferredIcon()
public static function getPreferredIcon(): ?stringTextOverrideCrudController::configureFields()
public function configureFields(string $pageName): iterableUserCrudController class
class UserCrudController extends AbstractCrudController UserCrudController.php:18
UserCrudController::getEntityFqcn()
public static function getEntityFqcn(): stringUserCrudController::getPreferredIcon()
public static function getPreferredIcon(): ?stringUserCrudController::configureFilters()
public function configureFilters(Filters $filters): FiltersUserCrudController::configureFields()
public function configureFields(string $pageName): iterableBase\Admin\Controller\Crud\Layout
ImageCrudController class
class ImageCrudController extends AbstractCrudController ImageCrudController.php:11
ImageCrudController::getEntityFqcn()
public static function getEntityFqcn(): stringImageCrudController::getPreferredIcon()
public static function getPreferredIcon(): ?stringImageCrudController::configureFields()
public function configureFields(string $pageName): iterableSettingCrudController class
class SettingCrudController extends AbstractCrudController SettingCrudController.php:14
SettingCrudController::getEntityFqcn()
public static function getEntityFqcn(): stringSettingCrudController::getPreferredIcon()
public static function getPreferredIcon(): ?stringSettingCrudController::createEntity()
public function createEntity(string $entityFqcn): objectSettingCrudController::configureFilters()
public function configureFilters(Filters $filters): FiltersSettingCrudController::configureFields()
public function configureFields(string $pageName): iterableBase\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(): stringAdapterCrudController::getPreferredIcon()
public static function getPreferredIcon(): ?stringAdapterCrudController::configureActions()
public function configureActions(Actions $actions): ActionsNothing is created here: each kind has its own CRUD, which knows its settings.
AdapterCrudController::isDeletable()
public function isDeletable(object $entity): boolAdapterCrudController::configureFilters()
public function configureFilters(Filters $filters): FiltersAdapterCrudController::configureFields()
public function configureFields(string $pageName): iterableBase\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(): stringPageCrudController::getPreferredIcon()
public static function getPreferredIcon(): ?stringPageCrudController::configureFilters()
public function configureFilters(Filters $filters): FiltersPageCrudController::configureFields()
public function configureFields(string $pageName): iterableBase\Admin\Controller\Crud\Thread
TagCrudController class
class TagCrudController extends AbstractCrudController TagCrudController.php:14
TagCrudController::getEntityFqcn()
public static function getEntityFqcn(): stringTagCrudController::getPreferredIcon()
public static function getPreferredIcon(): ?stringTagCrudController::configureFilters()
public function configureFilters(Filters $filters): FiltersTagCrudController::configureFields()
public function configureFields(string $pageName): iterableTaxonCrudController 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(): stringTaxonCrudController::getPreferredIcon()
public static function getPreferredIcon(): ?stringTaxonCrudController::configureFilters()
public function configureFilters(Filters $filters): FiltersTaxonCrudController::configureFields()
public function configureFields(string $pageName): iterableBase\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(): stringConnectionCrudController::getPreferredIcon()
public static function getPreferredIcon(): ?stringConnectionCrudController::configureActions()
public function configureActions(Actions $actions): ActionsConnectionCrudController::configureFilters()
public function configureFilters(Filters $filters): FiltersConnectionCrudController::configureFields()
public function configureFields(string $pageName): iterableGroupCrudController class
class GroupCrudController extends AbstractCrudController GroupCrudController.php:16
GroupCrudController::getEntityFqcn()
public static function getEntityFqcn(): stringGroupCrudController::getPreferredIcon()
public static function getPreferredIcon(): ?stringGroupCrudController::createEntity()
public function createEntity(string $entityFqcn): objectGroupCrudController::configureFields()
public function configureFields(string $pageName): iterablePermissionCrudController class
class PermissionCrudController extends AbstractCrudController PermissionCrudController.php:14
PermissionCrudController::getEntityFqcn()
public static function getEntityFqcn(): stringPermissionCrudController::getPreferredIcon()
public static function getPreferredIcon(): ?stringPermissionCrudController::configureFields()
public function configureFields(string $pageName): iterableBase\Admin\DependencyInjection
AdminConfiguration class
class AdminConfiguration extends AbstractBaseConfiguration AdminConfiguration.php:10
AdminConfiguration::getConfigTreeBuilder()
public function getConfigTreeBuilder(): TreeBuilderAdminExtension class
class AdminExtension extends AbstractBaseExtension AdminExtension.php:15
AdminExtension::getConfiguration()
public function getConfiguration(array $config, ContainerBuilder $container): AdminConfigurationAdminExtension::load()
public function load(array $configs, ContainerBuilder $container): voidBase\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): voidBase\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(): arrayActiveAdminsSubscriber::onKernelRequest()
public function onKernelRequest(RequestEvent $event): voidAdminActionSubscriber 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(): arrayAdminActionSubscriber::onKernelController()
public function onKernelController(ControllerEvent $event): voidNestHeaderSubscriber 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(): arrayNestHeaderSubscriber::onKernelResponse()
public function onKernelResponse(ResponseEvent $event): voidBase\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): staticFilter::getProperty()
public function getProperty(): stringFilter::getLabel()
public function getLabel(): TranslatableInterface|string|nullFilter::setLabel()
public function setLabel(TranslatableInterface|string|null $label): staticFilter::getType()
public function getType(): stringFilter::setType()
public function setType(string $type): staticFilter::asBoolean()
public function asBoolean(): staticFilter::asDate()
public function asDate(): staticFilter::asNumeric()
public function asNumeric(): staticFilter::asChoice()
public function asChoice(array $choices): static$choicesvalue => label
Filter::asAssociation()
public function asAssociation(iterable $entities, ?callable $labelCallback = null): staticFilter 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(): arrayFilter::applyWith()
public function applyWith(callable $applyCallable): staticFilter::getComparisons()
public function getComparisons(): arrayThe 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$valuethe raw request value (already checked non-empty)
Filter::isActive()
public static function isActive(mixed $value): boolA 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(): staticFilters::add()
public function add(Filter|string $propertyOrFilter): staticFilters::remove()
public function remove(string $property): staticFilters::get()
public function get(string $property): ?FilterFilters::getAll()
public function getAll(): arrayFilters::isEmpty()
public function isEmpty(): boolBase\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 = []): FormBuilderInterfaceFieldFormBuilder::createForm()
public function createForm(mixed $data, iterable $fields, string $page = FieldDescriptor::PAGE_NEW, array $formOptions = []): FormInterfaceFieldFormBuilder::addField()
public function addField(FormBuilderInterface $builder, FieldDescriptor $descriptor): voidFieldFormBuilder::getFormOptions()
public function getFormOptions(FieldDescriptor $descriptor): arrayMaps 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$localecurrent 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).
$defaultLocalethe 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): arrayThe 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): LayoutConfigSnapshots 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(): staticLayoutConfig::fromArray()
public static function fromArray(mixed $data): staticLayoutConfig::getVersion()
public function getVersion(): intLayoutConfig::getColumns()
public function getColumns(): intLayoutConfig::setColumns()
public function setColumns(int $columns): staticLayoutConfig::getItems()
public function getItems(): arrayLayoutConfig::setItems()
public function setItems(array $items): staticLayoutConfig::restoreItem()
public function restoreItem(string $key): staticClears 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(): arrayLayoutScope 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 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,
_currentresolves 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(): arrayLayoutScope::isValid()
public static function isValid(string $scope): boolLayoutStore 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): LayoutConfigLayoutStore::save()
public function save(string $scope, LayoutConfig $config): voidPageCustomization 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): arrayBase\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): boolWhether 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(): arrayThe 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): arrayProfile 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): arrayNests 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): intHow 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): arrayCode-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): arrayBase\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): ?stringBase\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(): \TraversablePaginator::count()
public function count(): intPaginator::getPage()
public function getPage(): intPaginator::getPageSize()
public function getPageSize(): intPaginator::getTotal()
public function getTotal(): intPaginator::getPageCount()
public function getPageCount(): intPaginator::hasPreviousPage()
public function hasPreviousPage(): boolPaginator::hasNextPage()
public function hasNextPage(): boolPaginator::getPreviousPage()
public function getPreviousPage(): intPaginator::getNextPage()
public function getNextPage(): intBase\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): boolAdminRouteLoader::load()
public function load(mixed $resource, ?string $type = null): RouteCollectionAdminRouteRegistry 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(): ?selfAdminRouteRegistry::__construct()
public function __construct(array $controllerFqcns = [], array $dashboardControllerFqcns = [], protected readonly string $urlPrefix = '/admin')$dashboardControllerFqcnsevery 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(): ?stringThe 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(): stringAdminRouteRegistry::getSlug()
public function getSlug(string $controllerFqcn): stringAdminRouteRegistry::getControllerForEntity()
public function getControllerForEntity(string|object $entity): ?stringThe 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'): stringAdminRouteRegistry::slugify()
public static function slugify(string $controllerFqcn): stringController 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): staticAdminUrlGenerator::setAction()
public function setAction(string $action): staticAdminUrlGenerator::setEntityId()
public function setEntityId(mixed $entityId): staticAdminUrlGenerator::set()
public function set(string $paramName, mixed $paramValue): staticAdminUrlGenerator::unset()
public function unset(string $paramName): staticAdminUrlGenerator::unsetAll()
public function unsetAll(): staticAdminUrlGenerator::generateUrl()
public function generateUrl(int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): stringBase\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): ?stringNull 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): boolCrudAccessChecker::reset()
public function reset(): voidPermission 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_FIELD = 'EA_VIEW_FIELD'const EA_EXIT_IMPERSONATION = 'EA_EXIT_IMPERSONATION'
Permission::exists()
public static function exists(?string $permissionName): boolSecurityVoter 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(): stringBusinessSettingsSection::getFields()
public function getFields(): arraySettingsSectionInterface 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(): stringself::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): arrayEvery 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(): stringSiteSettingsSection::getFields()
public function getFields(): arraySpamKeySection 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(): stringSpamKeySection::getFields()
public function getFields(): arrayBase\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(): voidCrudFieldsTestCase::testOnlyWhatTheRecordNeedsIsRequired()
public function testOnlyWhatTheRecordNeedsIsRequired(): voidCrudFieldsTestCase::testWhatTheRecordNeedsIsRequired()
public function testWhatTheRecordNeedsIsRequired(): voidCrudFieldsTestCase::testAnOptionalFieldTakesAnEmptyValue()
public function testAnOptionalFieldTakesAnEmptyValue(): voidCrudFieldsTestCase::testOneFieldPerProperty()
public function testOneFieldPerProperty(): voidCrudFieldsTestCase::testTheSlugFollowsAField()
public function testTheSlugFollowsAField(): voidBase\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\AdminContextThe 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\MenuItemThe 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): ?stringAccepts 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(): arrayAdminTwigExtension::adminFieldChoices()
public function adminFieldChoices(mixed $field): arrayThe 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): ?stringThe 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 = []): arrayThe 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.
$actionsthe page's actions, in code order
$storedaction name => stored position
- Returns
action name => final position
AdminTwigExtension::adminEntityCrud()
public function adminEntityCrud(mixed $entity): ?stringThe 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): boolWhether 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): boolWhether 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): ?stringAn 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 = []): stringDashboardWidgetTwigExtension class
class DashboardWidgetTwigExtension extends AbstractExtension DashboardWidgetTwigExtension.php:10
DashboardWidgetTwigExtension::__construct()
public function __construct(protected readonly DashboardWidgetTypeRegistry $registry)DashboardWidgetTwigExtension::getFunctions()
public function getFunctions(): arrayDashboardWidgetTwigExtension::getWidgetType()
public function getWidgetType(string $name): ?DashboardWidgetTypeInterfaceDevVersionTwigExtension 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(): arrayDevVersionTwigExtension::getVersion()
public function getVersion(): stringEntityNameTwigExtension 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(): arrayEntityNameTwigExtension::entityName()
public function entityName(?string $entityFqcn, bool $plural = false): stringEntityNameTwigExtension::humanize()
public static function humanize(string $text): stringSymfony'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(): arraySiteIconTwigExtension::siteIcons()
public function siteIcons(): arrayThe 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(): stringAnalyticsCardWidgetType::getTemplate()
public function getTemplate(): stringAnalyticsCardWidgetType::getDefaultLabel()
public function getDefaultLabel(): stringAnalyticsCardWidgetType::getDefaultIcon()
public function getDefaultIcon(): ?stringAnalyticsCardWidgetType::getTemplateVars()
public function getTemplateVars(MenuItem $widget): arrayAnalyticsSeriesPalette 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
pageViewscolumn, 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(): stringComplaintsWidgetType::getTemplate()
public function getTemplate(): stringComplaintsWidgetType::getTemplateVars()
public function getTemplateVars(MenuItem $widget): arrayCompositeWidgetType 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(): stringCompositeWidgetType::getTemplate()
public function getTemplate(): stringCompositeWidgetType::getTemplateVars()
public function getTemplateVars(MenuItem $widget): arrayCounterWidgetType 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_INSTANCESconst 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(): stringCounterWidgetType::getTemplate()
public function getTemplate(): stringCounterWidgetType::getDefaultLabel()
public function getDefaultLabel(): stringCounterWidgetType::getDefaultIcon()
public function getDefaultIcon(): ?stringCounterWidgetType::getTemplateVars()
public function getTemplateVars(MenuItem $widget): arrayDashboardWidgetTypeInterface 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(): stringMatches the blockName passed to MenuItem::block().
DashboardWidgetTypeInterface::getTemplate()
public function getTemplate(): stringDashboardWidgetTypeInterface::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): ?DashboardWidgetTypeInterfaceEntityViewsWidgetType 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(): stringEntityViewsWidgetType::getTemplate()
public function getTemplate(): stringEntityViewsWidgetType::getDefaultLabel()
public function getDefaultLabel(): stringEntityViewsWidgetType::getDefaultIcon()
public function getDefaultIcon(): ?stringEntityViewsWidgetType::getTemplateVars()
public function getTemplateVars(MenuItem $widget): arrayLinkableEntityRegistry 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): arrayUp 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): arrayEvery $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): ?LinkableInterfacePaletteDashboardWidgetTypeInterface 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(): stringTranslation key or literal label shown in the palette, and used as the default label for a freshly added instance.
PaletteDashboardWidgetTypeInterface::getDefaultIcon()
public function getDefaultIcon(): ?stringPaletteWidgetTypeRegistry 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): ?PaletteDashboardWidgetTypeInterfacePaletteWidgetTypeRegistry::all()
public function all(): arrayTimelineEventRegistry 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(): stringWelcomeWidgetType::getTemplate()
public function getTemplate(): stringWelcomeWidgetType::getDefaultLabel()
public function getDefaultLabel(): stringWelcomeWidgetType::getDefaultIcon()
public function getDefaultIcon(): ?stringWelcomeWidgetType::getTemplateVars()
public function getTemplateVars(MenuItem $widget): array