Blade Components for Reusable UI

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...

Blade Components for Reusable UI

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

AspectAnonymousClass-based
FilesOnly resources/views/components/*.blade.phpA class in app/View/Components plus a view
Props@props([...])Constructor parameters
PHP logicPresentation onlyFormatting, helper methods, light lookups
Good forButtons, badges, cards, alertsDynamic 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:

FeatureUse it whenExample
:variant="..."A prop comes from a fixed set of choicesButton primary / secondary / danger
{{ $variant ?? 'primary' }}A sensible default that callers may overrideDefault button colour
@props(['active' => false])A boolean that is normally offActive menu item
<x-dynamic-component :component="$type" />The component is chosen at runtimeTable rows of several kinds
@foreach inside the viewYou need markup-level logicTable 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 action or 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 $attributes to its outer element.
  • Props have sensible defaults, and booleans are written active rather 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.

Yudhi
Written by
Yudhi
Founder & Lead Developer, GudangCode

Yudhi is the founder of GudangCode and a Laravel developer who has built dozens of ready-to-use business information systems — from POS and HRIS to management apps. He writes guides and articles on GudangCode to help Indonesian developers run, understand, and deploy Laravel source code correctly.

LaravelPHPMySQLSistem Informasi Bisnis See all articles by Yudhi
Want the full source code & apps?

Sign up free to download ready-to-use business applications, information systems, and Laravel source code.

Sign Up Free & Download
Blade Component Laravel Programming & Laravel
Share this article
Back to Blog
📚 Free Learning Hub

Learn Coding for Free at DhieCoderWeb

Explore Laravel, PHP, JavaScript tutorials, source code, web development guides, and practical programming tips.

DhieCoderWeb
100+
Tutorials
Free
Learning
SEO
Tips
Visit Dhiecoderweb.com →

Get Full Access Now!

Join our membership and unlock exclusive access to all premium features. Fast, easy, and ready to use instantly.

Join Membership Now