<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · JUNIOR

Як влаштовані Blade-компоненти, слоти й атрибути?

Компонент - це ізольований шматок розмітки з явним контрактом: `@props` (або конструктор класу) описують очікувані дані й забирають ці ключі з `$attributes`, `$slot` приймає вкладений вміст, а решта переданих атрибутів осідає в `ComponentAttributeBag` і виводиться через `$attributes->merge()`, де `class` склеюється, а інші атрибути перекриваються викликом. `@include` навпаки не має ні контракту, ні слотів: він просто вставляє файл, якому видно всі змінні батьківської сторінки.

Передали в компонент `class="mt-6"`, а власні класи компонента зникли. Чому?
У компоненті є `@props(['type'])`. Чому `type` не виводиться разом із `{{ $attributes }}`?
На сторінці є `$order`, всередині `<x-order-card>` він раптом `null`. Що сталося?
Коли ви зробите компонент, а коли обійдетеся звичайним `@include`?
Blade компоненти слоти attributes merge include

Тег <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 --}}
Що область видимості компонента ізольована: усередину потрапляє лише те, що передали атрибутами, плюс `View::share()` і те, що витягли через `@aware`; `@include` натомість успадковує всі змінні батька.
Що `@props` і параметри конструктора класового компонента виконують дві ролі одразу: створюють змінну з дефолтом і виймають цей ключ з `$attributes`, тому `type` не витікає в HTML.
Що `$attributes->merge(['class' => '...'])` для `class` конкатенує значення, а для будь-якого іншого атрибута передане ззовні значення перекриває дефолт.
Різницю між анонімним компонентом (лише blade-файл у `resources/views/components/`) і класовим (`App\View\Components\*`, конструктор з ін'єкцією залежностей, публічні методи, `shouldRender()`).
Що `$slot` - це об'єкт `ComponentSlot`, а не рядок: у нього є `isEmpty()`/`isNotEmpty()` і власний `$slot->attributes`, а неоголошений іменований слот не існує як змінна.
Писати в корені компонента `<div class="alert {{ $class ?? '' }}">` замість `$attributes->merge()`: передані ззовні `id`, `data-*`, `wire:model` губляться повністю.
Виводити `{{ $attributes }}` і дивуватися, що в HTML з'явився `type="error"`: ключ не оголосили в `@props`, тож він лишився в мішку атрибутів.
Розраховувати, що всередині компонента доступні змінні сторінки. Компонент бачить лише передані дані, і `$order` там буде undefined, а не `null`.
Перевіряти іменований слот через `$actions->isEmpty()`: якщо слот не передали, змінної немає взагалі, і потрібен `@isset($actions)`.
Робити запити до бази в конструкторі класового компонента: у циклі по 50 елементах це 50 запитів, і причину N+1 потім шукають у контролері.
Перетворювати на компонент кожен другий блок сторінки: рендер компонента створює об'єкт і окремий вкладений view, тому в циклі на тисячі ітерацій це помітно дорожче за `@include`.
ПОРАДА

Тримайте у відповіді три речі: контракт (`@props` / конструктор), вміст (`$slot` і іменовані слоти) і все інше (`$attributes` з `merge`). Далі одним реченням про вибір: компонент - коли є багаторазовий елемент інтерфейсу з параметрами та вкладеним вмістом, `@include` - коли просто ріжете довгий шаблон на частини всередині однієї сторінки.

оновлено 6 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Оголошені в `@props` (або в конструкторі класового компонента) ключі виймаються з `ComponentAttributeBag`, тому `except()` для них не потрібен. `merge()` має два режими: `class` конкатенується, будь-який інший атрибут вважається дефолтом і перекривається тим, що передали в тезі. Компонент має ізольовану область видимості й отримує лише передані дані плюс `View::share()`, тоді як `@include` успадковує всі змінні батьківського шаблона. Непереданий іменований слот не створює змінної взагалі, тож звертання до `$actions` без `@isset` дасть undefined variable.