NTM Solutions

Chủ Nhật, 16 tháng 8, 2026

Giao diện dùng Blade Components

Đây là cách mình thường làm cho dự án Laravel lớn: không dùng package, chỉ tạo các Blade Components để chuẩn hóa giao diện form. 

Sau này tất cả CRUD chỉ việc gọi component.

Cấu trúc

resources/
└── views/
    └── components/
        └── ui/
            ├── input.blade.php
            ├── textarea.blade.php
            ├── select.blade.php
            ├── button.blade.php
            └── card.blade.php


1. Card

resources/views/components/ui/card.blade.php

@props([
    'title' => '',
    'description' => ''
])

<div class="bg-white rounded-xl border border-gray-200 shadow-sm">

    @if($title)
        <div class="px-6 py-4 border-b">
            <h2 class="text-xl font-semibold text-gray-800">
                {{ $title }}
            </h2>

            @if($description)
                <p class="text-sm text-gray-500 mt-1">
                    {{ $description }}
                </p>
            @endif
        </div>
    @endif

    <div class="p-6">
        {{ $slot }}
    </div>

</div>

2. Input

resources/views/components/ui/input.blade.php

@props([
    'label',
    'name',
    'type' => 'text',
    'value' => null,
])

<div class="mb-5">

    <label
        for="{{ $name }}"
        class="block text-sm font-semibold text-gray-700 mb-2">
        {{ $label }}
    </label>

    <input
        id="{{ $name }}"
        type="{{ $type }}"
        name="{{ $name }}"
        value="{{ old($name, $value) }}"
        {{ $attributes->merge([
            'class' =>
            'inline-block rounded-lg border border-gray-300 bg-white
             px-4 py-2.5
             focus:border-blue-500
             focus:ring-4
             focus:ring-blue-100
             outline-none
             transition'
        ]) }}>

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

</div>

3. Textarea

@props([
    'label',
    'name',
    'rows' => 4,
    'value' => null,
])

<div class="mb-5">

    <label
        for="{{ $name }}"
        class="block text-sm font-semibold text-gray-700 mb-2">
        {{ $label }}
    </label>

    <textarea
        id="{{ $name }}"
        name="{{ $name }}"
        rows="{{ $rows }}"
        {{ $attributes->merge([
            'class' =>
            'w-full rounded-lg border border-gray-300
             px-4 py-2.5
             focus:border-blue-500
             focus:ring-4
             focus:ring-blue-100'
        ]) }}>{{ old($name, $value) }}</textarea>

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

</div>

4. Button

@props([
    'color' => 'blue'
])

<button
    {{ $attributes->merge([
        'class' =>
        "px-6 py-2 rounded-lg
         bg-$color-600
         hover:bg-$color-700
         text-white
         transition"
    ]) }}>
    {{ $slot }}
</button>

Lưu ý: Với Tailwind JIT, class động như bg-$color-600 sẽ không được build nếu không safelist. An toàn hơn là dùng @class hoặc match để ánh xạ màu.


5. Select

@props([
    'label',
    'name',
    'options' => [],
    'placeholder' => null,
    'multiple' => false,
])

<label
    for="{{ $name }}"
    class="block text-sm font-semibold text-gray-700 mb-2">
    {{ $label }}
</label>


<select
    id="{{ $name }}"
    name="{{ $multiple ? $name . '[]' : $name }}"
    @if($multiple) multiple @endif

    {{ $attributes->merge([
        'class' =>
        'inline-block rounded-lg border border-gray-300 bg-white
         px-4 py-2.5
         focus:border-blue-500
         focus:ring-4
         focus:ring-blue-100
         outline-none
         transition'
    ]) }}>

    @if($placeholder && !$multiple)

        <option value="">
            {{ $placeholder }}
        </option>

    @endif


    @foreach($options as $key => $option)

        @php

            /*
             * Eloquent model
             */
            if (is_object($option)) {

                $value = $option->id;
                $text = $option->name;

            }

            /*
             * Array:
             * ['value' => 'draft', 'label' => 'Nháp']
             */
            elseif (is_array($option)) {

                $value = $option['value'];
                $text = $option['label'];

            }

            /*
             * Associative array:
             * 'draft' => 'Nháp'
             */
            else {

                $value = $key;
                $text = $option;

            }

        @endphp


        <option
            value="{{ $value }}"

            @selected(
                $multiple
                    ? in_array(
                        $value,
                        old($name, [])
                    )
                    : old($name) == $value
            )>

            {{ $text }}

        </option>

    @endforeach

</select>


@error($name)

    <p class="text-red-600 text-sm mt-2">
        {{ $message }}
    </p>

@enderror 

Ghi chú: chọn 01.

6. Select Multiple

@php
    $selectedValues = collect($value)->map(fn ($id) => (string) $id)->toArray();
@endphp

<div class="mb-3">
    <label for="{{ $name }}" class="form-label">
        {{ $label }}
    </label>

    <select
        id="{{ $name }}"
        name="{{ $name }}[]"
        class="form-select @error($name) is-invalid @enderror"
        multiple
    >
        @foreach ($options as $option)
            @php
                $optionValue = is_array($option)
                    ? $option['value']
                    : ($option->id ?? $option);

                $optionLabel = is_array($option)
                    ? $option['label']
                    : ($option->name ?? $option->title ?? $option);
            @endphp

            <option
                value="{{ $optionValue }}"
                @selected(in_array((string) $optionValue, $selectedValues, true))
            >
                {{ $optionLabel }}
            </option>
        @endforeach
    </select>

    @error($name)
        <div class="invalid-feedback">
            {{ $message }}
        </div>
    @enderror
</div>

Ghi chú: chọn nhiều mục.


7. Sử dụng

<x-app-layout>

<div class="max-w-3xl mx-auto py-8">

    <x-ui.card
        title="Thêm Category"
        description="Nhập thông tin danh mục">

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

            @csrf

            <x-ui.input
                label="Tên Category"
                name="name"/>

            <x-ui.input
                label="Slug"
                name="slug"/>

            <x-ui.textarea
                label="Mô tả"
                name="description"/>

            <div class="flex justify-end gap-3">

                <a
                    href="{{ route('categories.index') }}"
                    class="px-6 py-2 rounded-lg border hover:bg-gray-100">
                    Hủy
                </a>

                <x-ui.button>
                    Lưu
                </x-ui.button>

            </div>

        </form>

    </x-ui.card>

</div>

</x-app-layout>

Ưu điểm

  • ✅ Toàn bộ CRUD có giao diện đồng nhất.

  • ✅ Muốn đổi style chỉ sửa 1 component.

  • ✅ Code create.blade.phpedit.blade.php ngắn, dễ đọc.

  • ✅ Phù hợp để xây dựng khóa học Laravel dài vì tránh lặp lại hàng trăm dòng HTML.

Mình còn khuyên thêm một bước nữa: tạo bộ UI riêng kiểu AdminLTE nhưng viết bằng Tailwind (card, table, alert, modal, form, pagination, badge...). 

Khi đó toàn bộ khóa học sẽ có một "design system" thống nhất thay vì chỉ có các component rời rạc. Đây là cách nhiều dự án Laravel chuyên nghiệp tổ chức giao diện quản trị.

Làm Flash Message:

Ý tưởng này rất đáng làm. Chỉ cần include một lần trong x-app-layout là toàn bộ CRUD sẽ có thông báo.

resources/views/components/alert.blade.php

@php

$alerts = [
    'success' => [
        'title' => 'Thành công',
        'bg' => 'bg-green-50',
        'border' => 'border-green-200',
        'text' => 'text-green-800',
        'icon' => '✓',
    ],
    'error' => [
        'title' => 'Lỗi',
        'bg' => 'bg-red-50',
        'border' => 'border-red-200',
        'text' => 'text-red-800',
        'icon' => '✕',
    ],
    'warning' => [
        'title' => 'Cảnh báo',
        'bg' => 'bg-yellow-50',
        'border' => 'border-yellow-200',
        'text' => 'text-yellow-800',
        'icon' => '⚠',
    ],
    'info' => [
        'title' => 'Thông tin',
        'bg' => 'bg-blue-50',
        'border' => 'border-blue-200',
        'text' => 'text-blue-800',
        'icon' => 'ⓘ',
    ],
];

@endphp

@foreach($alerts as $type => $alert)

    @if(session($type))

        <div
            x-data="{ show: true }"
            x-init="setTimeout(() => show = false, 5000)"
            x-show="show"
            x-transition
            class="mb-6 rounded-lg border {{ $alert['border'] }} {{ $alert['bg'] }} p-4 shadow">

            <div class="flex justify-between">

                <div class="flex gap-3">

                    <div class="text-xl">
                        {{ $alert['icon'] }}
                    </div>

                    <div>

                        <div class="font-semibold {{ $alert['text'] }}">
                            {{ $alert['title'] }}
                        </div>

                        <div class="{{ $alert['text'] }}">
                            {{ session($type) }}
                        </div>

                    </div>

                </div>

                <button
                    @click="show = false"
                    class="{{ $alert['text'] }}">
                    ✕
                </button>

            </div>

        </div>

    @endif

@endforeach

Sau đó chỉ cần đặt ở đầu nội dung của layout:

<x-app-layout>

    <div class="py-8">

        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">

            <x-alert />

            {{ $slot }}

        </div>

    </div>

</x-app-layout>

Controller chỉ cần:

return redirect()
    ->route('categories.index')
    ->with('success', 'Thêm Category thành công!');

hoặc

->with('error', 'Không thể xóa Category.');

Mình còn đề xuất một bản "xịn" hơn

Thay vì chỉ có <x-alert />, hãy tạo luôn Flash Message tự động biến mất sau 4–5 giây bằng Alpine.js (đã có sẵn trong Breeze). 

Khi đó thông báo sẽ giống AdminLTE, Filament hoặc Jetstream:

  • Có hiệu ứng trượt xuống.

  • Tự mờ dần rồi biến mất.

  • Có nút ✕ để đóng thủ công.

  • Không cần JavaScript riêng, chỉ vài dòng Alpine.

Đó là phiên bản mình sẽ chọn cho một khóa Laravel 12 vì nhìn hiện đại hơn hẳn mà gần như không tăng độ khó.

Không có nhận xét nào:

Đăng nhận xét

Facebook Youtube RSS