Blade components and the partial that had eleven parameters

The card partial took eleven variables. Nine of them were optional, two were required, and the only way to find out which was which was to read the template and check every conditional. It was included in nineteen places, four of which passed a variable the partial had stopped using in 2018.

The symptom

@include('partials.card', [
    'title'       => $product->name,
    'body'        => $product->summary,
    'image'       => $product->image_url,
    'footer'      => null,
    'classes'     => 'mb-3 shadow-sm',
    'headingTag'  => 'h3',
    'linkUrl'     => route('products.show', $product),
    'linkText'    => __('View'),
    'showBadge'   => $product->isNew(),
    'badgeText'   => __('New'),
    'compact'     => false,
])
$ grep -rn "include('partials.card'" resources/views | wc -l
19

$ grep -rn 'compact' resources/views/partials/card.blade.php
# nothing. the variable has not been read since 2018.

ErrorException: Undefined variable: badgeText
  (View: /app/resources/views/products/index.blade.php)

The undefined variable notice is what happens when a caller forgets one, and it appears in the middle of the rendered output rather than at the point of the mistake. Nothing anywhere declares that title is required and compact is dead.

Why it happens

A partial is a file. It has no signature, no defaults, no types and no way to reject a call that is missing something — @include extracts an array into the template’s scope and hopes. Every variable is optional in the sense that omitting it produces a notice rather than an error, and every variable is required in the sense that the template may use it.

The consequence is that the interface exists only in the reader’s head, which is why the dead parameter survived two years. Nothing could have told anybody it was dead.

The fix

A class-based component, where the constructor is the contract

final class Card extends Component
{
    public string $title;
    public ?string $image;
    public string $classes;

    public function __construct(
        string $title,
        ?string $image = null,
        string $classes = ''
    ) {
        $this->title   = $title;
        $this->image   = $image;
        $this->classes = $classes;
    }

    public function render(): View
    {
        return view('components.card');
    }
}

A missing title is now an argument-count error naming the component, at the point of use, rather than a notice in the middle of the page. The types are real, so passing an integer where a string was expected is a TypeError rather than a coerced value nobody noticed. And the dead parameter cannot survive, because removing it from the constructor breaks every caller that still passes it.

<x-card :title="$product->name" :image="$product->image_url"
        class="mb-3 shadow-sm">
    {{ $product->summary }}
</x-card>

The colon prefix passes an expression rather than a literal string, which is the one piece of syntax to learn and the one people get wrong first. Anything computed belongs in a method on the component rather than in the constructor, because the constructor runs before the component knows about its slots.

Anonymous components, for the ones with no logic

A component that is only markup does not need a class, and creating one for every button produces a directory of files containing nothing but a render method.

{{-- resources/views/components/button.blade.php --}}
@props(['variant' => 'primary', 'size' => null])

<button {{ $attributes->merge(['class' => 'btn btn-'.$variant]) }}>
    {{ $slot }}
</button>

{{-- <x-button variant="danger" class="mt-2" data-confirm="1"> --}}

@props declares the named attributes with defaults, and everything not declared lands in $attributes — so the component passes through classes, data attributes and event handlers without enumerating them. merge rather than assignment is what lets a call site add a class without replacing the component’s own, which is the behaviour every design-system component wants and almost none get right by hand.

The rule that works is a class when there is logic and anonymous when there is not, and moving between them is a file rename plus a constructor. Starting anonymous and promoting when a method appears is the cheaper direction.

Slots, and the difference between a parameter and a body

<x-card :title="$title">
    <x-slot name="header">
        <span class="badge">{{ $product->stock }}</span>
    </x-slot>

    {{ $product->summary }}

    <x-slot name="footer">
        <x-button>{{ __('Add to basket') }}</x-button>
    </x-slot>
</x-card>

{{-- inside: {{ $header ?? '' }} {{ $slot }} {{ $footer ?? '' }} --}}

The distinction worth being deliberate about: a parameter is a value the component uses, and a slot is markup the caller supplies. Three of the original eleven variables were markup passed as strings — footer, badgeText and linkText — which meant escaping was ambiguous and any HTML in them was either double-escaped or unescaped.

Slots also solve the eleven-parameter problem structurally rather than cosmetically. A component with four parameters and two slots is legible; the same component with ten parameters is not, and the difference is that the slots carry the parts that vary in structure rather than in value.

What to do about the nineteen call sites

# the two can coexist, so this is not a big-bang migration
$ ls resources/views/partials/card.blade.php    # still there
$ ls resources/views/components/card.blade.php  # new

# convert per template, verify, delete the partial last
$ grep -rln "include('partials.card'" resources/views

# and the check that nothing was missed
$ grep -rn 'partials.card' resources/ | wc -l
0

Converting one template at a time with a visual check after each is what makes this reviewable, and the partial staying until the last caller is gone is what makes it interruptible. The four callers passing compact were the interesting ones: converting them meant deciding whether the parameter had ever done anything, and the answer was no.

Verifying it worked

$ backstop test
  ✓ 40 of 40                    # rendered output unchanged

$ php artisan test --filter Card
 ✓ it requires a title
 ✓ it renders the header slot only when one is given
 ✓ it merges the caller's classes rather than replacing them

$ git diff --stat resources/views/
 21 files changed, 118 insertions(+), 284 deletions(-)

Testing a component on its own is the thing that was not possible before: Blade::render() takes a component and its arguments and returns a string, so the class-merging behaviour above is an assertion rather than a hope. Those three tests took ten minutes and cover the behaviour that nineteen call sites were relying on implicitly.

The visual regression run is what makes the migration safe. Nineteen call sites replacing one partial should produce no visual difference at all, and any pixel that moved is a caller that was doing something on purpose.

What this costs

Two component styles and a decision at every use. The class and anonymous forms look identical from the calling side, which is good, and it means somebody adding a component has to pick — and the wrong pick is a file to move later rather than a mistake. Writing the rule down in the project README is cheaper than having the conversation four times.

The subtler cost is that components are resolved by convention from a directory, so a typo in a tag name renders nothing rather than erroring — <x-crad> is silently treated as an unknown component and produces empty output in some versions and a clear error in others. That is a genuine regression from @include, which at least failed loudly on a missing file, and it is worth knowing before it costs somebody twenty minutes.