Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Building Web Apps with Laravel  Module 4 – Blade, components and the front end

Blade components, layouts and page metadata

Write Blade components with props, merged attributes and named slots, an accessible form field, htmx fragments and a layout that sets each page's tags.

  • Intermediate
  • 15 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Write anonymous and class components with props and a typed constructor
  • Merge a caller's classes and attributes with a component's defaults, and fill named slots
  • Build a form field whose label, hint and error point at its input by id
  • Render one fragment of a view for an htmx request with fragmentIf and a Vary header
  • Set each page's title, canonical URL, Open Graph tags and JSON-LD from one layout component

Before you start

What you’ll learn · Before you start

What you will learn

  • Write anonymous and class components with props and a typed constructor
  • Merge a caller's classes and attributes with a component's defaults, and fill named slots
  • Build a form field whose label, hint and error point at its input by id
  • Render one fragment of a view for an htmx request with fragmentIf and a Vary header
  • Set each page's title, canonical URL, Open Graph tags and JSON-LD from one layout component

Before you start

On this page

Pathshala’s notice form needs one title field: a label, an input, a hint and an error message, tied together by three ids and an aria-invalid flag. Copy that markup into a dozen forms and sooner or later one view forgets an id, and the input loses its name or its error for everyone who uses a screen reader. Written once as a Blade component: A reusable part of a Blade view used as an <x-name> tag. An anonymous one is a single file in resources/views/components; a class one adds a PHP class in app/View/Components., the field prints the same ids on every page that uses <x-field>, and so do the badge, the card and the layout that owns each page’s <head>.

This lesson writes those components on Laravel 13.34 and prints what each one renders: anonymous and class components with their props, attributes merged with a caller’s own, named slots, an accessible field before and after a failed submit, a fragment that answers an htmx request with 341 bytes where the whole page takes 1,181, and a layout component that sets the title, canonical link, Open Graph tags and JSON-LD of every page.

A component is one Blade file, or a class with its view

A notice card on Pathshala’s home page is a title, a kind (general or urgent) and a body. None of that needs PHP logic, so the card can be an anonymous component: one file, resources/views/components/notice-card.blade.php, used in any view as <x-notice-card>, because Blade turns the tag’s name into the file’s name. A fee badge is different. It maps a FeeStatus enum to words and an icon, and that mapping is code, so the badge gets a class. php artisan make:component FeeBadge writes the class into app/View/Components and its view into resources/views/components; with --view, the same command writes the view alone, which is all an anonymous component is:

What make:component writes, with and without --view Python · make_component_session.py
from session import run

# What make:component writes for a class component and, with --view, for an anonymous one, in a fresh project.
# Masked: the view stub's comment, an inspiring quote that Laravel picks at random on every run.
run(
    """
php artisan make:component FeeBadge
cat app/View/Components/FeeBadge.php
cat resources/views/components/fee-badge.blade.php
php artisan make:component notice-card --view
ls resources/views/components
""",
    app=True,
    masks=[(r"<!-- .+ -->", "<!-- a quote picked at random -->")],
)

Output

$ php artisan make:component FeeBadge
   INFO  Component [app/View/Components/FeeBadge.php] created successfully.

   INFO  View [resources/views/components/fee-badge.blade.php] created successfully.
$ cat app/View/Components/FeeBadge.php
<?php

namespace App\View\Components;

use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;

class FeeBadge extends Component
{
    /**
     * Create a new component instance.
     */
    public function __construct()
    {
        //
    }

    /**
     * Get the view / contents that represent the component.
     */
    public function render(): View|Closure|string
    {
        return view('components.fee-badge');
    }
}
$ cat resources/views/components/fee-badge.blade.php
<div>
    <!-- a quote picked at random -->
</div>
$ php artisan make:component notice-card --view
   INFO  View [resources/views/components/notice-card.blade.php] created successfully.
$ ls resources/views/components
fee-badge.blade.php
notice-card.blade.php

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 make_component_session.py

The class arrived with an empty constructor and a render() method that returns view('components.fee-badge'). The second command reported a single file, because an anonymous component has no class to write.

Each kind declares its prop: A value a Blade component declares as its own input, in @props for an anonymous component or as a constructor parameter for a class one. It becomes a variable in the view, never an HTML attribute. in its own way. The card lists them at the top of its file: @props(['title', 'kind' => 'general']) makes title a prop with no default and gives kind the default general. The badge’s props are its constructor’s parameters, and every public property and public method of the class is visible in its view, so public FeeStatus $status arrives as $status and the method icon() is called as $icon(). The probe renders both components, then makes the two mistakes a team tends to make first:

The two components, the enum and the probe that renders them

component-probe/resources/views/components/notice-card.blade.php

@props(['title', 'kind' => 'general'])

<article {{ $attributes->merge(['class' => 'notice notice-'.$kind]) }}>
    <h3>{{ $title }}</h3>
    {{ $slot }}
</article>

component-probe/app/View/Components/FeeBadge.php

<?php

namespace App\View\Components;

use App\Enums\FeeStatus;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;

// <x-fee-badge :status="$instalment->status" />: the constructor's parameters are the component's props.
class FeeBadge extends Component
{
    public function __construct(public FeeStatus $status)
    {
    }

    // A public method is callable in the view as $icon().
    public function icon(): string
    {
        return match ($this->status) {
            FeeStatus::Paid => '✓',
            FeeStatus::Due => '!',
            FeeStatus::Overdue => '×',
        };
    }

    public function render(): View|Closure|string
    {
        return view('components.fee-badge');
    }
}

component-probe/resources/views/components/fee-badge.blade.php

<span {{ $attributes->class(['badge', 'badge-'.$status->value]) }}><span aria-hidden="true">{{ $icon() }}</span> {{ $status->label() }}</span>

component-probe/app/Enums/FeeStatus.php

<?php

namespace App\Enums;

// The state of one fee instalment, stored as a string column.
enum FeeStatus: string
{
    case Paid = 'paid';
    case Due = 'due';
    case Overdue = 'overdue';

    // The words a page shows: the meaning never rests on a colour alone.
    public function label(): string
    {
        return match ($this) {
            self::Paid => 'Paid',
            self::Due => 'Due this week',
            self::Overdue => 'Overdue',
        };
    }
}

component-probe/components.php

<?php

// Renders an anonymous component (notice-card) and a class component (fee-badge) with Blade::render,
// then two mistakes: the anonymous component without its required title, and the class component given a string.
use App\Enums\FeeStatus;
use Illuminate\Support\Facades\Blade;

require __DIR__.'/vendor/autoload.php';
$app = require __DIR__.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

// An error's class and the first clause of its message, without the "(View: …)" and "called in …" tails.
function first_clause(Throwable $e): string
{
    $message = preg_replace('/ \(View: [^)]*\)/', '', $e->getMessage());

    return class_basename($e).': '.explode(', called in', $message)[0];
}

echo "1. Anonymous component, two props and two extra attributes:\n";
echo Blade::render(<<<'BLADE'
<x-notice-card title="Holiday on Friday" kind="urgent" class="mb-4" data-id="17">
    <p>The centre stays closed; evening batches resume on Monday.</p>
</x-notice-card>
BLADE), "\n";

echo "2. Class component, one badge per status:\n";
foreach (FeeStatus::cases() as $status) {
    echo '   ', trim(Blade::render('<x-fee-badge :status="$status" />', ['status' => $status])), "\n";
}

echo "\n3. The anonymous component without its title:\n";
try {
    echo Blade::render('<x-notice-card kind="urgent">Body only</x-notice-card>');
} catch (Throwable $e) {
    echo '   ', first_clause($e), "\n";
}

echo "\n4. The class component given the string 'paid' instead of a FeeStatus:\n";
try {
    echo Blade::render('<x-fee-badge status="paid" />');
} catch (Throwable $e) {
    echo '   ', first_clause($e), "\n";
}
Both components, then a missing prop and a wrong type Python · components_session.py
from session import run

# Renders the anonymous notice-card and the class fee-badge components of the component-probe folder,
# then the notice-card with its required title left out.
run(
    """
php components.php
""",
    app=True,
    overlay=("component-probe",),
)

Output

$ php components.php
1. Anonymous component, two props and two extra attributes:
<article class="notice notice-urgent mb-4" data-id="17">
    <h3>Holiday on Friday</h3>
    <p>The centre stays closed; evening batches resume on Monday.</p>
</article>

2. Class component, one badge per status:
   <span class="badge badge-paid"><span aria-hidden="true">✓</span> Paid</span>
   <span class="badge badge-due"><span aria-hidden="true">!</span> Due this week</span>
   <span class="badge badge-overdue"><span aria-hidden="true">×</span> Overdue</span>

3. The anonymous component without its title:
   ViewException: Undefined variable $title

4. The class component given the string 'paid' instead of a FeeStatus:
   ViewException: App\View\Components\FeeBadge::__construct(): Argument #1 ($status) must be of type App\Enums\FeeStatus, string given

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 components_session.py

In the first block title and kind were read as props and never became HTML attributes, while class="mb-4" and data-id="17", which the card does not declare, landed on its <article>. The three badges came from one view that calls $status->label() and $icon(). The words carry the meaning and the icon is aria-hidden, so neither a colour nor a symbol has to carry it alone. The last two blocks are the mistakes. A prop without a default is required, so leaving title out stopped the render with Undefined variable $title. The typed constructor caught the other slip: status="paid" passes the string paid, and PHP refused it as a FeeStatus. The colon form, :status="$instalment->status", passes a PHP expression instead of a string, and it is the form a view uses for anything that is not plain text.

Key idea

An anonymous component is a Blade file whose props are listed in @props; a class component adds a PHP class whose constructor parameters are its props, typed and checked. Start with the file, and add the class when the component needs code: a type on a prop, a lookup or a method the view calls.

Checkpoint

Check: Anonymous and class components

  1. Question 1 of 2 A notice card needs a title, a kind and a body, and no PHP logic. Where does it live, and how does a view use it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: resources/views/components/notice-card.blade.php, used as <x-notice-card>

    Blade looks up <x-notice-card> as components/notice-card.blade.php. A class in app/View/Components is only needed when the component has code of its own.

  2. Question 2 of 2 FeeBadge has the constructor __construct(public FeeStatus $status). A view writes <x-fee-badge status="paid" />. What happens, and what is the fix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The render fails because the string paid is not a FeeStatus; write :status="$instalment->status" so that a PHP expression is passed.

    The colon prefix makes an attribute a PHP expression. A typed constructor turns a wrong value into an error at render time instead of a wrong badge on the page.

Attributes merge into the root tag, and slots carry markup

A button component sets type="button" and two classes of its own, and a caller wants a full-width submit button in the danger style. Every attribute that the component does not declare as a prop lands in its attribute bag: The $attributes object of a Blade component: every attribute the caller passed that is not a prop. merge() prints it with the component's defaults, joining classes and styles and replacing other values., $attributes, and $attributes->merge([...]) prints the bag with the component’s defaults. For class and style, the caller’s value is added after the defaults; for any other attribute, the caller’s value replaces the default, unless the component wraps that default in $attributes->prepends(...) to join the two. $attributes->class([...]) adds classes on a condition, from an array whose keys are classes and whose values are tests, which is how the panel below turns tone="warning" into the class panel-warning.

The button and panel components, and the probe

component-probe/resources/views/components/button.blade.php

@props(['variant' => 'primary'])

<button {{ $attributes->merge(['type' => 'button', 'class' => 'btn btn-'.$variant]) }}>{{ $slot }}</button>

component-probe/resources/views/components/panel.blade.php

@props(['tone' => 'plain'])

<section {{ $attributes->class(['panel', 'panel-warning' => $tone === 'warning']) }}>
    @isset($heading)
        <h2 {{ $heading->attributes->class(['panel-heading']) }}>{{ $heading }}</h2>
    @endisset
    <div class="panel-body">{{ $slot }}</div>
    @isset($footer)
        <footer>{{ $footer }}</footer>
    @endisset
</section>

component-probe/attributes.php

<?php

// Renders <x-button> three times (defaults, a caller's overrides, a hostile attribute value), then <x-panel>
// with named slots and without them.
use Illuminate\Support\Facades\Blade;

require __DIR__.'/vendor/autoload.php';
$app = require __DIR__.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

$cases = [
    'Defaults only' => '<x-button>Save draft</x-button>',
    'Caller sets type, class and variant' => '<x-button type="submit" class="w-full" variant="danger">Delete notice</x-button>',
    'Caller passes a hostile title' => '<x-button :title="$hostile">Share</x-button>',
];
foreach ($cases as $label => $tag) {
    echo $label, ":\n   ", trim(Blade::render($tag, ['hostile' => '"><script>alert(1)</script>'])), "\n";
}

echo "\nA panel with a heading slot, the default slot and a footer slot:\n";
echo trim(Blade::render(<<<'BLADE'
<x-panel tone="warning" id="fee-reminder">
    <x-slot:heading class="text-lg">Fee reminder</x-slot>
    <p>Two instalments are overdue.</p>
    <x-slot:footer><a href="/fees">Open the fee page</a></x-slot>
</x-panel>
BLADE)), "\n";

echo "\nThe same panel with only the default slot:\n";
echo trim(Blade::render('<x-panel><p>No reminders today.</p></x-panel>')), "\n";
Merged attributes, an escaped value and named slots Python · attributes_session.py
from session import run

# Attribute merging (class appended, other defaults replaced, values escaped) and named slots, rendered by Blade.
run(
    """
php attributes.php
""",
    app=True,
    overlay=("component-probe",),
)

Output

$ php attributes.php
Defaults only:
   <button type="button" class="btn btn-primary">Save draft</button>
Caller sets type, class and variant:
   <button type="submit" class="btn btn-danger w-full">Delete notice</button>
Caller passes a hostile title:
   <button type="button" class="btn btn-primary" title="&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;">Share</button>

A panel with a heading slot, the default slot and a footer slot:
<section class="panel panel-warning" id="fee-reminder">
            <h2 class="panel-heading text-lg">Fee reminder</h2>
        <div class="panel-body"><p>Two instalments are overdue.</p></div>
            <footer><a href="/fees">Open the fee page</a></footer>
    </section>

The same panel with only the default slot:
<section class="panel">
        <div class="panel-body"><p>No reminders today.</p></div>
    </section>

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 attributes_session.py

The three buttons show the two merge rules and one safety rule:

Attribute The caller passed What the button printed
type submit submit: the default button was replaced
class w-full btn btn-danger w-full: added after the defaults
variant danger no attribute: it is a prop, read as $variant
title "><script>… &quot;&gt;&lt;script&gt;…: escaped

The last row matters most. A value bound with a colon goes through the same e() helper as {{ }} before it enters the bag, so the hostile title could not close its attribute and open a tag.

A slot: The markup a caller writes between a Blade component's opening and closing tags, printed inside the component with {{ $slot }}. is the markup a caller writes between a component’s tags, printed inside the component with {{ $slot }}. The panel also takes two named slot: An extra slot passed to a Blade component with <x-slot:name>. It arrives in the component as $name, and its own attributes are on $name->attributes.: <x-slot:heading> and <x-slot:footer> arrive as $heading and $footer, and a slot carries attributes of its own, which the component reads from $heading->attributes (that is where text-lg came from). A named slot that the caller leaves out is simply not set, so the panel wraps each one in @isset, and the last block printed a panel with no empty <h2> and no empty <footer>.

The choice between the two follows from the escaping. A prop is a value the component formats: a status, a count, a URL. A slot is markup the caller writes, such as a paragraph with a link in it. HTML passed in a prop is a string, and {{ }} prints it as text, tags and all; the same markup in a slot is part of the caller’s own template, compiled with it, with its echoes escaped there.

Keep style and script out of components

Under a Content-Security-Policy: A response header that lists where a page may load scripts, styles, images and other content from, so injected markup has no source the browser will run. More in “Laravel's security model and hardening” whose style-src and script-src leave out 'unsafe-inline', browsers refuse style="…" attributes and inline handlers such as onclick (the style-src-attr and script-src-attr rules of CSP Level 3). A component that styles itself with classes and loads its behaviour from a script file keeps working on a hardened site.

Checkpoint

Check: Merged attributes and named slots

  1. Question 1 of 2 A button prints $attributes->merge(['type' => 'button', 'class' => 'btn btn-primary']). A caller writes <x-button type="submit" class="w-full">. Which tag comes out?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: <button type="submit" class="btn btn-primary w-full">

    merge() appends the caller's classes and styles to the defaults and lets the caller's value replace the other defaults, as the probe's second button showed.

  2. Question 2 of 2 A panel's footer must hold a link, <a href="/fees">Open the fee page</a>. Should the caller pass it as a prop or as a named slot?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A named slot, <x-slot:footer>, because the link is markup the caller writes and it is compiled with the caller's template.

    Use a prop for a value the component formats, such as a status or a URL, and a slot for markup the caller writes.

An accessible field ties its label, hint and error to one input

A sighted visitor sees that “Up to 80 characters.” sits under the title box and that a red message below it is about that box. A screen reader says only what the markup says, so the field has to say it with ids. Three attributes do the work. The label’s for names the input’s id, and the label’s text becomes the input’s accessible name. aria-describedby lists the ids of the elements that describe the input, and the browser joins their text, in the order the ids are listed, into the input’s description. aria-invalid="true" marks a value the application refused. WCAG 2.2 sets the goal in success criterion 3.3.1: when a site detects an input error, it identifies the field and describes the error in text.

The <x-field> component writes all of it from one tag. It reads the field’s message from $errors, which Laravel shares with every view through the ShareErrorsFromSession middleware of the web group, and it adds the error paragraph, the error’s id in aria-describedby and aria-invalid only when there is a message. WAI-ARIA 1.2 is explicit about the first visit: until the user has tried to submit the form, authors should not set aria-invalid on a required field only because nothing has been entered yet. Every id comes from the field’s name, so two fields on one page never share one. The probe renders the field twice, the second time after the real validator refused an empty title:

The field component and its probe

component-probe/resources/views/components/field.blade.php

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

@php
    $id = $attributes->get('id', $name);
    $error = $errors->first($name);
    $helpId = $help ? $id.'-help' : null;
    $errorId = $error ? $id.'-error' : null;
    $describedBy = trim($helpId.' '.$errorId);
@endphp

<div class="field">
    <label for="{{ $id }}">{{ $label }}</label>
    <input {{ $attributes->merge([
        'type' => $type,
        'id' => $id,
        'name' => $name,
        'value' => old($name),
        'aria-describedby' => $describedBy ?: null,
        'aria-invalid' => $error ? 'true' : null,
    ]) }}>
    @if ($help)
        <p id="{{ $helpId }}" class="field-help">{{ $help }}</p>
    @endif
    @if ($error)
        <p id="{{ $errorId }}" class="field-error">{{ $error }}</p>
    @endif
</div>

component-probe/field.php

<?php

// Renders <x-field> as a first visit shows it (an empty error bag) and after a failed submit, when the
// ShareErrorsFromSession middleware shares the validator's messages with every view as $errors.
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ViewErrorBag;

require __DIR__.'/vendor/autoload.php';
$app = require __DIR__.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

$tag = '<x-field name="title" label="Notice title" help="Up to 80 characters." maxlength="80" required />';

echo "First visit (no errors):\n";
View::share('errors', new ViewErrorBag);
echo trim(Blade::render($tag)), "\n";

echo "\nAfter a submit with an empty title:\n";
$validator = Validator::make(['title' => ''], ['title' => ['required', 'max:80']]);
View::share('errors', (new ViewErrorBag)->put('default', $validator->errors()));
echo trim(Blade::render($tag)), "\n";
The title field before and after a failed submit Python · field_session.py
from session import run

# The accessible <x-field> component, before and after a failed submit.
run(
    """
php field.php
""",
    app=True,
    overlay=("component-probe",),
)

Output

$ php field.php
First visit (no errors):
<div class="field">
    <label for="title">Notice title</label>
    <input type="text" id="title" name="title" aria-describedby="title-help" maxlength="80" required="required">
            <p id="title-help" class="field-help">Up to 80 characters.</p>
        </div>

After a submit with an empty title:
<div class="field">
    <label for="title">Notice title</label>
    <input type="text" id="title" name="title" aria-describedby="title-help title-error" aria-invalid="true" maxlength="80" required="required">
            <p id="title-help" class="field-help">Up to 80 characters.</p>
                <p id="title-error" class="field-error">The title field is required.</p>
    </div>

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 field_session.py

On the first visit the input carried aria-describedby="title-help" and no other ARIA attribute. After the failed submit, the same tag printed aria-describedby="title-help title-error", aria-invalid="true" and a paragraph holding The title field is required., the validator’s own message. The two attributes the caller added, maxlength and required, passed through the bag, and required printed as required="required" because the bag writes a true value as the attribute’s own name. old('title') was empty in both renders and the bag skips a null value, so no value="" appeared. Step through the same two renders; each arrow is an id that one element names:

Interactive · step by step How the parts of one form field point at each other Step through the title field on a first visit and after a failed submit; each arrow is an id that one element names.
How the parts of one form field point at each other, step 6 of 6. Showing Label, Input (aria-invalid="true"), Help text, Error text, Screen reader. In focus: Screen reader.namesdescribedbydescribedbyLabelfor="title"Inputaria-invalid="true"Help textid="title-help"Error textid="title-error"Screen readerreads the inputinvalid, hint, error

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The label names the input. The label carries for="title" and the input carries id="title". A click on the words Notice title now focuses the input, and the input takes those words as its name.
  2. The hint gets an id. The component gives the help paragraph id="title-help" and writes the same id into the input's aria-describedby, so the hint belongs to this input and no other.
  3. A first visit has no error. The error bag is empty, so the component prints no error paragraph and no aria-invalid. A screen reader on the input says its name, then the hint.
  4. The submit fails. The validator refuses an empty title. Its message comes back in $errors, and the component adds a paragraph with id="title-error" that holds The title field is required.
  5. The input points at both. aria-describedby now lists two ids, title-help then title-error, and aria-invalid="true" appears. One component adds both, so no page that uses it can forget one.
  6. The reader hears the error. Focus on the input now gives its name, that the value is invalid, then the hint and the error text, in the order the ids are listed.

One error id for every field

A component that hard-codes id="field-error" gives every field on the page the same id, and each input’s aria-describedby then finds the first element with it, so the email field reads out the title’s error. Build each id from the field’s name, as $id.'-error' does here.

How a browser turns these attributes into what a screen reader announces is the subject of the web accessibility lesson on the accessibility tree and platform APIs.

Checkpoint

Check: A form field tied together by ids

  1. Question 1 of 2 After a failed submit, the title field shows a hint with id="title-help" and an error with id="title-error". Which attributes must the input carry?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • id="title", matching the label's for="title"
    • aria-invalid="true"
    • aria-describedby="title-help title-error"

    The label's for gives the input its name, aria-describedby its hint and error, and aria-invalid its state: three attributes the component sets together.

  2. Question 2 of 2 A teammate wants <x-field> to set aria-invalid="true" on every empty required field as soon as the form loads, to warn early. What do you answer?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No: WAI-ARIA 1.2 says not to mark a required field invalid only because nothing has been entered yet; set it once a submit has failed.

    The component adds aria-invalid only when $errors holds a message for the field, which happens only after a submit has been refused.

A fragment sends htmx one part of the page from the same URL

The notices page has a Refresh the list button. With A small JavaScript library whose HTML attributes, such as hx-get, send a request and swap the HTML that comes back into the page., the button needs three attributes: hx-get="/notices", hx-target="#notice-list" and hx-swap="outerHTML". The server’s part is to answer that request with the list alone, from the same URL, while a normal visit still gets the whole page. Blade marks the list with @fragment('notice-list') and @endfragment, and the route returns it with fragmentIf(): when its first argument is true, the response is the named Blade fragment: A part of a Blade view between @fragment('name') and @endfragment that a route can return on its own with fragment() or fragmentIf(), while the rest of the view still renders., and otherwise it is the whole view. htmx adds the header HX-Request: true to the requests it sends, so that header is the condition:

The notices route and the view with its fragment

routes/web.php · component-probe/routes/web.php

<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

// One URL, two answers: the whole page for a normal visit, only the list for an htmx request.
Route::get('/notices', function (Request $request) {
    $notices = [
        ['title' => 'Holiday on Friday', 'batch' => 'All batches'],
        ['title' => 'Mock interview moved to Hall 2', 'batch' => 'Spoken English (evening)'],
        ['title' => 'Fee counter open till 7 pm', 'batch' => 'All batches'],
    ];

    $html = view('notices.index', ['notices' => $notices])
        ->fragmentIf($request->hasHeader('HX-Request'), 'notice-list');

    // The same URL now has two bodies, so caches must key on the header too.
    return response($html)->header('Vary', 'HX-Request');
});

notices/index.blade.php · component-probe/resources/views/notices/index.blade.php

<x-layout title="Notices" description="Notices for every evening batch at Pathshala.">
    <h1>Notices</h1>
    <button hx-get="/notices" hx-target="#notice-list" hx-swap="outerHTML">Refresh the list</button>
    @fragment('notice-list')
        <ul id="notice-list">
            @foreach ($notices as $notice)
                <li>{{ $notice['title'] }} <small>{{ $notice['batch'] }}</small></li>
            @endforeach
        </ul>
    @endfragment
</x-layout>
One URL asked for twice, as a page and by htmx Python · fragment_session.py
from session import serve

# The notices route answered by php artisan serve: a normal request gets the whole page, a request with the
# HX-Request header (what htmx sends) gets only the notice-list fragment, and both carry Vary: HX-Request.
serve(
    """
curl -s -o /dev/null -w 'whole page: %{size_download} bytes\\n' http://127.0.0.1:8151/notices
curl -s -o /dev/null -w 'fragment: %{size_download} bytes\\n' -H 'HX-Request: true' http://127.0.0.1:8151/notices
curl -s -H 'HX-Request: true' http://127.0.0.1:8151/notices
curl -s -D - -o /dev/null -H 'HX-Request: true' http://127.0.0.1:8151/notices | grep -i '^vary'
""",
    overlay=("component-probe",),
    port=8151,
)

Output

# Terminal 1: start the server
$ php artisan serve --host=127.0.0.1 --port=8151 --no-reload
# Terminal 2: send requests
$ curl -s -o /dev/null -w 'whole page: %{size_download} bytes\n' http://127.0.0.1:8151/notices
whole page: 1181 bytes
$ curl -s -o /dev/null -w 'fragment: %{size_download} bytes\n' -H 'HX-Request: true' http://127.0.0.1:8151/notices
fragment: 341 bytes
$ curl -s -H 'HX-Request: true' http://127.0.0.1:8151/notices
        <ul id="notice-list">
                            <li>Holiday on Friday <small>All batches</small></li>
                            <li>Mock interview moved to Hall 2 <small>Spoken English (evening)</small></li>
                            <li>Fee counter open till 7 pm <small>All batches</small></li>
                    </ul>
$ curl -s -D - -o /dev/null -H 'HX-Request: true' http://127.0.0.1:8151/notices | grep -i '^vary'
Vary: HX-Request
# Terminal 1 showed this, then Ctrl+C stopped the server:
   INFO  Server running on [http://127.0.0.1:8151].

  Press Ctrl+C to stop the server

  … /notices .......................................... ~ …
  … /notices .......................................... ~ …
  … /notices .......................................... ~ …
  … /notices .......................................... ~ …

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fragment_session.py

The plain request got the whole page, 1,181 bytes with the layout’s head. The request with the header got 341 bytes, the <ul> and its three notices, about 29 % of the page. On a real page, with navigation, styles and a footer, the gap grows, because the fragment stays the size of the list. Two details matter more than the bytes.

First, the whole view still runs. fragment() renders the template in full and then returns the part it captured, so a query in the layout, or in any part of the page outside the fragment, runs on every refresh too. Work that only the full page needs belongs in the controller, behind the same HX-Request test.

Second, one URL now has two bodies. A cache that keys its entries on the URL alone could keep the 341-byte list and serve it to the next ordinary visit as if it were the page. The htmx documentation asks for a A response header that names the request headers a cache must add to the URL when it stores and reuses a response. in exactly this case, and the route sends Vary: HX-Request with both answers, as the last request printed.

Interactive · message by message One URL, answered with a whole page or one fragment Step through a first visit, then a click on Refresh the list, with the byte counts the example printed.
One URL, answered with a whole page or one fragment, step 6 of 6. Messages so far: Browser to Route: GET /notices; Route to View: render the view; Route to Browser: whole page, 1,181 bytes; Browser to Route: GET with HX-Request; Route to View: fragment notice-list; Route to Browser: the list, 341 bytes.Browserlist replacedRoutefragmentIfView@fragmentGET /noticesrender the viewwhole page, 1,181 bytesGET with HX-Requestfragment notice-listthe list, 341 bytes

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A first visit. The browser asks for /notices without an HX-Request header, as every normal visit and every search-engine crawler does.
  2. The whole view renders. hasHeader('HX-Request') is false, so fragmentIf renders the view in full: the layout's head, the heading, the button and the list.
  3. The whole page goes back. The response is the whole page, 1,181 bytes in the example, with Vary: HX-Request so that a cache stores it apart from the fragment.
  4. A click on Refresh. The button carries hx-get="/notices". htmx sends the same URL again, this time with the header HX-Request: true.
  5. The view runs, one part returns. The whole template still runs on the server, then fragmentIf returns only what sat between @fragment and @endfragment: the list and its three notices.
  6. Only the list goes back. The response is the list alone, 341 bytes. hx-swap="outerHTML" puts it in place of the old list, and the rest of the page stays as it was.

Fragments suit a page that is mostly rendered on the server and needs a few parts refreshed. fragmentsIf() takes a list of names and joins those fragments into one response when a request needs two parts of the view at once.

Checkpoint

Check: Fragments for htmx requests

  1. Question 1 of 2 The /notices route returns the whole page to a normal visit and only the list to htmx, and a shared cache sits in front of the app. What can go wrong without Vary: HX-Request?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The cache may store the 341-byte list under /notices and serve it to the next normal visit; Vary makes it key on the URL and the header.

    Vary: HX-Request tells every cache that the answer depends on that request header as well as on the URL.

  2. Question 2 of 2 The layout of the notices page runs a database query for its sidebar. A visitor clicks Refresh the list. Does that query run for the htmx request?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Yes: fragment() renders the whole view and then returns only the captured part.

    A fragment saves bytes on the wire, not work on the server. Queries that only the whole page uses go in the controller, inside an if on the HX-Request header, so a refresh skips them.

The layout owns the head, and every page passes its own values

A page’s title shows up far from the page: in tabs, history, bookmarks and search results, which is why the HTML standard asks for titles that still identify the page there. Sites that build a link preview read its Open Graph: A set of meta tags in a page's head whose property names start with og:, read by sites that build a link preview. The protocol requires og:title, og:type, og:image and og:url. tags, of which the protocol requires four: og:title, og:type, og:image and og:url. Every page needs its own values, and one component should print them all. Pathshala’s layout component takes one prop per field: title is required, and description, canonical, image and type have defaults in @props. The canonical URL: The preferred address of a page that can be reached at several, given by a link element with rel=canonical, so search engines count the copies as one page. names the page’s preferred URL (RFC 6596), so a page reachable with a tracking parameter or a trailing slash still counts as one page. A batch page passes its own values and a JSON-LD: A JSON format for linked data. A page embeds it in a script element of type application/ld+json to describe itself with a vocabulary such as schema.org. object, a schema.org Course with a name, a description and a provider:

The layout component, a batch page and the probe

component-probe/resources/views/components/layout.blade.php

@props([
    'title',
    'description' => 'Evening batches, notices and fees at Pathshala.',
    'canonical' => null,
    'image' => null,
    'type' => 'website',
    'schema' => null,
])

@php
    $canonical ??= url()->current();
    $image ??= url('/images/pathshala-card.png');
@endphp

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{ $title }} · Pathshala</title>
    <meta name="description" content="{{ $description }}">
    <link rel="canonical" href="{{ $canonical }}">
    <meta property="og:title" content="{{ $title }}">
    <meta property="og:type" content="{{ $type }}">
    <meta property="og:url" content="{{ $canonical }}">
    <meta property="og:image" content="{{ $image }}">
    <meta property="og:description" content="{{ $description }}">
    @if ($schema)
        <script type="application/ld+json">{!! json_encode($schema, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) !!}</script>
    @endif
    {{ $head ?? '' }}
</head>
<body>
    {{ $slot }}
</body>
</html>

batches/show.blade.php · component-probe/resources/views/batches/show.blade.php

<x-layout
    :title="$batch['name']"
    :description="$batch['summary']"
    :canonical="url('/batches/'.$batch['slug'])"
    :schema="[
        '@context' => 'https://schema.org',
        '@type' => 'Course',
        'name' => $batch['name'],
        'description' => $batch['summary'],
        'provider' => ['@type' => 'Organization', 'name' => 'Pathshala'],
    ]"
>
    <h1>{{ $batch['name'] }}</h1>
    <p>{{ $batch['summary'] }}</p>
</x-layout>

component-probe/metadata.php

<?php

// Renders a batch page through the layout component and prints its <head>, then renders a batch whose name a
// careless admin form let through with HTML in it, and prints the lines that carry the name.
use Illuminate\Support\Facades\View;

require __DIR__.'/vendor/autoload.php';
$app = require __DIR__.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

$batch = [
    'slug' => 'spoken-english-evening',
    'name' => 'Spoken English, evening batch',
    'summary' => 'Twelve weeks of guided speaking practice, Monday to Thursday after work.',
];

$html = View::make('batches.show', ['batch' => $batch])->render();
preg_match('~<head>(.*?)</head>~s', $html, $head);
echo "The <head> of /batches/spoken-english-evening:\n";
foreach (preg_split('/\R/', trim($head[1])) as $line) {
    if (trim($line) !== '') {
        echo '  ', trim($line), "\n";
    }
}

$hostile = ['name' => 'Tally </script><script>alert(1)</script>', 'slug' => 'tally'] + $batch;
$html = View::make('batches.show', ['batch' => $hostile])->render();
echo "\nA name with a script in it, in the two places it lands:\n";
foreach (preg_split('/\R/', $html) as $line) {
    if (str_contains($line, '<title>') || str_contains($line, 'ld+json')) {
        echo '  ', trim($line), "\n";
    }
}
The head of a batch page, and a name with a script in it Python · metadata_session.py
from session import run

# A batch page's <head> as the layout component builds it. APP_URL is set the way production sets it, so the
# canonical and Open Graph URLs are absolute https URLs (pathshala.example is a reserved example domain).
run(
    """
export APP_URL=https://pathshala.example
php metadata.php
""",
    app=True,
    overlay=("component-probe",),
)

Output

$ export APP_URL=https://pathshala.example
$ php metadata.php
The <head> of /batches/spoken-english-evening:
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Spoken English, evening batch · Pathshala</title>
  <meta name="description" content="Twelve weeks of guided speaking practice, Monday to Thursday after work.">
  <link rel="canonical" href="https://pathshala.example/batches/spoken-english-evening">
  <meta property="og:title" content="Spoken English, evening batch">
  <meta property="og:type" content="website">
  <meta property="og:url" content="https://pathshala.example/batches/spoken-english-evening">
  <meta property="og:image" content="https://pathshala.example/images/pathshala-card.png">
  <meta property="og:description" content="Twelve weeks of guided speaking practice, Monday to Thursday after work.">
  <script type="application/ld+json">{"@context":"https://schema.org","@type":"Course","name":"Spoken English, evening batch","description":"Twelve weeks of guided speaking practice, Monday to Thursday after work.","provider":{"@type":"Organization","name":"Pathshala"}}</script>

A name with a script in it, in the two places it lands:
  <title>Tally &lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt; · Pathshala</title>
  <script type="application/ld+json">{"@context":"https://schema.org","@type":"Course","name":"Tally \u003C/script\u003E\u003Cscript\u003Ealert(1)\u003C/script\u003E","description":"Twelve weeks of guided speaking practice, Monday to Thursday after work.","provider":{"@type":"Organization","name":"Pathshala"}}</script>

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 metadata_session.py

One tag produced the eleven lines of that head: the two meta lines every page starts with, the title with the site’s name after it, the description, the canonical link, five og: properties and the JSON-LD. The URLs are absolute and use https because the session set APP_URL=https://pathshala.example, which is how a command-line run learns its host. In a web request, url() builds from the request’s own Host header instead, so an app whose web server does not refuse unknown host names lists its trusted host: A host name the application agrees to answer to. A request with any other Host header is refused with a 400, so links are never built from a forged name. More in “Requests, input and uploaded files”, as requests and input showed; otherwise a forged Host header could end up in the canonical link.

The second block is the failure drill. A batch name holding </script><script>alert(1)</script> came out as text in <title>, escaped by {{ }}, and inside the JSON-LD as \u003C/script\u003E, because the layout encodes the object with json_encode and the JSON_HEX_TAG flag, which turns every < and > into \u003C and \u003E. This is one of the few places where a raw echo is right. Inside a <script> element the browser does not decode &quot;, so {{ }} would leave entities where the JSON needs quotes, and Js::from would print a JSON.parse('…') call, which is JavaScript and not the bare JSON a reader of application/ld+json expects.

Laravel also offers a first-party package for this job, laravel/head, at version 0.2.2, before its 1.0 release. Defaults go in a service provider with Head::defaults(), metadata on a route with withHead() and at run time with Head::title(), and an @head directive prints the result, including Open Graph, X cards and JSON-LD. Before 1.0 its API may still change, so the examples here keep to Blade; the table sets the package beside the two ways Blade has on its own:

Interactive · answer and compare Three ways to set a page's title and tags Answer the three questions about your project; rows that do not fit are marked, with the reason on each.
Will your team add a package whose version is still below 1.0?
Do many simple pages have titles that are known before any controller runs?
Do the views already use @extends and @section for their layout?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Template sections, a layout component and laravel/head against five needs
Approach A page sets its own title A default when a page sets none Values from the controller Open Graph and JSON-LD Extra packages
@section and @yield How a page sets it: a child view fills named holes in a parent layout Good one @section('title', ...) line in each child view Good @yield takes a default as its second argument Mixed works, but each field is its own @section block Mixed each page pushes its own tags into an @stack Good none: template inheritance is part of Blade
A layout component How a page sets it: one prop per field, with defaults in @props Good one tag with a prop per field, as in the example Good @props holds a default for every field Good a prop with a colon takes any PHP expression Good the layout prints og:* tags and JSON-LD from props Good none: components are part of Blade
laravel/head 0.2 How a page sets it: calls on a Head facade, printed by @head Good Head::title() at runtime, or withHead() on the route Good Head::defaults() in a service provider Good a runtime call replaces route values field by field Good builders for Open Graph, X cards and JSON-LD Mixed composer require laravel/head, at 0.2.2 before 1.0
How the answers decide
  • “Will your team add a package whose version is still below 1.0?” No, 1.0 or later only: rules out laravel/head 0.2. laravel/head is at 0.2.2, before 1.0, so its API may still change; a team that takes no 0.x packages keeps the tags in its own layout.
  • “Do many simple pages have titles that are known before any controller runs?” Yes, many static pages: adds a caveat to @section and @yield and A layout component. Only laravel/head sets a title on the route itself, with withHead(); with Blade alone every view passes its own title.
  • “Do the views already use @extends and @section for their layout?” Yes, every view extends a layout: adds a caveat to A layout component. A layout component means editing every view that extends the old layout; @section keeps working while you move views one at a time.

Checkpoint

Check: Page metadata from a layout component

  1. Question 1 of 2 A batch name may hold </script>. The layout prints a JSON-LD object inside <script type="application/ld+json">. Which line is right?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: {!! json_encode($schema, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) !!}

    The probe printed the hostile name as \u003C/script\u003E inside the JSON-LD and as escaped text in the title.

  2. Question 2 of 2 In production the canonical link is built with url() during a web request. What stops a forged Host header from ending up in it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Trusted hosts: $middleware->trustHosts(at: [...]), or a web server that refuses unknown host names

    Laravel's requests documentation says the Host header is used for absolute URLs during a web request; trusted hosts refuse a request that names any other host.

Practise

Try these on your own Pathshala project:

  1. Build <x-fee-badge> for a FeeStatus enum of your own, with words and an aria-hidden icon, and render it for every case, so the meaning never rests on a colour.
  2. Add an email field to the enrolment form with <x-field type="email">, submit it empty and then with not-an-email, and read aria-describedby and aria-invalid in the page source after each submit.
  3. Write a test that renders the field after $this->withViewErrors(['title' => 'The title field is required.']) with $this->blade(...) and asserts that aria-invalid="true" appears, and a second test that asserts it does not appear on a first visit.
  4. Give the notices view a second fragment for the number of notices, return both with fragmentsIf(), and check with curl that the htmx answer holds both and still carries Vary: HX-Request.
  5. Let a page pass a robots prop to the layout, print <meta name="robots" content="noindex"> when it does, and use it on the fee pages, which no search engine should list.

Then work through the self-check.

Practice that adapts to you

Practise this lesson

15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 What does php artisan make:component notice-card --view create?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: Only resources/views/components/notice-card.blade.php, an anonymous component

    --view writes the Blade file alone, and that file is the whole of an anonymous component.

  2. Question 2 of 15 FeeBadge has a public method icon(). How does the component's view print its result?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: {{ $icon() }}

    Public properties and public methods of a class component are visible in its view: $status and $icon().

  3. Question 3 of 15 The card declares @props(['title', 'kind' => 'general']). A view writes <x-notice-card kind="urgent">Body only</x-notice-card>. What happens?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: The render stops with Undefined variable $title.

    Give every optional prop a default in @props; a prop with no default must be passed.

  4. Question 4 of 15 By default, which attributes does $attributes->merge() join with the caller's value instead of replacing the default?

    Choose every answer that is right.

    Show the answer: Question 4 of 15

    Answer:

    • style
    • class

    class and style are joined; any other default is replaced unless the component wraps it in prepends().

  5. Question 5 of 15 A caller writes <x-button :title="$hostile">, where $hostile holds "><script>alert(1)</script>. What lands in the button?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: title="&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;"

    Values bound with a colon are escaped with the same helper as {{ }}.

  6. Question 6 of 15 The panel's footer slot is optional, and a caller leaves it out. How does the panel avoid printing an empty <footer>?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: It wraps the footer in @isset($footer), because a slot the caller leaves out is not set.

    An optional named slot is an unset variable when the caller leaves it out; test for it with @isset.

  7. Question 7 of 15 What is the value of aria-describedby?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: The ids of the elements that describe the control, separated by spaces

    aria-describedby="title-help title-error" names two elements, and their text becomes the input's description.

  8. Question 8 of 15 A field prints <label for="title"> and <input id="notice-title">. What breaks?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: The label no longer names the input, so the input has no accessible name, and a click on the label does not focus it.

    The component derives both ids from the field's name, so they always match.

  9. Question 9 of 15 An input has aria-describedby="title-help title-error". In what order does its description hold the two texts?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: The hint first, then the error: the order of the ids

    List the ids in the order a user should hear them: the hint, then the error.

  10. Question 10 of 15 Which request header does htmx send that the notices route tests with hasHeader()?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: HX-Request

    fragmentIf($request->hasHeader('HX-Request'), 'notice-list') returns the list only to htmx.

  11. Question 11 of 15 The whole page was 1,181 bytes and the fragment 341 bytes. What share of the page's bytes did the htmx answer carry, in percent, to the nearest whole number?

    Type a number.

    Show the answer: Question 11 of 15

    Answer: 29 % (anything from 28 to 30 counts)

    341 ÷ 1,181 is 0.289, so the fragment carried about 29 % of the page's bytes; on a page with navigation and styles the share is smaller still.

  12. Question 12 of 15 A visitor types /notices into the address bar. What does the route return, and why does that matter?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: The whole page, because the request has no HX-Request header; the same URL keeps working without htmx and for search engines.

    One URL serves both: a normal visit gets the page, htmx gets the part it swaps in.

  13. Question 13 of 15 Which four properties does the Open Graph protocol require on every page?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: og:title, og:type, og:image and og:url

    The layout prints the four required properties and og:description from the page's own props.

  14. Question 14 of 15 A batch page is reachable at /batches/spoken-english-evening and at the same path with ?ref=chat. What does the canonical link do?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: It names one preferred URL, so search engines count both addresses as one page.

    Each page sets its own canonical link; one value copied onto every page would claim that all pages are one.

  15. Question 15 of 15 A batch name holds </script><script>alert(1)</script>. How did it appear in the page's <title>?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: As text: &lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;, escaped by {{ }}

    Text in HTML goes through {{ }}; JSON inside a script element goes through json_encode with JSON_HEX_TAG.

Interview questions

Warm-up: what must never go into a page’s title, description or Open Graph tags, and why?

Anything that is not meant to be public. A title is read out of the page’s context: in tabs, history, bookmarks and search results, and sites that show link previews copy the Open Graph tags. So a fee page never names a student or the amount due in its title or description, and a private page asks search engines to leave it out with a noindex robots tag. Values that users typed go in through {{ }}, so a name with HTML in it stays text, and a JSON-LD block is encoded with JSON_HEX_TAG, so a </script> in the data cannot close the tag. Each page also sets its own canonical link and og:url: one value copied onto every page claims that every page duplicates a single URL, and every shared link then shows the same preview.

Two questions that go further into this lesson’s choices:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    You are designing <x-notice-card> for Pathshala: a title, a kind, a body that may hold a link, and an optional footer with links. Which inputs become props, which become slots, and how do you stop a wrong value from rendering a broken card?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    Pathshala's notices page needs a Refresh button, a filter by batch and a count of unread notices that stays current. When are Blade fragments with htmx enough, and at what point would you reach for Livewire or Inertia instead?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. How does an anonymous Blade component differ from a class component?

    An anonymous component is one Blade file in resources/views/components with its props listed in @props. A class component adds a PHP class whose typed constructor parameters are the props and whose public methods the view can call. Start with the file; add the class when the component needs code.

  2. What does $attributes->merge() print on a component's root tag?

    Every attribute the caller passed that is not a prop, combined with the component's defaults: the caller's classes and styles are added after the defaults, other defaults give way to the caller's value, and bound values are escaped.

  3. When does a component take a slot rather than a prop?

    A slot carries markup the caller writes; a named slot arrives as its own variable with its own attributes. A prop carries a value that the component formats itself.

  4. What must an accessible form-field component tie together?

    The label to the input through for and id, the hint and the error message through aria-describedby, and aria-invalid set only after a failed submit. One component doing all of it means no page forgets a part.

  5. How can one URL answer both a full page and an htmx request for one part of it?

    fragmentIf() returns only the named fragment of the view when the request comes from htmx, while the whole view still renders on the server. Vary: HX-Request keeps caches from mixing up the two answers.

  6. What does each page pass to the layout component's head, and what never goes in it?

    Its own title, description, canonical link, Open Graph tags and JSON-LD. Nothing private goes there, such as a student's name or an amount due, because tabs, history, search results and link previews show those fields; text is escaped with double braces, JSON-LD encoded with JSON_HEX_TAG.

Key takeaways

  • An anonymous component is one Blade file in resources/views/components whose props are listed in @props, and a class component adds a PHP class whose typed constructor parameters are its props and whose public methods its view can call.
  • $attributes->merge() prints every attribute that is not a prop together with the component’s defaults: the caller’s classes and styles are added after the defaults, other defaults give way to the caller’s value, and a bound value is escaped on its way in.
  • A slot carries markup that the caller writes, a named slot arrives as its own variable with its own attributes, and a prop carries a value that the component formats.
  • An accessible field ties its label to its input with for and id, lists the hint and the error in aria-describedby, and sets aria-invalid only after a failed submit, all from one component, so no page forgets a part.
  • fragmentIf() answers an htmx request with one named part of a view from the same URL while the whole view still renders on the server, and Vary: HX-Request keeps caches from mixing up the two answers.
  • A layout component prints each page’s own title, description, canonical link, Open Graph tags and JSON-LD, escaping text with {{ }} and encoding the JSON-LD with JSON_HEX_TAG.

Terms in this lesson

attribute bag
The $attributes object of a Blade component: every attribute the caller passed that is not a prop. merge() prints it with the component's defaults, joining classes and styles and replacing other values.
Blade component
A reusable part of a Blade view used as an <x-name> tag. An anonymous one is a single file in resources/views/components; a class one adds a PHP class in app/View/Components.
Blade fragment
A part of a Blade view between @fragment('name') and @endfragment that a route can return on its own with fragment() or fragmentIf(), while the rest of the view still renders.
canonical URL
The preferred address of a page that can be reached at several, given by a link element with rel=canonical, so search engines count the copies as one page.
JSON-LD
A JSON format for linked data. A page embeds it in a script element of type application/ld+json to describe itself with a vocabulary such as schema.org.
named slot
An extra slot passed to a Blade component with <x-slot:name>. It arrives in the component as $name, and its own attributes are on $name->attributes.
Open Graph
A set of meta tags in a page's head whose property names start with og:, read by sites that build a link preview. The protocol requires og:title, og:type, og:image and og:url.
prop
A value a Blade component declares as its own input, in @props for an anonymous component or as a constructor parameter for a class one. It becomes a variable in the view, never an HTML attribute.
slot
The markup a caller writes between a Blade component's opening and closing tags, printed inside the component with {{ $slot }}.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.