Coba hitung berapa kali markup tombol, kartu, atau input form yang sama disalin di proyek Laravel Anda. Ketika desainer meminta warna tombol diganti atau pesan error ditampilkan dengan gaya baru, Anda harus mengedit puluhan file, dan biasanya ada satu halaman yang terlewat dan terlihat berbeda sendiri.
Blade Component menyelesaikan masalah ini: satu potongan UI ditulis sekali, lalu dipakai seperti tag HTML. Panduan ini membahas Blade Component di Laravel 11 mulai dari dasar sampai pola yang dipakai di aplikasi nyata, termasuk komponen input form lengkap dengan pesan validasi.
1. Class Component vs Anonymous Component
| Aspek | Anonymous component | Class component |
|---|---|---|
| File | Hanya resources/views/components/*.blade.php | Kelas di app/View/Components + view |
| Deklarasi props | @props([...]) | Parameter constructor |
| Logika PHP | Minimal, sebatas tampilan | Bebas: format data, method helper, query ringan |
| Cocok untuk | Button, badge, card, alert | Menu dinamis, tabel dengan format angka, widget statistik |
Aturan praktis: mulai dari anonymous. Naikkan ke class component hanya ketika file Blade mulai berisi blok @php yang panjang.
2. Membuat Anonymous Component
Buat file 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>
Dipakai di halaman mana pun:
<x-button type="submit">Simpan</x-button>
<x-button variant="danger" class="w-100" wire:confirm="Yakin?">Hapus</x-button>
Atribut yang tidak dideklarasikan di @props (seperti class dan wire:confirm) masuk ke $attributes. Method merge() menggabungkan class tambahan dengan class bawaan, bukan menimpanya.
3. Mengirim Data: String vs Ekspresi PHP
Ini sumber kebingungan nomor satu. Atribut biasa dikirim sebagai string literal. Untuk mengirim variabel atau ekspresi PHP, beri awalan titik dua:
<x-product-card title="Kopi Susu" /> {{-- string "Kopi Susu" --}}
<x-product-card :product="$product" /> {{-- objek Product --}}
<x-product-card :stock="0" /> {{-- integer 0 --}}
<x-product-card :featured="true" /> {{-- boolean --}}
Nama atribut kebab-case otomatis dipetakan ke camelCase. Atribut show-price diterima sebagai $showPrice.
4. Slot dan Named Slot
$slot berisi konten di antara tag pembuka dan penutup. Untuk area lain, gunakan named slot:
{{-- 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="Ringkasan Penjualan">
Total hari ini: {{ number_format($total, 0, ',', '.') }}
<x-slot:footer class="text-end">
<x-button>Lihat Detail</x-button>
</x-slot:footer>
</x-card>
Named slot juga punya attribute bag sendiri, sehingga pemakai komponen bisa menambah class ke footer tanpa mengubah komponennya. Untuk mengecek slot kosong, gunakan $slot->isEmpty().
5. Komponen Input Form yang Benar-Benar Berguna
Bagian form adalah tempat duplikasi paling parah: label, input, nilai lama dari old(), dan pesan error. Satu komponen bisa menggantikan belasan baris per 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>
Karena file berada di subfolder form, pemanggilannya memakai titik:
<x-form.input name="nama" label="Nama Produk" :value="$product->nama" required />
<x-form.input name="harga" label="Harga" type="number" min="0" :value="$product->harga" />
Method class() pada attribute bag menerima array kondisional, jadi class is-invalid hanya muncul jika field itu gagal validasi.
6. Class Component untuk Logika
php artisan make:component StockBadge
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\Contracts\View\View;
class StockBadge extends Component
{
public function __construct(public int $stock, public int $minimum = 5) {}
public function level(): string
{
return match (true) {
$this->stock === 0 => 'habis',
$this->stock <= $this->minimum => 'menipis',
default => 'aman',
};
}
public function render(): View
{
return view('components.stock-badge');
}
}
{{-- resources/views/components/stock-badge.blade.php --}}
<span class="badge badge-{{ $level() }}">{{ $stock }} ({{ $level() }})</span>
Method publik pada kelas bisa dipanggil langsung di view seperti variabel yang berupa closure. Logika penentuan level mudah dibuat unit test tanpa merender HTML.
7. Layout sebagai Komponen
Breeze dan Jetstream sudah memakai pola ini. Buat resources/views/components/layouts/app.blade.php berisi kerangka HTML dengan {{ $slot }} di bagian konten dan {{ $title ?? config('app.name') }} di tag title. Lalu setiap halaman cukup ditulis:
<x-layouts.app>
<x-slot:title>Daftar Produk</x-slot:title>
...isi halaman...
</x-layouts.app>
8. Komponen Dinamis:.when, @props[], dan Looping
Tiga fitur kecil ini yang paling sering membuat komponen sulit dibaca. Memahami kapan memakainya membuat pustaka komponen jauh lebih bersih:
| Fitur | Pakai saat | Contoh |
|---|---|---|
:variant="..." | Propsi bisa berisi nilai dari sekumpulan pilihan | Tombol primary / secondary / danger |
{{ $variant ?? 'primary' }} | Nilai bawaan perlu, tapi bisa ditimpa pemanggil | Warna default tombol |
@props(['active' => false]) | Boolean yang nilai awalnya mati | Status aktif pada menu |
<x-loop :data="$items" as="item" /> | Tag yang sama diulang banyak kali | Daftar tag, baris tabel |
@foreach di dalam view | Butuh logika di dalam markup | Baris tabel dengan kondisi |
Perhatikan sel boolean di tabel. Menulis :active="true" dan :active="false" membuat pemanggil lebih panjang, sedangkan mendeklarasikan sekali di @props membuat pemakaiannya cukup <x-menu-item active />. Itu perbedaan kecil yang langsung terasa kalau komponen dipakai puluhan kali.
Untuk kasus yang benar-benar perlu, <x-dynamic-component :component="$type" /> memilih komponen berdasarkan nilai runtime. Pola ini bekerja untuk tabel yang punya beberapa jenis baris, tapi jangan menjadikannya pilihan utama. Menyebut komponen secara eksplisit jauh lebih mudah dibaca daripada mencarinya lewat variabel.
9. Attribute Bag: Cara yang Benar Menggabungkan Class
Ini detail yang membedakan komponen yang bisa di-style ulang dari yang kaku. Tanpa $attributes->merge(), class bawaan komponen menimpa class yang Anda kirim dari pemanggil:
{{-- salah: class pemanggil hilang --}}
<button {{ $attributes->merge(['class' => 'btn btn-primary']) }}>
{{-- benar: keduanya gabung, class pemanggil menang --}}
<button {{ $attributes->merge(['class' => 'btn btn-primary']) }}>
{{-- pakai kelas dari config Tailwind, mis. memaksa warna --}}
<button {{ $attributes->merge(['class' => 'btn']) }}>
{{-- kelas kondisional + merge sekaligus --}}
<button {{ $attributes->merge(['class' => ['btn', 'py-2', $variant => 'btn-primary', $disabled ? 'opacity-50' : '']]) }}>
Begini cara kerjanya. $attributes adalah Illuminate\View\ComponentAttributeBag yang berisi semua atribut yang tidak dideklarasikan di @props. Method merge() menggabungkan class bawaan dengan class dari pemanggil, dan ketika keduanya sama, nilai pemanggil yang dipertahankan. Itu persis perilaku yang Anda inginkan saat menulis <x-button class="w-full">.
Untuk perilaku yang lebih halus, Laravel menyediakan class() yang menerima kelas bawaan, kelas dari variant yang Anda definisikan sendiri, atau kelas dari framework:
{{-- theme application's own button variants --}}
<button {{ $attributes->class(['btn', 'btn-primary' => $variant === 'primary']) }}>
{{-- menyatukan utility milik Tailwind --}}
<div {{ $attributes->class(['p-4', 'rounded-lg' => $rounded, 'shadow' => $shadow]) }}></div>
Selalu lakukan merge pada elemen terluar komponen, bukan pada elemen di dalam. Kalau Anda merge di elemen yang tidak holding slot, semua atribut yang dikirim pemanggil akan hilang begitu saja tanpa error — penyebab paling umum "kenapa class saya nggak masuk?"
10. Komponen yang Menyimpan State: @aware dan Broadcast
PHP bersifat satu arah: data masuk lewat props, lalu view selesai. Untuk hal yang benar-benar membutuhkan state bersama, seperti panel yang bisa dibuka semua sekaligus atau filter yang menempel di sidebar, Blade menyediakan dua alat:
{{-- komponen induk: state.php --}}
@props(['panelOpen' => false])
<div x-data="{ open: @js($panelOpen) }">
{{ $slot }}
</div>
{{-- komponen anak membaca state induk tanpa meneruskannya manual --}}
@aware(['panelOpen'])
<button @click="$dispatch('toggle-panel')">...</button>
@aware membaca props dari komponen induk tanpa harus menulis rantai :panel-open="$panelOpen" di setiap anak. Ini menghemat banyak pekerjaan saat pohon komponen membesar, tapi ada aturan yang jelas: @aware hanya untuk state yang benar-benar bersama. Kalau sebuah komponen butuh nilai tertentu, teruskan sebagai props: lebih jelas dan lebih mudah dites.
Alternatif yang lebih ringan adalah @props(['message']) plus attribute events: komponen anak memanggil $dispatch('saved') dengan Alpine, dan induk mendengarkan @saved.window. Pola ini membuat komponen tetap stateless dan mudah diuji, dan biasanya itu pilihan yang lebih baik untuk aplikasi bisnis.
11. Kesalahan Umum dan Perbaikannya
- Variabel terkirim sebagai teks "$product": lupa titik dua. Tulis
:product="$product". - Class kustom hilang: komponen menulis
class="..."langsung tanpa$attributes->merge(). - Atribut muncul dua kali di HTML: prop yang sama tidak dideklarasikan di
@props, sehingga ikut masuk attribute bag. - Perubahan tidak terlihat: view ter-cache. Jalankan
php artisan view:clear. - Query database di dalam komponen yang dipakai di loop: menyebabkan N+1. Ambil data di controller, kirim lewat props.
- Komponen melakukan navigasi atau menyimpan form: komponen sebaiknya presentation-only. Letakkan aksi ini di form action atau controller, bukan di dalam view.
- Nama komponen tidak mengikuti konvensi:
<x-user-card />lebih baik daripada<x-card-user />supaya autofill tag bekerja dan pustaka komponen mudah dibaca.
Checklist Pustaka Komponen
- Satu komponen, satu tanggung jawab.
- Semua komponen meneruskan
$attributeske elemen terluarnya. - Props punya nilai default yang masuk akal, dan boolean ditulis
active, bukan:active="true". - Komponen form menangani
old()dan@error. - Subfolder dipakai untuk kelompok (
form,layouts,ui). - Tidak ada query di dalam view; semua data masuk lewat props.
- Daftar komponen dan contoh pemakaiannya dicatat di README proyek.
Jika Anda baru mengenal struktur proyek, mulai dari struktur folder project Laravel. Untuk menyesuaikan tampilan source code siap pakai tanpa merusak fungsi lain, baca cara kustomisasi source code tanpa merusak; mengganti markup berulang menjadi komponen adalah salah satu langkah teraman. Padding dan spacing yang konsisten paling mudah ditulis sebagai komponen <x-section />. Latihan composants dengan_attribute merging akan terbayar cepat begitu proyek mulai punya puluhan view.