Radix-style asChild for Blade/Livewire components: the parent goes
transparent and its attributes merge onto the slot's root element.
{{-- The parent... --}}
<x-card asChild class="p-4">
<button class="c1">Hi</button>
</x-card>
{{-- ...renders this (no <x-card> wrapping anything) --}}
<button class="c1 p-4">Hi</button>Without the flag, everything renders as before. Opt-in per component.
Author views stay one line — no @php/@if boilerplate. Forward the
bag and the slot, name the fallback tag, and give its defaults:
{{-- resources/views/components/button.blade.php --}}
<x-morph {{ $attributes }} tag="button" :defaults="['type' => 'button']" :flag="$__xmorphAsChild ?? false">{{ $slot }}</x-morph>tag: fallback element rendered when the flag is off.:defaults: merged into the fallback only ($bag->merge($defaults), so caller values win); the asChild merge never sees them.:flag: carries the@asChilddirective variable for scopes where it is visible (same?? falseguard as the old boilerplate).:bag="$attributes": optional escape hatch — an explicitComponentAttributeBag(or plain array) used instead of the spread bag, e.g. to forward a filtered bag.
The asChild marker is always stripped, on or off; class still
concatenates child-first and other conflicts keep the child value.
The internal component above is registered as <x-morph> by default.
When that name collides with an app component, publish the config and
rename it:
php artisan vendor:publish --tag=xmorph-config// config/xmorph.php
return ['alias' => 'as-child'];That gives <x-as-child ...> with the same props. After renaming,
clear the compiled views (php artisan view:clear); if config is
cached, re-cache it too (php artisan config:cache).
- PHP >= 8.1
- Laravel /
illuminate/view^10.0 - Livewire v3 is optional: first paint merges the same, with or without it.
composer require siddharthagf/xmorphThe provider is auto-discovered. No setup is needed unless you want to rename the internal component (see Component alias below).
Put asChild on the parent and a single-root child in the slot:
<x-card asChild class="p-4" id="parent">
<button class="c1" id="child">Hi</button>
</x-card>
{{-- <button class="c1 p-4" id="child">Hi</button> --}}Merge rules:
classconcatenates: child value first, then the parent's.- Any other conflict keeps the child value (
id="child"above). - The
asChildmarker never reaches HTML, on or off. - The parent's conditionals (
->merge(),->class([...])) resolve before merging, same as the normal Blade path.
They do exactly the same; use the first by default:
| Syntax | Example | Note |
|---|---|---|
| Bare (recommended) | <x-card asChild ...> |
Always on |
| Bound | <x-card :asChild="$cond" ...> |
Accepts a boolean (see below) |
| Directive | @asChild inside the slot |
Also detected in the tag (see below) |
The directive is also detected written inside the tag
(<x-card @asChild ...>), because Blade never executes directives there.
In class-component views the directive variable is not visible: use the bare or bound syntax there.
All three syntaxes accept a value; when falsy, the slot renders untouched (the marker is still stripped):
<x-button :asChild="$isLink">Falsy means: false, null, 0 / 0.0, '0', '', 'false' / 'no'
(case-insensitive, trimmed). Anything else enables the merge.
Leading HTML comments are skipped to find the first real tag and are re-emitted verbatim:
<x-button asChild class="p-4">
<!-- label -->
<a href="#">Link</a>
</x-button>
{{-- merges onto the <a>, the comment is preserved --}}An unclosed comment, or a comments-only slot, comes back untouched. The single-root check counts real tags only.
- A single-root slot merges onto it.
- A multi-root slot throws
SiddharthaGF\XMorph\MultipleRootElementsException: never a silent first-only merge. - Markup the parser cannot read safely (unbalanced quotes, unparsable tags) comes back untouched instead of breaking HTML.
Slot-child snippets used by tests/AsChildSyntaxTest.php live under resources/views/examples/, while the live <x-button> author example is tests/Fixtures/views/components/button.blade.php (used via the xmorph::button namespace in Livewire/functional tests).
The bundled simple parser is used by default
(Parsers\RootElementParser, no DOM, no dependencies). To use another
one, implement SiddharthaGF\XMorph\Contracts\HtmlParser:
use SiddharthaGF\XMorph\AsChild;
use SiddharthaGF\XMorph\Contracts\HtmlParser;
final class MyParser implements HtmlParser
{
public function merge(string $slotHtml, string $attributeString): string
{
// ...
}
}
// Per call:
AsChild::renderSlot($attributes, $slotHtml, new MyParser);
// Pinned for tests/container-less hosts (null restores):
AsChild::useParser(new MyParser);
// In Laravel, bind it and it is picked up:
// $app->singleton(HtmlParser::class, MyParser::class);Contract: merge onto the single root, throw
MultipleRootElementsException on multi-root, and return unsafe markup
untouched — never corrupt.
When the child is a Livewire component, the merge applies on first paint like with any other child. No cache involved: the package stores nothing anywhere.
Known limitation: when the child updates alone (no parent in the render tree), the first-paint merge is lost — Livewire re-renders the child from scratch and there is nowhere to recover the parent attributes from. Updates through the host (parent re-renders) merge again with no issue.
Behavior is documented above; these are the seams:
AsChild::renderSlot($attributes, $slotHtml, ?HtmlParser $parser = null)— the merge (see Custom parser).AsChild::useParser(?HtmlParser)— pins the parser outside the container.XmorphServiceProvider::consumeAsChildFlag($attributes): array—[bool $isAsChild, ComponentAttributeBag $stripped]; the marker is always stripped, on or off.View\Components\Morph(<x-morph>, alias viaconfig/xmorph.php) — the one-line author wrapper:tag,:defaults,:flag,:bag.
composer test # suite: unit, syntax matrix, functional
composer check # pint --test + phpstan + phpunit (stops at first failure)Individual gates: composer pint (fix style), composer pint:test
(check only), composer phpstan (static analysis, strict rules),
composer rector / composer rector:dry, composer test:random.