> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lunarphp.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Form Slices

> How an add-on adds its own fields to a first-party form's save, on a drafted edit page or a plain create or settings form.

A form slice is a namespaced contribution to a first-party form's save: an add-on declares fields, rules, and a commit for a record it does not own, and the panel composes them into that form under a namespace of the add-on's own.

## The stance on extending first-party forms

A [slot](https://docs.lunarphp.com/2.x/admin/extending/slots.md) 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 `attribute` or `channel`.
* Slices registered by an add-on through `Section::formExtensions()` are placed under `addon:{key}`, where `{key}` is the slice's `key()`. Their full keys are `addon:{key}:{field}`.

The slice never sees the prefix. It declares `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 a `Section` or `SectionExtension`'s `formExtensions()` hook:

```php theme={null}
use LunarPanelExample\Drafts\LoyaltyTierSlice;

public function formExtensions(): array
{
    return [LoyaltyTierSlice::class];
}
```

`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.

> [!NOTE]
>
> `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

```php theme={null}
namespace Lunar\Panel\Contracts;

interface FormSlice
{
    /** @return class-string<Model> */
    public function model(): string;

    public function key(): string;

    /** @return array<int, string> */
    public function fields(Model $record): array;

    /** @return array<string, mixed> */
    public function currentValues(Model $record): array;

    /** @return array<string, mixed> */
    public function normalize(array $data): array;

    /** @return array<string, mixed> */
    public function rules(Model $record): array;

    public function commit(Model $record, array $values): void;

    /** @return array<string, string> */
    public function labels(): array;
}
```

Extend the abstract `Lunar\Panel\Forms\FormSlice` rather than implementing the interface directly. It supplies a passthrough `normalize()` and an empty `labels()`.

| Method | Purpose |
| :- | :- |
| `model()` | The Eloquent model whose forms this slice joins. |
| `key()` | The namespace, `[a-z0-9_-]+`, unique per model. An add-on's key is placed under `addon:`. |
| `fields(Model $record)` | The bare field names for this record. Takes the record because a row-shaped slice derives its field set from data (which channels exist, which currencies are enabled). On a create form the record is a fresh, unsaved instance. |
| `currentValues(Model $record)` | The current stored value of every field, keyed by bare name and normalized the same way `normalize()` shapes incoming data. Seeds the form, and on a drafted page is the baseline conflict detection compares against. |
| `normalize(array $data)` | Shapes incoming values so equality against `currentValues()` holds. Receives only this slice's keys. |
| `rules(Model $record)` | Validation rules keyed by bare field, `.*` entries included. The composer prefixes the keys; rule parameters pass through verbatim. |
| `commit(Model $record, array $values)` | Persists the slice's values: every field present, current values overlaid with the submitted ones, unprefixed. Runs after the form's own action inside the same transaction, so a slice that throws rolls the record's changes back too. On a drafted page, skipped when none of the slice's values changed. Delegate to a core action. |
| `labels()` | Bare field to lang key, for the conflict dialog and validation messages. |

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.

> [!TIP]
>
> A rule parameter that names one of the slice's own fields needs the full key, because the validator sees the composed payload. The abstract class provides `$this->field('other')` for exactly that, returning `addon:{key}:other` for an add-on slice.

### `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.

```php theme={null}
namespace LunarPanelExample\Drafts;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
use Lunar\Core\Contracts\Actions\Customers\UpdatesCustomer;
use Lunar\Core\Models\Customer;
use Lunar\Panel\Forms\FormSlice;

class LoyaltyTierSlice extends FormSlice
{
    public const TIERS = ['bronze', 'silver', 'gold'];

    public function __construct(protected UpdatesCustomer $updatesCustomer) {}

    public function model(): string
    {
        return Customer::class;
    }

    public function key(): string
    {
        return 'example-addon';
    }

    public function fields(Model $record): array
    {
        return ['tier'];
    }

    public function currentValues(Model $record): array
    {
        /** @var Customer $record */
        return ['tier' => $record->meta['loyalty_tier'] ?? null];
    }

    public function normalize(array $data): array
    {
        // The select submits '' for "no tier"; the stored value is null.
        if (array_key_exists('tier', $data) && $data['tier'] === '') {
            $data['tier'] = null;
        }

        return $data;
    }

    public function rules(Model $record): array
    {
        return ['tier' => ['nullable', Rule::in(self::TIERS)]];
    }

    public function commit(Model $record, array $values): void
    {
        /** @var Customer $record */
        $meta = $record->meta?->getArrayCopy() ?? [];
        $meta['loyalty_tier'] = $values['tier'] ?? null;

        $this->updatesCustomer->execute($record, ['meta' => $meta]);
    }

    public function labels(): array
    {
        return ['tier' => 'example-addon::example.loyalty_tier'];
    }
}
```

The customer form now carries `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](https://docs.lunarphp.com/2.x/admin/extending/slots.md). `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()`:

```vue theme={null}
<script setup lang="ts">
import { FieldLabel, Select, useFormSlice } from '@lunarphp/panel';

const slice = useFormSlice<{ tier: string | null }>('example-addon');
</script>

<template>
    <FieldLabel for="loyalty-tier">Loyalty tier</FieldLabel>
    <Select id="loyalty-tier" v-model="slice.values.tier" :invalid="!!slice.errors.value.tier">
        <option :value="null">No tier</option>
        <option value="bronze">Bronze</option>
        <option value="silver">Silver</option>
        <option value="gold">Gold</option>
    </Select>
    <p v-if="slice.errors.value.tier">{{ slice.errors.value.tier }}</p>
</template>
```

The composable resolves the key to the `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:

| Property | Purpose |
| :- | :- |
| `values` | A reactive object holding only this slice's fields, by bare name. `v-model="slice.values.tier"` reads and writes `addon:example-addon:tier` on the page's form. Writing to a field the slice did not declare throws. |
| `errors` | Validation errors for this namespace, keyed by bare field. |
| `field(name)` | A writable ref for one field, for components that prefer a ref to a property path. |
| `isDirty` | Whether any of the namespace's fields differ from their pristine value. Changes elsewhere on the page leave it `false`. |
| `saving` / `committing` | The page form's status refs, passed through for a local indicator. |

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](https://docs.lunarphp.com/2.x/admin/extending/edit-drafts.md). Every registered slice is seeded up front from the shared `formSliceValues` 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.tier` on 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, `required` included, and errors surface in the same round as the form's own.

On a create page, `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.

> [!NOTE]
>
> 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 through `formSlices()` under bare namespaces:

| Slice | Namespace | Fields |
| :- | :- | :- |
| `ProductAttributeSlice` | `attribute` | One per mapped attribute handle. |
| `ProductChannelSlice` | `channel` | One per channel id, each an availability row. |
| `ProductCustomerGroupSlice` | `customer_group` | One per customer group id, each an availability row with the purchasable flag. |
| `SoleVariantSlice` | `variant` | The sole variant's fields on a simple-shape product, refused outright once the product has several variants. |

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](https://docs.lunarphp.com/2.x/admin/extending/edit-drafts.md) — the draft layer a slice composes into on the edit pages, and how to draft a resource the add-on owns outright.
* [Slots](https://docs.lunarphp.com/2.x/admin/extending/slots.md) — placing the component that edits the slice.
* [Testing an add-on](https://docs.lunarphp.com/2.x/admin/extending/testing.md) — `tests/panel/Feature/ExampleAddonTest.php` in 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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.