Skip to content

HTML Components Guide

Pramnos\Html\* holds the reusable building blocks that render markup on their own — each one constructed, configured with public properties, and turned into a string by render().

Class What it renders
Select a <select>, with no form around it
Input one input, textarea, checkbox or radio, with no form around it
SearchBox the cross-entity search box in an admin header
Pagination page links from a count, a current page and a URL pattern
Datatable a DataTables-backed table, with server-side paging and filters
Breadcrumb a breadcrumb trail, plus its BreadcrumbList structured data
Date a date field — datepicker or three dropdowns — with an optional time
Time a native time field, and what Date::$time renders
Icon one inline SVG glyph, or an icon wrapped in a link
Seo a canonical link and a ld+json structured-data block
Form\Field one field inside a form, with a label and a style preset

Every one of them also renders when cast to a string, so echo $component works.


Overriding a component's class names

Five routes, in the order you should reach for them.

1. Write CSS — for any pf-* hook

The commonest case, and it needs no PHP at all. A component emits a neutral hook; your stylesheet decides what it means:

.pf-pagination { gap: 1rem; }
.pf-pagination .current { background: var(--brand); }

Twenty-three hooks work this way, and all three scaffolded themes already carry rules for every one of them — so you are overriding a rule, not inventing one:

pf-action · pf-action-danger · pf-breadcrumb · pf-footsearch · pf-home · pf-icon · pf-muted
pf-omnibox (+ 9 pf-omnibox-* children) · pf-pagination · pf-skip-link · pf-state (+ -on/-off)
pf-visually-hidden

2. Declare the names once — component_classes

For the case a stylesheet cannot answer: markup that must carry a specific class because something other than CSS is looking for it. A jQuery plugin doing $('.breadcrumb') reads the name, not the rule.

// app/app.php
'component_classes' => [
    'breadcrumb'         => 'breadcrumb',
    'pagination'         => 'pagination',
    'pagination.current' => 'active',
    'icon'               => 'icon',
    'action'             => 'btn',
    'omnibox'            => 'search-wrapper',
    'stat_tile'          => 'kpi',          // modifiers follow: kpi--good, kpi__value
    'stat_grid'          => 'kpis',
],

Every key, with the hook it replaces, is in Html\ComponentClasses::KEYS. An unlisted key is reported by ComponentClasses::unknownKeys() rather than ignored — a misspelling is otherwise silent, and silence is indistinguishable from a feature that does not work.

An empty string means no class, and is honoured: there the caller did speak.

Why configuration and not the property below, when the property already existed. Because the objects are not all yours. A Breadcrumb is constructed in eight places in a scaffolded project — Document, Application, and an admin_breadcrumb and account_breadcrumb partial in each of three themes — and two of those are inside this framework, on the path that renders every page. A per-object property covers six of the eight. Read at construction, one declaration covers all of them.

Read at construction has a consequence: the object must be built after app.php. A component constructed earlier than that reads an applicationInfo that does not exist yet, and the framework's own hook wins silently — no warning that the key was ignored, and nothing to suggest that when it was read is the reason. Application builds its Breadcrumb immediately after loading the configuration for exactly this reason, and a test pins that ordering. If you construct a component during boot, construct it after the application's constructor has run, or set its property directly.

3. Replace the class on one object — where a property exists

For one object rather than the project — a single breadcrumb that has to differ from the rest. Configuration decides the default; this decides the outcome, and wins.

Component Property Default Controls
Pagination $containerElementClass pf-pagination the container
$currentPageClass current the current page's link
$containerElement div the element itself, not just its class
Breadcrumb $listClass pf-breadcrumb the <ol>
$extraStyle '' inline style on the container
Datatable $tableClass display the <table>
$breadcrumb->listClass = 'breadcrumb';        // Bootstrap's own name
$pagination->containerElementClass = '';      // no class at all
$pagination->containerElement = 'ul';         // and a different element

4. Add alongside — $extraAttributes

SearchBox, Select and Input take a raw attribute string, so you can add classes without replacing what is there:

$select->extraAttributes = 'class="my-select" data-role="filter"';

It is not escaped — it exists to carry markup, so never build it from user input.

5. Form fields — FieldStyles, keyed by what the application declared

Field takes no class properties. Its classes come from a per-theme preset:

echo $field->render(FieldStyles::for(FieldStyles::configured()));   // the project's UI system
echo $field->render(FieldStyles::for('tailwind'));                  // pinned to one

You rarely need to name the theme. app/app.php already carries scaffold_theme — the UI framework the project was generated against (plain-css resolves to the plain preset) — and it is the default:

new SettingsForm('settings');                 // uses scaffold_theme
new SettingsForm('settings', 'bootstrap');    // unless you say otherwise
FieldStyles::configured();                    // what that resolves to

Add a theme by adding an entry to the preset array; group, label, input, select, area, check and help are the keys. Keep it in step with the scaffolding directories — a project on a framework with no preset silently renders as plain, and a test asserts the two lists match.

And colours come from one file, not from any of this

None of the five routes is where a palette lives. app/themes/theme.css holds every colour and radius for the project, in daisyUI's theme-generator format, and pramnos theme:build propagates it to www/assets/css/theme-tokens.css, www/assets/theme-tokens.json and ThemeTokens::token() for PHP. See the Theme guide.

So a pf-* rule should be written in those tokens rather than in literals:

.pf-pagination .current {
    background: var(--primary-color, #2563eb);
    color: var(--white, #fff);
}

A rule with a hardcoded colour is a rule the palette cannot reach, and the failure shows up as one component that did not change when everything else did.

What you cannot replace, and why it does not matter

Which components follow project:switch-ui and which do not, and how to write an application's own templates so they do, is in the Theme Guide's Writing views that survive a UI switch.

Icon emits pf-icon and SearchBox emits four pf-omnibox-* names, with no property for any of them. Restyle them with CSS (route 1) — they are neutral hooks, so a rule is the whole answer and a property would only be a second way to do the same thing.

The real exception is Datatable. Nine of its classes are a CSS framework's own — btn btn-default btn-secondary dropdown-toggle, form-control, ui-buttonset — chosen by $bootstrap and $jui. $bootstrap follows the project's scaffold_theme (on for bootstrap or none, off for tailwind and plain-css), and its dropdown opens under Bootstrap 3 and 5 alike:

$datatable->bootstrap = false;   // DataTables' own look, a plain-link column picker
$datatable->jui = true;          // jQuery UI markup instead of Bootstrap

Two looks, and no route to a third. It is the one component you cannot bring onto a different CSS framework without editing the class, and the only one where route 1 does not save you.

Why there are two mechanisms

Routes 1 and 4 above look like opposites — one hides the theme's classes behind a neutral name, the other puts them in the markup. Both are right, for different kinds of class.

Tailwind's entire model is utilities in the markup. Hiding ten of them behind a pf-input and re-applying them with @apply fights the framework it is meant to serve, and leaves a stylesheet nobody on a Tailwind project expects to have to read. That is why Field uses a preset.

A breadcrumb's <ol>, on the other hand, is one element with one meaning. It wants one hook.

A structural class — one per element, standing for what this is — is pf-*, and the theme dresses it.

A utility-dense set the theme wants visible in the markup is a keyed preset.

Getting this backwards means emitting a framework's own name — Bootstrap's breadcrumb — as a literal in framework markup. It reads as neutral and is not: a Tailwind project gets an element carrying a name nothing in its stylesheet defines.

Every hook has a rule in every scaffolded theme, and a test asserts it. A hook with no rule anywhere is the failure this convention exists to prevent, and it is invisible — the markup is correct and the page merely looks wrong, in a way people blame on their own stylesheet.

Select

$status = new \Pramnos\Html\Select('statusSelect', $current);
$status->addOption('All', '');
$status->addOption('Active', 1);
$status->addOption('Inactive', 0);

echo $status->render();

The label is the first argument. addOption($label, $value), not the other way round.

When to use this rather than Form\Field

Form\Field renders a <select> too, and it is the right thing for a field in a form: it carries a label, a title, validation state, and its render() takes the form's style preset.

Html\Select is for a <select> with no form around it. The commonest case is a footer filter in a Datatable, where the rendered string is handed to addColumn() as markup:

$table->addColumn('Status', true, false, true, '', $status->render(), …);
Html\Select Form\Field
needs a form no yes
render() arguments none a $styles preset
carries a label no yes

Options

$select->addOption('Greece');                    // label is also the value
$select->addOption('Pinned', 'pinned', true);    // selected regardless of $current
$select->addOptions([1 => 'one', 2 => 'two']);   // value => label, in bulk
$select->hasOptions();                           // for a query that returned nothing

Multiple

$tags = new \Pramnos\Html\Select('tags', [2, 3]);
$tags->multiple = true;

The name becomes tags[], which is not cosmetic: without the brackets PHP keeps only the last submitted value and a multiple select silently behaves like a single one. An array current value selects every option it holds.

Escaping and comparison

It escapes. Labels and values go through htmlspecialchars() — its commonest use is a filter populated from a database column.

It compares as strings. The current value is matched with (string) $a === (string) $b. A loose == answers 0 == '' differently on PHP 7 and PHP 8, so a select with an "All" option of '' and a current value of 0 would select a different option depending on the interpreter.

Other attributes

$select->id       = 'status-filter';   // no id is emitted unless you set one
$select->required = true;
$select->extraAttributes = 'data-role="filter"';

No id is invented from the name, deliberately: two selects for one field on a page is ordinary — a filter above a table and another below it — and duplicate ids are invalid HTML that breaks <label for> and getElementById with no visible error.


Input

$search = new \Pramnos\Html\Input('q', $current);
$search->placeholder = 'Search…';
echo $search->render();

The companion to Select, and there for the same reason: Form\Field is for a control in a form and its render() needs the form's style preset; a filter above a table has no form.

One class for every input type

Text, checkbox, colour, time and the rest are one class: <input type="color"> and type="time" are native in every browser, so what each type needs is a tag with the attributes that apply to it, not a widget of its own.

Types

$input->type = 'number';   // text hidden password email url tel search number range
                           // date time datetime-local month week color
                           // checkbox radio file
                           // textarea

textfield is accepted as a spelling of text and colorpicker as one of color. An unrecognised type becomes text rather than being emitted — a browser treats an unknown type as text anyway, so passing a typo through only adds an attribute a validator will reject.

textarea is in the list even though it is not an <input>: it is the same job from a caller's point of view, and Form\Field already treats it as a type. The value goes between the tags.

Checkbox and radio

$active = new \Pramnos\Html\Input('active', $row['active']);
$active->type = 'checkbox';           // checked when $row['active'] === '1'
$active->checkedValue = 'yes';        // for a column that stores something else

value is the current state; checkedValue is what gets submitted. Swapping them renders a checkbox that submits whatever the row already held — which looks correct until somebody unticks it and the old value is saved back.

Attributes

Property Applies to
label all — rendered before the control, escaped, no trailing colon
id all — none is emitted unless you set one
required / readonly / disabled all
placeholder everything except checkbox, radio, file, color
min / max / step number, range and the date/time types
maxlength / minlength / size / pattern the textual types
title all — the tooltip, and the message a failed pattern shows
inputmode all — the on-screen keyboard (numeric, tel, decimal, …)
rows textarea (and size becomes cols)
autocomplete all
multiple file — appends [] to the name
extraAttributes all — rendered verbatim, not escaped

Each is emitted only where it applies, so a text input does not arrive carrying min="". min on a text field is invalid markup and a validator is the only thing that will ever tell you.

Two more things it does without being asked: a file input carries no value — every browser ignores it, validators reject it, and it is the one type whose value could only have come from the server; and no id is invented from the name, for the same reason as Select — two controls for one field on a page is ordinary, and duplicate ids break <label for> and getElementById with no visible error.

Native validation, without a JavaScript library

$code = new \Pramnos\Html\Input('code');
$code->pattern   = '[0-9]{4}';
$code->title     = 'Four digits, e.g. 1234';   // what the browser says when it fails
$code->minlength = 4;
$code->maxlength = 4;
$code->inputmode = 'numeric';                  // the right keyboard on a phone
$code->required  = true;

Always set title alongside pattern. Without it the browser reports "Please match the requested format", which tells the user they are wrong and not what right looks like. It is text written for a person, so it is escaped — which is why it is a property rather than something to pass through extraAttributes.

Error messages themselves are the browser's, in the browser's language. That is more correct than a framework's own translated strings and needs nothing here.

What it does not do

It pushes no CSS or JavaScript into the document while rendering, so echoing a control never changes the page's asset list. Document::addScript() and addStyle() are how a page declares what it needs.


PasswordToggle

The «show password» control that sits beside a password field. First recommendation of web.dev's sign-in form guidance, and the reason is mobile: on a phone the commonest cause of a failed sign-in is a typo in a field nobody can read. Someone who cannot see what they typed retries the same wrong thing, then resets a password they never forgot.

<label for="password">Password</label>
<input type="password" name="password" id="password" autocomplete="current-password" required>
<?php echo \Pramnos\Html\PasswordToggle::render('password', 'Show', 'Hide'); ?>

render(string $inputId, string $showLabel = '', string $hideLabel = '', string $class = ''). The id is the id of the input it controls; the labels default to translated «Show password» / «Hide password». The class is optional and normally left off — see below.

It is a sibling of the input, never one of its attributes

The one way to get this wrong:

<!-- WRONG: the toggle is inside class="" -->
<input id="smtp_pass" type="password" name="smtp_pass" class="<?php echo $input; ?>
<?php echo PasswordToggle::render('smtp_pass', '', ''); ?>" autocomplete="new-password">

render() returns markup, so the first > in it closes the <input>. Everything after that — the closing quote, autocomplete, value — becomes visible text in the page, and the field itself never renders. The symptom is a line of attribute soup beside an eye icon where a box should be.

It reads like a missing "> after the class, and it is not: the toggle belongs after the tag, not inside it.

<!-- Right: close the attribute, close the tag, then the toggle -->
<input id="smtp_pass" type="password" name="smtp_pass" class="<?php echo $input; ?>"
       autocomplete="new-password" value="">
<?php echo PasswordToggle::render('smtp_pass', '', ''); ?>

PasswordToggleMarkupTest sweeps every scaffolded view for it, structurally rather than by searching for one broken string — the next one will be in a different attribute of a different field.

It goes after the input, and it is an eye

Both of those are the same fix, arrived at twice.

The control renders as an eye icon, and its own script moves it inside the field's right-hand edge at runtime — wrapping the input in a position: relative span, positioning the button absolutely and adding right padding so a long password does not run underneath it. Nothing in a view or a theme has to change for that, and there is no stylesheet to ship.

It is done in JavaScript rather than in the markup because the two requirements pull in opposite directions. The button has to come after the input in the document: with no tabindex anywhere, tab order is document order, so a control written into the label row above the field means tabbing off the username lands on «Show» instead of on the password box. Absolute positioning moves the button visually without moving it in the document, so the tab order stays right and the icon still sits in the field.

tabindex="-1" on the button would have fixed the tab order too, and is the wrong answer: it makes the control mouse-only, which removes it for the visitor most likely to need it.

The words are the accessible name, not the visible content: aria-label and title carry them, and both are swapped along with aria-pressed so a screen reader hears the new state. A bold «Show password» printed under the box is louder than the field it belongs to, for something most people never press.

The icon is an inline SVG sized in em and stroked in currentColor, so it fits a theme nobody told it about, and it works in a project with no icon set, no build step and no network. The $class argument still exists for a caller that wants its own button styling, but a theme's btn brings padding, a border and a background that fight the positioning — the scaffolded views pass nothing.

The button ships hidden

It is rendered with the hidden attribute and unhidden by its own script. A control that cannot do anything is worse than no control: without JavaScript a visible «show» button is something a person presses twice and then distrusts the rest of the form. A no-JS visitor sees exactly the form they saw before, and the field never depended on the script.

The script repeats, on purpose

Every button carries the script, and the script guards itself in the browser — the first copy binds a delegated listener on document, the rest return immediately.

A static «already emitted» flag would be process state, not request state: a process that renders more than one response — an in-process test client, a long-running worker, anything producing two pages — would give the second page a button with no listener behind it. A few hundred repeated bytes is the better trade against a bug that only appears outside a plain request, which is exactly where nobody looks for it.

What it does not touch

Only type changes. name, id and autocomplete stay as they are, because those three are what a password manager matches on — a toggle that renamed the field would stop it offering the saved password, which costs more than an unreadable field does. Focus and the caret position are preserved too: toggling mid-word and losing your place is the same frustration in a different shape.

The id is checked, not escaped

It reaches an HTML attribute and a getElementById call. An id is written by a developer as a constant, so anything outside ^[A-Za-z][A-Za-z0-9_:.-]*$ raises InvalidArgumentException rather than being quietly encoded into a control that addresses nothing.

echo (new \Pramnos\Html\SearchBox())->render();

The markup for the cross-entity search box. The results come from Search\Registry, the behaviour from data-pf-omnibox in assets/js/pf-utils.js, and the styles from assets/css/style.css — all three already on a scaffolded page, so this contributes no script and no stylesheet of its own.

A scaffolded theme renders it in the header already. Reach for this class only to put a second one somewhere else.

$box = new \Pramnos\Html\SearchBox('/api/1.0/admin/search');
$box->placeholder       = 'Find anything…';
$box->id                = 'sidebar-search';   // renames the input, label and panel together
$box->minimumCharacters = 3;
$box->debounce          = 400;
$box->label             = 'Αναζήτηση';
$box->value             = $_GET['q'] ?? '';  // a results page shows what was searched for

The endpoint defaults to <api_prefix>/admin/search, read from app/app.php rather than assumed — a project served under /v1 would otherwise get a box pointing at a 404, and a 404 on a search box reads as "search is broken".

The one component that does default an id

Input and Select deliberately invent none. This does, because aria-controls and aria-activedescendant are associations by id, and without them a screen reader has a text field and an unrelated list rather than a combobox. One box per page is also the premise, so the collision risk that made the others refuse does not apply — set id if you need two.

The accessible name is a real visually-hidden <label>, not an aria-label: an aria-label is invisible to a translation tool that only reads element text, and the result is a translated interface whose search box announces itself in English.


StatTile

A figure with a label, the tile every administration dashboard draws: MRR, "paying", "waiting in the queue".

use Pramnos\Html\StatTile;

echo StatTile::grid([
    StatTile::render('MRR', '€1,240', 'ARR €14,880'),
    StatTile::render('Waiting in the queue', '312', 'oldest 4 min', state: StatTile::WARNING),
    StatTile::render('Failed today', '0', link: adminUrl('Queue') . '?show=failed'),
    StatTile::progress('Paying customers', 18, 50, "the plan's 90-day target"),
]);
Method
render($label, $value, $note = '', $link = null, $state = null) one tile; with a link, the whole tile is the link
progress($label, $current, $target, $note = '', $link = null) current / target and a <progress> bar; good once the target is reached; no bar when the target is 0
grid($tiles) tiles side by side, wrapping on a narrow screen

The value is shown as given. Format it first (currency, thousands, a percentage), because only the caller knows what the number is. Every string is escaped.

States are StatTile::GOOD, WARNING and CRITICAL. A state is drawn as a coloured left border, so it reads without colour too, and is on the element as data-state for a script. Anything else is ignored rather than emitted as a class.

What a theme styles: pf-stat-grid, and pf-stat-tile with __label, __value, __note, __progress and the --good, --warning and --critical modifiers. The value uses tabular digits, so a number that changes does not shift its neighbours. Each scaffold theme's style.css dresses them from its own colours, in light and dark. A theme without those rules can copy the Stat tiles block from the framework's scaffolding/themes/<ui>/style.css into it, or rename the hooks with stat_tile and stat_grid in component_classes.

Chart

A chart drawn with the page's Chart.js, in the theme's colours, with no inline script.

echo \Pramnos\Html\Chart::render('bar', ['Jan', 'Feb', 'Mar'], [
    ['label' => 'Revenue', 'data' => [1200, 1850, 990]],
    ['label' => 'Target',  'data' => [1500, 1500, 1500], 'type' => 'line', 'color' => 'warning'],
], ['title' => 'Revenue by month', 'format' => 'currency', 'currency' => 'EUR']);

Types: line, bar, pie, doughnut, radar and polarArea. Other types are refused.

Datasets: each is ['label' => …, 'data' => [numbers]], and each can also take:

  • color — a theme colour (primary, secondary, accent, info, success, warning, error) or any CSS colour.
  • colors — one colour per slice, for a pie or a doughnut.
  • type — to mix a line into bars.

Options:

Option What it sets
title The caption, and the table's caption
height The height in pixels (300)
format number, currency (with currency, EUR by default) or percent
decimals The most fraction digits shown (2)
stacked Stack the bars or areas
chart Chart.js options merged over the drawn ones, for anything not modelled here
id The figure's id

What the markup holds. The figure carries the data as <script type="application/json">. A browser does not run that, so a CSP without 'unsafe-inline' lets it through. pf-utils.js draws every [data-pf-chart-figure] once Chart.js is on the page; rendering enqueues the chartjs asset when the document has it registered.

Colours. They are read from the theme when the chart is drawn: daisyUI's --color-*, Bootstrap's --bs-*, or the plain theme's --primary-color. Axes and text take the theme's text colour. A change of data-theme, or of the OS colour scheme, draws the chart again.

Numbers. Tooltips and axis labels use Intl.NumberFormat in the page's <html lang>.

Charts added later. A figure inserted after the page loaded, such as a dashboard card fetched after a change, is drawn as it arrives. window.pfCharts.draw(root) draws the figures under root that are not drawn yet, for a page that wants to say so itself.

The table. Under the chart is a table of the same numbers. It is what a screen reader reads, and what a page without Chart.js shows. Once the chart is drawn, the table is hidden visually and kept for screen readers.

The pf-chart-* classes are styled in every theme's components.css. A chart in an assistant's answer is a different thing: data-pf-chart, which pf-chat.js draws as SVG.

$bc = new \Pramnos\Html\Breadcrumb();
$bc->addItem('Home', sURL);
$bc->addItem('Users', sURL . 'users');
$bc->addItem('Alice');          // no url — the current page
echo $bc->render();

It emits the trail twice: as an <ol class="pf-breadcrumb"> (the class is $listClass) for the reader, and as BreadcrumbList JSON-LD for a search engine, from the same items.

A label is rendered as HTML; a title and a URL are escaped. Callers pass markup in a label on purpose — an icon, an emphasised word — which is why the structured-data name is strip_tags()d rather than escaped. So a label built from user input has to be escaped by whoever builds it. The title and href attributes have no such contract and are escaped here: a title defaults to the label, and one double quote in a name would otherwise end the attribute and make everything after it markup the visitor chose.

Every label is wrapped in a heading, and your CSS has to know

<li class="breadcrumb-item"><h5><a href="…"><span>Users</span></a></h5></li>
<li class="breadcrumb-item active" aria-current="page"><h4><span>Alice</span></h4></li>

The descending heading levels are how the trail expresses depth to a crawler. A heading is a block element, and Tailwind's Preflight strips its size but not its display — so a stylesheet that styles the list and forgets the headings gets a trail where each label fills its own line and the separators drop underneath:

Home Dashboard
 /        /      Tokens

Which is what it looks like in a browser, and nothing in any log. Reset them:

.breadcrumb-item h1, .breadcrumb-item h2, .breadcrumb-item h3,
.breadcrumb-item h4, .breadcrumb-item h5, .breadcrumb-item h6 {
    display: inline;
    margin: 0;
    padding: 0;
    font-size: inherit;
    font-weight: 400;
    line-height: inherit;
}

All three bundled themes carry that rule in their style.css. A hand-written theme, or an application compiling its own Tailwind, needs it too.

render() does not escape labels. It is documented that way because a caller sometimes wants markup in a crumb — so anything dynamic has to go through htmlspecialchars() before addItem().


A page in a dialog — data-pf-modal

A link to a page better read over the one it is on — a task, a day's figures — opens in a <dialog> with one attribute:

<a href="<?= adminUrl('Queue/history') ?>" data-pf-modal>Statistics</a>
<a href="<?= adminUrl('Queue/show/42') ?>" data-pf-modal="Task 42">42</a>

pf-utils.js fetches the address with X-Pf-Fragment: 1 and shows what comes back. The server needs nothing per screen: for that header the html document renders the view's output alone — no theme, no head, its inline scripts still given the CSP nonce — and Breadcrumb::admin() draws no trail. The document stays html, so a view finds its .html.php template as for a page. Document::isFragmentRequest() answers the same question for a view that wants to leave something else out.

  • The dialog is the page's one #pf-modal, opened with showModal(): the page behind it is inert, so focus stays inside, and Escape, the × button and a click on the backdrop close it. It is named by the fragment's first heading, or by the attribute's value, or by the link's text. Closing it empties it and gives focus back to the link. pf:modal:open is dispatched on the document with { dialog, url } once it shows.
  • A plain link where it cannot be a dialog: a browser without <dialog>, a click with a modifier key (a new tab), or a fetch that fails navigates to the address as usual. The address is always a whole page of its own, so it can be bookmarked and shared.
  • The page cache keeps a fragment and its page as two entries; the response carries Vary: X-Pf-Fragment.
  • Scripts in the fragment do not run — content set into the dialog is markup. A fragment's links and forms work as anywhere else: a form posted from it navigates the page.

The pf-modal, pf-modal-close and pf-modal-body hooks are styled in each scaffolded theme's components.css.

Figures that change while the reader is away — data-pf-refresh-on-return

An element with an id and data-pf-refresh-on-return is refreshed when the reader comes back to the page — the tab visible again, or the window focused. pf-utils.js fetches the page's own address with X-Pf-Fragment: 1 and gives each marked element the content and aria-label of the element with the same id; the rest of the page, an open tab included, stays as it is.

<div id="pf-mail-tracking"<?= $tracked ? ' data-pf-refresh-on-return' : '' ?>>
    <?= (int) $clicks ?> clicked
</div>

Meant for what changes because of what the reader went off to do — a message page's click count after they followed one of its links in another window. A page with nothing marked fetches nothing, focus and visibility on one return are one fetch, and a failed fetch leaves the page as it is.

Date and time

$when = new \Pramnos\Html\Date('startdate', $record->starts);
echo $when->render();
// …and on the way back in
$record->starts = $when->getDate('post');

One dd/mm/yyyy text field with a datepicker attached. getDate() reads it back from {name}_datepicker, which is the name render() emitted — the round trip is the contract, and the wire format is deliberately not type="date", because a native date input posts YYYY-MM-DD and every receiving end already parses the other one.

render() enqueues the bootstrap-datepicker script and its stylesheet, both from the source documentAssetSource picks. The year bounds (minyear, maxyear) reach the calendar as startDate / endDate. The same call also carries jQuery UI's options (dateFormat: 'dd/mm/yy', changeYear, changeMonth, yearRange, and an onChangeMonthYear that keeps the day inside the new month), so a page where jQuery UI loads last and owns jQuery.fn.datepicker gets a working calendar too. Each library ignores the other's options.

The field is size="10" and carries Bootstrap's form-control, which is width:100% — right inside a grid column, the whole line outside one. A project that lays its date fields out inline drops the class with 'component_classes' => ['date' => ''] in app.php; $date->class adds classes of its own either way.

The second argument is a Unix timestamp, and 0 — or null, which is read as 0 — means no date: with nothing in the request, getDate() answers 2, so a filter can test < 10000 for "not set".

A time beside the date

$when->time = true;             // adds a Time widget, posting {name}_timepicker
$when->timeChangeLine = false;  // …on the same line

getDate() combines the two into one timestamp.

The time field itself is a native <input type="time"> — Pramnos\Html\Time, usable on its own — which submits HH:MM on every platform that matters. The browser draws the clock, validates the value and localises how it is displayed.

Three dropdowns instead of a datepicker

$birth = new \Pramnos\Html\Date('birth', $user->born);
$birth->calendar = false;              // day / month / year boxes
$birth->dropdownYear = true;           // year as a <select> too
$birth->dropdownRequireSelect = true;  // start empty — "not answered" stays visible
$birth->dropdownLabels = false;        // drop the D: M: Y: labels

A birth date is the case this exists for: a datepicker asking somebody to page back forty years is worse than picking a year from a list. Without dropdownYear the year is a type="number" field bounded by minyear/maxyear, rather than a two-thousand-option list.

dropdownRequireSelect is the one worth understanding. Without it the boxes pre-select the current value, and a field nobody filled in comes back as a real date the visitor never chose — indistinguishable from one they did. With it, nothing is selected until somebody selects it, and getDate() returns 0 for an unanswered field.

These boxes post as {name}day, {name}month and {name}year; getDate() reads whichever set $calendar says to expect.


Datatable, and its Datasource

Datatable renders the table and its JavaScript; Datatable\Datasource answers the AJAX requests DataTables makes. This section covers what a caller configures, not a full tour.

Values from the request are safe to pass

A column's search value (addColumn(…, $searchvalue)), the table's $search and its source address are written into the table's script as JSON string literals, with quotes, <, > and & escaped. A value taken from the address, such as a list filtered by a link, cannot end the string or the <script>. $codeEmbed is the exception: it is written as given, so anything put there from a request has to be encoded by whoever puts it there.

It draws with the project's UI system

DataTables writes its own DOM: the search box, the page-length select, the page buttons and the "processing" note. A theme's classes cannot reach that DOM from the markup, so Datatable hands the classes to DataTables. It does so once per page, before the table is drawn, according to scaffold_theme:

scaffold_theme Table Controls
bootstrap, or none table table-striped table-bordered table-hover DataTables' own, or its Bootstrap integration's
tailwind table table-zebra (table-sm with size = 'sm') daisyUI: input input-sm, select select-sm, btn btn-sm page buttons with btn-active, Buttons' buttons btn btn-sm
plain-css table table-hover the plain stylesheet's form-control, btn btn-outline-secondary btn-sm, pf-ui-current

Under tailwind the table is drawn at the size the rest of the page reads at. Set $table->size = 'sm'; for a compact table-sm, for a wide table that has to fit. The column picker (showHide) is a "Show ▾" button with the columns in a list that scrolls, in the system's dropdown classes. It is a <details>, so it opens with no script and from the keyboard, as Bootstrap's dropdown does under bootstrap.

Under tailwind and plain-css, the table also uses DataTables' own pager renderer and the lfrtip layout. Those replace a Bootstrap integration's renderer and grid, where the page loads one.

The table sits in a .pf-datatable wrapper, and every theme's components.css styles the rest under it:

  • the controls on two lines;
  • the processing note over the table;
  • sort marks that need no icon font. The Bootstrap integration's marks expect Glyphicons, which draw as empty boxes on a page without them.

jQuery.fn.dataTable.ext.classes is global. So the classes also reach a table that the page starts with its own dataTable() call, provided an Html\Datatable was rendered on the same page.

Post-processing the rows

Datasource::getList($table, $fields, false) returns the array instead of the JSON, for a caller that decorates rows before encoding them. The array is always in the DataTables 1.9 shape — sEcho, iTotalRecords, iTotalDisplayRecords, aaData — whichever version made the request; DataTables 1.10 reads those names, and sEcho carries its draw counter. Only $encode = true, where the Datasource builds the response itself, answers a 1.10 request with draw/recordsTotal/recordsFiltered/data.

$result = \Pramnos\Html\Datatable\Datasource::getList('items', ['id', 'name'], false);
foreach ($result['aaData'] as &$row) {
    $row[1] = htmlspecialchars((string) $row[1], ENT_QUOTES, 'UTF-8');
}
unset($row);
echo json_encode($result);

Waiting before searching

$table = new \Pramnos\Html\Datatable('users', sURL . 'users/data');
$table->searchDelay     = 1200;   // ms; default 500
$table->minSearchLength = 3;      // characters before a column filter searches; 0 = no guard

The table debounces in two places — its footer filters' own keyup handler, and DataTables' searchDelay option for the global box — and searchDelay feeds both. It is a property because it is not one number for every table: a list with six LEFT JOINs behind it wants a second or more, against 600 on a light report. With a fixed value the query rate on exactly the heaviest lists is the one nobody can lower.

minSearchLength guards the per-column footer filters (footerTextSearch). Without it each keystroke is one AJAX request and one server-side query: typing papadopoulos into a column filter sends twelve, and the first of them is LIKE '%p%' across the whole table.

An empty box is always let through, whatever the minimum — clearing a filter has to clear the filter, or the column stays filtered on a term no longer on screen.

A column can have its own minimum, which applies to that column instead of the table's:

$table->addColumn('ID', ['minSearchLength' => 1, 'footsearch' => true]);   // an id of 7 is a whole answer
$table->addColumn('Phone', true, true, true, '', '', true, 'left', true);  // the table's 3: every mobile starts 69

Filtering one column

One box over every column answers find this person. It cannot answer the administrators registered this month, which is most of what an operator asks a list — so a column can carry its own filter:

$table->footerTextSearch = true;          // a text box under every column that asks for one

//                 label   visible  sortable  searchable  type   footer  showHide  align   filter        value
$table->addColumn('Username', true,    true,     true,     '',     '',     true,   'left',  true);
$table->addColumn('Type',     true,    true,     true,    'num', $select->render(), true, 'left', 'dt-users-type', '90');

The 9th argument is the filter:

Value What appears
true a text box under the column, debounced and guarded by minSearchLength
an id string the control you rendered into the footer (6th argument) — the table wires change and a debounced keyup on that id
false (default) no filter for this column

The 10th is the filter's current value, applied on load. Without it a bookmarked or returned-to filter shows its chosen value while the table shows every row, which reads as a filter that does not work.

An id rather than true is how an enumerated column gets a dropdown: nobody guesses that "administrator" is stored as 90, and a numeric column is matched equal rather than with LIKE, so a text box on it is a worse question as well as a harder one.

$filter = new \Pramnos\Html\Select('usertype_filter');
$filter->id = 'dt-users-type';
$filter->addOption('Any type', '');
$filter->addOptions(\Pramnos\User\UserTypes::options());   // Admin (90), Manager (80), …

The bundled Users, Applications and Organizations lists ship with these on, and create:crud generates them for every column of a new entity.

A filter the Datasource should not apply as a LIKE — a range, a join, a value that means something other than itself — is read before getList() and taken:

use Pramnos\Html\Datatable\Datasource;

$status = Datasource::takeColumnSearch(3);   // the value, and render() leaves column 3 alone
$where  = $status !== '' ? 'WHERE a.status = ' . (int) $status : '';
$rows   = Datasource::getList('orders', $fields, false, $where);

Datasource::columnSearch($i) reads without taking. Both read DataTables 1.10's columns[N][search][value] and 1.9's sSearch_N alike, so the code does not depend on which version of the client made the request. A multiple select's choice — an array, since its .val() is one — reads as its non-empty values joined with commas, '3,7', as 1.9 sent it; explode(',', …) it, and take it, since left to render() it is a LIKE on that string.

The generated box carries class="pf-footsearch" and the column's label as its placeholder. The class is not decoration: a bare <input> under a modern CSS reset has no border, no padding and no background, so the filter row was there and invisible — every bundled theme styles it, and a theme of your own should too.

The boxes filter their own table only, so two tables on one page do not filter each other, and the placeholder is a real placeholder attribute — nothing writes the label into the value or restores it on blur.

Row actions, as icons

use Pramnos\Html\Icon;

$row[] = Icon::link(adminUrl('users/view/') . $id, 'view', 'View this user')
       . Icon::link(adminUrl('users/edit/') . $id, 'edit', 'Edit this user')
       . Icon::link(adminUrl('users/delete/') . $id, 'deactivate', 'Deactivate', [
             'data-confirm' => 'Deactivate this user?',
             'class'        => 'pf-action-danger',
         ]);

View Edit Deactivate repeated on every row spends more width on words than on data, and after the first row the words carry no information. Icon::link() is the same action in a 28-pixel cell — labelled twice, as aria-label and title, because an icon-only control with neither is a control only its author can use.

Inline SVG rather than an icon font or a CSS class: these are rendered by a controller into JSON a DataTable inserts, so the markup has to work in every theme. currentColor and a 1em box mean an icon inherits whatever the cell around it already is. Icon::names() lists the set; an unknown name renders nothing rather than a broken glyph.

Icon::svg('edit') is the glyph on its own, for a button: the framework's own admin uses it for the full-width actions beside a record, so "edit" looks the same in a table cell and in a button.

Make the first column a link too. A row whose only way in is the last cell makes the whole row a target people click with nothing happening. The bundled lists link the id and the name; create:crud links the first visible column.

Telling the Datasource what a column is

$ds = new \Pramnos\Html\Datatable\Datasource();
//        name        format     details  start  end   ignoreOnOthertypes  min   max
$ds->addField('id',    'int',     '',      false, true, false,              1,    null);
$ds->addField('email', 'email');
$ds->addField('notes', 'text',    '',      true,  true, true,               3,    null);

$startWildcard is false by default, so a column search is LIKE 'term%'. That default is the rule almost everywhere rather than a rarely-hit fallback: render() calls addField() with a bare column name for every field declared as a plain string, which is how most applications declare all of them. Pass true per column where matching mid-word is worth losing the index for.

The global search sends one term, and each column decides whether it applies to it:

format Applies when How
email the term is a valid address whereLike(), with the column's wildcards
phone it looks like a phone number (10–14 chars of digits, spaces, dashes, +) LIKE
numeric / number / int it is numeric, and within min/max by value =
date — not searched; formatdetails is the output format
anything else unless ignoreOnOthertypes and the term is a number or an address; and within min/max by length whereLike(), with the column's wildcards

A column that declines contributes no clause, and if every column declines, nothing is filtered. A column's own filter (sSearch_<n>) is a whereLike() too.

Text is matched ignoring case and accents on every database, so μαρια finds Μαρία on PostgreSQL as it does under MySQL's _ci collations — see whereLike() in the Query Builder Guide.

Each of those is a real cost avoided rather than a nicety:

  • = on a numeric column. LIKE '%5%' on a number matches 5, 15, 50 and 1523 — a search for an id returning a page of unrelated rows, which is what "the search box does not work" looks like.
  • ignoreOnOthertypes. On a wide list, searching every free-text column for 12345 costs a scan each and returns noise; the numeric columns answer that query.
  • min / max. A one-character term against a large text column is a full scan, and a term outside a numeric column's range cannot match it at all.
  • The wildcards. A leading % is what makes an index unusable, which is why it is off by default and asked for per column.

Reading the query a filter produced

$ds->render('orders', $fields);
echo \Pramnos\Html\Datatable\Datasource::$lastQuery;

The SQL of the last list query, across all instances — a debug and test hook, and the only way to see what a filter actually produced. An admin screen can show the query behind a list; a test can assert on an ORDER BY without building a dataset large enough to make the ordering observable. It is set before the query runs, so a query that throws is still there to read. Nothing reads it to decide anything.

Grouping

$ds->render(
    'orders', $fields, false, $where, $join, true, 5, 'datatables',
    false, null, '', 'where',
    'a.`customer_id`'        // ← GROUP BY, without the keyword
);

Its own parameter rather than something smuggled into the where string, which is fragile.

It applies to the counts as well as the rows, and that is the half worth stating: QueryBuilder::count() preserves a GROUP BY (it documents that), so COUNT(*) on a grouped query returns the size of the first group. A pager built on that promises pages that are not there. The counts are therefore taken before the grouping is applied, as COUNT(DISTINCT <the grouped columns>).

$groupBy, $join and $whereStatement are interpolated: they are column lists and SQL fragments from the calling code, never from a request.


A list that is not one table's rows — ArraySource

Some lists have no single query to page: segments beside groups, or rows with a figure worked out in PHP. Datatable\ArraySource answers the same server-side request from rows held in memory:

use Pramnos\Html\Datatable\ArraySource;

public function data(): mixed
{
    \Pramnos\Framework\Factory::getDocument('json');

    return \Pramnos\Http\Response::json(ArraySource::answer(
        $rows,                                       // every row, with the values it sorts by
        [0 => 'name', 2 => 'members'],               // column index => the key it sorts by
        static fn (array $row): string => $row['name'] . ' ' . $row['description'],   // searched
        fn (array $page): array => array_map($this->cells(...), $page),               // drawn
    ));
}
  • It reads DataTables' own parameters — draw, start, length (-1 for all), search[value], order[N] — and answers draw, recordsTotal, recordsFiltered and data.
  • The search matches anywhere in the row's text, ignoring case. Text sorts ignoring case, and a second order entry breaks a tie. A column missing from the map is not sorted by.
  • The cells are drawn for the page alone, so what is costly per row — a lookup, a link list — goes in the last callable, never into $rows.
  • $table->aoData sends a page's own filter with every request, such as a tab: [['name' => 'kind', 'value' => $kind]], read back from $_POST['kind'].

Every row is in memory, which suits hundreds or a few thousand. A longer list wants its own query and Datasource.

Pagination

$pagination = new \Pramnos\Html\Pagination($totalPages, $page, '/genres/p/:page');
$pagination->containerElementClass = 'results-pages';
$pagination->previousButtonText    = '<img src="/img/prev.svg" alt="Previous" />';
$pagination->displayFirstLast      = false;

echo $pagination->render();

This is the presentation half of paging. QueryBuilder covers the query half — forPage(), limit(), offset().

The two numbers are read as numbers whatever their type, because the current page usually comes straight from the URL: '3' is page 3, and /page/abc from a crawler is page 1, clamped like any other out-of-range page.

The URL is a pattern

:page is replaced with the page number:

'/genres/p/:page'         ->  /genres/p/4
'/search?q=x&page=:page'  ->  /search?q=x&page=4

A pattern with no :page gets /:page appended. Rendering twice gives the same result: render() never modifies the pattern, so a template with a pager above and below a list gets the same links from both.

$pagination->firstPageUrl = '/genres';   // page 1 has its own address

Two URLs for one page is duplicate content as far as a crawler is concerned, which is the whole reason these are paths rather than a query parameter.

Options

Property Default
containerElement 'div' element wrapping the whole thing
containerElementClass 'pf-pagination' (from component_classes) class on it
pageContainerElement '' element wrapping each link — 'li' for a list
currentPageClass 'current' class on the current page's link
adjacents 2 pages shown either side of the current one
steadyWindow true the number row keeps 2 × adjacents + 1 numbers at the ends too
displayNextPrevious true the ‹ › buttons
displayFirstLast true the « » buttons
displayEdgePages true the pinned 1 and last-page numbers beside the ellipses
previousButtonText / nextButtonText '&laquo;' / '&raquo;' markup, not escaped
firstButtonText / lastButtonText '&laquo;&laquo;' / '&raquo;&raquo;' markup
dotsText '&hellip;' shown where pages are elided. markup

The button properties are not escaped

They exist to hold an <img>. That makes them the one place a caller's string reaches the page unfiltered, so they must never be built from user input. Page numbers, URLs and the container class are escaped.

What it does without being asked

  • A single page renders nothing at all. One page is not a paginated result, and an empty container is something a stylesheet still puts margins around.
  • Both ends are always reachable, even in a long list — one click to page 1 or to the last page is the point of a numbered pager over previous/next alone. Set displayEdgePages = false for a window that is purely relative to the current page.
  • Dots only where something is hidden. 1 … 2 would be a lie about a page that is right there.
  • Previous and next disappear at the ends. A "previous" on page 1 either links to page 1 or to page 0, and both are worse than absent.
  • An out-of-range current page is clamped. Page numbers arrive from a URL, so they arrive wrong; page 0 and page 99 of 5 both produce a usable pager.
  • Links are labelled for a screen reader with aria-label and aria-current. A link whose content is an image or an ellipsis has no accessible name otherwise — a screen reader reads the URL.

Reading the page from a script

Every link carries data-page, the page it goes to, as an integer. That covers the numbered links and first, previous, next and last, each with the page it leads to. Read that, not the href:

document.querySelectorAll('nav [data-page]').forEach(function (link) {
    link.addEventListener('click', function (event) {
        event.preventDefault();
        loadPage(Number(link.dataset.page));      // jQuery: $(this).data('page')
    });
});

The href is shaped by the pattern (/p/:page, ?page=:page), and a first page with its own firstPageUrl has no number in it at all. The ellipsis is not a link and has no data-page.

A window with no pinned ends

$pagination->displayEdgePages = false;   // … 8 9 [10] 11 12 …   — no 1, no 20
$pagination->displayFirstLast = true;    // « and » still reach both ends

The two are independent, and that combination is the common one: the ends stay one click away through the buttons, while the number row keeps a constant width as the reader moves through it.

The ellipses stay honest either way — they appear only where a page is actually hidden, and the threshold moves with the setting. With 1 on screen a gap exists from page 3, so 1 … 2 would be a lie; with 1 off screen a gap exists from page 2, and omitting the dots there would hide the fact that page 1 exists at all.

The window keeps its width at the ends

Near an end, the numbers the window cannot show on one side are shown on the other. With adjacents = 2 page 1 shows 1 2 3 4 5, page 2 the same, the last page 72 73 74 75 76: 2 × adjacents + 1 numbers on every page, or every page when there are fewer. A row that narrows at the ends is a row that moves under the pointer as the reader steps through it.

steadyWindow = false cuts the window at the ends instead — 1 2 3 on page 1.


It is a navigation landmark

Every link carries aria-label="Page 7" and the current one aria-current="page" — which help once you are inside the pager. The <nav> is what gets you inside: without it the pager is an anonymous run of anchors, and somebody moving through a page by its regions passes straight over it.

<nav aria-label="Pagination"><div class="pf-pagination"> … </div></nav>

$navigationLabel names the region — a page can hold more than one, and a reader listing the regions hears the labels, not the markup. A caller who sets containerElement to nav themselves gets the label on their own element rather than a second region wrapped around it; two nested navigation landmarks announce the region twice and neither is the answer.

Turning the numbers off

$pagination->displayPageNumbers = false;   // leaves only « ‹ › »

Not a preference. In search results the page count is large and moves with the filters: a reader on page 7 of 40 cannot say what page 7 is, and after narrowing the query it is a different page 7. Two links are the entire meaningful interface there, and a row of twelve numbers is noise that changes width as you read it.

It is distinct from $displayEdgePages, which only decides whether 1 and the last page are pinned inside that row. This removes the row.

With the numbers off and neither button pair enabled, render() returns an empty string. Every branch would be skipped and what was left was an empty <nav> — and an empty landmark is worse than none: it appears in a reader's list of regions and leads nowhere. Same rule as a single page, reached a different way.

Form\Field

The one that belongs to a form. It is not an alternative to Input and Select — it is built on them.

$field = new \Pramnos\Html\Form\Field('email', 'Email', 'email', required: true);
$field->description = 'We never share it.';
$field->pattern     = '[^@]+@[^@]+';
$field->title       = 'name@example.com';
echo $field->render(\Pramnos\Html\Form\FieldStyles::for(\Pramnos\Html\Form\FieldStyles::configured()));

Field keeps what is actually about being in a form — the label, the description, the required marker, the style preset, effectiveValue(), readSubmitted(), and an id defaulted to the name. The tag itself is built by Input or Select, so the rules about which attributes a type may carry live in one place.

So min/max/step appear only on the types they apply to, never on text — where they would be invalid markup that browsers accept and validators reject.

$label is the label, $title is the HTML attribute

$field->label = 'Email address';      // what the reader sees
$field->title = 'name@example.com';   // the tooltip, and what a failed pattern says

The two are separate because the HTML attribute has a job of its own: title is the tooltip, and it is what the browser shows when a pattern fails. A class that spent its title on the label could not offer either.

One more difference from Input: the checkbox companion

<input type="hidden" name="active" value="0" />
<label for="active"><input type="checkbox" id="active" name="active" value="1" /> Active</label>

Without it an unchecked box submits nothing at all, which is indistinguishable from the field not being on the form — so a setting could be switched on and never off. Input does not do this, because a filter checkbox outside a form has no such problem.

Field also treats anything that is not '0' or '' as checked, which is broader than Input's equality test: a setting stored as 1, yes or true all mean the same thing to a settings form.

Its own type vocabulary

textfield, datetime and image are Field's names, resolved before the control is built: datetime → datetime-local, image → text (it always carried a path, and nothing ever rendered a picker for it). Passing them to Input directly would get you a plain text box.


A validation message that belongs to its field

A <small> under an input is not attached to it — it is a sentence that happens to sit nearby, which is a relationship only a sighted reader can see. Without aria-describedby somebody using a screen reader hears the label and the control and never the explanation, so the field that needs the most explanation gives none.

$field        = new Field('email', 'Email', 'email', null, 'Your work address');
$field->error = 'That address is already registered.';

echo $field->render(FieldStyles::for(FieldStyles::configured()));
<input type="email" name="email" id="email"
       aria-describedby="email-error email-description" aria-invalid="true" …>
<small id="email-error" role="alert">That address is already registered.</small>
<small id="email-description">Your work address</small>

$error is the rendering end of validation, not a second validator: Validation\FormRequest and View::$errors still own that. Set it and the field is marked invalid and says why, to everybody.

Three details are deliberate:

  • The error id comes first. aria-describedby is read in the order the ids are listed, and a field that is wrong should say so before it explains what it wanted. The other way round, the correction arrives after the instruction it corrects.
  • role="alert" is on the message, not the field. A message that appears after a failed submit has to interrupt to be heard. On the field it would re-announce the whole field every time anything about it changed.
  • A field with neither text gets no attribute at all. An aria-describedby pointing at an id that is not on the page is worse than its absence: the reader announces the field and then nothing, and the silence reads as a bug in the reader.

Selects render down a different path in that class and get the same treatment — which is exactly how one of two paths otherwise ends up accessible and the other does not.

Seo

Two strings for the <head>, and the only two pieces of markup there that a crawler reads differently from a browser.

use Pramnos\Html\Seo;

echo Seo::canonicalLink('https://example.com/stations/kosmos');
echo Seo::jsonLd([
    '@context' => 'https://schema.org',
    '@type'    => 'RadioStation',
    'name'     => $station['name'],
    'url'      => $url,
]);

Both are static and neither has state, which is why this one is not constructed like the components above.

They live here rather than on Document because a page does not have to be built through a Document to need them: an application rendering a layout template directly wants the same string, produced the same way, and a second copy of the encoding rules below is how the two drift apart. Document::setCanonical() and Document::addStructuredData() call these.

jsonLd()

The encoding flags are not a preference. Each answers a specific way the block goes wrong:

Flag What it prevents
JSON_HEX_TAG the only injection this format has — a </script> inside any value ends the block early and everything after it is parsed as markup. Structured data is assembled from record titles and descriptions, which is exactly where such a string arrives from.
JSON_HEX_AMP the same reasoning one step out, for consumers that re-parse the block out of an HTML string.
JSON_UNESCAPED_SLASHES every URL becoming https:\/\/… — valid JSON, and unreadable in view-source, which is the only place anybody ever checks it.
JSON_UNESCAPED_UNICODE non-Latin text becoming \uXXXX, with the same cost and no benefit.

Absent is not empty. Omit a key you have no value for rather than emitting "genre": "". An empty string is a claim that the field is blank, which is a different statement from not making the claim, and consumers treat it as one. The method will not do this for you — it cannot tell a deliberate empty string from a lookup that returned nothing, and guessing would be worse than either.

An empty array returns an empty string, and so does data that cannot be encoded — a resource handle, invalid UTF-8, recursion. A page with no structured data is a smaller problem than a page with a broken script block in its head, and the caller gets to carry on rendering.

<link rel="canonical" href="https://example.com/stations/kosmos">

The URL is escaped with ENT_QUOTES | ENT_SUBSTITUTE and not double-encoded, so a URL that already contains an entity survives. An empty or whitespace-only URL returns an empty string rather than href="", which resolves to the current page and tells a crawler the opposite of nothing.

Breadcrumb renders its own BreadcrumbList block through the same encoder — see Breadcrumb.


SiteIdentity

Organization and WebSite, on every page, from settings the installation already has.

Without it the only @type on a page would be BreadcrumbList — the shape of a page's position in a hierarchy that is never named.

Html\SiteIdentity::jsonLd();    // called for you, from Document::seoHeadMarkup()
Html\SiteIdentity::graph();     // the same thing as data, to extend before rendering

It reads sitename, sitelogo and social_profiles from settings, and asserts only what is configured, for the same reason the meta tags do:

  • An unset logo is absent, not "logo": "". An empty field is not a blank — it is a claim that the thing does not exist.
  • A malformed entry in sameAs is dropped rather than published. It does not fail loudly; it quietly stops the organisation matching the entity it names.
  • A SearchAction appears only where Search\Registry has sources. One that leads to a page returning nothing is offered to a reader, tried once, and teaches them the site is broken.

Empty when the site has no configured name, because a name is the one field both objects need to be worth asserting.

Absent is not empty

The rule jsonLd() documents, enforced by the head renderer too.

<meta name="description" content=""> is not the absence of a description. It is a claim that this page has none — and a crawler records the claim rather than falling back to the page text. An application that never set the property said nothing of the sort.

So description, og:title, og:url, og:image and og:description are emitted only when they have a value. og:title and og:site_name still fall back to the page title and the sitename setting, because those are values the framework has, not claims it invents.

Two more rules in the same renderer. viewport is emitted by the document, not left to the theme, so a theme that omits it cannot produce a page Google labels not mobile-friendly with no signal that anything is missing. And twitter:card is emitted only when there is an image for it: X reads the OpenGraph tags for everything else, and a summary_large_image card promising an image the page does not have renders worse than no card.

No xmlns:og or xmlns:fb attributes are emitted — RDFa declarations that nothing parses since Facebook moved to <meta property>.

See also