From a staff member’s point of view
On a draft-backed edit page (Customers, Discounts, and other first-party record forms):- Typing in a field autosaves shortly after the field stops changing — there is no explicit save button for the drafted fields themselves. A small status note near the form shows “Saving draft…” then “Draft saved”.
- Leaving the page with unsaved changes (closing the tab, following a link elsewhere) prompts first; navigating away anyway keeps the edits as a draft rather than discarding them.
- Returning to the same record later restores the draft automatically, with a banner noting when it was saved and a Discard action to drop it and revert to the saved values.
- Clicking Save (or the page’s equivalent commit action) commits the draft. If nobody else touched the same fields in the meantime, it saves normally.
- If another staff member changed one of the same fields since this draft started tracking it, a conflict dialog appears listing only the conflicting fields — not the whole form — with the value the user typed, the current database value, and a choice to keep their own value or take the other one. Fields neither staff member touched in common are never affected.
Opting a resource in
Drafting is not automatic per model — aSection registers a DraftableResource definition describing exactly which fields it drafts and how they read and write. Return definitions from Section::draftables():
The DraftableResource contract
Lunar\Panel\Drafts\DraftableResource rather than implementing the interface directly — it supplies sensible defaults for normalize() (a no-op passthrough) and labels() (an empty map, so the conflict dialog falls back to the raw field key).
model()— the Eloquent model class this definition drafts.fields()— the allowed field keys. Autosave and commit reject any key outside this set. A field key is usually a column name, but can be anything the resource defines meaning for — a relation-backed key likecustomer_group_ids, or a whole JSON payload treated as one field.currentValues(Model $record)— the record’s current value for every field key, normalized the same waynormalize()shapes incoming draft data. This is the baseline conflict detection compares against.normalize(array $data)— shapes incoming draft values into the same formcurrentValues()reports, so equality comparison between “what the draft has” and “what the database has” is meaningful (for example, coercing an empty string tonullfor a nullable text column, or sorting a relation’s id array).rules(Model $record)— validation rules for a full commit payload, run before conflict detection.commit(Model $record, array $values)— applies a validated, conflict-free value set to the record. Always delegate to the core action contracts here; the panel layer never writes model fields directly.labels()— field key to lang key, used to label a field in the conflict dialog.
Worked example
Lunar\Panel\Sections\Sales\CustomerDraftResource drafts the customer edit form’s scalar columns plus the pivot-backed customer_group_ids:
commit() delegates to UpdatesCustomer, the core action contract — the draft layer never bypasses it to write columns directly.
Wiring the routes
A draftable resource needs three routes, registered inside the owning section’s route closure next to the record’s existing CRUD routes, all handled by the panel’s sharedEditDraftController:
There is no
GET route for a draft: the edit page’s own Inertia response includes a draft prop ({data, updated_at}, or null when there is none) for the current staff member, resolved as part of the normal page load.
The JS side
Drafted edit pages useuseEditDraft in place of Inertia’s useForm, both exported from @lunarphp/panel.
useEditDraft exposes:
values— reactive form state, the pristine record overlaid with any restored draft.isDirty,saving,committing,savedAt,hasDraft,restoredFrom— status for the autosave indicator and the restored-draft banner.errors— validation errors from a failed commit.conflicts— the per-field conflict set from a 409 response.commit()— sends the current diff immediately (not waiting out the autosave debounce) and, on success, reloads the page so the server’s session flash message shows.resolve(resolutions, rebase)— re-commits after the staff member resolves conflicts, pinning each resolved field’srebasevalue to the current database value they were shown (so a further change to the same field between resolving and re-committing conflicts again, rather than being silently overwritten).discard()— reverts local values to pristine and deletes the draft.
values with a debounce (roughly 750ms by default), sends only the changed fields, and serialises requests so a slower, older response can never clobber a newer one. A diff that empties back out (the user undid their own change) triggers a DELETE instead of a PATCH.
Two ready-made components pair with the composable:
DraftActions— the small “Saving draft… / Draft saved” status plus save/discard controls, driven entirely from theEditDraftFormobjectuseEditDraftreturns.DraftConflictDialog— the resolution dialog: one row per conflicting field, the drafted value and the current database value side by side, keep-mine / take-theirs choices, and aresolveevent carrying the resolutions and rebase payload back to the caller.
@lunarphp/panel alongside useEditDraft, so an add-on’s own drafted edit page builds on exactly the same pieces as a first-party one.
Expiry
Drafts left untouched are pruned automatically:lunar.panel.drafts.ttl_days (config/lunar/panel.php, default 7) controls how many days of inactivity mark a draft stale enough to discard. This runs as part of the panel’s scheduled maintenance, alongside its other prune tasks — no separate command to run.
Drafts are invisible to anyone but their owner until committed. There is no “someone has unsaved changes” indicator on a record’s list row, and no locking — a second staff member can open and start editing the same record freely; the conflict dialog only appears if both save the same field.