DomBuilder — the server-side DOM
SepoEngine\App\Dom\DomBuilder builds HTML strings from PHP arrays. It is
the server's answer to document.createElement — same mental model, no tree.
$dom = new DomBuilder();
echo $dom->createDiv(['classList' => ['card'], 'text' => 'Hello']);
// <div class="card">Hello</div>
There is no node object, no reparenting, no querySelector. createElement()
returns a finished string, and you compose by concatenation. That is the whole
design: cheap, no DOM extension loaded, and the output is exactly what you
wrote. The cost is that once a string is built you cannot go back and change
it — decide before you render.
One builder per page. index.php creates it, Registry.php shares it into
every content module, templates receive it as $b. Don't new DomBuilder()
in a template unless you're guarding against it being absent.
1. The createElement pipeline
Everything routes through one method:
public function createElement(string $tag, array $props = []): string
Five steps, in order:
1. Component or tag? If $tag is a class that implements DomComponent,
it's instantiated and render($props, $this) is called instead. The
is_subclass_of($tag, DomComponent::class) check is deliberate — an untrusted
$tag can't be used to instantiate arbitrary classes.
2. Tag validation. /^[a-zA-Z][a-zA-Z0-9]*$/, then lowercased. Anything
else throws InvalidArgumentException. Note the pattern has no hyphen —
custom elements (<my-widget>) are rejected. Use a div with a class or a
DomComponent.
3. Attributes — see §2.
4. Void elements. area base br col embed hr img input link meta source
track wbr return <tag attrs> with no closing tag. Any content you passed is
silently dropped.
5. Content — html or text, then children. See §3.
2. Attribute props
Rendered in this fixed order, regardless of the order in your array:
| prop | shape | renders |
|---|---|---|
id | string | id="…" |
classList | array | class="a b c" (empties filtered) |
style | array | style="prop: val; …", keys camelCase → kebab |
| mapped attrs | string | verbatim, escaped |
| boolean attrs | truthy | bare attribute |
data | array | data-<key>="…" |
attrs | array | escape hatch for anything else |
Mapped attributes
Only these pass through by name:
type value placeholder href src name for action method target rel alt title
role min max step maxlength pattern autocomplete colspan rowspan scope
Anything not on that list — aria-*, tabindex, contenteditable,
download, srcset — must go through attrs.
Boolean attributes
required checked selected disabled readonly multiple autofocus autoplay
controls loop muted novalidate hidden open
Truthy renders the bare attribute; falsy omits it. 'disabled' => false is
correctly absent, not disabled="false".
style
Array only. Keys are kebab-cased (backgroundColor → background-color),
values escaped:
'style' => ['display' => 'flex', 'gap' => '8px', 'fontSize' => '0.9rem']
// style="display: flex; gap: 8px; font-size: 0.9rem"
Vendor prefixes: write them literally ('-webkit-line-clamp') — the kebab
converter only splits on uppercase, so a leading dash survives but
WebkitLineClamp would lose it.
data
Keys are stripped to [a-zA-Z0-9-], values cast to string:
'data' => ['id' => $uid, 'id-field' => 'sp_user_uid']
// data-id="…" data-id-field="sp_user_uid"
Two rules:
- Use kebab-case keys.
'idField'rendersdata-idField, which HTML lowercases todata-idfield, so the browser exposes it asdataset.idfield— notdataset.idField. Kebab is whatdataset.idFieldactually needs. - Encode structures yourself. Values are cast with
(string), so arrays break.'data' => ['row' => json_encode($row, JSON_UNESCAPED_UNICODE)].
attrs
'attrs' => [
'aria-label' => 'Close',
'aria-hidden' => true, // bare attribute
'tabindex' => false, // omitted (also null)
]
3. Content: text, html, children
'text' => 'escaped, always'
'html' => 'raw, trusted — YOUR responsibility'
'children' => [ ['tag' => 'span', 'text' => '…'], '<hr>' ]
html wins over text. Pass both and text is ignored.
children is appended after whichever of the two ran. A child that's a
string is concatenated raw; a child that's an array is recursed through
createElement($child['tag'] ?? 'div', $child) — so children nest arbitrarily
deep and take all the same props.
$b->createDiv([
'classList' => ['row'],
'children' => [
['tag' => 'span', 'text' => $label, 'style' => ['color' => '#6b7280']],
['tag' => 'strong', 'text' => $value],
],
]);
The trust boundary
This is the single most important thing about this class:
| prop | escaping |
|---|---|
text | escaped via htmlspecialchars(ENT_QUOTES, UTF-8) |
id, classList, style, mapped attrs, data, attrs | escaped |
html | raw. Not touched. An injection point. |
columnTemplates output | raw. The template escapes its own values. |
So the rule in every template:
$e = static fn($v): string => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
'template' => static fn($v): string => '<strong>' . $e($v) . '</strong>',
Prefer text whenever you're not composing markup. Reach for html only when
you're concatenating output that DomBuilder itself produced.
4. Helper wrappers
createDiv createSpan createButton createAnchor createMain
createInput createLabel createForm — thin aliases for
createElement('div', …) etc.
createForm is the one with behaviour: when the method isn't GET and
RouterSettings::bool('csrf_protection') is on, it prepends a hidden
_csrf input from Csrf::token().
$b->createForm([
'method' => 'POST',
'action' => Url::to('/admin/profile/change-password'),
'html' => $fields, // _csrf is prepended to this
]);
Because it's prepended to html, and html renders before children, the
token is always the first field regardless of which you used.
5. Components
A component is a class implementing DomComponent:
namespace SepoEngine\App\Components;
use SepoEngine\App\Dom\DomBuilder;
use SepoEngine\App\Dom\DomComponent;
class Badge implements DomComponent
{
public function render(array $props, DomBuilder $b): string
{
$tone = (string)($props['tone'] ?? 'neutral');
$colors = ['ok' => '#16a34a', 'warn' => '#d97706', 'bad' => '#dc2626'];
return $b->createSpan([
'classList' => ['sp-badge', 'is-' . $tone],
'text' => (string)($props['text'] ?? ''),
'style' => [
'color' => $colors[$tone] ?? '#6b7280',
'fontWeight' => '600',
'fontSize' => '0.8rem',
],
]);
}
}
Called by class name — the builder is passed in, so components compose:
$b->createElement(Badge::class, ['text' => 'Active', 'tone' => 'ok']);
Existing components in the codebase: Card (header/body), Alert
(type/text), InputField (label/name/type/value/required),
Button (text/variant/type/data), UserProfile, Breadcrumbs,
WidgetGrid, DashboardLayout.
Components that need CSS expose a static styles() returning a <style>
block, echoed once in <head> (see index.php) — never per instance.
6. createTable — the big one
One call produces a styled, sortable, paginated table wired to the client's CRUD action system.
echo $b->createTable([
'data' => $rows,
'headers' => ['sp_user_email' => 'Email', 'sp_user_status' => 'Status'],
]);
Parameters
Data & display
| param | meaning |
|---|---|
data | rows (arrays or objects — cast to array) |
headers | ['col' => 'Label']. Omitted → keys of the first row |
columnTemplates | ['col' => fn($val, $row): string], raw HTML |
id | table id, default dataTable |
style | inline style overrides on <table> |
emptyMessage | shown when data is empty |
Pagination
| param | meaning |
|---|---|
perPage | rows per page, default 10 |
currentPage | 1-indexed, default 1 |
baseUrl | link base — with a {page} placeholder, or the page is appended as a query param |
pageParam | query key, default page |
Sorting
| param | meaning |
|---|---|
sortable | all columns sortable |
sortableColumns | explicit list; overrides sortable |
currentOrderBy / currentOrder | drives the header UI only — you sort the data |
orderByParam / orderParam | query keys, default orderby / order |
CRUD wiring
| param | becomes |
|---|---|
app | data-app on every <tr> |
idField | data-id-field, and the id source |
rowIdKey | column supplying data-id (defaults to idField) |
schema | JSON-encoded onto every <tr> as data-schema |
rowActions | per-row buttons; an Actions column is added automatically |
What it emits
<div class="dom-table-responsive">
<table id="…" class="dom-table">
<thead><tr><th scope="col" aria-sort="ascending"><a class="dom-th-sort" href="…">Email ▴</a></th>…</tr></thead>
<tbody>
<tr data-row='{"…"}' data-id="…" data-app="UsersManagerApp"
data-id-field="sp_user_uid" data-schema='{"title":…,"fields":…}'>
<td data-label="sp_user_email" data-field="sp_user_email">…</td>
<td data-label="Actions"><button data-method="update" …>Edit</button></td>
</tr>
</tbody>
</table>
</div>
<div class="dom-pagination-wrapper">…</div>
Every <tr> carries the full record (data-row) plus the form schema, so the
client's data-method delegate walks outward from a clicked button, finds its
context, and builds the modal — no per-page modal config, no server-rendered
modal markup.
rowActions
'rowActions' => [
['method' => 'update', 'label' => 'Edit', 'variant' => 'primary'],
[
'method' => 'delete',
'label' => 'Remove',
'variant' => 'danger', // primary | secondary | danger
'confirm' => 'Remove this user?',
'showIf' => fn(array $row): bool => $row['role'] !== 'admin',
'class' => 'extra-css-class',
],
],
delete auto-gets data-confirm="Permanently delete this record?" unless you
override it. showIf is evaluated per row — a hidden button isn't rendered at
all, so it can't be re-enabled in devtools (though the server must still
enforce the rule; see the resource system's allow / baseWhere).
showIf conditions
A closure works, but the declarative form covers most cases and mirrors TableApp's where-builder, so the same rule reads the same in a query and in a template:
'showIf' => ['sp_api_active' => 'active'] // equals
'showIf' => ['sp_api_active' => ['active', 'trial']] // one of (IN)
'showIf' => ['sp_api_active !=' => 'revoked'] // not equal
'showIf' => ['sp_user_level >=' => 5] // ordering
'showIf' => ['sp_user_email LIKE' => '%@corp.com'] // wildcard
'showIf' => ['sp_a' => 1, 'sp_b' => 2] // AND
'showIf' => ['OR' => ['sp_a' => 1, 'sp_b' => 2]] // ANY
'showIf' => ['NOT' => ['sp_a' => 1]] // negate
'showIf' => fn(array $row): bool => … // still fine
Comparison rules, because these values arrive from a database as strings:
- equality compares as strings, so
1matches"1"but""never matches"0" - a
nullexpectation means is null; comparing against''also matches null, since an absent column and an empty one are the same thing to a template - ordering compares numerically only when both sides look numeric, otherwise as strings — so ISO dates still order correctly
- a missing column is null, so a rule on a column the row doesn't carry is false rather than an error
- a
boolexpectation reads the value the way atinyintmeans it:['sp_active' => true]matches1/"1"but not0/"0"/""
renderIf — conditional content
The same matcher, exposed for use inside templates. The server twin of the
client DomBuilder's renderIf():
'template' => fn($v, $row) => $b->renderIf(
$row,
['sp_api_active' => 'active'],
'<span style="color:#16a34a;">Live</span>',
'<span style="color:#9ca3af;">Disabled</span>',
),
Either branch may be a callable receiving ($row, $builder) — use that when a
branch is expensive, so the unused side is never built. Both branches are raw
HTML and own their escaping, same rule as columnTemplates.
showIf($row, $condition) is public too, when you just need the boolean.
Sorting links
sortHref() handles the case that used to break: when a baseUrl already has
orderby/order in its query string, a naive append leaves the old values
in place and clicking a header appears to do nothing. It parses the URL, splits
off any fragment, overrides the sort keys, and resets the page — so clicking
always re-sorts.
Without a baseUrl it falls back to buildUrl(), which reads
$_SERVER['PHP_SELF'] and $_SERVER['QUERY_STRING']. Behind a rewrite that
can produce /index.php?… instead of your clean route — pass an explicit
baseUrl on any routed page.
Styling
DomBuilder::tableStyles() returns a <style> block for .dom-table,
.dom-pagination, .dom-th-sort. Include it once per page, in <head> —
index.php already does. If you have a design system, copy those class names
into your own stylesheet and drop the call instead.
7. The client contract
The data attributes DomBuilder emits are the entire server→client interface:
| attribute | on | meaning | |||
|---|---|---|---|---|---|
data-app | record | which backend app owns it | |||
data-id | record | primary key value | |||
data-id-field | record | name of the primary key column | |||
data-schema | record | JSON form definition for the modal | |||
data-row | record | the full record, for prefill | |||
data-method | button | update \ | delete \ | add \ | custom |
data-confirm | button | confirmation prompt | |||
data-field | cell/value | which column this node displays |
Because it's attributes and not markup, a <tr> from createTable and a card
from AppRenderer are interchangeable to the client. Follow the same contract
in a hand-written template and it works with zero JS changes.
8. Gotchas
!empty() drops the string "0". id, classList, style, html,
data and attrs are all guarded with !empty(), so 'id' => '0' and
'html' => '0' vanish. text uses isset() && !== '', so 'text' => '0'
renders fine.
Mapped attributes skip empty strings. 'value' => '' renders no value
attribute at all — which matters when you're trying to clear an input.
'value' => '0' is fine.
Hyphens are invalid in tag names. No custom elements. Also no
<svg:path>-style prefixes.
Void tags eat their content. createInput(['text' => 'hi']) renders
<input>; the text is gone with no warning.
Unknown attributes need attrs. If an attribute you expect isn't
appearing, check the mapped list in §2 first — that's almost always why.
data values must be scalar. JSON-encode structures yourself.
Strings are final. No post-render mutation. Build the pieces you need before you concatenate.
createTable paginates the array you hand it. For true server-side paging
you must give it the full set, or pin perPage to the row count and render
your own pager (which is what AppRenderer does).
Empty data short-circuits. You get only the .dom-table-empty div — no
<thead>, so headers don't render for an empty result.
9. Recipes
Status badge cell
$e = static fn($v): string => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
'columnTemplates' => [
'status' => static function ($v) use ($e): string {
$color = $v === 'active' ? '#16a34a' : '#dc2626';
return '<span style="color:' . $color . ';font-weight:600;">' . $e(ucfirst((string)$v)) . '</span>';
},
],
Link cell — build it with the builder so escaping is handled:
'email' => static fn($v, $row) => $b->createAnchor([
'href' => 'mailto:' . (string)$v,
'text' => (string)$v,
]),
Label/value row (the shape used across the profile page):
$row = static fn(string $label, string $valueHtml): string => $b->createDiv([
'style' => ['display' => 'flex', 'justifyContent' => 'space-between', 'padding' => '8px 0'],
'html' => $b->createSpan(['text' => $label, 'style' => ['color' => '#6b7280']])
. $b->createSpan(['html' => $valueHtml]),
]);
A form that posts JSON — CSRF is automatic on non-GET:
$b->createForm([
'id' => 'thing-form',
'method' => 'POST',
'action' => Url::to('/admin/things/save'),
'html' => $b->createElement(InputField::class, ['label' => 'Name', 'name' => 'name', 'required' => true])
. $b->createElement(Button::class, ['text' => 'Save', 'type' => 'submit', 'variant' => 'primary']),
]);
A card the client can edit — the contract by hand:
$b->createDiv([
'classList' => ['sp-res-item'],
'data' => [
'app' => 'UsersManagerApp',
'id' => (string)$row['sp_user_uid'],
'id-field' => 'sp_user_uid',
'schema' => (string)json_encode($schema, JSON_UNESCAPED_UNICODE),
'row' => (string)json_encode($row, JSON_UNESCAPED_UNICODE),
],
'html' => $b->createDiv(['data' => ['field' => 'sp_user_email'], 'text' => $row['sp_user_email']])
. $b->createButton(['type' => 'button', 'text' => 'Edit', 'data' => ['method' => 'update']]),
]);
That last one is exactly what AppRenderer::itemNode() generates — which is
why you rarely need to write it. See RESOURCE-SYSTEM.md.