Skip to content
MCG Filament CMS Demo

Known traps

This page lists failures that developers building on this package have encountered in practice, where the page renders, no exception is raised and no log line is written. It is written for a developer who has something that is not working and no error message to search for. Each failure surfaces later and indirectly: as a control in the panel that appears to do nothing, as markup that is wrong in a way a browser does not report, or as a feature that reads as switched off.

Every entry has the same three parts:

  1. Symptom: what is observed. The symptom is listed first because it is the information a developer has when looking for the entry.
  2. Cause: what the code does to produce the symptom.
  3. Fix: what to change, and how to confirm the change worked.

Width classes emitted with no CSS rule

Symptom

On the About page, which uses the wide layout, prose blocks stored as narrow were clamped to 721px in a 1280px viewport, while the same section on the home page ran full width.

Cause

rich_text, image and content_section each had their own width field before the shared width control existed, and emitted .cms-block--narrow and .cms-block--full. Neither class had a rule in the base theme or in any skin, so the classes had no effect for months. Adding rules for those class names later applied the stored values with no change to the blocks' code.

Fix

Before introducing a class name, check whether it already has a rule. Before writing a rule for a class name, check whether any block already emits it.

A published layout overrides the package's layout everywhere

Symptom

A change to one of the package's layouts appears to have no effect.

Cause

resources/views/vendor/cms/layouts/default.blade.php is registered ahead of the package's own layout directory, so that publishing a layout overrides it. The override applies to everything: every content type that does not name a custom layout, every preview, and every test that runs afterwards in the same process. A layout published experimentally and left in place serves every later request.

The same applies to resources/views/vendor/cms/blocks. Publishing every block view excludes all of them from changes made to those views in later package releases.

Fix

If a layout change has no effect, look in resources/views/vendor/cms/layouts first and delete any published layout you do not intend to keep. Publish only the block views you intend to change.

A slot the layout never echoes still renders

Symptom

Every link in every table of contents on a docs, sidebar or home page is dead: the contents list links to #content while the rendered heading carries id="content-2". Nothing appears duplicated on the page.

Cause

The content view passes the default region as the slot, so a layout with no region tags receives its blocks through {{ $slot }}. Blade evaluates slot contents whether or not the component echoes them. A layout that ignored the slot and called <x-cms::region name="main" /> itself therefore rendered every block in main twice: once into the discarded slot and once on the page. Only the second pass reached the page, but the discarded pass had already used the anchor content, so the visible heading received content-2. Every query those blocks make also ran twice.

Fix

The package fixes this in the content view, which now fills the slot only when the layout does not declare a main region of its own. The check reads what the template declared, not what LayoutRegions reports, because LayoutRegions reports main for every layout, including layouts that never contain the tag.

Specificity ties settled by source order

Symptom

A CSS rule that is present in cms.css has no effect on the page, and the element carries the class the rule names.

Cause

The rule is tied or beaten on specificity rather than ignored. Three cases were measured in cms.css:

  1. .cms-aside h2 is (0,1,1) and .cms-block--rich-text h2 is also (0,1,1). Source order settled the tie, which left a 3xl display heading in a 17rem sidebar column. Qualifying the selector with the block class raises it to (0,2,1).
  2. :where() contributes no specificity, so :where(.cms-block--hero) loses to .cms-block. The hero was left inset, with the page visible on either side of it. :is() contributes its argument's specificity. Use :is() when the selector must win and :where() when it must not.
  3. An existing .cms-block rule hard-coded max-w-6xl and sat below the new width rules, so every width preset resolved to 1152px even though the correct custom property was set on the element. The bleed rule lost to the same rule and had to be moved below it.

All three were found by measuring in a browser. A fourth case, involving a skin rule, is described under A skin rule winning a specificity tie.

Fix

  1. Calculate the specificity of the rule and of every rule that matches the same element before rewriting a rule that appears to be ignored.
  2. Raise the specificity or move the rule, depending on which rule should win.
  3. Check the result in a browser at a real viewport width.

A block view that drops $cmsAttrs

Symptom

The width preset, custom classes, anchor and ARIA role that an editor set in the panel have no effect on the page. The block's own content renders, validates, saves and publishes correctly.

Cause

The block's view does not merge $cmsAttrs into its root element. Those five controls appear in the panel and are stored correctly in the version. Only the final rendering step discards them. No error is raised, so the failure is usually reported later as a panel control that does not work.

The generator was fixed on 2026-08-07: cms:make-block emits the $cmsAttrs merge and the cms-block--{key} class, and two tests in GeneratorCommandsTest cover it. The previous stub wrote class="cms-block cms-block-{key}" (a single dash, which no stylesheet targets) and no $cmsAttrs. The same failure remains possible in any hand-written or forked view.

Fix

Keep the $cmsAttrs merge when forking a block view, and include it when writing one. A one-line test asserting on the width class detects its absence.

A temporary upload's real path is not a path on S3

Symptom

An upload fails with File does not exist, naming a path that was never local, while the file is in the bucket where it should be. The failure does not reproduce on a development machine.

Cause

Any host running more than one application container, Laravel Cloud included, must set livewire.temporary_file_upload.disk to S3, because the browser uploads the file to one container and submits the form to another.

TemporaryUploadedFile::getRealPath() returns Storage::disk($disk)->path($path). On a local disk that value is an absolute filename. On S3 it is the bare object key. Code that passes that value to an API expecting a local file produces the error above.

A development machine has the temporary upload disk set to local, where getRealPath() returns a correct filename, so the code works until it is deployed.

Fix

Wrap the path-taking call in TemporaryUpload::withLocalCopy(). It passes your callback a local path and deletes the temporary copy afterwards, including when the callback throws. When the temporary disk is already local it copies nothing and passes the existing path, so it can be used on every upload:

PHP

use Mcg\FilamentCms\Support\TemporaryUpload;

TemporaryUpload::withLocalCopy($upload, fn (string $path) => $item
    ->addMedia($path)
    ->usingFileName($upload->getClientOriginalName())
    ->toMediaCollection(MediaItem::COLLECTION));

Media Library's addMediaFromString($file->get()) and addMediaFromStream($file->readStream()) read through the disk and need no change. Only APIs that take a file path need the wrapper.

Stale CSS from a leftover public/hot file

Symptom

Styles are stale after a rebuild, and rebuilding again changes nothing.

Cause

A public/hot file was left behind by a Vite dev server that is no longer running. While the file exists, Laravel points asset URLs at that dev server, and the browser serves a stylesheet cached from before the change.

Fix

Delete public/hot. Laravel then serves the built asset.

backdrop-filter creates a containing block

Symptom

The mobile navigation panel and its scrim are sized to the 69px header instead of the viewport.

Cause

backdrop-filter on .cms-header made the header the containing block for its fixed-position descendants.

Fix

The property is now on .cms-header::before, which paints the same effect without creating a containing block. A test asserts that no rule in cms.css or signal.css places backdrop-filter anywhere else.

An eased visibility transition computes hidden on the first frame

Symptom

focus() on a newly opened panel has no effect, and no error is raised.

Cause

The panel's visibility transition is eased, so its visibility computes as hidden on the first frame after opening.

Fix

Use a stepped transition: visibility 0s linear 0s when opening and visibility 0s linear 0.32s when closing. Do not use a forced reflow; a reflow addresses the symptom rather than the transition.

A skin rule winning a specificity tie

Symptom

On one skin, the hero's h1 renders at body-copy size. On the other skins it is correct. No conflict is reported.

Cause

The skin set its heading sizes with [data-cms-skin='press'] h1.cms-block__heading, which is (0,2,1). The hero's own heading rule is (0,2,0). Skins are imported after cms.css, so the skin rule won everywhere, including inside the hero. A skin is loaded last, so it wins every specificity tie it enters.

Fix

Exclude the elements the skin rule should not reach with a selector that adds no specificity: :not(:where(.cms-block--hero, .cms-block--hero-carousel) *). :where() counts as zero specificity, so the :not() adds none.

The same padding declared on a wrapper and on what it wraps

Symptom

A multi-slide hero has 429px of content inside 704px of height. The same block measured 1133px locally against 645px on production.

Cause

.cms-block--hero and .cms-carousel__slide each declared the same padding-block. A single-slide hero renders as static markup and applies the padding once. A multi-slide hero renders the carousel inside the block and applies it twice. The local and production measurements differed because the two heroes were not the same: one held one slide and the other held several. The doubled padding looked like a deliberate amount of space until it was compared with the same hero holding one slide.

Fix

Where a wrapper and its contents can both be styled, assign the spacing to one of them, and check the case where both are present.

A default width preset mapped onto its neighbor by a layout

Symptom

On the reading layout, the normal and narrow width presets render at the same width. Nothing reports that two of the four presets are identical.

Cause

The width control offers narrow, normal, wide and bleed. normal is the default and emits no class, so it takes whatever width .cms-block has. .cms-main--reading clamps .cms-block to the narrow measure, so normal became identical to narrow.

Fix

A preset that emits no class inherits whatever the layout does to the base class. If a default must be a distinct value, give the base class that value explicitly and let each layout that needs a different value override it.

A style change that leaves half a rule behind

Symptom

In the navigation block, the current sub-page link renders 0.75rem left of its siblings.

Cause

The current-page link carried an accent border and a negative left margin equal to the sub-level indent, so the border sat on the rule its siblings hang from. A later commit removed the border and left the negative margin.

Fix

When a declaration exists only to make room for another, remove both together. A leftover declaration of this kind produces a shift that looks like a layout defect.

The language switcher shows letters instead of a flag

Symptom

On Windows, Chrome and Edge draw the flag in .cms-languages__flag as the two letters it is built from, for example CA instead of the Canadian flag. Firefox on the same machine draws the flag correctly.

Cause

The markup is correct. Locales::flag() returns a pair of regional indicator symbols (fr-CA becomes U+1F1E8 U+1F1E6), which is the sequence every platform draws a flag from. Segoe UI Emoji, the Windows emoji font, has no flag glyphs, so Chrome and Edge draw each indicator on its own as a letter. Firefox bundles an emoji font that includes the flag glyphs. A flag that renders in Firefox and not in Chrome on the same machine therefore indicates a missing font rather than a content error.

The package names a font that has the glyphs and bundles none. The theme's typefaces are loaded rather than shipped for the same reason: no font license is distributed with the package.

Fix

  1. In a terminal at the site's root, install the font file with npm install country-flag-emoji-polyfill.
  2. In your own stylesheet, add the @font-face rule:

CSS

@font-face {
    font-family: 'Twemoji Country Flags';
    src: url('../../node_modules/country-flag-emoji-polyfill/dist/TwemojiCountryFlags.woff2') format('woff2');
    unicode-range: U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067,
        U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
    font-display: swap;
}
  1. On your site's credits or license page, attribute the artwork. It is Twemoji, licensed under CC-BY-4.0, which requires attribution.

Only the font file from that package is used. The package's JavaScript exists to inject the same rule at runtime, and the front end ships no JavaScript unless a block requests it. The unicode-range descriptor keeps the 76KB font from being downloaded on pages that show no flag, and confines the family to flag code points so that naming it changes no other text.

A locale with no region subtag gets no flag. Locales::flag() returns null when the locale has fewer than two parts or its last part is not two letters. en and fr render as a bare code; fr-ca and es-mx get a flag.

Two robots meta tags on one page

Symptom

The page carries two <meta name="robots"> tags, one saying index and one saying noindex. Which one a crawler honors is not defined.

Cause

<x-cms::head> always emits one robots tag, through the seo component, with a default of index,follow. A view that needed noindex added its own tag underneath instead of passing the directive to the component, so the page was sent with both. The affected pages were the 404 page and every page after the first of a tag archive, where noindex exists to stop paginated slices from competing with page one in search results.

The test that should have caught it asserted toContain('noindex'), which is also true of a page that carries index,follow beside it.

Fix

Pass the directive to the component through its robots key. A null value falls through to the default: <x-cms::head :seo="['robots' => 'noindex, follow']" />

In a test, count the robots tags rather than searching for the substring.

The 404 page that could never be displayed

Symptom

The package's themed 404 view, which shipped for months, is never shown on any installation.

Cause

Every not-found path throws NotFoundHttpException, which Laravel resolves against the application's errors.404 view. The package's view is cms::errors.404, a namespace that resolution never looks in, and there was no publish tag for it.

The only code rendering the view was a test calling view() directly, so the test suite passed while no visitor could reach the page. This is the same pattern as the $cmsAttrs entry above: the test exercised the component, not the wiring, so it proved only that the component compiles.

Fix

The package fixes this with RendersCmsNotFound, which serves the view on the CMS's own routes. When writing middleware like it: Illuminate\Routing\Pipeline overrides handleException() to render a throwable through the exception handler inside the pipeline, so $next() returns a finished 404 response and a catch around it never runs. Read the response status instead.

A tree screen that is permanently empty

Symptom

A content type has a Tree screen in the panel. The screen loads, draws its header and its empty state, and shows no rows, however many rows the list screen shows for the same type. Nothing is logged and no filter is applied.

Cause

The type's rows are parented to content of a different type. ContentTree::forType() does two things in sequence:

src/Support/ContentTree.php PHP

$rows = Content::query()->where('type', $type)->get();   // one type only

return $this->nest($rows, null);                          // roots = parent_id null

The first line restricts the result to one type. The second treats a row as a root only when its parent_id is null. A row whose parent is a different type therefore satisfies neither condition: the parent is not in $rows to nest under, and the row is not a root because its parent_id is set. Every such row is dropped, and a type where every row is parented across types produces a tree with zero roots.

The case that produced this entry was a post type with a routePattern() of /blog/{slug} and isTree() returning true, whose five posts were each parented to /blog, a page. The Blog Posts tree screen drew nothing, while the list screen drew all five.

A tree screen cannot show a parent of another type. The screen's drag handler moves a row to a new parent within the type the screen is scoped to, and the move writes a path through PathResolver. A branch containing rows of another type would offer drag targets for which no move behavior is defined.

To confirm this cause, count the rows the tree screen can draw as roots:

PHP

// Rows of this type that the tree screen can draw as roots:
Content::query()->where('type', 'post')->whereNull('parent_id')->count();

A result of zero means an empty tree, whatever the total row count is.

Fix

Return false from isTree() on a type whose rows are parented across types, or whose rows have no parent at all. This removes the Tree screen.

Before doing so, check the second effect of the same method. isTree() governs two unrelated things:

  1. Whether the Tree screen exists. TreeContent::canAccess() returns $type->isTree() && ….
  2. Whether the Parent select appears on the edit form. ContentForm adds it inside if ($type->isTree()).

Returning false removes both. The parent_id values already in the column are left in place, but there is no longer any control that sets one, so new content of that type cannot be given a parent.

Check what reads parent_id for the type before returning false:

  • Breadcrumbs::parentOf() walks parent_id and does not filter by type, so a breadcrumbs block on that content shows no trail for content created after the change. This is the usual reason to leave isTree() returning true despite the empty screen.
  • The page_list block takes a parent_id and lists that page's children. A type that gets its index page from page_list needs the parent set. A type whose index block queries by content type does not.
  • PathResolver walks the parent chain only for a type with no routePattern(). A route pattern fixes the path regardless of the tree, so the URL does not change.

If the type needs a parent but has no use for the screen, there are two options:

  1. Leave isTree() returning true and accept the empty Tree screen.
  2. Separate the two concerns in your own content type subclass. The package does not currently separate them.

A utility naming a token you have not defined compiles to nothing

Symptom

One element renders unstyled, for example a button the color of the page or a heading in the body typeface, while every element around it is styled correctly. There is no warning, no error, and nothing in the build output.

Cause

Tailwind resolves bg-brand-600 against a --color-brand-600 token. When the token does not exist, the utility is not emitted at all, so the class is present in the markup and selects no rule. This usually happens in a bespoke skin or a block override, because both are written against token names before the tokens themselves are defined.

Fix

Define the tokens in the same stylesheet that imports cms.css:

CSS

@theme {
    --color-brand-500: #dc2626;
    --color-brand-600: #b91c1c;
    --font-display: 'Oswald', var(--font-sans);
}

When an element looks unstyled, check that the token exists before checking anything else. The full token vocabulary is on the design system page.

A Tailwind utility in a panel view compiles to nothing

Symptom

An element in the admin panel renders unstyled, for example an image that ignores its container and extends past it, or a row whose items are the wrong size, while the same class works in a front-end view.

Cause

Filament v5 ships a semantic stylesheet with classes such as fi-section, fi-btn and fi-badge, and no generic utilities. w-full, flex, gap-3 and rounded-lg in a panel view therefore select nothing in a customer's installation. They work in the package's own monorepo only because its resources/css/app.css carries @source lines pointing at the package's views, which a customer's installation does not have.

No error is produced in either environment. The class is in the markup, the element is on screen, and the browser's inspector reports the computed value rather than the value the class was meant to set. When the media picker's thumbnail carried w-full object-contain, the element reported max-width: none and object-fit: cover, which came from Filament's own rule rather than either class, and a 400px image extended out of a 256px card.

The previous entry describes the reverse case, where the utility exists and the token does not.

Fix

  1. In the browser's inspector, check the element's computed style rather than its class list.
  2. In resources/css/panel.css, write the rule under a class name that starts with fi-cms-. The service provider inlines that stylesheet into the panel's <head>.

Filament's published assets older than the installed Filament

Symptom

After a composer update, the panel's browser console fills with ReferenceError messages such as filamentGlobalSearch is not defined, isTabSelected is not defined and dismiss is not defined. Tabs, repeaters, date pickers and notifications stop responding. One request in the network log returns 404, for a file such as /js/filament/forms/components/repeater.js?v=5.10.2.0.

Cause

Filament serves its JavaScript and CSS from copies under public/js/filament and public/css/filament, published by php artisan filament:assets. Composer does not refresh those copies. When the installed Filament is newer than the copies, the panel requests files the old set does not contain, and every Alpine component defined in those files is missing.

Fix

  1. In a terminal at the application root, run php artisan filament:assets.
  2. In the browser, reload the panel. The 404 and the ReferenceError messages no longer appear.

To prevent a recurrence, check that composer.json contains @php artisan filament:upgrade under scripts.post-autoload-dump. That entry re-publishes the assets after every Composer update. cms:install adds it since 0.78.4; an application installed earlier may not have it.

Nested config keys missing after an upgrade

Symptom

A feature added in a new release reads as switched off on an installation that upgraded, and switched on everywhere else. Nothing in the release notes says it is opt-in, and the application's config file has no key to turn on.

Cause

Laravel merges a package's config into the application's with mergeConfigFrom, which merges only the top level. A published config/cms.php already has a blocks key, so the package's blocks array is discarded whole, including any key added to it after the file was published. The same is true of theme, forms and every other top-level group except mcp.

mcp is the exception since 0.78.4. FilamentCmsServiceProvider::fillMissingConfigKeys() adds any named key under mcp that the published file lacks, recursing into named groups such as mcp.web and mcp.blocks. It never adds to or replaces a list, so a published 'gated' => [] stays empty.

An installation that has never published config/cms.php is unaffected. The only installations that show the symptom are ones that published the file and then upgraded, which is why it is hard to reproduce.

The shallow merge has affected this package three times, each time on a key inside an existing group:

  1. blocks.livewire enables single-file Livewire blocks. A host that published its config before the key existed found that the whole mechanism did nothing. The key is now read with a default at the call site, config('cms.blocks.livewire', ['blocks' => null]), so an upgrade turns it on. A host that wants it off writes 'livewire' => [].
  2. mcp.settings.denied lists the site-setting groups the MCP server refuses to read. Two groups added to the list hold credentials: webhooks carries each endpoint's HMAC signing secret, and notifications carries a Slack incoming-webhook URL. An installation with its own array never received either. Those two groups are now unioned in at the call site rather than defaulted, so no configuration can remove them.
  3. mcp.name was added in 0.78.3. An installation that set CMS_MCP_NAME saw no change, because its published mcp group had no key reading the variable. This case led to fillMissingConfigKeys(). The same fill also adds mcp.blocks.gated to a config published before that key existed, so on such an installation the html block now requires confirm_raw_html over MCP, which is the shipped default.

Fix

  • When adding a nested key to this package, read it with a default or union it in at the point of use. Do not rely on the application having the shipped config file.
  • When upgrading an installation, diff the published config/cms.php against the package's copy after each release rather than assuming the merge added new keys. Command and config reference lists every key the current version ships.

Text contrast below 4.5:1 in a skin

Symptom

There is no visible symptom. The site looks correct, and the token that fails is the one designed to look muted.

Cause

The design system documents a 4.5:1 contrast floor for every text color against the surface it sits on, in light and dark mode, in every skin. The floor was documented for months without being measured. When it was measured, the base theme and four of the five skins that existed at the time were below it on --color-cms-faint, and had been since each was written:

Contrast ratio of --color-cms-faint against each skin's surface, measured before the v0.29.2 fix
Skin faint, light faint, dark
Base 3.36 3.96
Press 3.44 4.05
Signal 3.67 3.86
Marquee 4.10 4.00
SaaS 2.51 4.23
Arcade passed passed

The base theme's --color-cms-code-comment measured 3.80 in light mode. Those tokens color captions, navigation headings, the language switcher, footer notes, small block headings and code comments. All of them are text, so 4.5:1 is the correct floor and none of them qualifies for the 3:1 exception for non-text elements.

Arcade passed because its palette had been calculated for contrast rather than chosen by eye. A muted token is the one most likely to fail unnoticed: faint is designed to look faint, so a visual check of the page reports it as working.

The fix in v0.29.2 itself pushed two skins below the floor. Both are dark by default, and darkening a light-mode value for their own surface changed a value that had previously passed.

Fix

The v0.29.2 fix moved lightness only, so each palette keeps its hue and chroma. For a new or changed skin:

  1. Measure every text token's contrast ratio as a number before shipping the skin, in light and dark mode.
  2. Measure again after any change to the palette.
  3. Model the real cascade when measuring. A skin's root rule is (0,1,0) and the package's dark-mode block is (0,2,0), so the package's dark values override the skin's root values, and only the skin's own dark-mode rule overrides the package. Measuring the tokens as a flat map tests a stylesheet that does not exist.

A partial write blanks the page's title

Symptom

An edit that changed only blocks succeeds and the blocks are correct, but the page's title, navigation label and three exclusion flags are gone. Nothing is logged, and the site looks correct until that draft is published.

Cause

A version stores two kinds of data: the block list, and the page's own fields (title, subtitle, nav_label, display_date and the exclude_from_* flags). VersionManager::save() writes exactly the payload it is given, so a caller that supplies blocks and nothing else writes a version whose attributes are [].

This defect was present in the package until it was found by diffing a page's first inline save against the version before it: every block reported unchanged while title went from "About Us" to null. Three callers had each worked around it at their own call site, which hid it. The MCP block tools spread contentAttributes() into their payload by hand, and the shared-region action passes title explicitly.

Fix

Write through ContentWriter::update(), which carries the existing fields forward. It reads the current draft, or the published version when there is no draft, and merges the payload over it, so a field the payload does not mention keeps its value. It applies the same rule to the content row's own columns and to blocks in a region that the submitted form did not include:

PHP

// Keeps the title, the flags and the SEO. Changes only the blocks.
app(ContentWriter::class)->update($content, [
    'blocks' => $blocks,
    'change_note' => 'Edited from my own tool',
]);

// Clearing is still possible, by naming the field rather than omitting it.
app(ContentWriter::class)->update($content, ['nav_label' => null]);

VersionManager::save() is the lower layer and has no such fallback. Publishing, restoring and the importer are built on it, and each of those supplies a complete payload. If you call VersionManager::save() directly, supply the attributes yourself with $version->contentAttributes().

The panel builder shows a container as Empty

Symptom

A page whose columns, tabs or accordion render correctly on the front end opens in the panel with that block collapsed to Columns β€” Empty. Saving the form, even a save that changed only the page title, writes the empty container back, and the children are missing from the new draft.

Cause

Three containers accept rows stored in a shape that predates them. A column stored before columns could hold blocks has heading, body and cta_label keys and no blocks key. BlockDefinition::cast() converts those fields into a one-block list, and it runs on read: BlockTree::walk() calls it. The front end, the MCP block paths and inline editing therefore see children that the stored array does not contain.

A reader that reads the stored array directly sees the legacy shape and no children. The panel's builder was one such reader. EditContent::fillForm() filled it from $version->blockList(), the builder rendered an empty repeater, and on save the form's empty state replaced the stored children.

PHP

// Wrong: hands the builder whatever shape the version happens to be stored in.
$blocks = $version?->blockList() ?? [];

// Right: the same children cast() gives every other reader.
$blocks = BlockTree::upgraded($version?->blockList() ?? []);

BlockTree::upgraded() maps cast() over a block list and is idempotent, so running it on an already-upgraded list returns the list unchanged.

BlockTree::upgraded() casts at every level of nesting. Its first version cast only the top level, which caused a second failure. A container's cast() creates children from the old row's fields and stops there: it converts a cta_label into a button_group, and does not apply what a button_group requires. alignment is a required field on button_group, no column ever stored one, and the created child reached the form without it. The builder shows no alignment field for a nested child, so there was nothing on screen to fill in, and the page refused to save with this error:

Plain text

Block [button_group] at position 2 β€Ί columns 1 β€Ί blocks 2 is invalid.
The alignment field is required.

The error appeared on a block whose author had changed nothing, and on a three-column band it appeared for all three columns. The renderer substitutes a default value for the missing field, so the page looks correct on the front end until the next save.

The same defect affected BlockTree::replaceAt() and the inline editing mutations. They read the raw data to find a block's siblings, data_set() created the missing blocks key, and LegacyContainers::rows() skips any row that already has a blocks key. The column was therefore marked as upgraded while its created children were dropped.

A third instance produced the opposite symptom. BlockTree::moveBefore() read a block's siblings through cast() and wrote them back unconditionally, so on a legacy container a move that reordered nothing still returned a different array: the upgraded one. Callers that test $moved === $blocks to decide whether anything changed always decided that something had, and wrote a new version when an editor clicked a drag handle without moving anything.

Fix

The package now applies these rules, and a tool built on the package should follow them:

  1. Read a block's children through BlockTree, not directly from data. Every path through BlockTree reads siblings through cast().
  2. Pass a block created by an upgrader through its own cast() before anything validates it, at every level of nesting. This is the same rule slotsOf() follows.
  3. Treat writing the cast shape back as a change. Code that compares a block list before and after an operation must compare two lists in the same shape, or defer the upgrade to a write that was going to happen anyway. moveBefore() now returns its input untouched when the order is unchanged.

Rebinding JavaScript on morph.updated instead of morphed

Symptom

Code that re-initializes a third-party library after a Livewire re-render works for elements that were already on the page and not for elements the render just added. The new elements start working after a second, unrelated re-render.

Cause

Livewire publishes both a morph.updated hook and a morphed hook, and they run at different times. morph.updated runs once per element, while the morph is still in progress, and its el is the element being updated. morphed runs once, after morph() has returned (trigger2("morphed", { el, component }) in livewire.js), and its el is the component's root element.

Code that re-attaches a library by querying the document from morph.updated queries a DOM that is still being assembled, and the elements this render adds do not exist yet:

JavaScript

// Wrong: runs before the elements this render is adding exist.
Livewire.hook('morph.updated', ({ el }) => { rebind() })

// Right: runs once, with the finished DOM.
Livewire.hook('morphed', ({ el }) => { rebind() })

The page tree had this defect. Sortable was rebound on morph.updated, so expanding a node queried for its lists before that node's list had been inserted, and the new list received no Sortable instance. The list became draggable only when a later morph, such as expanding a second node, found it already present. Measured after a single expand, morph.updated bound 1 of 6 lists and morphed bound 6 of 6. Fixed in v0.53.1.

The defect was present but had no visible effect while the tree rendered expanded, because every list existed when the initial binding ran. Commit 877025b made the tree open collapsed, so a list had to be revealed before it could be dragged, and from that commit the defect made dragging fail.

Fix

  1. Register the rebinding code on morphed rather than morph.updated.
  2. Reverse the containment test at the same time. In morph.updated, el is a descendant, so the component checks this.$el.contains(el). In morphed, el is the component root, which contains the Alpine element instead. A condition carried over unchanged never matches, and the rebinding code never runs. The page tree in resources/views/admin/content-tree.blade.php checks all three relationships: el === this.$el || el.contains(this.$el) || this.$el.contains(el).