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.
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:
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:
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";
} 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
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.
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"; 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=""><script>alert(1)</script>">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>… |
"><script>…: 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
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:
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"; 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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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:
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> 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.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A first visit. The browser asks for /notices without an HX-Request header, as every normal visit and every search-engine crawler does.
- 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.
- 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.
- A click on Refresh. The button carries hx-get="/notices". htmx sends the same URL again, this time with the header HX-Request: true.
- 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.
- 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
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:
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";
}
} 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 </script><script>alert(1)</script> · 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 ", 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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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
Practise
Try these on your own Pathshala project:
- Build
<x-fee-badge>for aFeeStatusenum of your own, with words and anaria-hiddenicon, and render it for every case, so the meaning never rests on a colour. - Add an email field to the enrolment form with
<x-field type="email">, submit it empty and then withnot-an-email, and readaria-describedbyandaria-invalidin the page source after each submit. - Write a test that renders the field after
$this->withViewErrors(['title' => 'The title field is required.'])with$this->blade(...)and asserts thataria-invalid="true"appears, and a second test that asserts it does not appear on a first visit. - 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 carriesVary: HX-Request. - Let a page pass a
robotsprop 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.
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.
-
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?
-
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?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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/componentswhose 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
forandid, lists the hint and the error inaria-describedby, and setsaria-invalidonly 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, andVary: HX-Requestkeeps 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 withJSON_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
- Blade templates (components, slots and fragments) (Laravel (laravel.com))
- Validation (displaying the validation errors) (Laravel (laravel.com))
- HTTP requests (configuring trusted hosts) (Laravel (laravel.com))
- Laravel Head (Laravel (laravel.com))
- laravel/head (versions and requirements) (Packagist)
- htmx documentation (requests, headers and caching) (htmx.org)
- Accessible Rich Internet Applications (WAI-ARIA) 1.2 (W3C)
- Accessible Name and Description Computation 1.2 (W3C)
- Web Content Accessibility Guidelines (WCAG) 2.2 (W3C)
- HTML Living Standard: the title element (WHATWG)
- The Open Graph protocol (The Open Graph protocol (ogp.me))
- RFC 6596: The Canonical Link Relation (IETF)
- JSON constants (JSON_HEX_TAG) (PHP (php.net))
- Content Security Policy Level 3 (W3C)
- Schema.org: Course (Schema.org)
Report a problem with this lesson
Kept only in this browser. Your Learn progress