Тег <x-alert> не існує в HTML: його ще на етапі компіляції шаблона розбирає ComponentTagCompiler, який перетворює тег на PHP-код рендера компонента. Далі є розвилка. Якщо знайдено клас App\View\Components\Alert, створюється його екземпляр, атрибути тега розкладаються по параметрах конструктора за іменами (user-id лягає в $userId), а те, що конструктор просить і чого немає серед атрибутів, резолвиться з сервіс-контейнера. Якщо класу немає, Laravel шукає файл resources/views/components/alert.blade.php і рендерить AnonymousComponent. В обох випадках усередину потрапляє рівно три речі: оголошені дані, вкладений вміст у вигляді $slot і всі нерозібрані атрибути у вигляді $attributes. Змінних сторінки, на якій компонент викликали, там немає, і саме на цьому найчастіше спотикаються: $order у шаблоні компонента не null, його там просто немає.
Контракт оголошують по-різному, а працює він однаково. В анонімному компоненті це @props(['type' => 'info', 'title' => null]): кожен ключ стає змінною з дефолтом і одночасно вилучається з мішка атрибутів. У класовому ту саму роль грають публічні властивості конструктора, а шаблону на додачу доступні всі публічні методи класу, тому {{ $isActive($item) }} викликається прямо з розмітки. Передають дані двома способами: title="Оплата" віддає рядковий літерал, :title="$order->title" з двокрапкою - PHP-вираз. Коли ім'я змінної збігається з іменем пропса, працює скорочення :$title.
Усе, чого немає в контракті, осідає в $attributes - об'єкті ComponentAttributeBag, який потрібно самому вивести на кореневому елементі. Головний його метод - merge(), і поведінка тут навмисно асиметрична: class конкатенується з тим, що передали ззовні (тому class="mt-6" у виклику доповнює власні класи компонента, а не стирає їх), а будь-який інший атрибут у merge() вважається дефолтом і поступається значенню з тега. Через це role="alert" у виклику перекриє role="status" із компонента, а data-test чи wire:model, про які компонент нічого не знає, просто проїдуть у HTML. Коли класи залежать від стану, поруч ставлять $attributes->class(['p-4', 'bg-red-100' => $hasError])->merge([...]). А якщо в компонента два кореневі елементи, атрибути ділять: обгортці $attributes->except(['name']), самому полю $attributes->whereStartsWith('wire:').
Вкладений вміст приходить у $slot. Це об'єкт ComponentSlot, тому в нього можна спитати isEmpty() або isNotEmpty() перед тим, як малювати обгортку (майте на увазі, що переніс рядка й пробіли теж рахуються за вміст, а для суворішої перевірки у свіжих версіях є hasActualContent()). Іменовані слоти оголошують у виклику: <x-slot:actions> у Laravel 9 і новіших, <x-slot name="actions"> у восьмій. Непереданий слот не створює змінної, тож перевірка тільки через @isset. Власні атрибути слот теж має, вони доступні як $actions->attributes, і це зручно, коли компонент задає базові класи, а сторінка їх доповнює.
Залишається вибір між компонентом і @include. Директива вставляє шаблон із повним доступом до змінних батька, тому це інструмент нарізання довгої сторінки на шматки, які поза цією сторінкою сенсу не мають: @include('orders.partials.summary'). Компонент заводять тоді, коли елемент справді багаторазовий, має параметри, приймає вкладений вміст і має пропускати крізь себе атрибути: кнопка, поле форми, модалка, картка. Ізольована область видимості тут працює на вас: видно, що саме компонент вимагає, і випадкове перейменування змінної на сторінці його не зламає. Ціна теж є. Рендер компонента створює об'єкт і окремий вкладений view, тому в циклі на тисячі рядків таблиці @include дешевший; запити до бази в конструкторі компонента дають N+1, який потім довго шукають у контролері; @props не перевіряє типів, тож контракт тримається на дисципліні. І ще одне з практики: стилі та скрипти компонента виносьте в @push усередині @once, інакше на десятьох екземплярах на сторінці отримаєте десять однакових тегів.
{{-- resources/views/components/alert.blade.php: анонімний компонент --}}
{{-- @props оголошує контракт: ці ключі стають змінними й зникають з $attributes --}}
@props(['type' => 'info', 'title' => null])
@php
$palette = match ($type) {
'error' => 'border-red-300 bg-red-50',
default => 'border-sky-300 bg-sky-50',
};
@endphp
{{-- merge: class склеюється з переданим ззовні,
решта атрибутів тут лише дефолти й поступаються виклику --}}
<div {{ $attributes->merge(['class' => "rounded border p-4 $palette", 'role' => 'status']) }}>
@if ($title)
<p class="font-semibold">{{ $title }}</p>
@endif
{{-- $slot - це ComponentSlot, тому в нього можна спитати про вміст --}}
@if ($slot->isNotEmpty())
<div class="text-sm">{{ $slot }}</div>
@endif
{{-- слот, який не передали, не існує як змінна: потрібен @isset, а не isEmpty() --}}
@isset($actions)
<div class="mt-3">{{ $actions }}</div>
@endisset
</div>
{{-- resources/views/orders/show.blade.php: виклик --}}
<x-alert type="error" title="Оплата не пройшла" class="mt-6" role="alert" data-test="pay">
Банк відхилив транзакцію.
<x-slot:actions>
<a href="{{ route('orders.retry', $order) }}">Спробувати ще раз</a>
</x-slot:actions>
</x-alert>
{{-- Результат: class="rounded border p-4 border-red-300 bg-red-50 mt-6",
role="alert" (перекрив дефолт status), data-test проїхав наскрізь,
а type і title у HTML не потрапили, бо оголошені в @props --}}
Тримайте у відповіді три речі: контракт (`@props` / конструктор), вміст (`$slot` і іменовані слоти) і все інше (`$attributes` з `merge`). Далі одним реченням про вибір: компонент - коли є багаторазовий елемент інтерфейсу з параметрами та вкладеним вмістом, `@include` - коли просто ріжете довгий шаблон на частини всередині однієї сторінки.