Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
Filament Page Builder Canvas
carljanzell/filament-page-builder
claude-code
laravel
drag-and-drop
page-builder
livewire
filament
filament-plugin

src/Filament/Pages/DesignPage.php is an abstract Filament resource page. A consumer subclasses it, sets only protected static string $resource, and registers it as 'design' => DesignPage::route('/{record}/design'). It is the only server-side write path for the canvas. Everything stays in memory...

Install
php artisan boost:add-skill carljanzell/filament-page-builder

Save this content to: .claude/skills/filament-page-builder-canvas/SKILL.md

# Canvas: `DesignPage` and undo history

`src/Filament/Pages/DesignPage.php` is an **abstract** Filament resource page. A consumer subclasses it, sets only `protected static string $resource`, and registers it as `'design' => DesignPage::route('/{record}/design')`. It is the only server-side write path for the canvas. Everything stays in memory in Livewire until an explicit `save()`, because a database write per drag would put a round trip in the middle of the gesture.

Related: [STORAGE.md](STORAGE.md) (the tree ops it calls), [FRONTEND.md](FRONTEND.md) (the JS that calls it), [BLOCKS.md](BLOCKS.md) (`isVisible()` and editables).

---

## State

| Property | Holds | Why it matters |
|---|---|---|
| `$blocks` | The whole flat tree in document order | Round-trips to the browser **on every request**. This is why history is *not* kept here. |
| `$selectedId` | The selected block's id | Read by the JS (`$wire.selectedId`) for keyboard shortcuts. |
| `$blockData` | Inspector form state for **the selected block only** | The form's `statePath`. Its shape differs from the stored data (TipTap doc, upload arrays). |
| `$blockSettings` | Style-tab state for the selected block | Bound with `wire:model.live="blockSettings.{token}"`. |
| `$isDirty` | Unsaved changes flag | Read by the JS `beforeunload` / `livewire:navigate` guards. |

Computed properties (`getXProperty`): `rootBlocks` (the decorated tree, adding `view`, `label`, `isKnown`, `hasContent`, `isContainer`, `slotNames`, `children[slot]`), `structure` (the outline, built from `rootBlocks` via `slotNames`, so ghosts are excluded), `palette` (`registry()->visible()`), `paletteGroups`, and `canUndo` / `canRedo` (session reads). `renderableBlocks` (flat, decorated) is documented public API, but **nothing in the package reads it**.

---

## Mutations

| Method | Authorisation gate | Notes |
|---|---|---|
| `moveBlock($id, $to, $parent, $slot)` | **none on type, deliberately**; `canPlace($parent, $slot)` | An editor may reorder or remove a block they can't author, and retired types can be moved. `BlockTree::move` refuses cycles and depth. A parent must be a container that currently exposes `$slot` (or both null for the page root). |
| `insertBlock($type, $at, $parent, $slot)` | `registry()->isVisible($type)` + `canPlace` | A palette *click* with a selection inserts at the end of a container's first slot, or as the next sibling of a leaf. An explicit drop passes `$at`/`$parent`/`$slot` and wins. It seeds `data` from `defaults()` and selects the new block. |
| `duplicateBlock($id)` | `isVisible` on the source **and every descendant** | Refuses retired types and any subtree that holds a type the user may not author. |
| `removeBlock($id)` | **none, deliberately** | Removes the subtree. It deselects if the selection was inside it, and it has no server-side confirmation (the JS confirms). |
| `revealGhost($id)` | via `moveBlock` | Orphans become roots. Hidden-slot children land in the parent's last visible column. |
| `setBlockField($id, $field, $value)` | `isVisible` + declared in `editables()` + `Editable::accepts($value)` + not `richText` | The inline-edit path. The markup's `@editable` is **not** trusted. `richText` is refused until TipTap is mounted. |
| `commitSelectedBlock()` | skips `!isVisible` types | Otherwise the inspector renders no fields and the empty state **wipes the content**. |
| `commitSelectedSettings()` | token allow-list | See "Style settings" below. |

**The shape every mutation follows:** compute `$next` → `return` if `$next === $this->blocks` → `remember()` → assign → `syncDirty()`. Dirty is `$blocks !== $savedBlocks`. Record history **only once you know something will change**. A step recorded for a no-op makes the next undo appear to do nothing (test: `does not record a commit that changes nothing`).

**Two authorisation layers:** `Resource::canEdit($record)` (a 403 from `authorizeAccess()` at mount) decides who may open the canvas at all, and `PageBlock::isVisible()` decides who may *author* a type. Hiding a block from the palette is presentation only, since every mutation is reachable over the wire. That's why each authoring method re-checks `isVisible` rather than `has()`.

---

## The inspector: commit and sync rules

The form is `->components(fn () => $this->selectedBlockSchema())->statePath('blockData')`, a closure over the current selection.

- **Commit through `getState()`, never raw state.** `getState()` turns a TipTap document into HTML and an upload array into a path, and runs the dehydration hook that **moves an upload out of temporary storage**. Committing raw state writes a document array into the database and breaks every renderer.
- **`getState()` validates.** A half-filled block throws `ValidationException`. The fallback commits `normaliseData($this->blockData)` but **restores the stored value for each declared top-level file field**, because an in-progress upload has only a temporary URL, and storing it leaves a dead link. The fallback passes no `fileFields`, so a nested (repeater) upload in an invalid block commits its raw upload state.
- **`syncInspector()` never commits.** Undo replaces `$blocks` while the inspector still holds the state that was just undone, so committing would write it straight back (test: `undoes a content edit without the inspector writing it back`).
- **Drop the cached schema before filling it:** `$this->cacheSchema('form', null)` and then `form->fill()`. The schema is cached per request and built from `$selectedId`. Changing the selection mid-request leaves it pointing at the previous block (or at none), the inspector hydrates empty, and the next commit wipes the block. `setBlockField()` does the same when it edits the selected block.
- **Filament fields are deferred by default.** Text typed into the inspector but not yet sent rides along with the *next* request (select, undo, save). `updatedBlockData()` commits it **before** that action runs, so an undo immediately after typing undoes the typing.
- **`selectBlock()` commits data and then settings for the outgoing block** before it switches.

---

## Style settings

`commitSelectedSettings()` rebuilds `settings` from scratch. For each token in `styleTokens()`, it keeps the submitted value only if it's a string that appears in that token's allowed values. A crafted `'13px lime'` is dropped, and choosing `Default` (`''`) removes the key.

- Allowed values come from `tokenValues()`: the values of a **list** (`['sm', 'md']`) or the **keys** of a map (`['sm' => 'Small']`). A map with **integer keys that isn't a list** (`[1 => 'One', 2 => 'Two']`) can never validate. The `<select>` submits the label, and the strict `in_array` check against int keys fails. Use string keys.
- `styleTokens()` **replaces** the defaults (`padding` / `background` / `width` / `align`) rather than merging with them. The package CSS styles only the default names, so custom tokens need CSS in the app. See [RENDERING.md](RENDERING.md) for which token names ever reach the HTML.

---

## Undo history (`src/Support/BlockHistory.php`)

It lives in the **session** under `filament-page-builder.history`, not in a Livewire property. Thirty snapshots of a block array that runs to tens of kilobytes would otherwise put megabytes on the wire for each request.

| Fact | Consequence |
|---|---|
| `LIMIT = 30` | The 31st-oldest step is gone |
| One stack, tagged `owner` = the Livewire component id | `mount()` calls `clear()`. Opening the canvas in a second tab wipes the first tab's history, and the first tab's next push wipes the second's |
| A new push empties `future` | The usual redo-branch abandonment |
| `travel()` calls `syncDirty()` | Dirty clears when `$blocks` matches the public `$savedBlocks` snapshot from the last successful save |
| The session holds 30 × the page size | A **cookie** session driver will overflow browser cookie limits. The canvas needs a server-side driver (file, database, redis) |

---

## `save()`

It commits the inspector (data, settings, then anchor), refreshes the record, and **refuses** if `updated_at` no longer matches `$loadedUpdatedAt`. Otherwise it runs `$record->{blocksAttribute()} = $this->blocks; $record->save();`, refreshes the lock timestamp, calls `markSaved()`, and sends a success notification.

- It writes `$blocks` **as-is**: retired types, ghosts, application keys and all the tree keys. **Never prune at persist time.** Commit `83310f3` fixed a canvas that filtered unknown types on load and destroyed them on save. Skip at *render* time only.
- It bypasses the resource's form lifecycle: no validation and no `mutateFormDataBeforeSave`. Model observers and events **do** fire.
- Duplicate ids in the raw column are reminted on mount; the canvas is marked dirty so the editor can persist both copies.

---

## The full-screen shell

| Override | Why |
|---|---|
| `getHeading()` → `''`, `getBreadcrumbs()` → `[]` | The editor's own chrome owns the top. The browser tab still uses `getTitle()` → `Design: {record}`. |
| `getMaxContentWidth()` → `Width::Screen` | |
| `getExtraBodyAttributes()` adds `fpb-edit-mode` | The CSS hook that hides Filament's sidebar, topbar and header. See [FRONTEND.md](FRONTEND.md). |
| `getPageClasses()` → `['fpb-editor-page']`, **never `fpb-page`** | `fpb-page` marks the *public* wrapper, and rules hung off it strip a slot back to a bare grid cell. Putting it on the editor erased the drop wells (incident `dc08dbc`). |
| `exitUrl()` | The resource's `index` page if it has one, otherwise the edit page. |
| `formEditorUrl()` | `null` when the resource has no `edit` page, because the canvas may be the only editing surface (fixture `LayoutPageResource`). Never link to the edit page unconditionally. On a nested page the link asks for confirmation first, so it carries `:spa-mode="false"`. Livewire's `wire:navigate` fires on mousedown and mouseup, before the click handler, so on an SPA panel Cancel came too late. |

`styleTokens()`, `canvasStylesView()` and the `blocksAttribute()` fallback all call `FilamentPageBuilderPlugin::get()` → `filament('page-builder')`. **The design page must live on a panel that registered the plugin**, or those calls throw.

---

## Naming traps

- **`prepareBlocks()`, not `hydrateBlocks()`.** Livewire treats `hydrate{Property}` / `dehydrate{Property}` / `updating{Property}` / `updated{Property}` as lifecycle hooks for **any** public property: `$blocks`, `$selectedId`, `$blockData`, `$blockSettings`, `$isDirty`. A helper named `hydrateBlocks` gets called on every request. `updatedBlockData()` and `updatedBlockSettings()` are the only deliberate hooks.
- Every public method is callable from the browser. Anything that shouldn't be callable (like `remember()`, `syncInspector()` or `travel()`) must stay `protected`.

Canvas: DesignPage and undo history

src/Filament/Pages/DesignPage.php is an abstract Filament resource page. A consumer subclasses it, sets only protected static string $resource, and registers it as 'design' => DesignPage::route('/{record}/design'). It is the only server-side write path for the canvas. Everything stays in memory in Livewire until an explicit save(), because a database write per drag would put a round trip in the middle of the gesture.

Related: STORAGE.md (the tree ops it calls), FRONTEND.md (the JS that calls it), BLOCKS.md (isVisible() and editables).


State

Property Holds Why it matters
$blocks The whole flat tree in document order Round-trips to the browser on every request. This is why history is not kept here.
$selectedId The selected block's id Read by the JS ($wire.selectedId) for keyboard shortcuts.
$blockData Inspector form state for the selected block only The form's statePath. Its shape differs from the stored data (TipTap doc, upload arrays).
$blockSettings Style-tab state for the selected block Bound with wire:model.live="blockSettings.{token}".
$isDirty Unsaved changes flag Read by the JS beforeunload / livewire:navigate guards.

Computed properties (getXProperty): rootBlocks (the decorated tree, adding view, label, isKnown, hasContent, isContainer, slotNames, children[slot]), structure (the outline, built from rootBlocks via slotNames, so ghosts are excluded), palette (registry()->visible()), paletteGroups, and canUndo / canRedo (session reads). renderableBlocks (flat, decorated) is documented public API, but nothing in the package reads it.


Mutations

Method Authorisation gate Notes
moveBlock($id, $to, $parent, $slot) none on type, deliberately; canPlace($parent, $slot) An editor may reorder or remove a block they can't author, and retired types can be moved. BlockTree::move refuses cycles and depth. A parent must be a container that currently exposes $slot (or both null for the page root).
insertBlock($type, $at, $parent, $slot) registry()->isVisible($type) + canPlace A palette click with a selection inserts at the end of a container's first slot, or as the next sibling of a leaf. An explicit drop passes $at/$parent/$slot and wins. It seeds data from defaults() and selects the new block.
duplicateBlock($id) isVisible on the source and every descendant Refuses retired types and any subtree that holds a type the user may not author.
removeBlock($id) none, deliberately Removes the subtree. It deselects if the selection was inside it, and it has no server-side confirmation (the JS confirms).
revealGhost($id) via moveBlock Orphans become roots. Hidden-slot children land in the parent's last visible column.
setBlockField($id, $field, $value) isVisible + declared in editables() + Editable::accepts($value) + not richText The inline-edit path. The markup's @editable is not trusted. richText is refused until TipTap is mounted.
commitSelectedBlock() skips !isVisible types Otherwise the inspector renders no fields and the empty state wipes the content.
commitSelectedSettings() token allow-list See "Style settings" below.

The shape every mutation follows: compute $next → return if $next === $this->blocks → remember() → assign → syncDirty(). Dirty is $blocks !== $savedBlocks. Record history only once you know something will change. A step recorded for a no-op makes the next undo appear to do nothing (test: does not record a commit that changes nothing).

Two authorisation layers: Resource::canEdit($record) (a 403 from authorizeAccess() at mount) decides who may open the canvas at all, and PageBlock::isVisible() decides who may author a type. Hiding a block from the palette is presentation only, since every mutation is reachable over the wire. That's why each authoring method re-checks isVisible rather than has().


The inspector: commit and sync rules

The form is ->components(fn () => $this->selectedBlockSchema())->statePath('blockData'), a closure over the current selection.

  • Commit through getState(), never raw state. getState() turns a TipTap document into HTML and an upload array into a path, and runs the dehydration hook that moves an upload out of temporary storage. Committing raw state writes a document array into the database and breaks every renderer.
  • getState() validates. A half-filled block throws ValidationException. The fallback commits normaliseData($this->blockData) but restores the stored value for each declared top-level file field, because an in-progress upload has only a temporary URL, and storing it leaves a dead link. The fallback passes no fileFields, so a nested (repeater) upload in an invalid block commits its raw upload state.
  • syncInspector() never commits. Undo replaces $blocks while the inspector still holds the state that was just undone, so committing would write it straight back (test: undoes a content edit without the inspector writing it back).
  • Drop the cached schema before filling it: $this->cacheSchema('form', null) and then form->fill(). The schema is cached per request and built from $selectedId. Changing the selection mid-request leaves it pointing at the previous block (or at none), the inspector hydrates empty, and the next commit wipes the block. setBlockField() does the same when it edits the selected block.
  • Filament fields are deferred by default. Text typed into the inspector but not yet sent rides along with the next request (select, undo, save). updatedBlockData() commits it before that action runs, so an undo immediately after typing undoes the typing.
  • selectBlock() commits data and then settings for the outgoing block before it switches.

Style settings

commitSelectedSettings() rebuilds settings from scratch. For each token in styleTokens(), it keeps the submitted value only if it's a string that appears in that token's allowed values. A crafted '13px lime' is dropped, and choosing Default ('') removes the key.

  • Allowed values come from tokenValues(): the values of a list (['sm', 'md']) or the keys of a map (['sm' => 'Small']). A map with integer keys that isn't a list ([1 => 'One', 2 => 'Two']) can never validate. The <select> submits the label, and the strict in_array check against int keys fails. Use string keys.
  • styleTokens() replaces the defaults (padding / background / width / align) rather than merging with them. The package CSS styles only the default names, so custom tokens need CSS in the app. See RENDERING.md for which token names ever reach the HTML.

Undo history (src/Support/BlockHistory.php)

It lives in the session under filament-page-builder.history, not in a Livewire property. Thirty snapshots of a block array that runs to tens of kilobytes would otherwise put megabytes on the wire for each request.

Fact Consequence
LIMIT = 30 The 31st-oldest step is gone
One stack, tagged owner = the Livewire component id mount() calls clear(). Opening the canvas in a second tab wipes the first tab's history, and the first tab's next push wipes the second's
A new push empties future The usual redo-branch abandonment
travel() calls syncDirty() Dirty clears when $blocks matches the public $savedBlocks snapshot from the last successful save
The session holds 30 × the page size A cookie session driver will overflow browser cookie limits. The canvas needs a server-side driver (file, database, redis)

save()

It commits the inspector (data, settings, then anchor), refreshes the record, and refuses if updated_at no longer matches $loadedUpdatedAt. Otherwise it runs $record->{blocksAttribute()} = $this->blocks; $record->save();, refreshes the lock timestamp, calls markSaved(), and sends a success notification.

  • It writes $blocks as-is: retired types, ghosts, application keys and all the tree keys. Never prune at persist time. Commit 83310f3 fixed a canvas that filtered unknown types on load and destroyed them on save. Skip at render time only.
  • It bypasses the resource's form lifecycle: no validation and no mutateFormDataBeforeSave. Model observers and events do fire.
  • Duplicate ids in the raw column are reminted on mount; the canvas is marked dirty so the editor can persist both copies.

The full-screen shell

Override Why
getHeading() → '', getBreadcrumbs() → [] The editor's own chrome owns the top. The browser tab still uses getTitle() → Design: {record}.
getMaxContentWidth() → Width::Screen
getExtraBodyAttributes() adds fpb-edit-mode The CSS hook that hides Filament's sidebar, topbar and header. See FRONTEND.md.
getPageClasses() → ['fpb-editor-page'], never fpb-page fpb-page marks the public wrapper, and rules hung off it strip a slot back to a bare grid cell. Putting it on the editor erased the drop wells (incident dc08dbc).
exitUrl() The resource's index page if it has one, otherwise the edit page.
formEditorUrl() null when the resource has no edit page, because the canvas may be the only editing surface (fixture LayoutPageResource). Never link to the edit page unconditionally. On a nested page the link asks for confirmation first, so it carries :spa-mode="false". Livewire's wire:navigate fires on mousedown and mouseup, before the click handler, so on an SPA panel Cancel came too late.

styleTokens(), canvasStylesView() and the blocksAttribute() fallback all call FilamentPageBuilderPlugin::get() → filament('page-builder'). The design page must live on a panel that registered the plugin, or those calls throw.


Naming traps

  • prepareBlocks(), not hydrateBlocks(). Livewire treats hydrate{Property} / dehydrate{Property} / updating{Property} / updated{Property} as lifecycle hooks for any public property: $blocks, $selectedId, $blockData, $blockSettings, $isDirty. A helper named hydrateBlocks gets called on every request. updatedBlockData() and updatedBlockSettings() are the only deliberate hooks.
  • Every public method is callable from the browser. Anything that shouldn't be callable (like remember(), syncInspector() or travel()) must stay protected.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel