mtownsend/response-xml
Return XML from your Laravel API as easily as JSON. This package adds a fluent xml() response helper that converts Eloquent models, collections, arrays, and paginators into clean, customizable XML, with support for custom root nodes, headers, and encoding — no manual string building required.
The package is a thin macro layer over spatie/array-to-xml. Install it and you can return XML from any controller without touching Response internals:
composer require mtownsend/response-xml
On Laravel 5.5+ there is nothing to register — the service provider is auto-discovered and registers an xml macro on the response factory plus a global xml() helper. Only add Mtownsend\ResponseXml\ResponseXmlServiceProvider::class to config/app.php if you are on an older skeleton. If you want a global default root element, publish the package config (php artisan vendor:publish --provider="Mtownsend\ResponseXml\ResponseXmlServiceProvider") rather than passing $xmlRoot everywhere.
First working route:
Route::get('/users/{id}', function (int $id) {
return response()->xml([
'user' => [
'id' => $id,
'name' => 'Jane Doe',
],
]);
});
curl -i will show Content-Type: text/xml and a <response> root wrapping your data. That is the whole mental model: you shape a PHP array, the package serializes it.
Where to look first:
response()->xml() signature in the README/service provider — it controls status, headers, root element, namespace, and libxml options.spatie/array-to-xml docs. Every quirk about nested arrays, _attributes, _value, repeated elements, and key sanitization comes from that dependency, not this package.First real use case: legacy integrations, sitemaps, RSS/Atom feeds, and clients that send Accept: application/xml. Start by building one endpoint that mirrors an existing JSON endpoint, then put the array-building logic in a dedicated transformer so it stays testable.
Controller response with full control. The macro signature is essentially:
response()->xml(
$data,
$status = 200,
$headers = [],
$xmlRoot = 'response',
$domain = null,
$options = null
);
Use it directly for one-off endpoints:
return response()->xml($payload, 201, ['Cache-Control' => 'no-store'], 'order');
Repeated elements via list arrays. Numeric keys mean "repeat this element". This is the pattern for collections:
return response()->xml([
'users' => [
['id' => 1, 'name' => 'Jane'],
['id' => 2, 'name' => 'John'],
],
], 200, [], 'users');
Attributes and text content. Do not invent your own convention — use spatie's reserved keys:
return response()->xml([
'user' => [
'_attributes' => ['id' => 42, 'active' => 'true'],
'name' => 'Jane',
'bio' => [
'_attributes' => ['lang' => 'en'],
'_value' => 'Hello & welcome',
],
],
]);
Sitemaps and namespaced roots. The $domain argument is the cleanest way to set a default xmlns:
return response()->xml(
['url' => $urls], // each: ['loc' => ..., 'lastmod' => ...]
200,
[],
'urlset',
'http://www.sitemaps.org/schemas/sitemap/0.9'
);
Content negotiation instead of duplicate routes. Decide the format once and branch at the response boundary:
$accept = $request->header('Accept', '');
$data = (new UserResource($user))->resolve();
return Str::contains($accept, ['xml', 'application/xml'])
? response()->xml(['user' => $data], 200, [], 'user')
: response()->json($data);
Add format=xml as an explicit query fallback — browsers frequently send Accept: text/html and will never hit the XML branch otherwise.
Normalize before serializing. Eloquent models, Collections, and API Resources are not arrays in the way ArrayToXml expects. Always run ->toArray()/->resolve() and format Carbon dates and enums explicitly. Consider a small XmlPresenter or toXmlArray() method on your resources so JSON and XML share one domain representation.
Testing. Treat the response as a normal Illuminate\Http\Response:
$response = $this->get('/users/1');
$response->assertOk();
$response->assertHeader('Content-Type', 'text/xml; charset=UTF-8');
$response->assertSee('<name>Jane Doe</name>', false); // false = don't escape
$this->assertXmlStringEqualsXmlString(
'<response><user><id>1</id></user></response>',
$response->getContent()
);
Caching. XML is a string once built. For hot endpoints, cache the serialized body and return it with a manual content type rather than re-serializing per request:
$xml = Cache::remember("user:{$id}:xml", 300, fn () => response()->xml(...)->getContent());
return response($xml)->header('Content-Type', 'text/xml; charset=UTF-8');
Content-Type is text/xml, not application/xml. Many SOAP-ish clients and API gateways are picky. Override via the $headers argument rather than fighting the macro. Also, assert on the exact charset your installed version emits when writing tests.assertSee() escapes by default. Passing true (the default) will make <name> assertions fail. Always pass false when asserting markup.$xmlRoot/$domain. If you need root attributes beyond the namespace, build the root manually with spatie directly or a DOMDocument.null, [], '', and false all produce different output across spatie versions. Test each shape you rely on; don't assume <foo/> versus omission.true becomes 1 and false becomes an empty string in PHP casting; Carbon becomes Y-m-d H:i:s via __toString. Format to Y-m-d\TH:i:sP or true/false strings explicitly before handing the array over.&, <, and > for you. Passing htmlspecialchars()-prepared values results in double-escaping (&amp;). Pass raw values._attributes, _value (and version-dependent _cdata) are interpreted, not emitted as child elements. Renaming a domain field to one of these without knowing this is a classic silent bug.$response->getContent() through xmllint --format - or simplexml_load_string() in a test. A well-formedness check catches escaping and invalid-name issues faster than eyeballing.$options is a libxml passthrough. Useful for things like LIBXML_NOBLANKS/LIBXML_NOCDATA, but behaviors differ between dependency versions. Pin spatie/array-to-xml in composer.json if your output shape matters.DOMDocument or a dedicated package — you'll spend less time fighting the array abstraction.xml() helper. If another package defines xml(), you get a fatal redeclaration. Check app()->bound()/helper registration order, or just stick to response()->xml().How can I help you explore Laravel packages today?