diff --git a/docs/dom.md b/docs/dom.md
index 5ba90815..53c07d9f 100644
--- a/docs/dom.md
+++ b/docs/dom.md
@@ -1160,6 +1160,30 @@ $xml = $mapper($someNode);
$xml = $doc->stringifyNode($someNode);
```
+#### to_unsafe_legacy_document
+
+Converts a `Dom\XMLDocument` (PHP 8.4+) into a legacy `DOMDocument` via an XML round-trip.
+This is useful when interoperating with libraries that still expect the legacy `DOMDocument` type.
+
+The `documentURI` is preserved on the resulting `DOMDocument`.
+
+**Caveat:** Line numbers in the resulting `DOMDocument` may differ from the original because
+the new DOM's `saveXML()` can reformat the output (e.g., collapsing multi-line opening tags
+into single lines).
+
+```php
+use VeeWee\Xml\Dom\Document;
+use function VeeWee\Xml\Dom\Mapper\to_unsafe_legacy_document;
+
+$doc = Document::fromXmlFile('some.xml');
+
+// Using the convenience method on Document:
+$legacyDoc = $doc->toUnsafeLegacyDocument();
+
+// Or using the mapper function directly:
+$legacyDoc = $doc->map(to_unsafe_legacy_document());
+```
+
#### xslt_template
Allows you to map an XML document based on an [XSLT template](xslt.md).
diff --git a/src/Xml/Dom/Document.php b/src/Xml/Dom/Document.php
index 38c35b1b..e6e88bc7 100644
--- a/src/Xml/Dom/Document.php
+++ b/src/Xml/Dom/Document.php
@@ -9,12 +9,14 @@
use Dom\Node;
use Dom\XMLDocument;
use Dom\XPath as DOMXPath;
+use DOMDocument;
use VeeWee\Xml\Dom\Traverser\Traverser;
use VeeWee\Xml\Dom\Traverser\Visitor;
use VeeWee\Xml\ErrorHandling\Issue\IssueCollection;
use VeeWee\Xml\Exception\RuntimeException;
use function Psl\Vec\map;
use function VeeWee\Xml\Dom\Locator\document_element;
+use function VeeWee\Xml\Dom\Mapper\to_unsafe_legacy_document;
use function VeeWee\Xml\Dom\Mapper\xml_string;
use function VeeWee\Xml\Internal\configure;
@@ -101,6 +103,18 @@ public function toUnsafeDocument(): XMLDocument
return $this->document;
}
+ /**
+ * Converts this document into a legacy DOMDocument via an XML round-trip.
+ *
+ * The documentURI is preserved. Note that line numbers may differ from the original
+ * because the new DOM's saveXML() can reformat the output (e.g., collapsing
+ * multi-line opening tags into single lines).
+ */
+ public function toUnsafeLegacyDocument(): DOMDocument
+ {
+ return $this->map(to_unsafe_legacy_document());
+ }
+
/**
* @template T
* @param callable(XMLDocument): T $locator
diff --git a/src/Xml/Dom/Mapper/to_unsafe_legacy_document.php b/src/Xml/Dom/Mapper/to_unsafe_legacy_document.php
new file mode 100644
index 00000000..fb01b203
--- /dev/null
+++ b/src/Xml/Dom/Mapper/to_unsafe_legacy_document.php
@@ -0,0 +1,46 @@
+ disallow_issues(
+ static function () use ($document): DOMDocument {
+ $xml = xml_string()($document);
+
+ $legacy = new DOMDocument();
+ disallow_libxml_false_returns(
+ $legacy->loadXML($xml),
+ 'Unable to load XML into legacy DOMDocument'
+ );
+
+ // documentURI must be set AFTER loadXML() because loadXML() resets it.
+ $documentUri = $document->documentURI;
+ if ($documentUri !== '') {
+ $legacy->documentURI = $documentUri;
+ }
+
+ return $legacy;
+ }
+ );
+}
diff --git a/src/bootstrap.php b/src/bootstrap.php
index 63f962f4..099050a4 100644
--- a/src/bootstrap.php
+++ b/src/bootstrap.php
@@ -71,6 +71,7 @@
'Xml\Dom\Manipulator\Xmlns\rename' => __DIR__.'/Xml/Dom/Manipulator/Xmlns/rename.php',
'Xml\Dom\Manipulator\Xmlns\rename_element_namespace' => __DIR__.'/Xml/Dom/Manipulator/Xmlns/rename_element_namespace.php',
'Xml\Dom\Manipulator\append' => __DIR__.'/Xml/Dom/Manipulator/append.php',
+ 'Xml\Dom\Mapper\to_unsafe_legacy_document' => __DIR__.'/Xml/Dom/Mapper/to_unsafe_legacy_document.php',
'Xml\Dom\Mapper\xml_string' => __DIR__.'/Xml/Dom/Mapper/xml_string.php',
'Xml\Dom\Mapper\xslt_template' => __DIR__.'/Xml/Dom/Mapper/xslt_template.php',
'Xml\Dom\Predicate\is_attribute' => __DIR__.'/Xml/Dom/Predicate/is_attribute.php',
diff --git a/tests/Xml/Dom/Mapper/ToLegacyDocumentTest.php b/tests/Xml/Dom/Mapper/ToLegacyDocumentTest.php
new file mode 100644
index 00000000..bd801add
--- /dev/null
+++ b/tests/Xml/Dom/Mapper/ToLegacyDocumentTest.php
@@ -0,0 +1,63 @@
+- hello
');
+ $legacy = $doc->toUnsafeLegacyDocument();
+
+ static::assertInstanceOf(DOMDocument::class, $legacy);
+ static::assertSame('hello', $legacy->getElementsByTagName('item')->item(0)->textContent);
+ }
+
+ public function test_it_preserves_document_uri(): void
+ {
+ $doc = Document::fromXmlString(
+ '',
+ \VeeWee\Xml\Dom\Configurator\document_uri('/some/path/file.xml')
+ );
+ $legacy = $doc->toUnsafeLegacyDocument();
+
+ static::assertSame('/some/path/file.xml', $legacy->documentURI);
+ }
+
+ public function test_it_preserves_namespaces(): void
+ {
+ $xml = 'value';
+ $doc = Document::fromXmlString($xml);
+ $legacy = $doc->toUnsafeLegacyDocument();
+
+ $items = $legacy->getElementsByTagNameNS('http://example.com', 'item');
+ static::assertSame(1, $items->length);
+ static::assertSame('value', $items->item(0)->textContent);
+ }
+
+ public function test_it_can_be_used_as_mapper(): void
+ {
+ $doc = Document::fromXmlString('');
+ $legacy = $doc->map(to_unsafe_legacy_document());
+
+ static::assertInstanceOf(DOMDocument::class, $legacy);
+ static::assertSame('root', $legacy->documentElement->localName);
+ }
+
+ public function test_it_preserves_xml_declaration(): void
+ {
+ $xml = '' . PHP_EOL . '';
+ $doc = Document::fromXmlString($xml);
+ $legacy = $doc->toUnsafeLegacyDocument();
+
+ static::assertSame('1.0', $legacy->xmlVersion);
+ static::assertSame('UTF-8', $legacy->encoding);
+ }
+}