A module contributes admin screens without shipping a line of frontend code. It declares what the screen contains; the platform's own components render it.
Modules & ports says a module ships no admin code. This page is what it ships
instead.
How to read the status marks. ✅ Works today — measured against the codebase, with the contract named. [!NOTE] Decided, not built — the design is settled and nothing implements it. Every number on this page is counted from the tree, not recalled.
What it buys#
The admin SPA is one Angular application, built once and shipped as a static bundle. It has no knowledge of any module.
That single fact is where three separate decisions come from, and they were each argued on their own before anyone noticed they were the same argument:
- Themes ship prebuilt. A theme need not know which modules are installed, because no module contributes code to it.
- Third-party admin screens are declared, not coded. A module you install today gets screens without rebuilding the frontend.
- Federation is deferred. Loading remote frontend code solves a problem the platform does not have while screens stay declarative.
And the everyday consequences: no redeploy to change a screen, and uniform theming, translation, keyboard behaviour and permission handling across every module's UI — because the screens are the platform's, and only the declarations differ.
The vocabulary#
Measured across the tree (2026-09-16):
| Declaration | Files | Modules using it |
|---|---|---|
navigraph |
73 | 35 |
datagrids |
37 | 26 |
forms |
40 | 21 |
layout |
34 | 22 |
Navigation — config/modules/<m>/navigraph/*.yaml ✅#
A node carries title, sortOrder and a meta bag. Counted across all 73
files, meta actually carries:
icon (230) · action (185) · showWhen (155) · position (95) · type (47,
e.g. separator) · label (46) · section (44) · surface (44) · route (43)
· permissions (33) · primary (26) · danger (25) · target (13) ·
confirm (7) · activeWhen (5) · variant, busyWhen, busyLabel (1 each)
Conditions are declarable and composable. showWhen takes
{field, op, value} and nests under and: / or::
/approve:
title: Approve
sortOrder: 25
meta:
icon: check-lg
action: approve
showWhen:
and:
- { field: _selected, op: eq, value: true }
- { field: _status, op: eq, value: pending }
Trees are addressed by name: 33 files declare into navi.admin (the
sidebar), the rest into navi.toolbar.<module>.<screen> and
navi.admin.topbar. Contract: NaviNode, seeded by NaviGraphSeeder.
A YAML file alone is inert. The seeder only visits trees a tagged PHP
contributor advertises, so a file with no contributor is never seeded. 52
classes implement NaviGraphContributorInterface (counted by the implements
clause; a plain file-name grep returns 56, four of which are the seam itself).
Most are stubs whose only job is to make the seeder look.
✅ But it is not silent, and this is the part to know before you spend an
afternoon. coolms:navi:seed reports trees the YAML declares that the run will
never touch, and distinguishes the two cases:
[WARNING] Declared in YAML but NOT seeded by this run:
· navi.toolbar.example (declared by: example) — no contributor claims this tree
· navi.admin (declared by: example) — claimed by another module; a scoped run does not reach it
NaviGraphSeeder::findUnreachableTrees() is the report, and
UnreachableTreeReportTest covers it. Its own docblock records why it exists:
without it the run prints created: 0, skipped: 0, which reads exactly like
"already up to date" — and two features in one arc shipped a file that looked
right and a screen that never changed.
Do not subtract these numbers: one contributor may advertise several trees, so 73 YAML files and 52 contributors do not map one to one. The mechanism is the load-bearing fact, not the arithmetic.
Lists — config/modules/<m>/datagrids/*.yaml ✅#
Contract: DataGrid\Domain\Config\DataGridConfig, rendered by the
coolms-datagrid component. Top-level keys, by how many of the 37 use them:
id, label, dataSource, columns (37 each) · showActionColumn (36) ·
loadingMode (35) · rowActions (33) · emptyLabel (9) · draggable (3) ·
tree (2) · defaultSort, reorderRoute (1 each)
Per column: label · sortable · filterable · hideable ·
defaultVisible · filterOp (the RQL operator) · type · field (a Doctrine
relation path, so a column may sort and filter across a join) · icon ·
confirm · options / enumOptions · badgeMap · truncate · showWhen ·
hrefPrefix · subtitleField · imageField.
Yes — a grid can declare its empty state. emptyLabel names the thing the
grid lists, so an unfiltered empty grid reads "No contacts yet" rather than a
generic line; a filtered-empty grid deliberately ignores it and says "No
matches", because the two situations need different words.
datagrid.component.ts consumes it and a spec covers the fallback. 9 of 37
grids set it; the other 28 get the generic wording.
Forms — config/modules/<m>/forms/*.yaml ✅#
Contract: the form config, rendered by FormConfigRenderBuilder. Per field, by
use: type (130) · options (117) · label (112) · hint (40) · children
(40, so layouts nest) · required (37) · constraints (28, e.g. NotBlank,
Length, Regex) · placeholder (26) · attr (22) · dataSource (17) ·
help, width, groups (11) · route / routeParams + bindValue /
bindLabel (10, a remote picker) · readonly_on_edit (9) · autocomplete (9)
· widget (8) · showWhen (8) · layout (5) · relation + cardinality (5)
· contexts with create / edit variants (4).
Top level: fields, options, class, default_options, option_source,
data_class, render_kind.
Wizard steps are not in this vocabulary. No shipped form declares a
multi-step flow. (step does appear in three form files — it is the numeric
input's increment, not a wizard step.)
Records — dynamic entities ✅#
A type defined at runtime, with no PHP class. Its fields are rows carrying
entity_alias, name, type, validation_rules, options, sort_order,
form_config, api_config, and the serializer groups.
Two generic screens render it with no per-type code:
DynamicTypeDataGridConfigProvider returns a grid config for any existing
type — the core columns are always prepended, and toRow() spreads the record's
extras, so a field reaches the row without a generated column — and
FormConfigRenderBuilder renders the form.
A type with no field definitions has a grid but no form: the builder
throws Form '<alias>' not found.
Settings — a settings contributor ✅#
Contract: ModuleSettingsContributorInterface returning
ModuleSettingsDefinition(key, module, label, formId, moduleLabel, moduleIcon, defaults). 13 modules contribute. defaults is the key set — a key absent
from it does not exist (ADR-165), and the effective value is composed by one
reader.
Field widgets and option sources — the two registries ✅#
A new input type: implement FieldWidgetProviderInterface —
fieldType(): string and widget(array $fieldConfig): array.
FieldWidgetRegistryInterface::widgetForType() aggregates every provider.
5 implementations ship.
A new list of choices: implement OptionSourceProviderInterface,
autoconfigured with coolms.option.source. Keys are dot-namespaced
<module>.<noun> — calendar.timezones, identity.users — and two providers
claiming one key throw at boot. 10 sources ship.
These two are the extension points that widen the vocabulary itself: a widget added here is available to every form, in every module, immediately.
The boundary#
The honest limit, stated so you know when to stop trying.
A module cannot declare a route. app.routes.ts holds 56 route entries,
49 of them lazy-loaded feature bundles, all hand-written. meta.route points at
a route that already exists; it does not create one. A genuinely new screen at a new URL is frontend
work in the platform, not a declaration.
A module cannot declare a bespoke screen. Of 57 admin page components, 22 render a declared grid. The other 35 are hand-written Angular — dashboards, editors, wizards, detail pages. The vocabulary covers lists, forms, navigation, settings and records, which is most of what a module needs and not all of it.
A tree needs a PHP stub, as above: 56 contributors for 73 YAML files.
Not declarable today, each of which is platform work. This list is not a guess: it is what a trial conversion could not express.
Of the 35 hand-written pages, only 4 render a flat table at all — the rest are dashboards, editors and detail views that no grid vocabulary was ever going to cover. Taking the two cleanest as trials, here is precisely what stopped them:
- No
durationcolumn type. The 13 supported types aretext,boolean,number,filesize,date,datetime,time,badge,enum,link,avatar,icon,snippet(11 are used by shipped grids). A dwell time in seconds rendered as "2m 14s" has no type;numberloses the formatting. - No bar, progress or sparkline column. A cell that draws rather than writes is outside the vocabulary.
- No value derived from the whole result set. The timing report's bar is
this row's average ÷ the largest average in the table. Every declaration describes a row; nothing expresses "relative to the aggregate". - A
dataSourcecannot take a route parameter. All 37 shipped grids declaretype: staticwith an emptydataand apageSize; a grid scoped to/definitions/:id/timinghas no way to say so. - One declaration is one grid. The second trial page renders three
related tables under one heading. A page composed of several grids is not
expressible, and
layout/*.yamlcarries onlytype,id,template.
And two already known:
- wizard steps in a form;
- an empty state for anything that is not a
coolms-datagrid— of the 8 admin pages that render a collection and say nothing when it is empty, 0 are declared grids, soemptyLabelcannot reach them.
Read that list as the point of this section. The vocabulary a third-party module must live inside is one the platform itself bypasses for 35 of its 57 screens — so a hole in it is found by an outside author, who has no option to hand-write around it, rather than by us. Five of the gaps above were found by attempting two conversions; none had been reported before.
The cost#
A declared screen cannot do everything a coded one can. That trade is deliberate, and the price is real:
- You are limited to the vocabulary above. When a screen needs something not in it, the answer is not a workaround — it is a platform change, and that is a slower path than writing the component yourself would have been.
- Indirection. A bug in a declared screen is in one of three places: the YAML, the renderer, or the data. Reading the YAML does not tell you what the user saw.
- The vocabulary is discovered, not obvious. Nothing tells a module author
that
filterOpexists. This page is the fix for that, and it will drift from the code unless it is re-measured.
What the trade buys is that every screen in the product behaves the same way — the same filtering, the same keyboard handling, the same permission checks, the same dark mode — including screens written by people who have never seen the frontend. A module that ships its own components gets none of that for free, and gets to maintain it forever.