Dual-render widgets: BaseBladeWidget + BaseLivewireWidget#37
Conversation
Splits the widget rendering tech from widget identity so a downstream package can swap any widget's frontend render path from Blade to Livewire without redeclaring form schema, defaults, lifecycle hooks, or preview. - Extract HasWidgetDefaults trait holding all static metadata defaults shared between Blade and Livewire bases. - Rename current BaseWidget body to BaseBladeWidget; keep BaseWidget as a back-compat shim so existing widgets and stubs are untouched. - Add BaseLivewireWidget that mounts a Livewire component via <livewire:dynamic-component>, passing $data as a prop and pre-rendered children as the default slot. livewire/livewire stays a soft dependency (composer suggest). - Widen render() return type on BaseView to View|Htmlable|string so both bases satisfy the contract; the call site already stringifies output. - Add BaseView::renderChildrenToHtml() for the Livewire slot path. - Switch type checks in RegistersWidgets, LayupContent, and WidgetDefaultCompletenessTest from BaseWidget class to the Widget interface so Livewire widgets are recognised everywhere. - Update LayupAssertions to coerce View|Htmlable|string render results. - Demonstrate the identity-trait pattern on Heading, Button, NumberCounter, and Newsletter; remaining widgets unchanged. - Add docs/advanced/livewire-widgets.md describing when to use which base, the slot-based children model, and how a downstream package re-registers a type to swap render tech. All 1130 tests pass.
|
Warning Rate limit exceeded
To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (7)
📝 WalkthroughWalkthroughThe changes introduce a dual rendering architecture enabling widgets to render via either Blade (default) or Livewire through opt-in Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Review rate limit: 0/1 reviews remaining, refill in 39 minutes and 25 seconds.Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/View/BaseLivewireWidget.php (1)
57-75: Add an explicit Livewire dependency guard inrender().Line 69 currently defers missing-dependency failure to
Blade::render(), producing an opaque error. A direct pre-check at the method entry point provides immediate, actionable diagnostics for this soft dependency.The
class_exists(\Livewire\Livewire::class)pattern is consistent with how the codebase validates optional dependencies elsewhere (e.g.,InstallCommand).Proposed change
public function render(): string { + if (! class_exists(\Livewire\Livewire::class)) { + throw new \RuntimeException( + 'BaseLivewireWidget requires livewire/livewire. Install it with: composer require livewire/livewire' + ); + } + $template = <<<'BLADE' <livewire:dynamic-component :component="$component" :wire:key="$key" :data="$data" > {!! $childrenHtml !!} </livewire:dynamic-component> BLADE;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/View/BaseLivewireWidget.php` around lines 57 - 75, The render() method defers a missing Livewire dependency to Blade::render(), causing an opaque error; add an explicit guard at the start of BaseLivewireWidget::render() that checks class_exists(\Livewire\Livewire::class) and throws a clear exception (or uses existing helper) when Livewire is not installed, mirroring the pattern used in InstallCommand; locate the render() method and insert this pre-check before constructing $template so callers get an immediate, actionable error if Livewire is missing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/advanced/livewire-widgets.md`:
- Around line 22-35: Add a fenced-code language specifier ("text") to the two
code fences in docs/advanced/livewire-widgets.md that currently start without
one: the ASCII diagram block beginning with "Crumbls\Layup\Contracts\Widget"
(the diagram used to describe BaseBladeWidget/BaseLivewireWidget) and the
log/example block that begins with "Layup: Widget type 'newsletter' already
registered..."; replace the opening ``` with ```text for both blocks so
markdownlint MD040 is satisfied while preserving the existing fence content and
closing backticks.
In `@src/View/BaseView.php`:
- Around line 95-112: renderChildrenToHtml() currently casts each child's
render() result to string which breaks when render() returns an Htmlable that
lacks __toString(); change the concatenation to detect Htmlable results and call
toHtml() instead of string-casting: call $child->render(), if the result is an
instance of \Illuminate\Contracts\Support\Htmlable (or has toHtml()), append
result->toHtml(), otherwise append (string) result; update the use/imports as
needed and keep behavior for View and string returns unchanged.
---
Nitpick comments:
In `@src/View/BaseLivewireWidget.php`:
- Around line 57-75: The render() method defers a missing Livewire dependency to
Blade::render(), causing an opaque error; add an explicit guard at the start of
BaseLivewireWidget::render() that checks class_exists(\Livewire\Livewire::class)
and throws a clear exception (or uses existing helper) when Livewire is not
installed, mirroring the pattern used in InstallCommand; locate the render()
method and insert this pre-check before constructing $template so callers get an
immediate, actionable error if Livewire is missing.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9f4590c4-d98a-4116-aa38-fdd9afaa32e2
📒 Files selected for processing (23)
CHANGELOG.mdcomposer.jsondocs/advanced/custom-widgets.mddocs/advanced/extending-widgets.mddocs/advanced/livewire-widgets.mddocs/api-reference/widget-contract.mdsrc/Support/Concerns/RegistersWidgets.phpsrc/Support/LayupContent.phpsrc/Testing/LayupAssertions.phpsrc/View/BaseBladeWidget.phpsrc/View/BaseLivewireWidget.phpsrc/View/BaseView.phpsrc/View/BaseWidget.phpsrc/View/ButtonWidget.phpsrc/View/Concerns/HasWidgetDefaults.phpsrc/View/Concerns/Identity/ButtonIdentity.phpsrc/View/Concerns/Identity/HeadingIdentity.phpsrc/View/Concerns/Identity/NewsletterIdentity.phpsrc/View/Concerns/Identity/NumberCounterIdentity.phpsrc/View/HeadingWidget.phpsrc/View/NewsletterWidget.phpsrc/View/NumberCounterWidget.phptests/Unit/WidgetDefaultCompletenessTest.php
The first commit shipped BaseLivewireWidget, HasWidgetDefaults, four identity traits, and switched internal type checks to the Widget interface -- all without tests. Closing the gap. - tests/Unit/BaseLivewireWidgetTest.php: contract conformance, Widget interface polymorphism, render() output structure including pre-rendered children in the slot, registry registration, LayupContent serialization, HasWidgetDefaults defaults, lifecycle pass-through. - tests/Unit/IdentityTraitCompositionTest.php: pins the load-bearing refactor claim that "same identity trait + different base = identical metadata" by comparing every static method (type, label, icon, category, defaults, preview, content form schema field names, toArray()) between the four shipped Blade widgets and Livewire-flavour fixtures using the same trait. - tests/Unit/RenderChildrenToHtmlTest.php: exercises BaseView's new child-rendering helper across all three legal render() return types (View | Htmlable | string) including a heterogeneous mix. - tests/TestCase.php: register Livewire's service provider so render tests can mount real components. Filament normally registers it transitively but the Testbench environment only loads what we ask for. 37 new tests, 85 new assertions. Full suite: 1167 passed.
Test coverage addedClosing a gap in the original PR: the first commit shipped
Pins the load-bearing claim of the refactor: "same identity trait + different base = identical metadata." Constructs a Livewire-flavour fixture for each of the four traits (
If anyone later inlines render-path-specific logic into one of these traits, the suite will fail.
This exercises the
Registered Totals: 1167 passed (3648 assertions), up from 1130 / 3563 in the original PR. Pint clean across |
- BaseView::renderChildrenToHtml(): Htmlable implementations are not required to define __toString(), so the previous (string) cast would crash on a custom Htmlable that lacks it. Now branches on instanceof Htmlable and calls toHtml() directly. View and string returns are unchanged (concrete Illuminate\View\View defines both toHtml() and __toString()). New regression test in RenderChildrenToHtmlTest exercises an inline anonymous Htmlable that intentionally has no __toString(). - docs/advanced/livewire-widgets.md: tag the architecture-diagram and log-output fenced blocks with `text` so markdownlint MD040 passes. Suite: 1168 passed.
Summary
Introduces a second rendering path for Layup widgets: alongside the existing Blade view component path, widgets can now opt in to rendering through a Livewire component. Same
Widgetcontract, same editor experience, same registration -- only the frontend render path differs. A downstream package can swap any built-in widget that ships with an identity trait (currently Heading, Button, NumberCounter, Newsletter) to a Livewire flavour without redeclaring form schema, defaults, or preview.This is purely additive.
BaseWidgetis preserved as an abstract alias; all 100+ existing widgets, every stub, every doc reference, and every downstream extension continues to work without modification. All 1130 existing tests pass unchanged.Why
Layup's widget system has always been excellent for static content: editors arrange widgets, the page renders to fixed HTML on the server, Alpine handles light interactivity. That model breaks down for widgets that need genuine server-side state -- live counters, contact forms with server validation, search-as-you-type, polling notifiers, multi-step flows. Embedding
@livewire(...)calls inside individual Blade widget views was possible but ugly: it leaks Livewire wiring into every widget that wants state, doesn't compose cleanly with children, and gives no way for a downstream package to swap a widget's render path without editing the widget itself.The goal: keep Blade as the default (zero overhead, zero new dependencies for the 95% case), make Livewire a first-class opt-in for the 5%, and design the contract so progressive replacement is a one-class change.
Architecture
Three orthogonal concerns, each with a single home:
Crumbls\Layup\View\Concerns\Identity\.BaseBladeWidgetfor the default Blade path,BaseLivewireWidgetfor the Livewire path.$data,$children, position-in-parent, recursive child rendering. Lives inBaseView, which both bases extend.The
Widgetinterface is the source of truth for what a widget is. Both bases implement it. Anywhere code accepts "a widget" --WidgetRegistry::register(), content serialization inLayupContent, test assertions inLayupAssertions, auto-discovery inRegistersWidgets-- it now accepts either flavour. Layup's own internals were updated to check forinstanceof Widgetinstead ofinstanceof BaseWidgetso the polymorphism actually holds.What changed
New:
BaseLivewireWidgetCrumbls\Layup\View\BaseLivewireWidgetis the sibling ofBaseBladeWidget. It implements the sameWidgetcontract via the sameHasWidgetDefaultstrait. Itsrender()mounts a Livewire component via<livewire:dynamic-component>:The widget's
$datais passed as a Livewire prop (typed asarrayon the Livewire component). Children are pre-rendered to HTML on the server (recursing throughBaseView::renderChildrenToHtml()) and passed as the default Livewire slot, so children remain polymorphic -- a child of a Livewire widget can be a Blade widget, another Livewire widget, or any mix. Each child manages its own rendering.The base does not import any class from
livewire/livewire. It usesBlade::render()with the<livewire:dynamic-component>tag string, so the package autoloads cleanly even when Livewire isn't installed.livewire/livewireis declared incomposer.jsonsuggest(notrequire).New:
BaseBladeWidgetCrumbls\Layup\View\BaseBladeWidgetis the renamed body of the previousBaseWidget. The default Blade rendering path is unchanged:view('layup::components.{type}', ['data' => $data, 'children' => $children]). ThegetViewName()override hook is preserved.The render return type is widened from
Illuminate\Contracts\View\ViewtoView|Htmlable|string. Existing Blade widgets are unaffected (returning aViewstill satisfies the wider type via covariance); the wider type letsBaseLivewireWidget::render()return astring. Subclasses that previously declared: Viewstill work because PHP allows narrowing in subtypes.Back-compat:
BaseWidgetCrumbls\Layup\View\BaseWidgetis now an abstract alias that extendsBaseBladeWidget:Every existing widget (
extends BaseWidget), stub, doc reference, and downstream extension continues to work without modification.instanceof BaseWidgetandis_subclass_of(..., BaseWidget::class)still match all current widgets. The only thing they don't match isBaseLivewireWidgetsubclasses, which is correct -- those have a different render path.New:
HasWidgetDefaultstraitCrumbls\Layup\View\Concerns\HasWidgetDefaultsextracts the static metadata defaults shared between both bases:getIcon(),getCategory(),getDefaultData(),getPreview(),prepareForRender(),getValidationRules(),getSearchTerms(),isDeprecated(),getDeprecationMessage(), lifecycle hooks (onSave,onCreate,onDelete,onDuplicate),getAssets(),toArray(). BothBaseBladeWidgetandBaseLivewireWidgetusethis trait. Avoids duplicating ~140 lines of identical default behaviour across the two bases.New: identity traits
Crumbls\Layup\View\Concerns\Identity\ships per-widget identity traits for select widgets that may have multiple render flavours:HeadingIdentityButtonIdentityNumberCounterIdentityNewsletterIdentityEach trait holds the static metadata for one widget -- type, label, icon, category, form schema, defaults, preview -- with no rendering logic. The corresponding built-in widget class (
HeadingWidget,ButtonWidget, etc.) is now a four-line shell:A downstream package ships a Livewire flavour by extending
BaseLivewireWidgetand pulling in the same trait:Same
getType()returns'heading', same form, same defaults. Different render path. Register the Livewire flavour against the sameWidgetRegistryand the existing override-with-warning behaviour (WidgetRegistry::register()line 32-34) handles the swap. The remaining ~95 built-in widgets are untouched -- the identity-trait pattern is opt-in per widget and only matters when shipping multiple render flavours of the same widget.New:
BaseView::renderChildrenToHtml()Used by
BaseLivewireWidget::render()to produce the slot content. Available on everyBaseViewsubclass for any custom render path that needs pre-rendered children.Internal type-checking switched to interface
Every place in the package that introspects widgets at runtime was switched from
instanceof BaseWidget/is_subclass_of(..., BaseWidget::class)to interface-basedinstanceof Widget/is_subclass_of(..., Widget::class):Crumbls\Layup\Support\Concerns\RegistersWidgets::discoverAppWidgets()(auto-discovery forapp/Layup/Widgets/)Crumbls\Layup\Support\LayupContent::serializeNodes()and::resolveNodeType()(content tree serialization)Crumbls\Layup\Testing\LayupAssertions(annotations and helpers)tests/Unit/WidgetDefaultCompletenessTest::discoverShippedWidgets()(test discovery)This is the load-bearing change for polymorphism: without it, Livewire widgets would be invisible to auto-discovery and serialization. The interface check matches both bases. Existing code that does
instanceof BaseWidgetstill works for Blade widgets only -- downstream code that wants to support Livewire widgets should follow the same interface-based pattern, but no immediate change is required.Test helper updates
Crumbls\Layup\Testing\LayupAssertions::assertWidgetRenders()andassertWidgetRendersWithDefaults()previously called$widget->render()->render()-- assumingrender()returns aViewwhose ownrender()produces the string. With the wider return type, the inner call could be a string or anHtmlableinstead. A newrenderToString(View|Htmlable|string)protected helper coerces all three into a string before assertion. Existing tests that call$widget->render()->toHtml()directly continue to work because Blade widgets still return aView(which implementsHtmlable).Docs
Three doc files added or expanded:
docs/advanced/livewire-widgets.md(new, 280 lines) -- comprehensive guide covering the architecture overview with diagram, when-to-use-which decision matrix, anatomy of a Livewire-rendered widget (widget class + Livewire component + Blade view), the render flow internals, the slot-based children model, the identity-trait pattern for multi-flavour widgets, migration steps for existing custom widgets, asset-pipeline considerations, editor preview vs. frontend render, performance considerations, testing patterns, security notes, installation, and full API reference.docs/api-reference/widget-contract.md(expanded) -- now documentsBaseBladeWidget,BaseLivewireWidget,HasWidgetDefaults, the identity-trait registry, theBaseView::renderChildrenToHtml()helper, and the runtime type-checking guidance (instanceof Widgetoverinstanceof BaseWidget).docs/advanced/extending-widgets.md-- adds a callout pointing at the Livewire docs for render-tech swaps, since the four widgets with identity traits can be swapped without redeclaring metadata.docs/advanced/custom-widgets.md-- one-line callout pointing at the Livewire docs.CHANGELOG.md-- detailed Unreleased entry under Added and Changed.What did not change
Widgetinterface inCrumbls\Layup\Contracts\Widget. Identical, byte-for-byte.BaseViewdesign tab schema, advanced tab schema, form-tab structure, helper methods (visibilityClasses,buildInlineStyles,animationAttributes,walkComponents, etc.).frontend/page.blade.php,frontend/loop.blade.php,components/section.blade.php, etc. The call site ({!! $widget->render() !!}) is unchanged because it already stringifies whatever comes back.WidgetDefaultCompletenessTest), which switched fromBaseWidgetclass checks toWidgetinterface checks while still asserting the same behaviour.app/Layup/Widgets/discovery still finds every widget that previously worked, plus now finds Livewire widgets too.stubs/layup-widget.php.stub) -- still extendBaseWidget. Generated widgets work without modification becauseBaseWidgetis nowBaseBladeWidgetunder a different name.HeadingWidget,ButtonWidget,NumberCounterWidget,NewsletterWidget) have identical static APIs -- traits inline their methods into the using class, soHeadingWidget::getType()resolves the same as before.composer.jsonrequireblock.livewire/livewireis declared only in the newsuggestblock.Migration notes
For users who only ever use Blade widgets: no action required. Continue extending
BaseWidget. The only thing you'll see is the newlivewire-widgets.mdpage in the docs.For users who want to add a Livewire widget:
composer require livewire/livewire.BaseLivewireWidget. ImplementgetType(),getLabel(),getLivewireComponent(), plus whatever metadata you'd put on a Blade widget.array $dataprop, your state, yourmount()and actions, a Blade view that emits{{ $data['...'] }}and{{ $slot }}for children).config/layup.phpwidgetsor programmatically viaWidgetRegistry::register().For users who want to swap a built-in widget for a Livewire flavour: see the new
docs/advanced/livewire-widgets.md#identity-traits-shipping-multiple-flavours-of-the-same-widgetsection. The four widgets with identity traits can be swapped without redeclaring metadata; for any other built-in, you'd need to re-implement the form schema and defaults yourself.For downstream code that does runtime widget introspection (custom registries, content walkers): if you check
instanceof BaseWidgetoris_subclass_of(..., BaseWidget::class)and want Livewire widgets to be visible, switch to theWidgetinterface. If you don't intend to support Livewire widgets, no change required.Test plan
vendor/bin/pest-- 1130 passed, 3563 assertions, 26s.vendor/bin/pint --test-- all clean acrosssrc/,tests/,docs/,CHANGELOG.md.HeadingWidget::getType(),ButtonWidget::getDefaultData(),NumberCounterWidget::getContentFormSchema(),NewsletterWidget::getPreview()all resolve identically to pre-refactor.BaseWidgetshim is recognised as parent of all existing widgets viais_subclass_of().tests/Unit/RenderingTest.php+tests/Feature/PageControllerTest.phprendering assertions).WidgetRegistry::register()accepts both bases and surfaces the override-warning when a downstream class re-registers an existing type.Security note
wire:click/wire:submithandler that mutates state or performs authenticated work must gate inside the Livewire component (Auth::check()/ policy checks). The page builder is public; the docs page covers this explicitly under "Security".{{ }}(default) and avoid{!! !!}for editor-controlled fields unless the editor sanitises them. Same posture as Blade widgets today.Performance note
BaseWidget→BaseBladeWidgetrename is a one-level inheritance hop with no behavioural change. The widenedrender()return type adds nothing at runtime.Blade::render()call to mount, one Livewiremount()invocation, one componentrender()invocation server-side, plus awire:idwrapper and morphdom diffing client-side, plus a Livewire backend round-trip per action. For pages with one or two interactive widgets among static content, unnoticeable. For pages full of Livewire widgets, the docs recommend reconsidering whether the page-builder model is the right tool.OpenAPI / API contract changes
None. No HTTP routes added, removed, or modified. No request/response shapes changed.
Database / migration changes
None. No schema changes, no data migrations.
Rollback plan
This PR is purely additive at runtime. To roll back:
git revert fe0c161 0ca1616.BaseWidgetcontinues to exist after revert. No downstream code that extendsBaseWidgetwould need to change either way.Risk assessment
BaseWidgetis preserved. All existing public APIs are intact.livewire/livewireis a soft dependency. The docs call this out; recommend a follow-up integration test in the first downstream consumer.Out of scope
make:layup-widget --livewirescaffolding flag. Existingmake:layup-widgetcontinues to scaffold Blade widgets; a Livewire variant could be added later if usage warrants it.getAssets()and Livewire's@assetsdirectives. Both work; the docs explain when to use which. A unified declaration could be added later.Summary by CodeRabbit
Release Notes
New Features
livewire/livewiresoft dependency enables Livewire-based widget rendering.Documentation
Refactor