skip to content

In a Blade component, how does $attributes->merge() treat class compared with other attributes, and when do you need class() or prepends()?

level: middleimportance: must knowfreq 55%

answer

  1. attributes that are not props
  2. class and style are appended
  3. other defaults are overridden
  4. class() for conditional classes
  5. prepends() joins a non-class default

basics

~20 s

merge() adds default attributes: class and style defaults are joined with the caller's values, while any other default is replaced by the caller's value. class() adds classes conditionally; prepends() makes a non-class default join instead.

solid answer

~40 s

Any attribute not declared as a prop lands in `$attributes`, a `ComponentAttributeBag`, and `{{ $attributes }}` prints them. `$attributes->merge(['class' => 'alert alert-'.$type])` joins its default classes with the caller's, defaults first, so `<x-alert class="mb-4">` yields `class="alert alert-warning mb-4"`; `style` is joined the same way. For every other key, merge supplies a **default** the caller overrides: `merge(['type' => 'button'])` gives `type="submit"` if the caller passed it. To join a non-class value instead, wrap the default in `$attributes->prepends('...')`. `$attributes->class(['p-4', 'ring' => $active])` merges conditional classes and chains with `merge()`. Filters - `only`, `except`, `whereStartsWith`, `whereDoesntStartWith`, `filter`, `first` - split one bag across several elements.

code

html · 11 lines
html
{{-- resources/views/components/alert.blade.php --}}
@props(['type' => 'info', 'dismissible' => false])

<div {{ $attributes
        ->class(['alert', 'alert-'.$type, 'pr-10' => $dismissible])
        ->merge(['role' => 'alert', 'data-controller' => $attributes->prepends('alert')]) }}>
    {{ $slot }}
</div>

{{-- <x-alert type="danger" class="mb-6" role="status" data-controller="autohide">Overbooked</x-alert> --}}
{{-- renders class="alert alert-danger mb-6" role="status" data-controller="alert autohide" --}}

go deeper

for a junior

Recall that extra attributes land in $attributes and that merge() joins class values but lets the caller override other defaults.

for a middle

Explain merge's per-key rules, prepends() for list-like attributes, class() for conditionals, and the only/except/whereStartsWith filters.

for a senior

Design components whose root forwards attributes predictably, split bags across inner elements, and avoid duplicate or swallowed attributes.

for a principal

Standardise attribute-forwarding rules across a component library so callers can always style, identify and wire any component.

## The attribute bag A Blade component receives two kinds of input from its tag: **props**, which it declares (constructor parameters or `@props`), and **everything else**. Everything else - `class`, `id`, `aria-*`, `wire:*`, `x-*`, `data-*` - is collected into `$attributes`, an `Illuminate\View\ComponentAttributeBag`. Printing `{{ $attributes }}` inside the root element forwards all of them, so callers can style and wire a component without it declaring every possible attribute. String values bound with `:` that land in the bag are HTML-escaped as they enter it, and the bag is `Htmlable`, so `{{ $attributes }}` prints them without escaping a second time. ## merge(): defaults and joining `merge()` takes an array of **defaults** and returns a new bag. What happens per key: | Key | Component default | Caller passes | Result | |---|---|---|---| | `class` | `alert alert-warning` | `mb-4` | `alert alert-warning mb-4` | | `style` | `color: red` | `margin: 0` | `color: red; margin: 0;` | | `type` | `button` | `submit` | `submit` | | `type` | `button` | nothing | `button` | | `role` | `alert` | nothing | `alert` | So `class` and `style` are **appended** (default first, duplicates removed, `style` entries finished with `;`), and every other attribute is a **default the caller can replace**. Default values are HTML-escaped by `merge()` unless you pass `false` as its second argument. ## prepends(): joining a non-class attribute Some attributes behave like lists - `data-controller` in a JavaScript framework, `aria-describedby`. To keep the component's value and add the caller's, wrap the default: ```html <div {{ $attributes->merge(['data-controller' => $attributes->prepends('modal')]) }}> ``` Caller `data-controller="dirty-form"` then yields `data-controller="modal dirty-form"` instead of losing `modal`. ## class(): conditional classes `$attributes->class([...])` accepts the same array shape as the `@class` directive - numeric keys always apply, string keys apply when their value is true - and merges the result into the bag's `class`: ```html <div {{ $attributes->class(['modal', 'modal--wide' => $wide])->merge(['role' => 'dialog']) }}> ``` `style()` does the same for inline styles. ## Filtering one bag across several elements A modal has a wrapper, a panel and a close button; attributes aimed at each can be split: - `$attributes->only(['id'])` - just those keys. - `$attributes->except(['class'])` - everything else. - `$attributes->whereStartsWith('wire:model')` / `whereDoesntStartWith(...)` - route framework bindings to the inner input while the rest stay on the wrapper. - `$attributes->filter(fn ($value, $key) => ...)` - arbitrary rules. - `->first()` - the first value in a filtered bag. - `has('x')`, `hasAny([...])` and `get('x')` - inspect without printing. ## How the bag prints `{{ $attributes }}` renders each entry as `key="value"`, with two special cases worth knowing: - An attribute whose value is `false` or `null` is **omitted**, so `:disabled="$locked"` disappears entirely when `$locked` is false. - An attribute whose value is `true` renders with its own name as the value (`disabled="disabled"`), except Alpine's `x-data` and `wire:` attributes, which render with an empty value. That makes the bag a natural place for boolean HTML attributes: pass them bound with `:` and let the bag decide whether they appear. ## A hotel-admin example An alert component with defaults, conditional classes and a dismiss hook: 1. `@props(['type' => 'info', 'dismissible' => false])` declares the props. 2. The root element uses `$attributes->class(['alert', 'alert-'.$type, 'pr-10' => $dismissible])->merge(['role' => 'alert'])`. 3. A caller writes `<x-alert type="danger" class="mb-6" role="status" id="overbooking">`. 4. The output has `class="alert alert-danger mb-6"`, `role="status"` (the caller's value replaced the default) and `id="overbooking"` (forwarded). ## Common mistakes - Writing `class="alert {{ $attributes->get('class') }}"` and then also printing `{{ $attributes }}`, which emits two `class` attributes. - Expecting `merge(['type' => 'button'])` to combine with the caller's `type`. - Declaring `class` as a prop, which removes it from the bag and breaks merging. - Forgetting `{{ $attributes }}` entirely, so callers' `id`, `aria-*` and event attributes silently vanish.

  • Why does declaring 'class' in @props break a component's styling?
    Props are removed from the attribute bag. With `class` declared as a prop, the caller's classes become a variable and never reach `$attributes`, so `merge()` has nothing to join and the root element loses them unless the template prints the variable by hand. Leave `class` in the bag.
  • How would you send wire:model attributes to an inner input and the rest to the wrapper?
    Split the bag: `{{ $attributes->whereStartsWith('wire:model') }}` on the input and `{{ $attributes->whereDoesntStartWith('wire:model') }}` on the wrapper. Each call returns a new bag, so the original is unchanged and each element gets only its share.

saying these in an interview costs you the question

  • merge() overrides the caller's class with the component's default
  • merge() joins every attribute, including type and id
  • Attribute bag values are printed without any escaping
  • prepends() is needed to combine class values
  • Declaring class as a prop is the way to style the root element