mtownsend/request-xml
Adds simple XML response parsing to Laravel's HTTP client. Call xml() on responses to convert XML into arrays, making XML APIs as easy to work with as JSON. Lightweight and Laravel-friendly for legacy or SOAP-style endpoints.
Install the package and let Laravel auto-discover the service provider:
composer require mtownsend/request-xml
Version note (2.7.0). The latest release is a compatibility-only bump that adds Laravel 13.x support — no new public API, no deprecations, no changes to the xml() macro signature. If you're on Laravel 11/12 the constraint is unchanged in practice, but check what Composer actually resolved before you write code against it:
composer show mtownsend/request-xml # confirm 2.7.0 is what you locked
Pin the exact minor in composer.json ("mtownsend/request-xml": "^2.7") rather than floating, because the only thing this project ships between minors is framework-compatibility work — you want those bumps to be deliberate, not silent.
There is no mandatory configuration. If a config file is published (php artisan vendor:publish --provider="Mtownsend\RequestXml\Providers\RequestXmlServiceProvider"), treat it as the place where return-type/casting behavior is decided — pin it early so every controller gets the same shape.
Where to look first
xml() macro (path argument, options array, return type) for the version you lock.src/Providers/RequestXmlServiceProvider.php — confirm the macro is registered as Request::xml and see whether it boots per-request.First use case: an XML webhook
The natural entry point is a webhook endpoint that receives a raw XML body (payment gateways, supplier feeds, legacy ERP notifications):
// routes/api.php — CSRF doesn't apply to api.php, but see gotchas for web.php
Route::post('/webhooks/supplier', SupplierWebhookController::class);
// app/Http/Controllers/SupplierWebhookController.php
public function __invoke(Request $request)
{
$payload = $request->xml(); // Illuminate\Support\Collection
$items = $request->xml('order.items.item'); // dot-notated subtree
return response()->noContent();
}
Probe it before wiring anything in. Hit a throwaway route with raw body XML and dd($request->xml()->toArray()) — you need to see how your real payload shape (attributes, namespaces, repeated children) comes out the other side. curl -X POST -H "Content-Type: text/xml" --data-binary @fixture.xml http://localhost:8000/probe is more reliable than Postman’s form modes, which silently send application/x-www-form-urlencoded.
Parse at the edge, pass arrays inward. Keep $request->xml() calls in the controller or a FormRequest and convert to a plain array or DTO immediately. If the package is ever abandoned or you swap parsers, one file changes instead of fifteen.
final class SupplierOrderData
{
public static function fromCollection(Collection $xml): self
{
return new self(
reference: (string) $xml->get('reference'),
total: (float) $xml->get('totals.grand'),
);
}
}
FormRequest wrapper. The macro lives on the underlying request, so $this->xml() works inside a FormRequest — but name your own accessor to avoid confusion and to normalize the shape before validation:
class SupplierOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'reference' => ['required', 'string'],
'items' => ['required', 'array', 'min:1'],
];
}
protected function prepareForValidation(): void
{
$this->merge($this->xmlPayload()->toArray());
}
public function xmlPayload(): Collection
{
return $this->xml();
}
}
Subtree extraction for feed-like payloads. When the XML is a list, pull the repeating node path once and map it into rows rather than walking the whole tree:
$rows = $request->xml('feed.products.product')
->map(fn ($p) => [
'sku' => (string) $p['sku'], // attribute — verify this works in your version
'name' => (string) $p->name,
'price' => (float) $p->price,
]);
Middleware for content-type-agnostic controllers. If a controller should accept both JSON and XML, normalize early so downstream code never branches:
class NormalizeXmlBody
{
public function handle(Request $request, Closure $next)
{
if (str_contains($request->header('Content-Type', ''), 'xml') && $request->getContent()) {
$request->merge($request->xml()->toArray());
}
return $next($request);
}
}
Register it after StartSession and before SubstituteBindings so validation and route model binding see the merged data.
Integration service for outbound + inbound. The package only solves the inbound half. Keep outbound XML in a dedicated client so both directions live together:
class SupplierClient
{
public function push(string $xml): Response
{
return Http::withHeaders(['Content-Type' => 'text/xml'])
->withBody($xml, 'text/xml')
->post(config('services.supplier.url'));
}
public function parse(Request $request): Collection
{
return $request->xml('Envelope.Body.response');
}
}
Testing. Laravel’s $this->post() sends form-encoded data, not a raw body. Use the low-level call:
$response = $this->call(
'POST', '/webhooks/supplier', [], [], [],
['CONTENT_TYPE' => 'text/xml', 'HTTP_ACCEPT' => 'application/json'],
$xmlFixture
);
Store a couple of real XML fixtures in tests/Fixtures — including a “happy path with two items” and a “single item” case, since the single/multiple distinction is where shape bugs hide. When you upgrade Laravel majors, re-run this suite first: a framework-compat release like 2.7.0 touches service-provider boot and macro registration, which is exactly the surface your fixtures pin down.
Own the API surface. Wrap the macro in a small trait or base controller method (xmlPayload()) so your codebase depends on your method, not on mtownsend/request-xml. Given the package’s size and adoption, a 20-line simplexml_load_string fallback is a realistic exit strategy.
xml=<payload>) will not be seen; use $request->input('xml') and parse that yourself. Conversely, any XML-shaped body will parse regardless of Content-Type — add an explicit guard if your endpoint is strict about media types.post_max_size, a middleware check on strlen($request->getContent())) and never echo raw parse errors or the raw body back to the caller.$request->getContent() is non-empty and well-formed first, or accept that you’ll need to parse with an explicit error path in critical flows.soap:Body). Dot notation also doesn’t survive dots or hyphens in tag names well. If your payload is SOAP or attribute-heavy, test the exact node before committing to the package.collect($value)->wrap() or an explicit is_array() check in your DTO factory.Collection, an array, or a scalar can depend on the path argument and any published config. Decide once (collection + toArray() at the DTO boundary) and don’t let callers assume otherwise.Request::hasMacro('xml') if you also use packages that extend Request. Macros registered later win, so boot order in config/app.php matters — and this is the one thing a compat bump like 2.7.0 can plausibly shift.web.php will 419. Put them in api.php or exclude them explicitly (VerifyCsrfToken::$except on Laravel ≤10, bootstrap/app.php validateCsrfTokens on 11+).$request->getContent()) with a request ID before parsing — redact PII, and rotate logs. Without the raw body you cannot reproduce the parse.php artisan config:cache in deploy scripts or your changes won’t apply in production.Request::macro('xml', …) in a later-booting provider to change behavior without forking. Given the package’s maturity level, that override — or a straight replacement with a well-tested XML library — is the sane long-term hedge for anything revenue-critical.How can I help you explore Laravel packages today?