nb/oxymel
Oxymel is a lightweight, chainable XML builder for PHP. Build well-formed XML documents with a fluent API instead of string concatenation, handling elements, attributes, text, and nesting cleanly.
Install and locate the source.
composer require nb/oxymel
The package is tiny — read README.md first, then src/Oxymel.php. It is a single-class fluent XML builder, not a parser, not a DOM, and not a serializer for arbitrary objects. There is nothing to configure, no service provider, no config file. If you are in a Laravel app you can use Oxymel\Oxymel; anywhere, or bind a fresh instance through the container if you want it injected.
First use case: an XML response / feed.
use Oxymel\Oxymel;
$xml = Oxymel::init('catalog')
->tag('book', ['isbn' => '978-0-00-000000-0'])
->tag('title', 'The Book')
->close()
->tag('author', 'Jane Doe')
->close()
->close()
->xml();
return response($xml, 200, ['Content-Type' => 'application/xml']);
init() opens the root tag, tag() opens a child, close() pops the stack, and xml() (or the object's __toString()) renders the result. That is the entire mental model — if you can picture a stack of open tags, you can use Oxymel.
Nesting is manual, not structural. Every tag() you do not immediately self-close must be paired with a close(). Indent your PHP to mirror the XML tree so the pairing is visually obvious.
Attributes vs. content. Pass attributes as an array; pass scalar content as a string. A scalar in the attribute position is treated as content, which is why tag('title', 'The Book') works:
->tag('item', ['id' => 42, 'lang' => 'en'], 'Content goes here')
->close()
Mixing typed content. Use text() for escaped text, cdata() for CDATA blocks, comment() for <!-- -->, and raw() for pre-built XML you have already validated.
->tag('description')
->cdata('Contains <html> & "quotes" safely')
->close()
->tag('payload')
->raw($alreadyEscapedXml)
->close()
Sitemaps and RSS are the sweet spot. Generating a sitemap from Eloquent results reads almost like the output itself:
$oxymel = Oxymel::init('urlset', ['xmlns' => 'http://www.sitemaps.org/schemas/sitemap/0.9']);
foreach (Post::cursor() as $post) {
$oxymel->tag('url')
->tag('loc', route('posts.show', $post))
->close()
->tag('lastmod', $post->updated_at->toAtomString())
->close()
->close();
}
return response($oxymel->close()->xml(), 200, ['Content-Type' => 'application/xml']);
Streaming large documents. Because Oxymel accumulates a string internally, do not build a 200k-row export in memory. Either chunk it (write the head with Oxymel, loop with fwrite, write the tail) or switch to XMLWriter for that one job. Keep Oxymel for the 80% case where the document is small and readability matters more than throughput.
Testing. Assert against the rendered string, or better, simplexml_load_string($oxymel->xml()) in a test so you assert structure rather than whitespace-sensitive text. That decouples your tests from Oxymel's exact formatting.
Wrapping it. If you produce the same shape in several places (e.g. an XML error envelope), wrap it in a small dedicated class or a XmlResponse helper rather than repeating chains of tag()/close() across controllers.
Unbalanced tags are the #1 bug. A forgotten close() does not throw; it silently produces malformed XML that only fails downstream in the consumer. When something is off, dd($oxymel->xml()) and count open/close tags — the stack is your friend, and nesting mistakes are always visible there.
raw() is an injection vector. It writes exactly what you give it. Never pass user input to raw(). Use text() or cdata() — they escape. Treat raw() like {!! !!} in Blade: acceptable only for trusted, already-validated fragments.
Escaping is done for you — don't double-escape. text() and attribute values are escaped by the library. If you htmlspecialchars() first and then hand it to text(), consumers will see &amp;. Pass raw values in and let Oxymel do the encoding.
No pretty-printing and no XML declaration by default. Output is compact and (depending on version) lacks <?xml version="1.0"?>. If a consumer requires the prolog, prepend it yourself or check whether the version you pinned emits it — many SOAP/feed consumers are strict about this.
No namespaces, schema validation, or DTD support. The xmlns example above "works" only because it is just another attribute — Oxymel does not understand prefixes or namespace scoping. If you need real namespace-aware documents, use DOMDocument or XMLWriter. Likewise, Oxymel will happily build invalid XML; validate against an XSD in a test if the contract matters.
Whitespace in content is preserved literally. If you build content across multiple lines with heredocs, the leading indentation becomes part of the text node. Trim or concatenate deliberately.
Check the actual tag() signature against the source. The parameter order between attributes and content is the one place people trip up, and the scalar-shortcut behavior is easy to misremember — a one-minute read of src/Oxymel.php prevents a confusing debugging session.
Maintenance signal is weak. 22 stars and a low activity score means you should pin the version in composer.json and not expect fixes. The flip side is that the surface area is so small you can vendor or fork it in an afternoon if you need a bug fixed or a prettyPrint() extension. Extension points are limited to subclassing Oxymel or wrapping it in your own builder — there is no plugin system.
The honest verdict for day-to-day Laravel work: reach for Oxymel when you need to emit a small, hand-shaped XML document (sitemap, RSS, a legacy API response) and want the code to read like the XML. Skip it for parsing, for large streaming exports, for namespace-heavy standards (SOAP, OOXML), or anywhere the schema is strict enough that you want a validating serializer.
How can I help you explore Laravel packages today?