Đâ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-600sẽ không được build nếu không safelist. An toàn hơn là dùng@classhoặcmatchđể á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.phpvàedit.blade.phpngắ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