zenstruck / dom
DOM crawler with advanced selector API and assertions.
Requires
- php: >=8.2
- symfony/css-selector: ^6.4|^7.0|^8.0
- symfony/dom-crawler: ^6.4|^7.0|^8.0
Requires (Dev)
- phpstan/phpstan: ^1.4
- phpunit/phpunit: ^11.0
- symfony/phpunit-bridge: ^7.0|^8.0
- symfony/process: ^6.4|^7.0|^8.0
- symfony/var-dumper: ^6.4|^7.0|^8.0
- zenstruck/assert: ^1.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-10 22:51:25 UTC
README
Functional testing with Symfony can be verbose when working with the DOM. This library provides an expressive, auto-completable, fluent wrapper around Symfony's native DomCrawler with intelligent selector resolution and a chainable assertion API:
public function testViewPostAndComments() { $dom = new Dom($this->client->request('GET', '/posts/3')); $dom->assert() ->contains('My First Post') ->hasElement('h1') ->hasElementCount('#comments li', 2) ->fieldEquals('Search', '') ->fieldChecked('Subscribe') ->fieldSelected('Category', 'Technology') ; // find elements with intelligent selector resolution $title = $dom->findOrFail('h1'); $title->text(); // "My First Post" // form elements are automatically typed $select = $dom->findOrFail('Category')->ensure(Combobox::class); $select->selectedText(); // "Technology" }
Combine this library with zenstruck/browser to get the full fluent browser testing experience:
public function testViewPostAndAddComment() { $this->browser() ->visit('/posts/3') ->assertSuccessful() ->assertSeeIn('h1', 'My First Post') ->fillField('Comment', 'Great post!') ->click('Submit') ->assertSeeIn('#comments', 'Great post!') ; }
Installation
composer require zenstruck/dom --dev
Note
For assertions, zenstruck/assert is required: composer require --dev zenstruck/assert.
Usage
use Zenstruck\Dom; /** @var \Zenstruck\Dom $dom */ $dom = new Dom($html); // string, Crawler, or Response // FINDING ELEMENTS $dom->find('h1'); // Node|null - first matching element $dom->findOrFail('h1'); // Node - throws if not found $dom->findAll('li'); // Nodes - collection of matching elements // SELECTOR RESOLUTION (strings are auto-detected in this order) $dom->find('.my-class'); // 1. CSS selector $dom->find('Submit'); // 2. Button text/value/aria-label $dom->find('Click here'); // 3. Link text/aria-label/title $dom->find('Logo'); // 4. Image alt text $dom->find('main-content'); // 5. Element ID $dom->find('email'); // 6. Field name attribute $dom->find('Email Address'); // 7. Field label, aria-label or placeholder // EXPLICIT SELECTORS (force a specific type) $dom->find(Selector::css('.my-class')); $dom->find(Selector::xpath('//div[@class="foo"]')); $dom->find(Selector::id('main-content')); $dom->find(Selector::button('Submit')); $dom->find(Selector::link('Click here')); $dom->find(Selector::image('Logo')); $dom->find(Selector::testId('checkout')); // by data-testid attribute $dom->find(Selector::field('email')); // by name or label $dom->find(Selector::fieldForName('email')); // by name only $dom->find(Selector::fieldForLabel('Email')); // by label, aria-labelledby, aria-label or placeholder $dom->find(Selector::clickable('Submit')); // buttons first, then links (incl. <summary> and role="button"/"link") // SEPARATOR SYNTAX (inline type forcing) $dom->find('css:==:.my-class'); $dom->find('xpath:==://div[@class="foo"]'); $dom->find('id:==:main-content'); $dom->find('button:==:Submit'); $dom->find('link:==:Click here'); $dom->find('field:==:email'); $dom->find('testid:==:checkout'); // CALLBACK SELECTORS $dom->find(function (Dom $dom): ?Node { return $dom->find('ul')?->children()->first(); });
Node
Every matched element is returned as a Zenstruck\Dom\Node (or a more specific subclass
for form elements):
/** @var \Zenstruck\Dom\Node $node */ // TRAVERSAL $node->parent(); // immediate parent Node or null $node->ancestors(); // all ancestor Nodes $node->children(); // direct child Nodes $node->siblings(); // sibling Nodes $node->next(); // next sibling Node or null $node->previous(); // previous sibling Node or null $node->closest('form'); // closest ancestor matching selector $node->descendant('.item'); // first descendant matching selector $node->descendants('.item'); // all descendants matching selector // CONTENT $node->text(); // full text content (including children) $node->directText(); // only the node's own text $node->outerHtml(); // the node's outer HTML $node->innerHtml(); // the node's inner HTML // INTROSPECTION $node->tag(); // tag name (e.g. "div", "input") $node->id(); // value of id attribute or null $node->isVisible(); // visibility check $node->isInert(); // element or an ancestor has the inert attribute $node->attributes(); // Attributes object $node->attr('title'); // attribute value or null $node->data('user-id'); // data-* attribute value or null (data-user-id) $node->hasClass('active'); // has class // TYPE GUARDS $node->is(Checkbox::class); // true/false $node->ensure(Checkbox::class); // returns typed node or throws
Nodes Collection
findAll() and traversal methods return a Zenstruck\Dom\Nodes collection:
/** @var \Zenstruck\Dom\Nodes $nodes */ $nodes->count(); // number of matched nodes $nodes->first(); // first Node or null $nodes->last(); // last Node or null $nodes->filter('.active'); // narrow down with a selector $nodes->text(); // concatenated text of all nodes $nodes->html(); // concatenated outer HTML $nodes->map(fn(Node $n) => $n->text()); // map to array foreach ($nodes as $node) { // iterable }
Form Elements
Nodes are automatically resolved to their specific form element type:
/** @var \Zenstruck\Dom $dom */ // INPUT $input = $dom->findOrFail(Selector::field('email'))->ensure(Input::class); $input->value(); // current value $input->type(); // "text", "email", "password", etc. // TEXTAREA $textarea = $dom->findOrFail(Selector::field('bio'))->ensure(Textarea::class); $textarea->value(); // CHECKBOX $checkbox = $dom->findOrFail(Selector::field('terms'))->ensure(Checkbox::class); $checkbox->isChecked(); // RADIO $radio = $dom->findOrFail(Selector::field('gender'))->ensure(Radio::class); $radio->isSelected(); $radio->selected(); // the selected Radio node $radio->selectedValue(); // COMBOBOX (single select) $select = $dom->findOrFail(Selector::field('country'))->ensure(Combobox::class); $select->selectedOption(); // Option node $select->selectedValue(); $select->selectedText(); $select->availableOptions(); // all Option nodes $select->optionMatching('Canada'); // Option by value or text, or null // MULTISELECT $multi = $dom->findOrFail(Selector::field('roles'))->ensure(Multiselect::class); $multi->selectedOptions(); // array of Option nodes $multi->selectedValues(); $multi->selectedTexts(); // FILE $file = $dom->findOrFail(Selector::field('photo'))->ensure(File::class); $file->isMultiple(); // BUTTON $button = $dom->findOrFail(Selector::button('Submit'))->ensure(Button::class); $button->type(); // "submit", "button", "reset" $button->value(); // COMMON FIELD METHODS (all fields inherit from Field) $field->name(); // name attribute $field->value(); // current value $field->label(); // associated Label node or null $field->isDisabled(); $field->isRequired(); $field->isReadonly(); $field->form(); // parent Form node // FORM $form = $dom->findOrFail('form')->ensure(Form::class); $form->fields(); // all field Nodes $form->buttons(); // all button Nodes $form->submitButtons(); // submit-type buttons only $form->submitButton(); // first submit button
Assertions
All assertion methods return $this for chaining:
/** @var \Zenstruck\Dom $dom */ $dom->assert() // TEXT CONTENT ->contains('some text') // page contains text ->doesNotContain('some text') // page does not contain text ->containsIn('h1', 'some text') // element contains text ->doesNotContainIn('h1', 'some text') // element does not contain text // ELEMENT PRESENCE ->hasElement('nav') // element exists ->doesNotHaveElement('nav') // element does not exist ->hasElementCount('ul li', 5) // exact count // VISIBILITY ->elementIsVisible('#modal') // element is visible ->elementIsNotVisible('#modal') // element is not visible // ATTRIBUTES ->attributeContains('body', 'class', 'dark') // attribute contains value ->attributeDoesNotContain('body', 'class', 'light') ->attributeEquals('html', 'lang', 'en') // first matching element's attribute equals value ->attributeDoesNotEqual('html', 'lang', 'fr') // CLASSES ->hasClass('body', 'dark') // element has class ->doesNotHaveClass('body', 'light') // element does not have class // FORM FIELDS ->fieldEquals('Username', 'kevin') // field value equals ->fieldDoesNotEqual('Username', 'john') // field value does not equal ->fieldChecked('Remember me') // checkbox checked or radio selected ->fieldNotChecked('Remember me') // checkbox not checked ->fieldSelected('Role', 'Admin') // option is selected ->fieldNotSelected('Role', 'Guest') // option is not selected ->fieldIsDisabled('Username') // field has disabled attribute ->fieldIsEnabled('Username') ->fieldIsRequired('Username') // field has required attribute ->fieldIsOptional('Username') ->fieldIsReadonly('Username') // field has readonly attribute ->fieldIsNotReadonly('Username') ;
Known Limitations
Note
XPath case folding is ASCII-only. The translate() function used for case-insensitive
matching only handles A-Z. Non-ASCII characters are not case-normalized (XPath 1.0 limitation).
Note
isVisible() performs basic checks only. It detects hidden attributes, type="hidden"
inputs, and inline display:none/visibility:hidden styles. It does not evaluate CSS
stylesheets or inherited styles.
Testing
vendor/bin/phpunit