From 3a2ca7393995cabcec2b8423055ac2cb3f3d7fd6 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Wed, 11 Nov 2026 00:45:32 -0500 Subject: [PATCH 01/16] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7dfbc3a..17f630c 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,11 @@ changed without updating templates or other code. ```php use Eightfold\HTMLBuilder\Document; use Eightfold\HTMLBuilder\Element; +use Eightfold\HTMLBuilder\EmptyElement; Document::create('title') ->head( - Element::link() - ->omitEndTag()->props('rel stylesheet', 'href style.css'), + EmptyElement::link('rel stylesheet', 'href style.css'), Element::script()->props('src script.js') )->body( Element::p('paragraph content') From a4fc4a4430bb83b12124cfbcdfd57f421aa20443 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Wed, 11 Nov 2026 12:50:43 -0500 Subject: [PATCH 02/16] docs: Update README for 5.1 release --- README.md | 288 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 219 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 17f630c..2f57f9c 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,129 @@ # 8fold HTML Builder for PHP -HTML Builder extends and is a web-specific implementation of -[8fold XML Builder](https://github.com/8fold/php-xml-builder); similar to how -HTML extends and is a specific implementation of XML. +HTML Builder is a web-specific string compiler. + +Warning: Users of this library are responsible for sanitizing content. ## Installation `composer require 8fold/php-html-builder` -## Usage (basic) +## Usage -Warning: Users of this library are responsible for sanitizing content. +HTML Builder quickly outputs HTML strings with consistent +ordering of attributes for elements. -By default, HTML Builder can quickly output an HTML string with consistent -ordering of attributes for elements. The default order can be found in the -[`Element`](https://github.com/8fold/php-html-builder/blob/main/src/Element.php) -class, which can be overridden, if you prefer a different ordering. This helps -create and maintain a consistent HTML pattern. Further, this pattern can be -changed without updating templates or other code. +The default order is in the [`Element`](https://github.com/8fold/php-html-builder/blob/main/src/Element.php) +class. If you prefer a different ordering, you can extend and override the ‘Element’ class and ordering constant. + +### `Element` + +The fundamental building block for all output. ```php -use Eightfold\HTMLBuilder\Document; -use Eightfold\HTMLBuilder\Element; -use Eightfold\HTMLBuilder\EmptyElement; +use Eightfold\HtmlBuilder\Element; + +// non-empty element, using `create()` +Element::create('p', 'Hello, World!')->props('id intro'); + +// output +

Hello, World!

+ +// non-empty element, magic method +Element::p('Hello, World!')->props('id intro'); + +// output +

Hello, World!

+ +// empty element +Element::img()->omitEndTag()->props('src /path/to/image.png'); + +// output + +``` + +### `EmptyElement` + +The [`EmptyElement`](https://github.com/8fold/php-html-builder/blob/main/src/EmptyElement.php) class was introduced in the 5.1 release and is designed to mirror the input shorthand of HTML those elements compared to using the `Element` class to do the same. + +```php +use Eightfold\HtmlBuilder\Element; +use Eightfold\HtmlBuilder\EmptyElement; + +// empty element use Element +Element::img()->omitEndTag()->props('src /path/to/image.png'); + +// output + + +// using EmptyElement +EmptyElement::img('src /path/to/image.png'); + +// output same as previous +``` + +### `Document` + +The [`Document`](https://github.com/8fold/php-html-builder/blob/main/src/Document.php) class gives you to generate a complete HTML document entirely in PHP. + +``` +use Eightfold\HtmlBuilder\Document; +use Eightfold\HtmlBuilder\Element; +use Eightfold\HtmlBuilder\EmptyElement; Document::create('title') ->head( EmptyElement::link('rel stylesheet', 'href style.css'), Element::script()->props('src script.js') )->body( - Element::p('paragraph content') - ) -``` + Element::p('Hello, World!') + ); + +// Output (compressed): + +```html + +title

Hello, World!

-Output: +// Output (human-readable): ```html -title

paragraph content

+ + + title + + + + + +

paragraph content

+ + ``` -Output (formatted): +Further examples are in the [`tests`](https://github.com/8fold/php-html-builder/tree/main/tests) folder with an example extension. + +### `Comment` + +There’s even a [`Comment`](https://github.com/8fold/php-html-builder/blob/main/src/Comment.php) element for adding inline comments to the HTML. + +``` +use Eightfold\HtmlBuilder\Document; +use Eightfold\HtmlBuilder\Element; +use Eightfold\HtmlBuilder\EmptyElement; +use Eightfold\HtmlBuilder\Comment; + +Document::create('title') + ->head( + EmptyElement::link('rel stylesheet', 'href style.css'), + Element::script()->props('src script.js') + )->body( + Comment::create('comment-based-injection-point'), + Element::p('Hello, World!') + ); + +// Output (human-readable): ```html @@ -52,34 +135,138 @@ Output (formatted): +

paragraph content

``` -Further examples can be found in the -[`tests`](https://github.com/8fold/php-html-builder/tree/main/tests) folder with -an example extension. +### Properties -A note on properties: If a property is not in the ordered list, and the value of -the property is the same as the name of the property, HTML Builder will presume -it's a boolean value and will truncate the definition in the HTML output. +`Element` arranges all properties that are not in the ordered list alphabetically after the ordered properties. + +Properties with same name and value are presumed to be boolean properties (`required`), and will placed at the end of the element. ```php -Element::input()->omitEndTag()->props("required required"); -``` +use Eightfold\HtmlBuilder\EmptyElement; -Output: +EmptyElement::input('required required'); + +// Output: ```html ``` +You may also add properties individually to facilitate dynamic construction. + +```php +use Eightfold\HtmlBuild\Element; + +$callout = Element::div('Information in the callout.') + ->props('class callout', 'aria-role alert'); + +if ($isWarning) { + $callout = $callout->prop('is warning'); +} + +// output (human-readable): $isWarning = false +
+ Information in the callout. +
+ +// output (human-readable): $isWarning = true +
+ Information in the callout. +
+``` + +## Extending + +Each HTML Builder component accepts string literals and classes that implement the PHP-native `Stringable` interface. Further, HTML Build provides the [`PropertyInterface`](https://github.com/8fold/php-html-builder/blob/main/src/PropertyInterface.php), which extends the `Stringable` interface. Therefore, HTML Builder allows developers to create custom components and APIs without extending HTML Builder’s base components. + +## Performance + +The `/comparisons` folder can be used to run performance tests locally to compare the same output using: + +1. straight HTML, +2. standard PHP, +3. PHP HTML Builder alongside standard PHP (the Element class), +4. PHP HTML Builder only (the Document class), and +5. PHP HTML Builder only using PSR-4 autoloading. + +These results are from August 22nd, 2026 (running on PHP 8.5): + +|Configuration |Average time in milliseconds |Median time in milliseconds |Transfer size in kilobytes | +|:---|---:|---:|---:| +|1 |0.0022 |0.0020 |2.7 | +|2 |0.0151 |0.0125 |1.99 | +|3 |0.4314 |0.3025 |1.82 | +|4 |1.4446 |0.3672 |1.74 | +|5 |0.9059 |0.6750 |1.74 | + +These results are from February 26th, 2024: + +|Configuration |Average time in milliseconds |Median time in milliseconds |Transfer size in kilobytes | +|:---|---:|---:|---:| +|1 |0.0399683 |0.0017415 |2.7 | +|2 |0.0953241 |0.0160480 |1.99 | +|3 |1.6026525 |1.4603320 |1.82 | +|4 |1.7849769 |1.7918970 |1.74 | +|5 |8.9957415 |8.8755520 |1.74 | + +Things worth noting (as rough interpretations): + +1. PHP being used as a template engine has a negligible impact on speed. +2. Using `require_once` without PSR-4 autoloading decreases speed by roughly 1.5 milliseconds; however, once the code is made available, very little additional cost seems to be present. +3. Using PSR-4 decreases speed by roughly 6 milliseconds; however, ensures everything is present and available. +4. Using PHP HTML Builder Document and Element resulted in 1 kilobyte reduction in packet size. (Increased processing time in milliseconds most likely offset by decreased download time.) + +## Migrating from 5.0 to 5.1 + +While not a major release, it could have been, but the potential breaking change should only impact some static analysis tools - not runtimes - and has at least two solutions that have existed for multiple versions and many of us already have in place. + +Otherwise, this is a quality of life improvement release. + +### `EmptyElement` + +The first quality of life improvement is introducing an explicit way to create empty elements. + +The examples in the `EmptyElement` section above demonstrate the improvement. + +### Static analysis and element names + +- We expanded style and static analysis coverage to the `src/`, `tests/`, and `bin/` directories. +- We increased the [PHPStan](https://phpstan.org) level for HTML Builder to 10. +- We removed all [`ignoreErrors`](https://phpstan.org/user-guide/ignoring-errors#ignoring-in-configuration-file) from the PHPStan configuration. + +The valid PHPStan error that’s been with HTML Builder from the beginning was “Call to undefined static method”. + +The error could be silenced, and limited to only ignoring HTML Builder, in the PHPStan configuration (the first workaround): + +``` + ignoreErrors: + - '#Call to an undefined static method Eightfold\\HtmlBuilder\\Element::(.*)#' +``` + +We introduced the ‘create()’ named constructor to help those who did not want to ignore the error in their configurations as well (the second workaround): + +``` +Element::create('p', 'Hello, World!'); +``` + +The 5.1 release introduces a docblock, listing all HTML element names in the living specification; derived from sources maintained by the W3C organization and community. This means, as long as the element being created by the `__callStatic` magic method named constructor is in the docblock list, PHPStan will *not* throw an error. Further, if the element name is not in the list, PHPStan *will* throw an error, which may be informative to users. + +``` +// Works with PHPStan level 10 without extra setup +Element::p('Hello, World!'); +``` + +Note: You can still create custom element names while using static analysis by either calling the `create()` method (recommended) or ignoring via configuration. Using `create()` may enhance readability by distinguishing custom element names from those in the HTML spec. + ## Migrating from 4.x to 5.0 -This section highlights the changes to check when upgrading from 4.4.0. It -links to the [form control guidance](#form-control-guidance) for details that -apply to the new and existing APIs. +This section highlights the changes to check when upgrading from 4.4.0. It links to the [form control guidance](#form-control-guidance) for details that apply to the new and existing APIs. ### Required migration checks: markup and property targets @@ -305,43 +492,6 @@ elements. `wrapperProps()` remains a forwarding alias throughout 5.x and is marked with PHP's native `#[Deprecated]` attribute (supported since PHP 8.4). Calling it emits `E_USER_DEPRECATED`; removal is deferred until at least 6.0. -## Performance - -The `/comparisons` folder can be used to run performance tests locally to compare the same output using: - -1. straight HTML, -2. standard PHP, -3. PHP HTML Builder alongside standard PHP (the Element class), -4. PHP HTML Builder only (the Document class), and -5. PHP HTML Builder only using PSR-4 autoloading. - -These results are from August 22nd, 2026 (running on PHP 8.5): - -|Configuration |Average time in milliseconds |Median time in milliseconds |Transfer size in kilobytes | -|:---|---:|---:|---:| -|1 |0.0022 |0.0020 |2.7 | -|2 |0.0151 |0.0125 |1.99 | -|3 |0.4314 |0.3025 |1.82 | -|4 |1.4446 |0.3672 |1.74 | -|5 |0.9059 |0.6750 |1.74 | - -These results are from February 26th, 2024: - -|Configuration |Average time in milliseconds |Median time in milliseconds |Transfer size in kilobytes | -|:---|---:|---:|---:| -|1 |0.0399683 |0.0017415 |2.7 | -|2 |0.0953241 |0.0160480 |1.99 | -|3 |1.6026525 |1.4603320 |1.82 | -|4 |1.7849769 |1.7918970 |1.74 | -|5 |8.9957415 |8.8755520 |1.74 | - -Things worth noting (as rough interpretations): - -1. PHP being used as a template engine has a negligible impact on speed. -2. Using `require_once` without PSR-4 autoloading decreases speed by roughly 1.5 milliseconds; however, once the code is made available, very little additional cost seems to be present. -3. Using PSR-4 decreases speed by roughly 6 milliseconds; however, ensures everything is present and available. -4. Using PHP HTML Builder Document and Element resulted in 1 kilobyte reduction in packet size. (Increased processing time in milliseconds most likely offset by decreased download time.) - ## Details See [8fold XML Builder](https://github.com/8fold/php-xml-builder#readme) for more From abbafd87525630e743b809d53fa3f49b5421e3bb Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Wed, 11 Nov 2026 12:52:38 -0500 Subject: [PATCH 03/16] docs: Beginning building TOC --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2f57f9c..b827945 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ HTML Builder is a web-specific string compiler. Warning: Users of this library are responsible for sanitizing content. +- [Installation](#installation) + ## Installation `composer require 8fold/php-html-builder` From 5ea81cb6fd80eafac10ce0c8c9e617a79e66021a Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Wed, 11 Nov 2026 12:57:36 -0500 Subject: [PATCH 04/16] docs: Update code fences to render PHP and HTML --- README.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b827945..da50ecc 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,12 @@ HTML Builder is a web-specific string compiler. Warning: Users of this library are responsible for sanitizing content. - [Installation](#installation) +- [Usage](#usage) + - [`Element⁣`](#element) + - [`EmptyElement`](#emptyelement) + - [`Document`](#document) + - [`Comment`](#comment) + - ⁣ ## Installation @@ -27,20 +33,33 @@ use Eightfold\HtmlBuilder\Element; // non-empty element, using `create()` Element::create('p', 'Hello, World!')->props('id intro'); +``` -// output +Output: +```html +

Hello, World!

+``` +```php // non-empty element, magic method Element::p('Hello, World!')->props('id intro'); +``` -// output +Output: +```html +

Hello, World!

+``` +```php // empty element Element::img()->omitEndTag()->props('src /path/to/image.png'); +``` -// output +Output: +``` + ``` From 3df5ee5e955c25a0838ddfb88f12a0bc39b6ce30 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Wed, 11 Nov 2026 12:59:11 -0500 Subject: [PATCH 05/16] docs: Update code fences --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index da50ecc..8b7d71a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Output:

Hello, World!

``` +Non-empty element using magic method: ```php // non-empty element, magic method Element::p('Hello, World!')->props('id intro'); @@ -52,6 +53,7 @@ Output:

Hello, World!

``` +Empty element using magic method: ```php // empty element Element::img()->omitEndTag()->props('src /path/to/image.png'); From 47d19ebb7b4684339a86aaa446d15e4337966522 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Wed, 11 Nov 2026 13:00:59 -0500 Subject: [PATCH 06/16] docs: Update code fences --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8b7d71a..bc22b68 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Element::img()->omitEndTag()->props('src /path/to/image.png'); ``` Output: -``` +```html ``` @@ -75,11 +75,15 @@ use Eightfold\HtmlBuilder\EmptyElement; // empty element use Element Element::img()->omitEndTag()->props('src /path/to/image.png'); +``` -// output +Output: +```html +``` -// using EmptyElement +Using `EmptyElement`: +```php EmptyElement::img('src /path/to/image.png'); // output same as previous From 820c58c8bbb3d813035fa8150fff234de05c89b6 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Wed, 11 Nov 2026 13:02:55 -0500 Subject: [PATCH 07/16] docs: Update code fences --- README.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index bc22b68..9dfb29f 100644 --- a/README.md +++ b/README.md @@ -37,31 +37,26 @@ Element::create('p', 'Hello, World!')->props('id intro'); Output: ```html -

Hello, World!

``` Non-empty element using magic method: ```php -// non-empty element, magic method Element::p('Hello, World!')->props('id intro'); ``` Output: ```html -

Hello, World!

``` Empty element using magic method: ```php -// empty element Element::img()->omitEndTag()->props('src /path/to/image.png'); ``` Output: ```html - ``` @@ -93,7 +88,7 @@ EmptyElement::img('src /path/to/image.png'); The [`Document`](https://github.com/8fold/php-html-builder/blob/main/src/Document.php) class gives you to generate a complete HTML document entirely in PHP. -``` +```php use Eightfold\HtmlBuilder\Document; use Eightfold\HtmlBuilder\Element; use Eightfold\HtmlBuilder\EmptyElement; @@ -105,16 +100,15 @@ Document::create('title') )->body( Element::p('Hello, World!') ); +``` -// Output (compressed): +Output (compressed): ```html title

Hello, World!

// Output (human-readable): - -```html From ab9b4fdd225be5411c63e56d2eb2463e80b94a27 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Wed, 11 Nov 2026 13:05:20 -0500 Subject: [PATCH 08/16] docs: Update TOC --- README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9dfb29f..33269c1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Warning: Users of this library are responsible for sanitizing content. - [`EmptyElement`](#emptyelement) - [`Document`](#document) - [`Comment`](#comment) - - ⁣ + - [Properties](#properties)⁣ ## Installation @@ -102,13 +102,14 @@ Document::create('title') ); ``` -Output (compressed): +Output: ```html + title

Hello, World!

-// Output (human-readable): + @@ -127,9 +128,9 @@ Further examples are in the [`tests`](https://github.com/8fold/php-html-builder/ ### `Comment` -There’s even a [`Comment`](https://github.com/8fold/php-html-builder/blob/main/src/Comment.php) element for adding inline comments to the HTML. +There’s even a [`Comment`](https://github.com/8fold/php-html-builder/blob/main/src/Comment.php) component for adding inline HTML comments. -``` +```php use Eightfold\HtmlBuilder\Document; use Eightfold\HtmlBuilder\Element; use Eightfold\HtmlBuilder\EmptyElement; @@ -143,9 +144,9 @@ Document::create('title') Comment::create('comment-based-injection-point'), Element::p('Hello, World!') ); - -// Output (human-readable): +``` +Output (human-readable): ```html From 59f3c90376a020ee3a00a5d61c34c28d060cfbb8 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Wed, 11 Nov 2026 13:08:03 -0500 Subject: [PATCH 09/16] docs: Update TOC --- README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 33269c1..b22ff05 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Warning: Users of this library are responsible for sanitizing content. - [`Document`](#document) - [`Comment`](#comment) - [Properties](#properties)⁣ +- [Extending](#extending)⁣ ## Installation @@ -173,14 +174,14 @@ Properties with same name and value are presumed to be boolean properties (`requ use Eightfold\HtmlBuilder\EmptyElement; EmptyElement::input('required required'); +``` -// Output: - +Output: ```html ``` -You may also add properties individually to facilitate dynamic construction. +Properties me be added individually to facilitate dynamic construction. ```php use Eightfold\HtmlBuild\Element; @@ -191,13 +192,16 @@ $callout = Element::div('Information in the callout.') if ($isWarning) { $callout = $callout->prop('is warning'); } +``` -// output (human-readable): $isWarning = false +Output +```html +
Information in the callout.
-// output (human-readable): $isWarning = true +
Information in the callout.
From 24d9436ccfda3eb291595a3ce4ef1a8a0835b32b Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Wed, 11 Nov 2026 14:35:30 -0500 Subject: [PATCH 10/16] refactor: To use EmptyElement where applicable --- src/Components/Favicons.php | 25 +++++++++++++------------ src/Document.php | 3 ++- src/Forms/EmailInput.php | 3 ++- src/Forms/Password.php | 3 ++- src/Forms/Select.php | 3 ++- src/Forms/TextInput.php | 3 ++- 6 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/Components/Favicons.php b/src/Components/Favicons.php index 321410a..625e36b 100644 --- a/src/Components/Favicons.php +++ b/src/Components/Favicons.php @@ -6,6 +6,7 @@ use Stringable; use Eightfold\HtmlBuilder\Element; +use Eightfold\HtmlBuilder\EmptyElement; use Eightfold\HtmlBuilder\Components\FaviconMetroColors; @@ -118,36 +119,36 @@ private function safariThemeColor(): string public function __toString(): string { $elements = [ - Element::link()->omitEndTag()->props( + EmptyElement::link( 'rel apple-touch-icon', 'sizes 180x180', 'href ' . $this->path() . '/apple-touch-icon.png' ), - Element::link()->omitEndTag()->props( + EmptyElement::link( 'rel icon', 'type image/png', 'sizes 32x32', 'href ' . $this->path() . '/favicon-32x32.png' ), - Element::link()->omitEndTag()->props( + EmptyElement::link( 'rel icon', 'type image/png', 'sizes 16x16', 'href ' . $this->path() . '/favicon-16x16.png' ), - Element::link()->omitEndTag()->props( + EmptyElement::link( 'rel manifest', 'href ' . $this->path() . '/site.webmanifest' ), - Element::meta()->omitEndTag()->props( + EmptyElement::meta( 'name msapplication-TileColor', 'content ' . $this->metroTileColor() ), - Element::meta()->omitEndTag()->props( + EmptyElement::meta( 'name theme-color', 'content ' . $this->themeColor() ), - Element::link()->omitEndTag()->props( + EmptyElement::link( 'rel mask-icon', 'href ' . $this->path() . '/safari-pinned-tab.svg', 'color ' . $this->safariThemeColor() @@ -155,31 +156,31 @@ public function __toString(): string ]; if ($this->hasPath()) { - $elements[] = Element::link()->omitEndTag()->props( + $elements[] = EmptyElement::link( 'rel shortcut icon', 'href ' . $this->path() . '/favicon.ico' ); - $elements[] = Element::meta()->omitEndTag()->props( + $elements[] = EmptyElement::meta( 'name msapplication-config', 'content ' . $this->path() . '/browserconfig.xml' ); } if ($this->hasMetro() and $this->metroUsesWhite()) { - $elements[] = Element::meta()->omitEndTag()->props( + $elements[] = EmptyElement::meta( 'name msapplication-TileImage', 'content ' . $this->path() . '/mstile-144x144.png' ); } if ($this->hasAppName()) { - $elements[] = Element::meta()->omitEndTag()->props( + $elements[] = EmptyElement::meta( 'name application-name', 'content ' . $this->appName() ); - $elements[] = Element::meta()->omitEndTag()->props( + $elements[] = EmptyElement::meta( 'name apple-mobile-web-app-title', 'content ' . $this->appName() ); diff --git a/src/Document.php b/src/Document.php index 36363a0..98a80ea 100644 --- a/src/Document.php +++ b/src/Document.php @@ -6,6 +6,7 @@ use Stringable; use Eightfold\HtmlBuilder\Element; +use Eightfold\HtmlBuilder\EmptyElement; class Document implements Stringable { @@ -82,7 +83,7 @@ public function toString(): string $html = (string) Element::html( Element::head( $pageTitle, - Element::meta()->omitEndTag()->props($this->charset()), + EmptyElement::meta($this->charset()), ...$this->headContent() ), Element::body(...$this->bodyContent())->props(...$this->bodyProps) diff --git a/src/Forms/EmailInput.php b/src/Forms/EmailInput.php index 4592d36..e34f599 100644 --- a/src/Forms/EmailInput.php +++ b/src/Forms/EmailInput.php @@ -6,6 +6,7 @@ use Stringable; use Eightfold\HtmlBuilder\Element; +use Eightfold\HtmlBuilder\EmptyElement; use Eightfold\HtmlBuilder\PropertyInterface; class EmailInput implements Stringable @@ -130,7 +131,7 @@ public function __toString(): string $warning = self::warningMessage(); $inputProperties = self::inputProperties($warning === ''); - $input = Element::input()->omitEndTag()->props(...[ + $input = EmptyElement::input(...[ ...$inputProperties, 'type email', 'id ' . $this->id, diff --git a/src/Forms/Password.php b/src/Forms/Password.php index 813c42b..2ab6c1c 100644 --- a/src/Forms/Password.php +++ b/src/Forms/Password.php @@ -6,6 +6,7 @@ use Stringable; use Eightfold\HtmlBuilder\Element; +use Eightfold\HtmlBuilder\EmptyElement; use Eightfold\HtmlBuilder\PropertyInterface; use Eightfold\HtmlBuilder\Forms\PasswordAutocomplete; @@ -144,7 +145,7 @@ public function __toString(): string $warning = self::warningMessage(); $inputProperties = self::inputProperties($warning === ''); - $input = Element::input()->omitEndTag()->props(...[ + $input = EmptyElement::input(...[ ...$inputProperties, 'type password', 'id ' . $this->id, diff --git a/src/Forms/Select.php b/src/Forms/Select.php index 0ebeaf0..49430dc 100644 --- a/src/Forms/Select.php +++ b/src/Forms/Select.php @@ -6,6 +6,7 @@ use Stringable; use Eightfold\HtmlBuilder\Element; +use Eightfold\HtmlBuilder\EmptyElement; use Eightfold\HtmlBuilder\PropertyInterface; use Eightfold\HtmlBuilder\Forms\ControlProperties; @@ -273,7 +274,7 @@ private function selectGroup( $value = (string) $value; $memberId = $this->id . '-option-' . $index++; - $input = Element::input()->omitEndTag()->props(...[ + $input = EmptyElement::input(...[ ...$inputProperties, 'id ' . $memberId, 'name ' . $name, diff --git a/src/Forms/TextInput.php b/src/Forms/TextInput.php index c0ea48a..c271508 100644 --- a/src/Forms/TextInput.php +++ b/src/Forms/TextInput.php @@ -6,6 +6,7 @@ use Stringable; use Eightfold\HtmlBuilder\Element; +use Eightfold\HtmlBuilder\EmptyElement; use Eightfold\HtmlBuilder\PropertyInterface; class TextInput implements Stringable @@ -130,7 +131,7 @@ public function __toString(): string $warning = self::warningMessage(); $inputProperties = self::inputProperties($warning === ''); - $input = Element::input()->omitEndTag()->props(...[ + $input = Element::input(...[ ...$inputProperties, 'type text', 'id ' . $this->id, From 5d7461e0e8405e5ed0fdfabb092b0028302d21cf Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Wed, 11 Nov 2026 14:56:20 -0500 Subject: [PATCH 11/16] docs: Move 5.0 to 5.1 migration to docs/ --- docs/migrating-from-050000-050100.md | 76 ++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/migrating-from-050000-050100.md diff --git a/docs/migrating-from-050000-050100.md b/docs/migrating-from-050000-050100.md new file mode 100644 index 0000000..9f6a0fa --- /dev/null +++ b/docs/migrating-from-050000-050100.md @@ -0,0 +1,76 @@ +# Migrating from 5.0 to 5.1 + +While not a major release, it could have been, but the potential breaking change should only impact some static analysis tools - not runtimes - and has at least two solutions that have existed for multiple versions and many of us already have in place. + +Otherwise, this is a quality of life improvement release. + +- [New](#new) + - [`EmptyElement`](#emptyelement) component +- [Updated](#updated) + - (could cause frustration) [Static analysis](#static-analysis) may throw if the element name in an `Element` or `EmptyElement` static creation is not in the HTML specification, is not declared empty by the HTML specification, or both. + - (see previous) Removed all [`ignoreErrors`](https://phpstan.org/user-guide/ignoring-errors#ignoring-in-configuration-file) from the PHPStan configuration. + - (see previous) Increased the [PHPStan](https://phpstan.org) level for HTML Builder to 10. + - Expanded style and static analysis coverage to include `tests/` and `bin/` directories; not just `src/`. + - Form controls, `Favicons`, and `Document` use `EmptyElement` where applicable +- Deprecated: None +- Deleted: None + +## New + +### `EmptyElement` + +The first quality of life improvement is introducing an explicit way to create empty elements. + +The examples in the `EmptyElement` section in the [README](../README.md#emptyelement) demonstrates the improvement. + +In short: + +```php +use Eightfold\HtmlBuilder\Element; +use Eightfold\HtmlBuilder\EmptyElement; + +Element::img()->omitEndTag()->prop('id cat-picture', 'src /path/to/image.png'); + +// becomes +EmptyElement::img('id cat-picture', 'src /path/to/image.png'); +``` + +Outputs: + +```html + +``` + +## Updated + +### Static analysis + +The static analysis error that’s been with PHP HTML Builder from the beginning was “Call to undefined static method”. + +The error could be silenced, and limited to only ignoring PHP HTML Builder, in the PHPStan configuration (the first workaround): + +```yaml +ignoreErrors: + - '#Call to an undefined static method Eightfold\\HtmlBuilder\\Element::(.*)#' +``` + +We introduced the ‘create()’ named constructor, which gave a specific pattern for creating custom components and allowed users to avoid ignoring the static analysis error (the second workaround): + +```php +Element::create('p', 'Hello, World!'); +``` + +PHP HTML Builder 5.1 introduces two docblocks. + +[The first](../docblocks/elements-generated.txt) lists all HTML elements in the living specification. [The second](../docblocks/empty-elements-generated.txt) lists all *empty* elements in the living specification. Both are derived from sources maintained by the W3C organization and community. + +This means, when using the `__callStatic` magic method named constructor to create elements, if the element name is in the docblock list, static analyzers (specifically PHPStan) will *not* throw an error. Further, if the element name is not in the list, PHPStan *will* throw an error, which may be informative to users. + +```php +// Works with PHPStan level 10 without extra setup +Element::p('Hello, World!'); +``` + +Note: You can still create custom element names while using static analysis by either calling the `create()` method directly (recommended) or ignoring via configuration. + +Using `create()` may enhance readability by distinguishing custom element names from those in the HTML spec. From c5fd5292e90b215c93a942846a22ab13b341366d Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Wed, 11 Nov 2026 14:59:05 -0500 Subject: [PATCH 12/16] fix: TextInput using EmptyElement --- src/Forms/TextInput.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Forms/TextInput.php b/src/Forms/TextInput.php index c271508..cb3decf 100644 --- a/src/Forms/TextInput.php +++ b/src/Forms/TextInput.php @@ -131,7 +131,7 @@ public function __toString(): string $warning = self::warningMessage(); $inputProperties = self::inputProperties($warning === ''); - $input = Element::input(...[ + $input = EmptyElement::input(...[ ...$inputProperties, 'type text', 'id ' . $this->id, From f467f4077352a25aa9370161abc5e3c38bccfd39 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Wed, 11 Nov 2026 17:23:23 -0500 Subject: [PATCH 13/16] add: Migration guide template --- docs/migrating-template.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 docs/migrating-template.md diff --git a/docs/migrating-template.md b/docs/migrating-template.md new file mode 100644 index 0000000..9ef4323 --- /dev/null +++ b/docs/migrating-template.md @@ -0,0 +1,10 @@ +# Migrating from to + +- New: none +- Updated: None +- Deprecated: None +- Deleted: None + +## New + +## Updated From e67646645d47c37713f12fadbebcbc94d9d37b5d Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Wed, 11 Nov 2026 17:23:39 -0500 Subject: [PATCH 14/16] docs: Extract 4.x to 5.0 migration guide --- docs/migrating-from-04x-050000.md | 201 ++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/migrating-from-04x-050000.md diff --git a/docs/migrating-from-04x-050000.md b/docs/migrating-from-04x-050000.md new file mode 100644 index 0000000..684aec6 --- /dev/null +++ b/docs/migrating-from-04x-050000.md @@ -0,0 +1,201 @@ +# Migrating from 4.x to 5.0 + +- New + - [`TextArea`](#textarea) form control + - [`Select`](#select) adds `multiple()` mode and `inputProps()` methods; `SelectType` enum updated accordingly + - [Form controls preserve help IDs](#form-controls-preserve-help-ids) when warning is present + - [Text controls](#text-controls-containing-div) add a containing `div` element + - [All form controls have](#normalized-form-control-property-methods) `containerProps()`, `labelProps()`, and `inputProps()`; `FieldSet::labelProps()` targets the `legend` element + - `id()` is available on `TextInput`, `EmailInput`, `Password`, `TextArea`, and `Select`; allowing you to override the default `id` based on `name` + +- Updated or fixed: + - [`PropertyInterface`](https://github.com/8fold/php-html-builder/blob/main/src/PropertyInterface.php) extends PHP's `Stringable`; add `public function __toString(): string` alongside `prop()` and `props()`. + - `Select` [`radio()` and `checkbox()` IDs](#radio-and-checkbox-ids) use `-option-` instead of `-` + - `warningMessage` accessibility improved for each form control; `Fieldset` renders one warning for the group and [does not inspect child elements](#fieldset-does-not-inspect-children) +- Deprecated: + - [`wrapperProps()` on form controls](#wrapper-on-form-controls) +- Deleted: None + +## New + +### `TextArea` + +Escaping content remains caller-owned. + +`TextArea` is a new form control, and its value is rendered as text content. It renders the supplied value without escaping, decoding, or double encoding. + +```php +use Eightfold\HtmlBuilder\Forms\TextArea; + +TextArea::create( + $label, + $name, + $value = '', // caller-sanitized content, not a value attribute in ` can close the control and introduce markup. -Escaping flags, character encoding, and invalid-character handling remain the -implementer's choice. - -```php -use Eightfold\HtmlBuilder\Forms\TextArea; - -$value = "\nFirst line & second line\n"; -$escapedValue = htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); - -TextArea::create('Notes', 'notes', $escapedValue, 'Add more detail.') - ->inputProps('rows 4', 'required required') - ->labelProps('class notes-label'); -``` - -Values are element text, not a `value` attribute. Spaces, tabs, and leading, -internal, and trailing LF newlines are preserved: rendering adds one extra -leading newline that HTML consumes. Native HTML parsing normalizes CR and CRLF -line endings to LF; browsers apply their native textarea submission rules. -`inputProps()` targets the textarea and supports native `rows`, `required`, -`readonly`, and `disabled` attributes. Generated `id` and `name` override -conflicting input properties; `id()` also updates the label and warning -associations. String and `PropertyInterface` warnings follow the shared guidance -rules above. - -The escaping call above is an application example, not a component default. -To display literal entity syntax such as `&`, escape that text too (for -example, supply `&`). Callers remain responsible for sanitizing all -values, labels, warning markup, additional content, and attribute values. - -### Select attributes and selection - -`Select::inputProps()` targets the `` in radio/checkbox modes. Use native attributes appropriate to -those elements: for example, `required`, `disabled`, and help references on -either, and `size` on a dropdown. Selects, radios, and checkboxes do not support -`readonly`; the component does not emulate it. - -```php -use Eightfold\HtmlBuilder\Element; -use Eightfold\HtmlBuilder\Forms\Select; - -Select::create('Category', 'category', ['a' => 'A', 'b' => 'B'], 'b') - ->inputProps('required required', 'disabled disabled'); - -Select::create('Categories', 'categories[]', ['a' => 'A', 'b' => 'B'], ['a', 'b']) - ->multiple() - ->inputProps('size 4', 'aria-describedby categories-help') - ->content(Element::p('Choose one or more.')->props('id categories-help')); -``` - -The default mode is a single dropdown. `dropdown()` and `radio()` consider only -the first supplied selection; `multiple()` and `checkbox()` select every matching -option. Unmatched values select nothing in the generated markup (a native -single dropdown may still display its first option by default). Option matching -uses strings, including for numeric option keys. - -Dropdowns (including multiple dropdowns) and radio groups preserve the supplied -field name exactly. For PHP array submissions, supply a name such as -`categories[]` yourself. Checkbox groups retain the existing convention of -appending `[]` unless the name already ends with it. - -`containerProps()` targets the dropdown's `
` or the group's `
`. -For native group-wide disabling, use -`->checkbox()->containerProps('disabled disabled')` (or `radio()`); the browser -disables descendants through fieldset semantics, without rewriting the inputs. -Disabling a dropdown instead requires `inputProps('disabled disabled')`. -`labelProps()` targets the dropdown's label or the group's legend. - -`inputProps('required required')` on a radio group uses native radio-group -validation. On a checkbox group it makes **every checkbox required**, not -"at least one checkbox"; the component provides no generic group-required rule. - -Generated control IDs and submitted names take precedence over `inputProps()`; -group input types and option values are also generated. `checked` and `multiple` -properties supplied through `inputProps()` are ignored: use the `selected` -argument and explicit mode methods instead. A warning forces -`aria-invalid="true"` and merges help references as described above. Without a -warning, caller-supplied guidance and invalid-state properties remain unchanged. -Other repeated properties use the element builder's normal last-value behavior. - -### Fieldset warnings - -`Fieldset::create($label, $name, $warningMessage = '')` renders one group warning -with the ID `-warning`. String warnings are wrapped in a paragraph and -retain caller-supplied markup. `PropertyInterface` warnings retain their element, -content, and other properties; the generated ID is applied to a local clone, -leaving the caller's warning object unchanged. Repeated rendering is stable, and -the same warning object can be reused across components with independent IDs. - -Fieldset does not inspect or rewrite arbitrary children passed to `elements()`. -Callers remain responsible for setting `aria-invalid` and `aria-describedby` on -member controls, including references to the group warning when appropriate. - -### Migrating from wrapperProps - -In 5.0, use `containerProps()` instead of `wrapperProps()` on all form -components. This is a rename, not a change in property handling or target -elements. `wrapperProps()` remains a forwarding alias throughout 5.x and is -marked with PHP's native `#[Deprecated]` attribute (supported since PHP 8.4). -Calling it emits `E_USER_DEPRECATED`; removal is deferred until at least 6.0. - ## Details -See [8fold XML Builder](https://github.com/8fold/php-xml-builder#readme) for more -details. +See [8fold XML Builder](https://github.com/8fold/php-xml-builder#readme) for PHP HTML Builder’s sibling. ## Other From b8431e378b5af8a506c36658047814a4c00b6740 Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Wed, 11 Nov 2026 17:26:30 -0500 Subject: [PATCH 16/16] docs: Update TOC; prepare live test --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d6ab7d7..d7de893 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ Warning: Users of this library are responsible for sanitizing content. - [Performance](#performance)⁣ - Migrating (`docs/`) - [5.0 to 5.1](./docs/migrating-from-050000-050100.md) - - [`EmptyElement`](./docs/migrating-from-050000-050100.md#emptyelement) - - [Static analysis](./docs/migrating-from-050000-050100.md#static-analysis)⁣ - [4.x to 5.0](#migrating-from-4x-to-50)⁣ +- [Details](#details) +- [Other](#other)⁣ ## Installation