servo/fluidxml
FluidXML is a lightweight PHP library for creating, querying, and editing XML with a concise, fluent, jQuery-like API. Chain readable methods, target nodes with XPath selectors, handle namespaces, and cut boilerplate in XML-heavy PHP and Laravel applications.
FluidXML is a dependency-free, single-class library — no service provider, no config file, no facade. Install it and you're done:
composer require servo/fluidxml
Where to look first
fluidxml() helper (registered via Composer's files autoload). If you prefer DI, use new Servo\FluidXml\FluidXml(...) directly.add(), set(), attr(), and query() (plus xml()/html() for output).First use case — a sitemap route
The most common Laravel need for XML is a sitemap. In routes/web.php:
use function Servo\FluidXml\fluidxml;
Route::get('/sitemap.xml', function () {
$xml = fluidxml('urlset')
->namespace('http://www.sitemaps.org/schemas/sitemap/0.9');
Post::published()->lazy()->each(function ($post) use ($xml) {
$xml->add('url')
->add('loc', route('posts.show', $post))
->add('lastmod', $post->updated_at->toAtomString())
->add('changefreq', 'weekly');
});
return response($xml->xml(), 200, ['Content-Type' => 'application/xml']);
});
If that renders valid XML and passes assertXmlStringEqualsXmlString(), you understand the core mental model: every builder method returns a FluidXML instance for chaining, and the instance itself holds a mutable DOM tree.
Wrap it in a service. Don't scatter fluidxml() calls through controllers. Create App\Services\Xml\SitemapBuilder or AtomFeedBuilder with typed methods (addEntry(Post $post): self) and resolve it from the container. This gives you one place to swap in an interface if you ever migrate to spatie/laravel-sitemap or similar. It also makes the generator unit-testable without HTTP.
Large exports — stream, don't buffer. FluidXML keeps the whole DOM in memory. For big sitemaps or product feeds, build up chunks and stream with response()->streamDownload(), flushing every N nodes:
return response()->streamDownload(function () use ($query) {
echo '<?xml version="1.0" encoding="UTF-8"?><urlset>';
$query->chunk(1000, function ($rows) {
$chunk = fluidxml('urlset')->namespace('http://www.sitemaps.org/schemas/sitemap/0.9');
foreach ($rows as $row) { /* ->add('url', ...) */ }
echo $chunk->xml(['strip_declaration' => true]);
});
echo '</urlset>';
}, 'sitemap.xml', ['Content-Type' => 'application/xml']);
Alternatively use ->save('php://output') per chunk.
SOAP / XML APIs. The pattern that fits Laravel best is: build the envelope with FluidXML → send via Http::withBody($xml->xml(), 'text/xml')->post($endpoint) → parse the response with fluidxml($response->body())->query('//soap:Body/...'). Register namespaces before querying:
$envelope = fluidxml('Envelope')->namespace('soap', 'http://schemas.xmlsoap.org/soap/envelope/');
Eloquent → XML for partners/ERP. Use chunk() or lazy() combined with a fresh fluidxml() per chunk, and write to Storage::disk('s3')->append() or a local temp file. Avoid ->get() on large tables.
Interop with existing XML. fluidxml($dom), fluidxml($simpleXml), fluidxml($string) and fluidxml('existing.xml') all load. This is how you graft new nodes into an XML template (e.g., an Invoice XML schema) — load the skeleton, query for the insertion point, ->add() children, output.
Mutations on selections. append(), prepend(), remove(), add() on a query() result operate on every matched node. This makes bulk edits (e.g., stripping a <private> element from all entries in an OAI-PMH response) a one-liner.
Blade for the skeleton, FluidXML for the loops. A common hybrid: put the header/doctype in a Blade view, pass each chunk of generated XML as {!! $chunk !!}. Avoids the "how do I build namespaced attributes through Blade" pain while keeping rendering fast.
Testing. Wrap assertions in PHPUnit's assertXmlStringEqualsXmlString($expected, $actual) rather than string comparison — attribute ordering and whitespace will bite you otherwise. For feeds, keep one checked-in fixture of expected XML.
fluidxml() helper is autoloaded via Composer files. If you've already defined a function with that name (or a package does), you get a fatal collision. Wrap: use function Servo\FluidXml\fluidxml as fx; only works within a file, not globally. Safest is to instantiate new FluidXml() inside your service.->namespace('d', 'http://...') and query //d:url. This bites people coming from DOMXPath::registerNamespace patterns where they assume default-namespace queries just work.add() vs set() vs addChild(). add() always creates a new child and returns that child as context. set() ensures a single node exists with the given value (updates if present). Mixing them mid-chain is how you end up with duplicate <loc> tags. Rule of thumb: add() for repeated elements, set() for singletons.add() returns the new child, chaining ->add('url')->add('loc', ...)->add('lastmod', ...) adds the dates to <url>, not <urlset>. Great for fluency, dangerous when you assume you're still on the parent. Use a second variable when you need to escape the current context.->attr(). ->add('x', ['id' => 1, 'class' => 'y']) sets those as attributes on <x>. ->attr('id', 1) on a selection sets attributes on all matched nodes.->xml() emits <?xml version="1.0" ...?> + body. ->html() emits an HTML doctype. When you nest one FluidXML inside another via append()/add($child), the child won't be re-declared — good. But if you call ->xml() on the child and echo it manually, you'll get a second declaration and an invalid document. Strip it or use ->save('php://output').->save($path) uses PHP's filesystem directly. In Laravel this bypasses Storage disks, local-dev/ prod path differences, and Flysystem's atomic writes. Prefer Storage::disk(...)->put($path, $xml->xml()) (or ->append() per chunk) for portability and testability.DOMDocument only for validation while streaming output. Test against 100k records before shipping.fluidxml($str) throws — this is a hard failure path when calling third-party APIs. Wrap parses in try/catch and log the raw response before rethrowing; you will get HTML error pages from misbehaving endpoints on a bad day.$xml->setAttribute(), $xml->addElement(), etc. don't exist anymore). Read the README against the version you actually install and pin a specific constraint in composer.json.FluidXml to add domain helpers (e.g., ->addProduct(Product $p)), or wrap it in a value object. There's no event/hook system, so composition (a FeedBuilder owning a FluidXml) is cleaner than inheritance.assertXmlStringEqualsXmlString, and if you snapshot XML, normalize whitespace first. For feeds that include timestamps, Carbon::setTestNow() combined with a fixed time makes snapshots viable.How can I help you explore Laravel packages today?