Skip to content
Article Documentation
☀️ Light Clean and bright
📜 Sepia Warm and vintage
🌤️ Light Gray Subtle and neutral
🔵 Light Blue Calm and serene
🌙 Dark Easy on the eyes
🌑 Dark Gray Deep and modern
🔷 Dark Blue Professional and sleek
🌿 Forest Calm and natural

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. Contenthtml or text, then children. See §3.


2. Attribute props

Rendered in this fixed order, regardless of the order in your array:

propshaperenders
idstringid="…"
classListarrayclass="a b c" (empties filtered)
stylearraystyle="prop: val; …", keys camelCase → kebab
mapped attrsstringverbatim, escaped
boolean attrstruthybare attribute
dataarraydata-<key>="…"
attrsarrayescape 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 (backgroundColorbackground-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' renders data-idField, which HTML lowercases to data-idfield, so the browser exposes it as dataset.idfield — not dataset.idField. Kebab is what dataset.idField actually 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:

propescaping
textescaped via htmlspecialchars(ENT_QUOTES, UTF-8)
id, classList, style, mapped attrs, data, attrsescaped
htmlraw. Not touched. An injection point.
columnTemplates outputraw. 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

parammeaning
datarows (arrays or objects — cast to array)
headers['col' => 'Label']. Omitted → keys of the first row
columnTemplates['col' => fn($val, $row): string], raw HTML
idtable id, default dataTable
styleinline style overrides on <table>
emptyMessageshown when data is empty

Pagination

parammeaning
perPagerows per page, default 10
currentPage1-indexed, default 1
baseUrllink base — with a {page} placeholder, or the page is appended as a query param
pageParamquery key, default page

Sorting

parammeaning
sortableall columns sortable
sortableColumnsexplicit list; overrides sortable
currentOrderBy / currentOrderdrives the header UI only — you sort the data
orderByParam / orderParamquery keys, default orderby / order

CRUD wiring

parambecomes
appdata-app on every <tr>
idFielddata-id-field, and the id source
rowIdKeycolumn supplying data-id (defaults to idField)
schemaJSON-encoded onto every <tr> as data-schema
rowActionsper-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 1 matches "1" but "" never matches "0"
  • a null expectation 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 bool expectation reads the value the way a tinyint means it: ['sp_active' => true] matches 1/"1" but not 0/"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.

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:

attributeonmeaning
data-apprecordwhich backend app owns it
data-idrecordprimary key value
data-id-fieldrecordname of the primary key column
data-schemarecordJSON form definition for the modal
data-rowrecordthe full record, for prefill
data-methodbuttonupdate \delete \add \custom
data-confirmbuttonconfirmation prompt
data-fieldcell/valuewhich 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.