Introduction

Armed with the component basics, let's build the four components you'll drop into every Laravel project: form inputs, buttons, modals, and cards.

Key Concepts

  • Form input component: A reusable <x-forms.input> that handles the label, the input, error state, and old input in one tag.
  • Button component with variants: A <x-button variant="primary"> that maps variant and size props to the right Tailwind classes.
  • Modal component: A <x-modal> built on Alpine.js that responds to $dispatch('open-modal', 'name') events.
  • Card component with named slots: A <x-card> with optional header and footer slots for flexible layouts.

Real World Context

These four components replace hundreds of lines of duplicated markup in a typical CRUD application. Build them once and every form, dialog, and content panel in your app becomes a tag composition.

Deep Dive

Let's build real-world components you'll use in every Laravel project.

Form Input Component

bash
php artisan make:component Forms/Input
php
<?php

namespace App\View\Components\Forms;

use Illuminate\View\Component;
use Illuminate\View\View;

class Input extends Component
{
    public function __construct(
        public string $name,
        public string $type = 'text',
        public ?string $label = null,
        public ?string $value = null,
        public bool $required = false,
    ) {
        $this->label = $label ?? ucfirst(str_replace('_', ' ', $name));
        $this->value = old($name, $value);
    }

    public function render(): View
    {
        return view('components.forms.input');
    }
}
blade
<!-- resources/views/components/forms/input.blade.php -->
<div class="mb-4">
    <label for="{{ $name }}" class="block text-sm font-medium text-gray-700">
        {{ $label }}
        @if ($required)
            <span class="text-red-500">*</span>
        @endif
    </label>

    <input
        type="{{ $type }}"
        name="{{ $name }}"
        id="{{ $name }}"
        value="{{ $value }}"
        {{ $attributes->merge([
            'class' => 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500'
                . ($errors->has($name) ? ' border-red-500' : '')
        ]) }}
        @if($required) required @endif
    >

    @error($name)
        <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
    @enderror
</div>

Usage:

blade
<x-forms.input name="email" type="email" required />
<x-forms.input name="phone" label="Phone Number" />
<x-forms.input name="title" :value="$post->title" />

Button Component

blade
<!-- resources/views/components/button.blade.php -->
@props([
    'type' => 'button',
    'variant' => 'primary',
    'size' => 'md',
])

@php
$variants = [
    'primary' => 'bg-blue-600 hover:bg-blue-700 text-white',
    'secondary' => 'bg-gray-200 hover:bg-gray-300 text-gray-800',
    'danger' => 'bg-red-600 hover:bg-red-700 text-white',
    'success' => 'bg-green-600 hover:bg-green-700 text-white',
];

$sizes = [
    'sm' => 'px-3 py-1.5 text-sm',
    'md' => 'px-4 py-2',
    'lg' => 'px-6 py-3 text-lg',
];
@endphp

<button
    type="{{ $type }}"
    {{ $attributes->merge([
        'class' => 'rounded font-medium transition-colors ' . $variants[$variant] . ' ' . $sizes[$size]
    ]) }}
>
    {{ $slot }}
</button>

Usage:

blade
<x-button>Click Me</x-button>
<x-button type="submit" variant="success">Save</x-button>
<x-button variant="danger" size="sm" onclick="confirm('Sure?')">Delete</x-button>
blade
<!-- resources/views/components/modal.blade.php -->
@props([
    'name',
    'title' => '',
    'maxWidth' => 'md',
])

@php
$maxWidthClasses = [
    'sm' => 'max-w-sm',
    'md' => 'max-w-md',
    'lg' => 'max-w-lg',
    'xl' => 'max-w-xl',
    '2xl' => 'max-w-2xl',
];
@endphp

<div
    x-data="{ open: false }"
    x-on:open-modal.window="$event.detail === '{{ $name }}' ? open = true : null"
    x-on:close-modal.window="$event.detail === '{{ $name }}' ? open = false : null"
    x-on:keydown.escape.window="open = false"
    x-show="open"
    class="fixed inset-0 z-50 overflow-y-auto"
    style="display: none;"
>
    <!-- Backdrop -->
    <div
        x-show="open"
        x-transition:enter="ease-out duration-300"
        x-transition:enter-start="opacity-0"
        x-transition:enter-end="opacity-100"
        class="fixed inset-0 bg-gray-500 bg-opacity-75"
        @click="open = false"
    ></div>

    <!-- Modal -->
    <div class="flex min-h-screen items-center justify-center p-4">
        <div
            x-show="open"
            x-transition:enter="ease-out duration-300"
            x-transition:enter-start="opacity-0 scale-95"
            x-transition:enter-end="opacity-100 scale-100"
            class="relative bg-white rounded-lg shadow-xl {{ $maxWidthClasses[$maxWidth] }} w-full"
        >
            @if ($title)
                <div class="px-6 py-4 border-b">
                    <h3 class="text-lg font-semibold">{{ $title }}</h3>
                </div>
            @endif

            <div class="px-6 py-4">
                {{ $slot }}
            </div>

            @isset($footer)
                <div class="px-6 py-4 border-t bg-gray-50 flex justify-end gap-2">
                    {{ $footer }}
                </div>
            @endisset
        </div>
    </div>
</div>

Usage:

blade
<x-button @click="$dispatch('open-modal', 'confirm-delete')">
    Delete
</x-button>

<x-modal name="confirm-delete" title="Confirm Deletion">
    <p>Are you sure you want to delete this item?</p>

    <x-slot:footer>
        <x-button variant="secondary" @click="$dispatch('close-modal', 'confirm-delete')">
            Cancel
        </x-button>
        <x-button variant="danger" type="submit" form="delete-form">
            Delete
        </x-button>
    </x-slot>
</x-modal>

Card Component

blade
<!-- resources/views/components/card.blade.php -->
@props(['padding' => true])

<div {{ $attributes->merge(['class' => 'bg-white rounded-lg shadow']) }}>
    @isset($header)
        <div class="px-6 py-4 border-b font-semibold">
            {{ $header }}
        </div>
    @endisset

    <div @class(['px-6 py-4' => $padding])>
        {{ $slot }}
    </div>

    @isset($footer)
        <div class="px-6 py-4 border-t bg-gray-50">
            {{ $footer }}
        </div>
    @endisset
</div>

Complete Form Example

blade
<x-layout>
    <x-slot:title>Create Post</x-slot>

    <x-card class="max-w-2xl mx-auto">
        <x-slot:header>Create New Post</x-slot>

        <form method="POST" action="{{ route('posts.store') }}">
            @csrf

            <x-forms.input name="title" required />
            <x-forms.input name="slug" />

            <x-forms.textarea name="body" rows="10" required />

            <x-forms.select name="category_id" :options="$categories" required />

            <x-forms.checkbox name="published" label="Publish immediately" />
        </form>

        <x-slot:footer>
            <x-button variant="secondary" href="{{ route('posts.index') }}">
                Cancel
            </x-button>
            <x-button type="submit" form="create-post-form">
                Create Post
            </x-button>
        </x-slot>
    </x-card>
</x-layout>

Common Pitfalls

  1. Hardcoding styles inside the component body — Callers can't override them. Use $attributes->merge(['class' => '…']) so the default is overridable.
  2. Forgetting @props to declare expected props — Without it, unknown attributes silently leak into the rendered element.
  3. Missing aria-* attributes on interactive components — Modals, buttons, and dialogs need accessibility attributes to be usable by assistive tech.

Best Practices

  1. Declare every prop with @props([...]) — Documents the component's API and gives you default values for free.
  2. Always merge $attributes — Let callers add custom classes, IDs, data attributes, and event listeners.
  3. Use slots over giant prop arrays — A <x-modal> with a footer slot is cleaner than one with footerHtml, footerAlign, footerButtons props.

Summary

  • A form input component bundles label, input, error display, and old input support.
  • A button component maps variant/size props to Tailwind classes through a lookup map.
  • A modal component uses Alpine.js for reactivity and listens to open-modal/close-modal events.
  • A card component with header, footer, and default slots is the Swiss army knife of content panels.
  • Compose these into form pages to replace hundreds of lines of duplicated markup.
✓ Completed