This release adds the relations & nesting feature (relation managers +
nested resources, REL-01..22) and record View pages (VIEW-*), a
configurable record identifier field, the migration of panel icons to Symfony UX
Icons, and makes path_prefix authoritative for route matching. Pre-1.0 — the
public API may still change in a 0.x minor; API-affecting items are flagged below.
REL-01..22). The
relations & nesting feature is done across its five milestones: the shared table
core + descriptor + read-only one-to-many manager; one-to-many owned
create/edit/delete + associate/dissociate; many-to-many attach/detach + tabs;
nested resources; and this close-out (extraction, playground, browser-verify,
review). Dogfooded in the playground: Article → Comments (1:M, extracted via
->using()), Article ↔ Tags (M:N), Course → Lessons (nested).->using() (REL-19). A relation configures
its table()/form() inline by default, or extracts them to a dedicated
RelationManagerConfiguration subclass referenced by
Relation::using(MyConfig::class) — the same inline-or-dedicated split pages()
offers. The manager uses the class's table() for its columns and layers its
form() over the target resource's form in the owned create/edit modal.
(Behaviour change) wiring this also activates a relation's inline
->form() closure in the modal — previously stored but ignored; a resource that
set Relation::form(...) will now see it applied. Atrium:Form gains optional
relationResource/relationName mount arguments (additive; defaults preserve
behaviour). See Relations › Extracting a relation.REL-14..18, REL-20). A resource becomes nested
under a parent record by declaring parent(): ?ParentRelation —
ParentRelation::make(ParentResource::class)->relationship('tasks')->foreignKey('projectId')->recordTitle('name'),
validated lazily against the registry (parent registered, the named one-to-many
relation exists, foreign keys agree). An explicit nested route family
(/{prefix}/{parentResource}/{parentId}/{resource}/… for index/new/{id}/{id}/edit,
proven non-colliding with the flat routes) serves full CRUD scoped to the
parent: the list filters by the parent foreign key, create presets it, and a
child of another parent (a forged id) is a 404 (resolved against both the
child's scopeQuery() and the parent FK). A breadcrumb renders the ancestry
(Projects › Alpha › Tasks), and a parent-page relation manager whose target is
nested links each row into the child's nested view/edit pages and points New at
nested create instead of inline modals. (API) new AdminResource::parent()
hook (default null = top-level) and the ParentRelation builder;
Atrium\Action\ActionContext is no longer final — NestedActionContext extends
it to emit 5-segment URLs (additive, not a break); Atrium\Page\PageContext gains
optional parentResourceSlug / parentRecordId / parentRecords constructor
arguments and a nestedUrl() helper (additive); Atrium:DataTable gains an
optional parentId mount argument (additive) that scopes the list and emits nested
URLs. See Nesting resources.REL-02, REL-06, REL-07,
REL-08, REL-12). A manyToMany() relation (declared with pivotTable() +
pivotKeys() + optional pivotColumns()) renders a manager that Attaches an
existing record (a listLinkable picker that excludes already-linked rows, plus a
field per pivot column) and Detaches it (row + bulk; the pivot row is removed,
both records persist). Pivot reads/writes go through the RelationDataProvider
seam (Doctrine via DBAL — the pivot is never mapped as an entity; array adapter via
an in-memory pivot store). When a resource declares several relations, the host
renders a server-driven tab strip and mounts only the active relation's manager.
(API) new default-allow hooks canAttach(object $parent, object $child) /
canDetach(...) (override to restrict). Pivot-column display in the related
table is a later enhancement. Owned create/edit/delete remain one-to-many only.REL-05, REL-06, REL-07,
REL-09, REL-12). A one-to-many RelationManager now offers a full lifecycle
scoped to the parent: owned Create/Edit (an inline modal hosting the target
resource's form — create sets the foreign key in one transaction), Delete +
bulk delete, and Associate/Dissociate (link/unlink an existing record via a
listLinkable-backed picker). Managers are read-only on the View screen
(Relation::readOnlyOnView(), default true) and can be hidden per parent with
Relation::visible(fn ($parent) => …). (API) new default-allow authorization
hooks AdminResource::canAssociate(object $parent, object $child): bool and
canDissociate(...) (one-to-many) and canAttach(...) / canDetach(...)
(many-to-many) (override to restrict link/unlink); owned create/edit/delete
continue to gate on the target resource's can(...). Atrium:Form gains optional
embedded / presetValues / notifyEvent mount arguments (additive; defaults
preserve behaviour) so it can be hosted inside a relation manager's modal. Owned
row View navigation lands with nested resources (REL-M4).REL-01..04, REL-10, REL-11, REL-20). A resource
declares managed relationships with relations() returning Relation::make(...)
descriptors (one-to-many / many-to-many, explicit keys — no Doctrine in core). New
storage-agnostic RelationDataProvider seam (Doctrine + array adapters; one-to-many
read side) and an AbstractRecordTable core extracted from DataTable (no
behaviour change). A read-only RelationManager Live Component renders a
parent-scoped related table, embedded on the Edit page via a RelationManagers
host. Link/unlink actions, many-to-many, and nested resources land in subsequent
milestones. PRD: docs/PRDs/PRD-relations-nesting.md.id — a primary key named something else, or
a natural key such as a slug for human-readable URLs — by overriding the new
AdminResource::getIdentifierField(): string (defaults to 'id'). The
field is used both to read a record's identifier (building its row/view/edit
URLs) and to resolve a record back from a URL, honouring scopeQuery(). A
UUID/ULID key named $id already worked and still needs no configuration.
(API) DataProviderInterface::find() gains a trailing
string $idField = 'id' parameter — backward-compatible for callers, but a
signature change for any third-party provider implementation. The Doctrine
adapter keeps the fast identity-map path for primary-key lookups and queries
WHERE <field> = :id for a custom field; the array adapter matches on the
given field.VIEW-01..04, VIEW-11, VIEW-13,
VIEW-15, VIEW-18). A resource can expose a read-only View screen for one
record at the bare URL /{resource}/{id}, opt-in by registering a 'view' page
(Atrium\Page\ViewPage, with an Edit header link). Content is declared with
AdminResource::view(Schema) — a schema of read-only entry components
that live in the same layout tree as form fields; if a resource defines no
view(), the screen falls back to its form() fields rendered read-only. Ships
the Atrium\View\Entry base (the full shared configuration surface:
labelling, layout/align, record-aware visible/hidden, state/getStateUsing/
formatStateUsing/default/placeholder, tooltip/helperText/hint, icon,
url, inline actions) and Atrium\View\TextEntry (badge, colour, money,
date/time, numeric, limit/words, prefix/suffix, html, lists, copyable). Reuses the
existing view/canView($record) ability; adds PageContext::viewUrl(). The
remaining entry types (Icon/Image/Color/KeyValue/Repeatable/Code), view-screen
widget bands, ViewAction + clickable rows, and docs land in the following
milestones. PRD: docs/PRDs/PRD-record-view.md.VIEW-05..08, VIEW-10). Five more read-only
entries alongside TextEntry, each sharing the full base configuration surface:
Atrium\View\IconEntry (value as an icon; boolean() true/false ticks,
color(), size()), ImageEntry (image/avatar; circular()/square(),
imageSize()/imageWidth()/imageHeight(), defaultImageUrl(), with
URL-scheme sanitisation), ColorEntry (a sanitised colour swatch,
copyable()), KeyValueEntry (a 1-D array/JSON map as a key→value table,
keyLabel()/valueLabel()) and CodeEntry (a monospace, escaped code
block; language(), copyable()). The semantic colour vocabulary is shared with
Content\Text / Table\Column via a common ResolvesColor concern. Docs:
docs/integration-guide/pages/view.md.RepeatableEntry (VIEW-09). Repeats a nested entry schema
once per item of a relation / array attribute (a Doctrine collection, an array of
entities, or an array of maps), binding each item as the record for the nested
entries — schema(), columns(), grid() and contained(). It re-enters the
shared layout renderer, so nested containers and nested repeatables compose. This
completes the entry family.VIEW-12, VIEW-16,
VIEW-17). When a resource has a View screen, its list rows link to it by
default (a stretched overlay link; the row's buttons stay clickable), with a
configurable target — TableConfiguration::recordUrl() takes 'view'
(default), 'edit', a fn (object $record): ?string, or null to disable.
Adds Atrium\Table\Action\ViewAction (the bare-record link, gated by view)
and ActionContext::recordRootUrl(). The View screen's header now runs its
Delete server action through the shared confirm → delete plumbing (hosted by
a small RecordActions Live Component, so the otherwise-static screen needs no
client JS). ViewPage::headerWidgets()/footerWidgets() add record-scoped
widget bands above/below the entries (the widget context gains recordId),
reusing the list/dashboard widget mechanism. Docs:
docs/integration-guide/pages/view.md.Hardening from the full-solution code review. Several defense-in-depth fixes, none affecting a correctly-configured Doctrine install:
DataProviderInterface absent), edit/view and their nested
variants now return 404 instead of rendering form/view chrome that could
neither load nor authorize its record. create/list (which need no record)
are unaffected.Form::save() authorizes before developer hooks. canCreate()/canEdit()
now run before mutateFormDataBeforeValidate()/afterValidate(), so a forged
Live-action POST can no longer trigger those side-effect hooks unauthorized.Action::url(fn …) result is now
passed through the same javascript:/data:-rejecting guard as table row URLs
and view entries before reaching an href.attach() is now idempotent
(no duplicate pivot row / unique-constraint 500), its pivot lookup quotes
identifiers (reserved-word/portability safe), and the array adapter matches
pivot ids by strict, type-coerced comparison — both adapters now agree.TextEntry::lineClamp() works. The line-clamp-N utilities are now
compiled into the shipped stylesheet (previously purged, so the class had no
effect). Relation/confirmation modals and the relation tab strip gained
aria-labelledby / aria-selected / role="tabpanel".path_prefix is now authoritative for route matching, not just link
generation. The parametric routes (config/routes.php) previously hardcoded
/admin, while path_prefix only changed the URLs the panel generated — so
setting it to anything else broke the panel (links pointed somewhere the routes
did not match). The routes now mount under the %atrium.path_prefix% parameter,
so a single config value relocates both matching and generation together.
Setting path_prefix: '/administrator' now actually serves the panel there; the
default stays /admin and the route import is unchanged. Subdomain hosting
(admin.example.com) is documented via the route import's host: option. Docs:
docs/integration-guide/panel/customization.md#changing-the-url-the-panel-lives-at.
Icons now render through Symfony UX Icons.
The panel's hardcoded name → SVG path Twig map ([@Atrium](https://github.com/Atrium)/icon.html.twig, ~18
heroicons-outline glyphs) is replaced by a shipped Lucide
set registered under the atrium: icon-set prefix (via the bundle's
prependExtension()), rendered with the ux_icon() function and an internal
atrium_icon() helper that defaults bare names to the atrium: set. Icon names
anywhere in the panel — getNavigationIcon(), Action::icon(), tab/step icons —
now also accept any Iconify icon by passing a
namespaced name (lucide:rocket, mdi:home, …); bare names resolve to the
built-in set and an unknown one degrades to a neutral placeholder instead of
erroring. The panel's glyphs change appearance (heroicons → Lucide). New required
dependency: symfony/ux-icons. The [@Atrium](https://github.com/Atrium)/icon.html.twig template is removed —
apps that overrode it should instead alias or repoint the atrium icon set in
config/packages/ux_icons.yaml. Docs:
docs/integration-guide/panel/customization.md#icons.
First tagged release of Atrium — a PHP-configured, modular admin-panel framework for Symfony, powered by Live Components.
Highlights
DataProviderInterface/DataWriterInterface) with a Doctrine adapterPre-1.0: usable, but the public API may still change in a 0.x minor. See CHANGELOG.md for the full surface.
Install: composer require atriumphp/atrium:^0.1 (once registered on Packagist).
How can I help you explore Laravel packages today?