prateekbhujel / prototype-dom
Fluent traversal and rich-text transformations on native PHP 8.4+ DOM nodes.
Requires
- php: ^8.4
- ext-dom: *
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Fluent traversal and rich-text transformations on native PHP 8.4+ DOM nodes.
Walk from an input to its form, work with a collection of elements, edit article text without flattening its markup, or split a paragraph around a nested embed. Elements remain Dom\Element and Dom\HTMLElement objects: pass them directly to native DOM methods.
use PrateekBhujel\PrototypeDom\PrototypeDom; $doc = PrototypeDom::html('<!doctype html><form><input name="email"></form>'); $input = $doc->querySelector('input'); $input->up('form')->addClass('ready')->down('input')->val('hello@example.com'); echo $doc->saveHtml();
Inspired by Prototype's DOM traversal and the request in php-src #23575. The convenience methods live in this package; PHP's built-in DOM API stays unchanged.
Install
Requires PHP 8.4+ with ext-dom. No runtime Composer dependencies.
composer require prateekbhujel/prototype-dom
Native nodes, fluent helpers
PrototypeDom::html() and PrototypeDom::xml() create documents and register enhanced element classes with registerNodeClass(). Parsing and CSS selectors use PHP's native DOM implementation. The additional methods run in PHP; this package does not claim C-level performance for its helpers.
$doc = PrototypeDom::html($html); $xml = PrototypeDom::xml('<catalog><item id="first"/><item id="second"/></catalog>'); $previous = $xml->querySelector('[id="second"]')->previous('item');
For an existing modern document, call PrototypeDom::enable($doc) before retrieving elements. Previously retrieved objects keep their original class and identity. Registration changes the document's element-class mappings; do not use it to layer over another library's custom mappings.
The static Text and Tree helpers also accept plain native nodes without registration. This library does not accept the older DOMDocument/DOMElement classes.
Traverse and select
$el->up(); // Parent element $el->up('section', 1); // Second matching ancestor; index starts at zero $el->down(); // First direct child element $el->down('button', 2); // Third matching descendant $el->next('.item'); // Next matching sibling $el->previous('.item'); // Previous matching sibling $el->descendantOf('#article'); $el->ancestorOf($other); $el->find('.item'); // ElementCollection of descendants $el->children('li'); // Direct children $el->siblings('.active'); // Siblings, excluding self PrototypeDom::find($doc, 'a'); // Document or fragment selection
Single-element traversal returns null when there is no match. Negative traversal indices throw ValueError. parent() aliases up(); first($selector) aliases querySelector(). Native closest(), matches(), querySelector() and querySelectorAll() remain available. Traversal can cross HTML/SVG namespaces.
Collections are snapshots: removing nodes while iterating does not skip the next match.
$items = $doc->querySelector('ul')->children('li'); $items->matching('.active')->addClass('selected')->attr('aria-current', 'page'); $labels = $items->map(fn ($item) => $item->text()); $items->each(fn ($item, $index) => $item->attr('data-index', (string) $index)); $items->filter(fn ($item) => $item->hasClass('obsolete'))->remove(); $items->first(); $items->last(); $items->at(-1); count($items); $items[0]; // Also iterable, with toArray() for a plain array
each() stops when the callback returns false. Collections retain their members after DOM removal. Assign only existing indices or append with $items[]; sparse indices are rejected.
Edit text without losing markup
$doc = PrototypeDom::html('<!doctype html><p>Old <strong>Old</strong> <a href="/Old">Old</a></p>'); $p = $doc->querySelector('p'); $count = $p->replaceText('Old', 'New'); // 2; the link is untouched $p->highlightText('New'); echo $p->html(); // <mark>New</mark> <strong><mark>New</mark></strong> <a href="/Old">Old</a>
Replacements are plain text and are escaped on serialization. Existing elements and their attributes stay in place. Both methods return occurrence counts; highlightText() wraps matches in <mark>.
By default, text inside a, script, style, textarea, title, code, pre, mark, template, svg, math, and [contenteditable="false"] is excluded. Exclusion checks include ancestors outside the chosen root. Supply a CSS selector to replace that list, or '' to include all text:
$p->replaceText('Old', 'New', exclude: 'a, code, .widget');
Matching is literal, case-sensitive, and confined to individual text nodes. Hello <em>world</em> will not match Hello world. Empty search strings are rejected. Repeated highlighting skips existing marks with the default exclusions.
Split a paragraph around an embed
Rich-text editors often place an inline placeholder inside formatting. Promoting it to a block requires splitting the surrounding ancestors, not just removing a tag.
$doc = PrototypeDom::html('<!doctype html><article><p>Before <em>text <span data-embed="video">Video</span> after</em>.</p></article>'); $embed = $doc->querySelector('[data-embed]'); $split = $embed->isolate($embed->up('p'));
The article now contains:
<p>Before <em>text </em></p><span data-embed="video">Video</span><p><em> after</em>.</p>
Replace the promoted placeholder with the block markup your application needs. $split->before, $split->target, and $split->after reference the resulting native elements; a missing side is null.
The target and surrounding child nodes are moved, preserving their identity. Prefix wrappers are reused; suffix wrappers are shallow clones without id/xml:id to avoid duplicating IDs. Empty split-path wrappers are discarded. References to IDs on discarded wrappers are not rewritten. The ancestor must contain the target and have an element parent. Invalid relationships are rejected before mutation.
This operation restructures nodes; it does not validate HTML content models. Choose an ancestor whose parent can contain the promoted element. For an end-to-end example, run:
php examples/rich-text.php
Insert fragments in the right context
HTML parsing depends on the insertion parent. Parse table rows in a tbody, not a div:
$tbody = $doc->querySelector('tbody'); $rows = PrototypeDom::fragment($tbody, '<tr><td>New row</td></tr>'); $tbody->append($rows);
The context is not modified during parsing, and the returned Dom\DocumentFragment belongs to its document. Use the intended insertion parent in an HTML document; XML documents and template contexts are rejected. Native HTML parsing may normalize the source. There is no byte-for-byte source-preservation guarantee.
Content and attributes
$el->html(); // Inner HTML $el->html('<strong>Content</strong>'); $el->text(); // Text content $el->text('Plain <text>'); // Replaces all children with text $el->update($other->childNodes); // Moves a snapshot of every node $el->update('<em>HTML</em>'); // Also accepts one node or an iterable of nodes $el->empty(); // Same as update(null) $el->attr('title', 'Details'); $el->attr(['role' => 'note', 'data-id' => '42']); $el->attr('title'); // Null if absent $el->removeAttr('title', 'role'); $el->data('userId', 42); // data-user-id="42" $el->addClass('active', 'item')->removeClass('hidden'); $el->toggleClass('selected', true); $el->hasClass('active'); $el->wrap('div.wrapper'); $el->unwrap(); // Replace parent with all of its children $el->saveHtml(); $el->saveXml();
hasClassName, addClassName, removeClassName, and toggleClassName are aliases. Native operations such as append(), remove() and replaceWith() retain their native return types. Node updates move nodes rather than cloning them; use cloneNode(true) if the original subtree must remain.
PrototypeDom::create($doc, 'button.primary#save', ['text' => 'Save']) creates an element. Definitions support a tag, one ID and classes, not arbitrary Emmet expressions. PrototypeDom::strip($doc, 'script, style') removes matching elements. It is not an HTML sanitizer; neither is this package.
Static forms and inline styles
val(), values(), setValues() and serialize() work on input, textarea and select markup. Radio/checkbox groups, repeated names, disabled fields and multi-selects are supported:
$form->setValues(['email' => 'hello@example.com', 'roles[]' => ['editor', 'author']]); $values = $form->values(); $query = $form->serialize();
Names are literal keys, including []. A single ordinary value is a string; repeated names and multi-value fields produce lists. Serialization repeats the original field name in document order. Disabled controls and non-data inputs are omitted. These helpers inspect static descendant markup, not browser state, JavaScript, file uploads, or controls associated through an external form attribute. An unmatched single-select assignment removes selection attributes; a subsequent read applies the default selection from its markup.
css('color', 'red'), css('color'), and css(['color' => null]) edit/read/remove inline declarations. Quoted semicolons, URLs and untouched declaration text are preserved. This is not a CSS engine: getters read the last textual declaration, without resolving !important, computed styles or escaped property names.
Scope
Use this for server-side HTML/XML manipulation and rich-text processing. It does not execute JavaScript or render React. React hydration requires matching server/client markup; modify content before both render paths, or outside React-managed regions.
Established alternatives include Symfony DomCrawler for crawling/forms and HTMLPageDom for jQuery-style manipulation. Prototype DOM focuses on direct modern PHP DOM nodes, fluent traversal, and the structural/text operations shown above.
Development
composer install
composer test
CI runs the test suite on PHP 8.4 and 8.5 on Linux, macOS and Windows. The example output is also tested. Reports with a small input, expected output and PHP version are welcome.
MIT license. See LICENSE.