The stance on extending first-party forms
A slot can put an add-on’s component on the customer or product page, but on its own that component cannot take part in the page’s save. Anything staff type into it is lost on navigation, cannot be committed alongside the record, and gets none of the validation or conflict detection the form gives first-party fields. A slice fixes that without opening first-party forms to modification. The panel’s stance is one sentence: an add-on can add fields to any first-party form’s save, under a namespace it owns, and can never read, hide, disable, relax, or replace a field the panel owns. The kind of form decides the plumbing, not the contract. On a drafted edit page the add-on’s fields also autosave, restore, and conflict-check; on a plain create or settings form they post with it. Either way, add-ons add and only the host subtracts, so two add-ons installed together cannot break each other.The key scheme
Every form field has a key. A slice’s fields live under{namespace}:{field}:
- First-party slices, registered by the panel’s own sections, own a bare namespace such as
attributeorchannel. - Slices registered by an add-on through
Section::formExtensions()are placed underaddon:{key}, where{key}is the slice’skey(). Their full keys areaddon:{key}:{field}.
tier, the panel stores addon:example-addon:tier, and the slice’s commit() receives tier again. Because prefixing happens in the composer rather than in the slice, there is no API through which a slice could name a key outside its namespace. The addon namespace is reserved and cannot be claimed as a bare namespace.
Registering a slice
Return slice classes from aSection or SectionExtension’s formExtensions() hook:
Lunar\Panel\PanelManager resolves each class from the container and indexes it by the model it declares, then by namespace. Registration rejects a malformed key (it must match [a-z0-9_-]+), a first-party slice claiming addon, and a second class claiming a namespace another class already holds on the same model. Each throws an InvalidArgumentException at boot naming the classes involved, so a clash between two add-ons surfaces immediately rather than as a silent overwrite.
formSlices() is the sibling hook the panel’s own sections use to register slices under a bare namespace. It exists on Section and SectionExtension too, but an add-on should use formExtensions(). Nothing stops a service provider calling the first-party hook, since it runs with the same privileges as the panel’s own; the public hook simply does not offer a bare namespace.The FormSlice contract
Lunar\Panel\Forms\FormSlice rather than implementing the interface directly. It supplies a passthrough normalize() and an empty labels().
The slice persists its own values because it is the only thing that knows where its data lives. The panel never writes an add-on’s storage, and a post-save hook outside the transaction would leave ordering and atomicity to chance.
commit() is that hook, typed, ordered after the record’s own commit, and inside the same transaction.
DraftSlice for draft-only state
Lunar\Panel\Contracts\DraftSlice extends FormSlice adds one method, discard(Model $record, EditDraft $draft), called when a draft holding the slice’s keys is discarded, pruned, or orphaned by its record’s deletion. Only a slice that keeps state outside the draft’s JSON columns (staged uploads, for instance) needs it. There is no abstract for it: the slice extends Lunar\Panel\Forms\FormSlice and implements the contract, class StagedMediaSlice extends FormSlice implements DraftSlice. A plain FormSlice composes into a draft unchanged, so most slices never need this.
Worked example
LoyaltyTierSlice in lunarphp/panel-addon-example adds a loyalty tier to the customer form. The tier is kept in the customer’s meta column so the example stays schema-free; a real add-on would persist to its own table through its own action.
addon:example-addon:tier, on the edit page and the create page alike. Nothing in the slice knows that.
The JS side
The add-on edits its slice from a component placed on the page through a slot.useFormSlice and usePanelForm ship in @lunarphp/panel 0.2.1 and later, so an add-on that uses them depends on "@lunarphp/panel": "^0.2.1". Every zone on a form page sits inside the page’s form, so the component binds with useFormSlice, exported from @lunarphp/panel, passing the slice’s key():
addon:example-addon: namespace on the page’s form, finds that form above it in the component tree, and returns a view scoped to the namespace. It throws a descriptive error when called outside a page that hosts slices, such as a listing page.
useFormSlice returns:
The composable never exposes the underlying form, so a component has no path to another namespace’s keys or to the record’s own fields. The server enforces the same boundary independently: a request carrying
example-addon:tier without the addon: prefix is rejected as an unknown field on a drafted page and ignored by the slice composer on a plain one.
Drafted edit pages
On the first-party edit pages (customers, products, brands, collections, product types, variants) the form is an edit draft. Every registered slice is seeded up front from the sharedformSliceValues prop, and its fields behave exactly like the record’s own:
- Autosave and restore. Values persist as staff type and come back when they return to the record, with the same restored-draft banner and Discard action.
- Dirty guard. Leaving the page with an unsaved tier prompts first.
- Conflict detection. If another staff member changes the tier before this draft commits, the page’s conflict dialog lists it under the label from
labels(), with keep-mine and take-theirs choices, and nothing commits until it is resolved. - Validation. A failed rule maps to
errors.tieron the bound component, and the whole commit is refused. - Atomic commit. Every slice’s
commit()runs after the record’s own inside one transaction. If one throws, the record’s changes roll back.
Plain create and settings forms
The create pages and the settings edit forms post a plain Inertia form and redirect. They compose the same slices, with three differences that follow from a plain form carrying only what it posts:- Binding claims the namespace. A slice’s keys join the form only when a component binds to it with
useFormSlice. A page nobody extends posts exactly what it did before. - Only submitted namespaces commit. The slice’s
commit()runs when the request holds at least one of its keys, with the slice’s current values overlaid by the submitted ones. Absent namespaces are untouched. - Slice rules apply when present. Each rule is composed under
sometimes, so an unbound namespace cannot fail validation. Within a bound namespace the slice’s rules apply in full,requiredincluded, and errors surface in the same round as the form’s own.
fields(), currentValues(), and rules() see a fresh, unsaved instance of the model, and commit() receives the record the store action created, inside its transaction. Nothing autosaves or conflict-checks, and discard() never runs.
The settings index pages’ inline create dialogs expose no slot zone, so there is nothing to bind there; the roles form, the auth and account forms, and the order view are not composed.
Inertia’s form data uses dot paths on plain forms, so a slice field named
a.b would nest. Keep field names free of dots; first-party fields have none.How values reach the page
Lunar\Panel\Http\Middleware\HandlePanelInertiaRequests shares a formSliceValues prop on every panel response: the prefixed current values of every slice on the page’s record, or an empty object when the page has no record or the model has no slices. The record is the deepest model bound to the route, so a product variant’s edit page seeds the variant’s slices rather than the product’s. Create pages bind no record, so their controllers pass the prop for a fresh instance.
useEditDraft merges the prop into its initial values automatically (opt out with slices: false on a page that drafts some record other than the route’s deepest binding). usePanelForm, which the plain-form pages use in place of Inertia’s useForm, seeds a namespace from it on first bind. Both are exported from @lunarphp/panel, so an add-on’s own pages can host slices the same way.
First-party slices
The panel uses the same contract for its own sub-surfaces on the product edit page, registered throughformSlices() under bare namespaces:
These are the proof that the add-on surface is complete: a first-party card and an add-on’s slot component take the same path.
Stale keys
A stored draft can carry keys whose slice is no longer registered, for example after an add-on is uninstalled or a channel is deleted. Such keys are dropped when the draft commits rather than blocking it, so a record never becomes unsavable because of a package that is no longer there.See also
- Edit Drafts — the draft layer a slice composes into on the edit pages, and how to draft a resource the add-on owns outright.
- Slots — placing the component that edits the slice.
- Testing an add-on —
tests/panel/Feature/ExampleAddonTest.phpin the monorepo exercises the loyalty tier through the real customer routes: autosave, conflict, and commit on the edit page, and the plain store on the create page.