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: the panel shell, resources, tables, forms, actions, pages, dashboards & widgets, and the data layer described below. Pre-1.0 — the public API may still change in a 0.x minor.
Page screens: headings & header actions. Atrium\Page\Page is now a screen
descriptor: getTitle()/getHeading()/getSubheading() (computed defaults per
screen; the previously-dead ListPage is now used) and
getHeaderActions(PageContext): array. Header actions live on the page model —
ListPage::getHeaderActions() provides the default CreateAction ("New"), and a
create/edit page adds its own (override getHeaderActions() on the relevant Page).
They are hosted by the screen's Live Component: the DataTable for the list, and the
Form component for create/edit, where a server-driven action (e.g.
DeleteAction) runs against the loaded record with the confirm flow and a generic
post-action redirect (the record is re-resolved; if it's gone — deleted or out of
scope — the form redirects to the list). Data/lifecycle hooks remain on the
resource (pages aren't DI services). BC: TableConfiguration::headerActions()
is removed; the default New button lives on ListPage::getHeaderActions(), and the
resource has no header-action method. Docs:
docs/integration-guide/pages/overview.md; PRD: docs/PRDs/PRD-page-screens.md.
(PAG-01..06.) Public API change.
List-screen widgets (header & footer bands). A ListPage can render
widget bands above
(headerWidgets()) and below (footerWidgets()) its table, composed with the
same WidgetSlot + layout primitives as a dashboard via a new
Atrium\Page\ListWidgetsConfiguration. Each slot widget receives the
resource-identity context (slug, path prefix, labels) as params so it can scope
its own query. The widgets are independent of the table's live search/filters
(that state lives in the sibling DataTable component) — they reflect the unfiltered
resource; live filter-reactivity is intentionally out of scope. The dashboard and
list configurations now share an abstract base,
Atrium\Widget\WidgetLayoutConfiguration. BC: WidgetSlot moved from
Atrium\Dashboard\WidgetSlot to Atrium\Widget\WidgetSlot (it depends only on
Widget + the foundational Layout contract). Docs:
docs/integration-guide/tables/list-widgets.md; PRD: docs/PRDs/PRD-list-widgets.md.
(LW-01..05.) Public API change.
Dashboards & widgets. A new widget layer: Atrium\Widget\Widget (an
abstract descriptor service, not a Live Component), with two concrete families —
StatsWidget (a row of Stat cards) and ChartWidget (a Chart.js chart via
Symfony UX Chart.js, no JS build step). One generic Live Component host
(Atrium:Widget) mounts any widget by class name (a non-writable, checksummed
prop — unforgeable), enforces canView() on mount and refresh, and refreshes
each widget independently (manual action or getPollingInterval() polling).
Widgets are embeddable anywhere via <twig:Atrium:Widget widget="…" :params="…">
with scalar context. Widgets compute their own data from injected services —
the core gains no aggregation API and stays storage-agnostic.
Atrium\Dashboard\Dashboard is a routable, navigable, authorizable page;
register several. Its dashboard(DashboardConfiguration): DashboardConfiguration
(mirroring a resource's table()/form()) arranges widgets — a flat
->widgets([...]) stack, or a ->schema([...]) tree using the same layout
components as a form (Grid, Section, Fieldset, Flex) with a
WidgetSlot wrapping each widget class. Widget width is a placement concern
owned by the slot (it spans like a field), not the widget. Panel routing is
generalised — /admin renders the root dashboard (the built-in
DefaultDashboard welcome, replaceable at the root slug), and the former
/admin/{resource} route is now /admin/{slug}, dispatching to a dashboard or a
resource (slugs unique across both; collisions fail fast). The sidebar shows
dashboards and resources as two divided groups (the built-in default dashboard
appears when the app defines none). New dependency: symfony/ux-chartjs.
Docs: docs/integration-guide/widgets/; PRDs: docs/PRDs/PRD-dashboards-widgets.md,
docs/PRDs/PRD-dashboard-layout.md. (WGT-03..11, DSH-01..11, PNL-06..08.)
New public API.
Relation columns. Column::make('author.name') (any depth, e.g.
author.company.name) reads through a to-one relation and works everywhere a
plain column does — display, sort, search and filter. The Doctrine adapter
resolves dotted field names to idempotent LEFT JOINs (rows with a null
relation are kept; all values parameter-bound); the in-memory array provider
traverses the path via the property accessor. The default label humanises the
path (author.name → "Author name"); a null link in the chain renders an empty
cell. Docs: docs/integration-guide/tables/columns.md. (TBL-09.)
Navigation & access hooks on AdminResource: canAccess() (resource-level
gate — hides the nav entry and 403s every page; defaults to canViewAny()),
shouldRegisterNavigation() (reachable but hidden from the menu),
getNavigationSort() (menu order), and getNavigationBadge() /
getNavigationBadgeColor() (a badge next to the entry). The controller filters
and sorts the sidebar accordingly and gates create/edit/list on canAccess().
Docs: docs/integration-guide/resources/navigation.md. (Arbitrary render slots
remain out of scope.) Public Resource API addition.
Action lifecycle hooks on AdminResource: beforeAction/afterAction(string $action, object $record) bracket every row action's handler, and
beforeBulkAction/afterBulkAction(string $action, array $records) bracket a
bulk action's handler (records pre-filtered to those the user may act on). They
run inside the action's transaction. beforeDelete/afterDelete remain the
delete-specific convenience. Public Resource API addition.
Form validation lifecycle hooks on AdminResource:
mutateFormDataBeforeValidate($data, $operation) (clean raw input before
validation) and afterValidate($data, $operation) (react to valid data,
side-effect only — skipped on an invalid submit). Public Resource API
addition.
Custom persistence + atomic saves. AdminResource::handleRecordCreation()
and handleRecordUpdate(object $record, DataWriterInterface $writer) own the
write (defaulting to the data writer), so an app can persist through its own
service/command bus without replacing the form. The save path now runs
beforeSave → handle* → afterSave inside a single transaction via the new
DataWriterInterface::transactional(callable): mixed, so a failing afterSave
rolls the write back; deletes (record + bulk) are wrapped the same way. The
Doctrine writer uses wrapInTransaction; the array writer just runs the work.
Public Resource API + DataWriterInterface addition.
Query scoping via AdminResource::scopeQuery(DataQuery): DataQuery (default
no-op). Returns a query narrowed to the records the resource exposes
(multi-tenancy, ownership, soft-deletes) using DataQuery::withFilters([...]).
The scope is applied to the list, the count, select-all bulk actions, and
single-record resolution — so an out-of-scope id resolves to null (the edit
page 404s; a forged action finds nothing). Unlike the authorization hooks
(which hide actions on a still-visible row), scoping removes rows entirely.
Docs: docs/integration-guide/data/query-scoping.md. Public Resource API
addition.
Authorization hooks on AdminResource (canViewAny, canCreate,
canEdit, canDelete, canView, dispatched via can()). Enforced server-side
in three places: the controller returns 403 for a denied list/create/edit
page; the form save re-checks canCreate/canEdit; and the table hides and
refuses to run the built-in Edit/Delete/New actions (a bulk delete acts only on
permitted records). Custom actions opt in with Action::authorize('ability');
the built-in table actions set theirs automatically. Default is open
(security-agnostic core). Public Resource API addition.
Record lifecycle hooks on AdminResource: mutateFormDataBeforeFill,
mutateFormDataBeforeSave($data, $operation), beforeSave/afterSave($record, $operation), and beforeDelete/afterDelete($record). The save path runs
mutate → apply fields → beforeSave → persist → afterSave; delete runs
beforeDelete/afterDelete around the built-in delete actions. Public
Resource API addition.
Integration guide: docs/integration-guide/resources/authorization.md and
lifecycle-hooks.md (and a documentation policy + format — see
docs/integration-guide/README.md).
mutateFormDataBeforeFill() gained an $operation parameter and now runs on
create too. Signature is now mutateFormDataBeforeFill(array $data, string $operation): array; on create it receives the fields' defaults, so it can
seed a create form (previously it ran on edit only). Overriders must add the
parameter. Public Resource API change (pre-1.0; the hook was added earlier
in this same unreleased cycle).DataProviderInterface::find() gained an optional array $filters = []
parameter so record resolution can be scoped (see query scoping above). Callers
are unaffected; custom data-provider implementations must add the parameter.
Both built-in providers (Doctrine, array) honour it. Public contract change.table(TableConfiguration $table) hook
on AdminResource, mirroring form(Schema $schema): Schema. It replaces the
separate columns() / recordActions() / headerActions() / bulkActions()
methods — set them all on the Atrium\Table\TableConfiguration builder
(->columns([…])->recordActions([…])->bulkActions([…])). A fresh
TableConfiguration already carries the framework defaults (an Edit record
action and a "New" header action), so overriding table() keeps them unless a
setter overrides them. Public API change (pre-1.0; the four methods were
added earlier in this same unreleased cycle).TableConfiguration::filters([...]) renders a filter
bar that narrows the query through the data provider. SelectFilter (a
categorical dropdown) and TernaryFilter (all / true / false over a boolean
field), both extending Atrium\Table\Filter\Filter and declaring their own
template for extensibility. Filters resolve to equality conditions carried on
DataQuery::$filters and applied by both ArrayDataProvider and
DoctrineDataProvider (parameter-bound). Selections live in a writable
filterValues LiveProp (with a Reset action); a value for an unconfigured
filter name is ignored, so a forged value cannot inject a condition.Column: ->alignment('right')
(alignCenter() / alignRight()), ->width('8rem'), ->boolean() (renders a
check/cross icon instead of "Yes"/"No"), ->badge() with ->color('green') or
a per-value ->color(fn ($value, $record) => …), and ->visible(false) /
->hidden() to drop a column from the header, cells, search and sort. Cells are
now resolved to render-ready descriptors (Column::toCell()); renderValue()
is unchanged for direct callers.TableConfiguration:
->emptyState('No articles yet', 'Write your first one…', 'document') renders an
icon, heading and optional description when the table has no rows, instead of the
bare "No … found." default (which still applies when unconfigured).TableConfiguration: ->defaultSort('field', 'desc') orders the first load
(until the user sorts; the field need not be a sortable column), and
->paginated(25, [10, 25, 50]) sets the page size and, given options, renders a
per-page selector. A client-supplied page size is clamped to a configured
choice, so a forged value cannot request an arbitrarily large page.Atrium\Action subsystem.
TableConfiguration::headerActions() (defaults to a
CreateAction) and bulkActions() (defaults to none — returning actions
enables row selection). Both are subject-less list<Action>.Atrium\Table\Action\CreateAction (a primary "New" button linking
to the create page) and BulkDeleteAction (a confirmed server action that
deletes the whole selection through DataWriterInterface). Header actions now
render inside the DataTable Live Component (its card header), not the page
chrome, so server-driven header actions work; the hardcoded "New" button was
removed from the resource page template.Atrium\Action\Concern\InteractsWithBulkActions — a reusable trait owning a
server-driven selection state machine: per-row and whole-page toggles, plus a
select-all-matching-the-query mode (a flag with an exclusion list) so a
bulk action targets every record across all pages, not just the visible ones.
Confirmable requestBulkAction / confirmBulkAction, gated by visibility on
both request and run. The selection props are non-writable LiveProps (mutated
only through the actions), so a crafted request cannot forge a selection.Atrium\Action\Action gained subject-less rendering (toStandaloneView(),
getStandaloneUrl()) and isVisible() for header/bulk bars — which requires
a plain bool and fails closed for a subject-bound visibility closure, so a
destructive action a developer tried to gate with a closure is never silently
exposed. The action button template is parameterised (liveAction) and the
confirmation dialog was extracted to a shared components/confirm_modal.html.twig.Atrium\Action
subsystem (the shared base for table actions today; header/page/bulk actions
next).
Atrium\Action\Action — a fluent action (label/icon/color,
button()/link()/iconButton() styles, badge(), visible()/hidden(),
requiresConfirmation()); it is either a link (url()) or a server
action (action(Closure)). Atrium\Action\ActionGroup renders a set as a
no-JS <details> dropdown. Both implement ActionContract so a host renders
and runs them polymorphically, and each declares its own getTemplate() so
custom actions/groups can ship their own renderer.Atrium\Table\Action\EditAction (link to the edit
page) and DeleteAction (a confirmed server action that deletes through
DataWriterInterface). AdminResource::recordActions() declares them
(defaults to Edit); a resource picks any mix of links, server actions and
groups.DataTable renders the actions column and runs server actions via the
reusable Atrium\Action\Concern\InteractsWithActions trait: a server-driven
confirmation (no client JavaScript) gated by visibility on both request and
run, so a crafted request can neither surface nor execute a hidden action.
Rendering lives in reusable components/action{,s,_group}.html.twig partials.AtriumBundle (AbstractBundle) with path_prefix / brand configuration
and atrium.resource autoconfiguration.AdminResource, ResourceRegistry,
Column, DataProviderInterface, DoctrineDataProvider (stub).Column and ResourceRegistry.DoctrineDataProviderTest exercising count() and fetch()
offset/limit pagination against a real in-memory SQLite EntityManager
(EntityManagerFactory + Product fixture entity).KernelBootTest + AtriumTestKernel (MicroKernel): boots
FrameworkBundle + TwigBundle + AtriumBundle and asserts bundle registration,
ResourceRegistry wiring, autoconfiguration-based resource discovery
(RES-02), exposed configuration parameters, and [@Atrium](https://github.com/Atrium) Twig rendering.symfony/var-exporter (dev) and enabled Doctrine native lazy objects
in the test EntityManager (PHP 8.4+ requirement under ORM 3.6 / var-exporter 8).CODE_OF_CONDUCT.md (Contributor Covenant 2.1), GitHub
issue templates (bug report, feature request) and a pull request template.DataQuery value object; DataProviderInterface
now takes a query; DoctrineDataProvider builds a parameter-bound
QueryBuilder (search/sort/paginate/count); new in-memory ArrayDataProvider.Column value extraction + formatting (scalars, dates, enums, bool, arrays,
null) with a custom formatStateUsing() callback.DataTable Live Component (search bound to the URL, click-to-sort,
pagination, empty/loading states) and its Twig template.AdminController with parametric routes (/admin,
/admin/{resource}), Tailwind layout with registry-driven sidebar nav,
dashboard and resource pages. Doctrine wiring activates only when
DoctrineBundle is present.assets/dist/atrium.css, built from its own templates via the standalone
CLI — no Node) exposed through an AssetMapper path and an atrium_stylesheet()
Twig helper, so consumers need no Tailwind configuration. The JS entrypoint
is rendered via atrium_importmap().InteractsWithLiveComponents).symfony/ux-live-component, symfony/ux-twig-component,
symfony/routing and symfony/http-foundation to runtime requirements.Schema + fluent Field hierarchy: Text (email/url), Textarea, Number,
Select (static + optionsUsing() for dependent selects), Checkbox, Date,
DateTime — with normalize/format and Symfony-constraint validation.DataWriterInterface (DAT-03) with DoctrineDataWriter + in-memory
ArrayDataWriter; DataProviderInterface::find() for single-record loads.Form Live Component: hydrate/dehydrate via formData LiveProp, inline
validation (keeps last values), reactive ->live() fields driving dependent
selects, save through the writer, success notice / redirect. Field rendering
is split into per-type widget partials under components/form/widget/; each
Field declares its widget template via getTemplate() (and label placement
via rendersOwnLabel()), so third-party apps can add custom field types that
ship their own templates from any bundle — no change to the renderer.Page, ListPage, CreatePage, EditPage) with a
getRedirectUrl() hook and PageContext; AdminResource::pages(). Generic
dispatcher routes — /admin/{resource}/new and /admin/{resource}/{id}/edit
— resolve resource + action → Page → embed the Form component, so adding a
CRUD resource needs no route registration.symfony/validator as a runtime requirement.docs/integration-guide/forms/custom-fields.md — documents the custom field type extension point
(the getType() / getTemplate() / rendersOwnLabel() contract, the widget
template context, and a worked CountrySelect example).SCH-01..08). A form Schema is now a
tree of Atrium\Layout\Components (fields + layout containers), not a flat
list.
Atrium\Layout subsystem (view-agnostic, reusable by future
dashboards/infolists): Component contract, LayoutComponent base,
Grid/Flex/Section/Fieldset, and placement concerns HasColumnSpan
(columnSpan()/columnSpanFull()) and HasGrow (grow() for Flex
children; Flex::from() sets the row's breakpoint).Schema::components([...]) builds the tree; Schema::fields([...]) is kept
as the flat shortcut. Schema::getFields() flattens leaves depth-first for
hydration/validation; getComponents() exposes the tree for rendering.components/layout/*.html.twig) lays nodes out
in responsive grids; grid utilities are safelisted in assets/atrium.css
([@source](https://github.com/source) inline(...)) since they're composed in PHP.FRM-08, FRM-09) + the Get
state accessor (FRM-11).
Atrium\Form\Get — an invokable read accessor over the form state
($get('field')), handed to field callbacks.Atrium\Form\Concern\HasVisibility on Field: visible(bool|Closure),
hidden(bool|Closure), visibleOn(op), hiddenOn(op) — all
server-evaluated during the Live Component re-render (no client logic).Form component filters hidden fields out of the render tree (cloning
containers, dropping any left empty) and skips them in validation/hydration;
it exposes operation() (create/edit).Set accessor, validation DX and
presentation niceties (FRM-10..13).
Atrium\Form\Set — invokable write accessor over form state ($set('f', v)).Atrium\Form\Concern\HasReactivity on Field: afterStateUpdated(Closure)
(auto-implies live()); the callback gets ($state, Get, Set) and can derive
one field from another (e.g. SKU from name). The Form component diffs
formData against the previous render in a #[PreReRender] pass to detect the
changed field — robust to per-field sub-path model writes.Atrium\Form\Concern\HasValidationRules on Field: maxLength(),
minLength(), length(), regex() (→ Length/Regex); NumberField gains
min()/max() (→ GreaterThanOrEqual/LessThanOrEqual). rules([...])
stays the escape hatch.placeholder() (Text/Textarea/Number via HasPlaceholder),
autofocus(), hiddenLabel() on Field; widgets + wrapper updated.SCH-10). Atrium\Layout\Wizard + Atrium\Layout\Step,
a multi-step container, plus a WizardForm Live Component.
WizardForm extends Form: the wizard interaction (Next/Back, gated
advancement, a header that jumps backwards) is layered on the unchanged
hydrate/validate/save core. Form was made extensible for this — non-final,
with protected collectErrors()/fieldsIn()/schema() and a
focusContainer() hook the subclass overrides to focus an errored step.currentSteps prop); Next
validates only the current step's fields before advancing, Back is free,
and the wizard owns submit (Submit appears on the last step). All panels
render (inactive hidden), so navigating keeps in-progress input.AdminResource::getFormComponentName() — Atrium:Form by default, upgrading
to Atrium:WizardForm when the schema contains a Wizard. The form page
embeds it dynamically ({{ component(resource.formComponentName, …) }}).Step::make('Label')->icon()->description()->columns()->schema([…]).SCH-10). Atrium\Layout\Tabs + Atrium\Layout\Tab,
a tabbed container on the same schema tree.
selectTab
live action + an activeTabs prop), so it survives unrelated re-renders and
needs no client JavaScript. Every panel is rendered each request with
inactive ones hidden, so switching only flips an attribute — inputs in
other tabs stay in the DOM and keep their in-progress values across the
morph. A failed save focuses the first tab holding a validation error.Tab::make('Label')->icon(…)->badge(…)->columns(…)->schema([…]); Tabs has a
stable id (auto-derived from its tabs, or ->id()) used to key the state.FRM-12, FRM-13,
FLD-06, FLD-07).
->same(field) / ->different(field) (FRM-12) — cross-field comparison
rules the Form evaluates against the full submitted state (a Symfony
constraint can't see a sibling field); each takes an optional custom message.->inlineLabel() (FRM-13) — render a field's label beside the input in a
responsive column instead of above it.FLD-06/FLD-07): one input
per tag (a key + value input per pair), with add/remove handled by generic
addRow/removeRow Live Component actions. Fields opt in via the new
Atrium\Form\Field\RepeatableField contract, so the renderer stays generic;
empty rows are dropped on save by the existing normalize().dehydrated(false), and the
Tags / Key-value fields.
visible()/hidden()/visibleOn()/hiddenOn()
now apply to layout containers too (a hidden Section/Grid/Flex/Fieldset
drops its whole subtree). Visibility moved to
Atrium\Layout\Concern\HasVisibility over a generic Atrium\Layout\StateAccessor
(implemented by Atrium\Form\Get), keeping Atrium\Layout free of any form
dependency.Field::dehydrated(false) (FRM-14) — a field shown and validated but not
written to the model on save.TagsField (FLD-06, list<string>) and KeyValueField (FLD-07,
array<string,string>) — zero-JS, server-driven (comma string / key: value
lines) with chip / preview rendering.FLD-01..05): RadioField,
ToggleButtonsField (both extend SelectField), ToggleField (a switch,
extends CheckboxField), ColorField, and HiddenField. Field::rendersInLayout()
(default true; false for HiddenField) keeps a hidden value in the form state —
validated and persisted — while drawing no widget and taking no grid cell.
Widgets under components/form/widget/{radio,toggle,color,toggle_buttons}.html.twig.CNT-01..03) — static building blocks for a schema
(Atrium\Content\Text, UnorderedList, Image). They implement the shared
Atrium\Layout\Component contract but carry no form state: never hydrated or
validated, and skipped by Schema::getFields(). Text supports semantic
colour, size, weight, badge and raw-HTML modes. Useful for headings,
instructions and callouts placed beside fields (with columnSpan/grow).[@Atrium](https://github.com/Atrium)/icon.html.twig set) and a filled
active state; a sticky, blurred topbar.lg with colour transitions. All accents use primary.primary colour
(default palette "blue-energy"). Templates only use *-primary-* classes, so
re-skinning is one change — edit the eleven --color-primary-* values in
assets/atrium.css and composer build-css, or override --color-primary-*
at runtime in a stylesheet loaded after atrium.css (no rebuild; the utilities
resolve the CSS variables). Replaces the previous hard-coded indigo accent.Field types (TextField, TextareaField, NumberField,
CheckboxField, SelectField, DateField, DateTimeField) are now
non-final so applications can subclass them to add custom fields; the
abstract Field remains the public contract.max-w-2xl constraint).Field::getTemplate() now returns the field's layout
wrapper; the input widget moved to the new Field::getWidgetTemplate(). Custom
field types that shipped their own widget should override getWidgetTemplate()
instead of getTemplate() (see docs/integration-guide/forms/custom-fields.md).How can I help you explore Laravel packages today?