Skip to content

HTML helpers

src/Presentation/Helpers/Html.php holds eight plain functions in the global namespace: an asset queue, a dropdown builder, three escaping functions and two attribute helpers. There is no class - the file shares a name with Output\Html and has nothing to do with it.

The file opens with // TODO: Needs revision. Read the rest of this page with that in mind.

composer.json has no files autoload section, so these functions do not exist until something requires the file. Load it through Load::helper(), naming the module and the reserved staticphp project:

<?php
use StaticPHP\Core\Models\Load;
Load::helper(['Html'], 'Presentation', 'staticphp');

Or from the bootstrap configuration, which is the same call written as a config entry:

<?php
$config['autoload_helpers'][] = 'staticphp/Presentation/Html';

Entries are slash separated and read as project/module/name, which the bootstrap splits back into the three arguments of Load::helper(). The last segment is the file name - Html, not the Helpers directory it sits in.

<?php
function html_css(): void;
function html_js(): void;
function html_dropdown(
iterable $items,
mixed $selected = null,
?array $addons = null,
array|false $add_empty = false,
?string $as_value = null,
?string $as_text = null,
bool $grouped = false
): string;
function html_escape(mixed $value): string;
function html_escape_input(mixed $value): string;
function html_escape_textarea(mixed $value): string;
function html_set_selected(mixed &$current, mixed $needle): ?string;
function html_set_checked(mixed &$current, mixed $needle): ?string;

html_css() and html_js() are the same function over different markup. Called with arguments they append to a static array; called with none they echo the queue.

<?php
// In a controller or a partial:
html_css('/css/app.css');
html_css('i:body { margin: 0 }');
html_js('/js/app.js');
html_js('i:console.info("ready");');
// In the layout:
html_css();
html_js();
<link rel="stylesheet" type="text/css" href="/css/app.css" />
<style>body { margin: 0 }</style><script type="text/javascript" src="/js/app.js"></script>
<script type="text/javascript">console.info("ready");</script>

A value prefixed with i: is emitted inline - <style> or <script type="text/javascript"> - with the prefix stripped. Everything else becomes a <link rel="stylesheet"> or a <script src>.

Neither the paths nor the inline bodies are escaped or validated. They are markup you are writing, not data.

If the constant HTML_CSS_VERSION or HTML_JS_VERSION is defined, its value is appended to every non-inline url with a ? or, when the url already has a query string, a &. The constant holds the whole query fragment, not just a number:

<?php
define('HTML_CSS_VERSION', 'v=3');
define('HTML_JS_VERSION', 'v=3');
<link rel="stylesheet" type="text/css" href="/css/app.css?v=3" />
<link rel="stylesheet" type="text/css" href="/css/print.css?media=print&v=3" />
-- second call with no arguments:
<link rel="stylesheet" type="text/css" href="/css/app.css?v=3" />
<link rel="stylesheet" type="text/css" href="/css/print.css?media=print&v=3" />
<script type="text/javascript" src="/js/app.js?v=3"></script>

Builds a <select> from an array. Every value and every label goes through html_escape(); the $addons strings do not.

Parameter Purpose
$items [value => label]. A label that is itself an array becomes an <optgroup>
$selected The selected value, or an array of them
$addons [value => 'attributes'], plus the special key '#' for the <select> tag
$add_empty A one entry array, [value => label], prepended as the first option
$as_value Property name to read the value from when $items holds objects
$as_text Property name to read the label from
$grouped Internal - set on the recursive call, omits the <select> wrapper
<?php
echo html_dropdown(['lv' => 'Latvia', 'ee' => 'Estonia'], 'ee');
echo html_dropdown(
['lv' => 'Latvia', 'ee' => 'Estonia'],
null,
['#' => 'name="country" id="country"'],
['' => 'Choose one']
);
echo html_dropdown(
['Baltics' => ['lv' => 'Latvia', 'ee' => 'Estonia'], 'Nordics' => ['fi' => 'Finland']],
'fi'
);
echo html_dropdown(['<img src=x onerror=alert(1)>' => '<script>alert(1)</script>']);
<select><option value="lv">Latvia</option><option value="ee" selected="selected">Estonia</option></select>
<select name="country" id="country"><option value="" selected="selected">Choose one</option><option value="lv">Latvia</option><option value="ee">Estonia</option></select>
<select><optgroup label="Baltics"><option value="lv">Latvia</option><option value="ee">Estonia</option></optgroup><optgroup label="Nordics"><option value="fi" selected="selected">Finland</option></optgroup></select>
<select><option value="&lt;img src=x onerror=alert(1)&gt;">&lt;script&gt;alert(1)&lt;/script&gt;</option></select>

Four things worth noticing in that output:

  • Grouped options nest by recursion, and the group key is the <optgroup label>. Note that $add_empty is passed as false on the recursive call, so the empty option is never repeated inside a group.
  • The selection test is is_array($selected) && in_array($value, $selected) || $selected == $value, a loose comparison. In the second example $selected is null and the empty option’s value is '', and null == '' is true - which is why “Choose one” came back selected. That is usually what you want for an empty default. The same looseness makes '1' match an option keyed '01', since PHP compares two numeric strings numerically.
  • $as_value and $as_text read object properties ($text->{$as_value}), not array keys. An array of arrays needs converting first.
  • Both the key and the label are escaped, so hostile data in either is inert.
<?php
function html_escape(mixed $value): string
{
if ($value instanceof Stringable) {
$value = (string) $value;
} elseif (is_scalar($value) === false) {
$value = '';
}
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

ENT_QUOTES covers both quote characters, so one function is correct for html text and for either kind of quoted attribute. ENT_SUBSTITUTE replaces invalid UTF-8 rather than returning an empty string.

The normalisation in front of it is a whitelist, not a blacklist: a scalar is cast, a Stringable gets its __toString() and is then escaped like any other string, and everything else - an array, a plain object, null - becomes ''. So a value object that knows how to render itself renders, while an array that reached a template by mistake produces nothing rather than the word Array.

html_escape_input() and html_escape_textarea() are one-line aliases that call it. They used to escape less, and the source keeps the note explaining why that was wrong:

Escaping only the double quote left <, ' and & through, which is unsafe in every context except a double quoted attribute - html_escape covers all of them.

Keep using whichever name reads better at the call site; they behave identically.

html_set_selected() and html_set_checked() return the attribute string or null, ready to interpolate. Both take the current value by reference - so it has to be a variable, not an expression - and both accept an array, matching either a key or a value in it.

Run with $scalar = 'ee', $array = ['ee', 'lv'], $keyed = ['ee' => 'Estonia'] and $stringable an anonymous class whose __toString() returns a"b:

html_escape('<a href="x">&</a>') -> '&lt;a href=&quot;x&quot;&gt;&amp;&lt;/a&gt;'
html_escape(['a']) -> ''
html_escape(null) -> ''
html_escape($stringable) -> 'a&quot;b'
html_escape(new stdClass) -> ''
html_escape_input('a"b') -> 'a&quot;b'
html_escape_textarea('a"b> < />') -> 'a&quot;b&gt; &lt; /&gt;'
html_set_selected($scalar, 'ee') -> ' selected="selected"'
html_set_selected($scalar, 'lv') -> NULL
html_set_selected($array, 'ee') -> ' selected="selected"'
html_set_selected($keyed, 'ee') -> ' selected="selected"'
html_set_checked($array, 'lv') -> ' checked="checked"'
html_set_checked($array, 'fi') -> NULL
<?php
$selectedIds = [3, 7];
?>
<option value="3"<?= html_set_selected($selectedIds, 3) ?>>Three</option>

The scalar comparison is ==. PHP 8 made that much safer for strings - 0 == 'abc' is false now - but two numeric strings are still compared numerically, so an id of '1' matches an option keyed '01', and null still equals ''.

See also Fv::setSelected() and Fv::setChecked(), which answer the same question against the submitted request rather than against a variable you pass in.

Output\Html does not use any of these functions - it has its own static escape() with the same flags, and its own inputValue(). The two are independent; loading this file is not needed to render a table.