Count how many times the same button, card or form field markup has been copied across your Laravel project. When a designer asks for a new button colour or a different validation message style, you end up editing dozens of files, and one page always gets missed and looks out of place.
Blade components fix this: you write a piece of UI once and use it like an HTML tag. This guide covers Blade components in Laravel 11, from the basics to patterns used in real apps, including a form input component that handles old input and validation errors.
1. Class vs Anonymous Components
| Aspect | Anonymous | Class-based |
|---|---|---|
| Files | Only resources/views/components/*.blade.php | A class in app/View/Components plus a view |
| Props | @props([...]) | Constructor parameters |
| PHP logic | Presentation only | Formatting, helper methods, light lookups |
| Good for | Buttons, badges, cards, alerts | Dynamic menus, stat widgets, formatted tables |
Rule of thumb: start anonymous, and promote to a class only when the Blade file fills up with @php blocks.
2. An Anonymous Button
{{-- resources/views/components/button.blade.php --}}
@props(['variant' => 'primary', 'type' => 'button'])
@php
$classes = match ($variant) {
'danger' => 'btn btn-danger',
'outline' => 'btn btn-outline-primary',
default => 'btn btn-primary',
};
@endphp
<button type="{{ $type }}" {{ $attributes->merge(['class' => $classes]) }}>
{{ $slot }}
</button>
<x-button type="submit">Save</x-button>
<x-button variant="danger" class="w-100" onclick="return confirm('Sure?')">Delete</x-button>
Anything not listed in @props lands in $attributes. merge() appends extra classes to the defaults instead of replacing them.
3. Strings vs PHP Expressions
Plain attributes are passed as literal strings. Prefix with a colon to pass a variable or expression:
<x-product-card title="Iced Latte" /> {{-- the string --}}
<x-product-card :product="$product" /> {{-- a Product model --}}
<x-product-card :stock="0" /> {{-- integer 0 --}}
Kebab-case attributes map to camelCase variables: show-price becomes $showPrice.
4. Slots and Named Slots
{{-- resources/views/components/card.blade.php --}}
@props(['title' => null])
<div {{ $attributes->merge(['class' => 'card']) }}>
@if ($title)
<div class="card-header">{{ $title }}</div>
@endif
<div class="card-body">{{ $slot }}</div>
@isset($footer)
<div {{ $footer->attributes->merge(['class' => 'card-footer']) }}>{{ $footer }}</div>
@endisset
</div>
<x-card title="Today's Sales">
Total: {{ number_format($total, 2) }}
<x-slot:footer class="text-end">
<x-button>View details</x-button>
</x-slot:footer>
</x-card>
Named slots carry their own attribute bag, and $slot->isEmpty() tells you whether any content was passed.
5. A Form Input Worth Reusing
Forms are where duplication hurts most: label, input, old value and error message for every field.
{{-- resources/views/components/form/input.blade.php --}}
@props(['name', 'label' => null, 'type' => 'text', 'value' => null])
<div class="mb-3">
@if ($label)
<label for="{{ $name }}" class="form-label">{{ $label }}</label>
@endif
<input id="{{ $name }}" name="{{ $name }}" type="{{ $type }}"
value="{{ old($name, $value) }}"
{{ $attributes->class(['form-control', 'is-invalid' => $errors->has($name)]) }}>
@error($name)
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<x-form.input name="name" label="Product name" :value="$product->name" required />
<x-form.input name="price" label="Price" type="number" step="0.01" :value="$product->price" />
Subfolders become dot notation, and class() accepts conditional entries, so is-invalid appears only when that field failed validation.
6. A Class Component With Logic
php artisan make:component StockBadge
class StockBadge extends Component
{
public function __construct(public int $stock, public int $minimum = 5) {}
public function level(): string
{
return match (true) {
$this->stock === 0 => 'out',
$this->stock <= $this->minimum => 'low',
default => 'ok',
};
}
public function render(): View
{
return view('components.stock-badge');
}
}
<span class="badge badge-{{ $level() }}">{{ $stock }} ({{ $level() }})</span>
Public methods are callable from the view, and the level logic can be unit-tested without rendering any HTML.
7. Layouts as Components
Put your HTML skeleton in resources/views/components/layouts/app.blade.php with {{ $slot }} for content and {{ $title ?? config('app.name') }} in the title tag. Each page then becomes:
<x-layouts.app>
<x-slot:title>Products</x-slot:title>
...page content...
</x-layouts.app>
8. Dynamic Components, Booleans, and the Attribute Bag
A few small features cause most of the confusion in component libraries. Knowing when to reach for them keeps the markup readable:
| Feature | Use it when | Example |
|---|---|---|
:variant="..." | A prop comes from a fixed set of choices | Button primary / secondary / danger |
{{ $variant ?? 'primary' }} | A sensible default that callers may override | Default button colour |
@props(['active' => false]) | A boolean that is normally off | Active menu item |
<x-dynamic-component :component="$type" /> | The component is chosen at runtime | Table rows of several kinds |
@foreach inside the view | You need markup-level logic | Table rows with conditionals |
Look at the boolean row. Writing :active="true" and :active="false" makes every call site longer, while declaring it once in @props lets you write <x-menu-item active />. That difference is tiny in one file and obvious across fifty.
<x-dynamic-component> has a real use: a table where each row renders a different component based on a type column. Do not make it your default, though. Naming the component explicitly is far easier to read and to grep than resolving it through a variable.
9. The Attribute Bag, Done Properly
This is the detail that separates components you can restyle from components that are stuck. Without $attributes->merge(), the component's own class attribute overwrites whatever the caller passed:
{{-- merges; caller classes win on conflict --}}
<button {{ $attributes->merge(['class' => 'btn btn-primary']) }}>
{{-- force a colour with a !important utility --}}
<button {{ $attributes->merge(['class' => 'btn']) }}>
{{-- merge plus conditional classes --}}
<button {{ $attributes->merge(['class' => ['btn', 'py-2', 'btn-primary' => $variant === 'primary']]) }}>
Here is what happens. $attributes is an Illuminate\View\ComponentAttributeBag holding every attribute that was not declared in @props. merge() combines the component's default class with the caller's class, and when the same class appears twice the caller's value wins. That is exactly what you want when someone writes <x-button class="w-full">.
Laravel also ships class(), which is friendlier when your classes come from variants or framework-level definitions:
<button {{ $attributes->class(['btn', 'btn-primary' => $variant === 'primary']) }}>
<div {{ $attributes->class(['p-4', 'rounded-lg' => $rounded, 'shadow' => $shadow]) }}></div>
Always merge on the outer element that holds the slot. If you merge on an inner element instead, every attribute the caller sent is silently dropped, with no error at all. That is the most common cause of "why is my class not showing up?"
10. Shared State: @aware and Events
PHP flows one way: data enters through props, the view finishes. For the rare cases that genuinely need shared state, such as a panel that expands all sections at once, Blade offers @aware and Alpine events.
{{-- parent --}}
@props(['open' => false])
<div x-data="{ open: @js($open) }">{{ $slot }}</div>
{{-- child reads the parent prop without threading it through --}}
@aware(['open'])
<button @click="$dispatch('toggle-panel')">...</button>
@aware saves a chain of :open="$open" passings as the component tree grows. Keep it for truly shared state; if a component needs a specific value, pass it as a prop instead, because that is easier to test.
The lighter alternative is to keep components stateless and let Alpine carry the event: the child calls $dispatch('saved') and the parent listens with @saved.window. Stateless components are trivially testable, which usually makes them the better choice in a business application.
11. Common Mistakes
- The component receives the text "$product": missing colon. Use
:product="$product". - Custom classes disappear: the component hardcodes
class="..."instead of using$attributes->merge(). - An attribute renders twice: a prop was not declared in
@props, so it also landed in the attribute bag. - Edits do not show: cached views. Run
php artisan view:clear. - Database queries inside a component used in a loop: classic N+1. Load data in the controller and pass it as props.
- The component submits a form or navigates: keep components presentational. Put the action on the form's
actionor in the controller. - Naming breaks tag autocomplete:
<x-user-card />autocompletes better than<x-card-user />.
Component Library Checklist
- One component, one responsibility.
- Every component forwards
$attributesto its outer element. - Props have sensible defaults, and booleans are written
activerather than:active="true". - Form components handle
old()and@error. - Subfolders group related pieces (
form,layouts,ui). - No queries inside views; all data arrives as props.
- The README lists each component with a usage example.
If you are restyling a purchased codebase, customizing source code without breaking it explains why extracting repeated markup into components is one of the safest first steps. Consistent spacing is usually the next thing to extract: a <x-section /> wrapper pays for itself within a dozen views. For data-heavy components, pair this with optimizing Eloquent query performance.