noki/laravel-xml-converter
A lightweight Laravel package for converting XML to arrays and arrays or objects back to XML. It offers configurable handling for attributes, namespaces, and formatting, making XML integration simple in Laravel apps.
Install and confirm the surface area before writing any feature code. This is a thin, single-purpose package, so the first task is recon, not integration.
composer require noki/laravel-xml-converter
Then, in order:
vendor/noki/laravel-xml-converter/composer.json and look for extra.laravel.providers / aliases. If absent, register the provider manually — bootstrap/providers.php on Laravel 11+, or config/app.php on 10 and below.php artisan vendor:publish --provider="Noki\\XmlConverter\\XmlConverterServiceProvider"
If the command lists nothing, there is no config to publish — move on, don't hunt for it.vendor/noki/laravel-xml-converter/src before the README. With 32 stars and a single maintainer, the source is the documentation. You want two facts up front:
SimpleXML vs DOMDocument vs XMLReader) — this predicts namespace, CDATA, and memory behaviour.toArray / convert / xmlToArray / toXml / arrayToXml). The entire API fits on one screen; confirm it rather than guessing.First use case — parse an inbound XML payload into something Laravel already understands. The dominant real-world need is turning a partner webhook, SOAP response, or supplier feed into an array you can validate and persist:
use Noki\XmlConverter\Facades\XmlConverter; // confirm FQCN in src/
$xml = $request->getContent();
$data = XmlConverter::toArray($xml);
$validated = validator($data['order'] ?? [], [
'id' => 'required|string',
'total' => 'required|numeric',
])->validate();
Order::create($validated);
Second use case — emit XML. The inverse covers sitemaps, invoice exports, and legacy API responses:
$xml = XmlConverter::toXml(['root' => $order->toArray()]);
return response($xml, 200, ['Content-Type' => 'application/xml']);
If the method names differ from the above, grep public function in the package source and adapt — don't build abstraction layers around method names you haven't confirmed.
Laravel's request pipeline assumes form or JSON bodies; XML arrives as an opaque string, so $request->input() and Form Requests see nothing. Parse once, centrally, rather than in every controller:
// app/Http/Middleware/ParseXmlBody.php
public function handle(Request $request, Closure $next)
{
$contentType = $request->header('Content-Type', '');
if (str_contains($contentType, 'xml')) {
try {
$request->merge(XmlConverter::toArray($request->getContent()));
} catch (\Throwable $e) {
report($e);
abort(422, 'Malformed XML payload.');
}
}
return $next($request);
}
After this, XML endpoints behave exactly like JSON endpoints — validation, $request->input(), and Form Requests all work unmodified. This is the single highest-leverage integration point.
Given zero dependents and one maintainer, do not let the facade leak across the codebase. Bind one thin interface:
// app/Contracts/XmlEncoder.php
interface XmlEncoder
{
public function parse(string $xml): array;
public function encode(array $data, string $root = 'root'): string;
}
// app/Providers/AppServiceProvider.php
$this->app->bind(XmlEncoder::class, fn () => new class implements XmlEncoder {
public function parse(string $xml): array
{
return XmlConverter::toArray($xml);
}
public function encode(array $data, string $root = 'root'): string
{
return XmlConverter::toXml([$root => $data]);
}
});
Swapping to symfony/serializer or a native DOMDocument implementation later becomes a one-file change rather than a grep across forty controllers. With a package this small, you are the maintainer — write it so future-you can replace it.
The converter gives you a nested associative array of strings. Treat it as raw input, not domain state:
$data = $encoder->parse($xml);
$order = new OrderData(
id: (string) $data['order']['id'],
total: (float) $data['order']['total'],
createdAt: Carbon::parse($data['order']['created_at']),
lines: collect(Arr::wrap($data['order']['line'] ?? []))->map(
fn ($line) => new OrderLineData(
sku: (string) $line['sku'],
quantity: (int) $line['quantity'],
)
)->all(),
);
Note the Arr::wrap — the converter cannot know whether a single <line> or many will appear, so it typically returns either a scalar-shaped array or a list. Wrapping is the defensive form that survives both cases.
Anything over a few hundred kilobytes belongs in a queued job with a size cap and a timeout:
// app/Jobs/ImportSupplierFeed.php
public int $timeout = 120;
public function handle(XmlEncoder $encoder): void
{
$xml = Storage::disk('feeds')->get($this->path);
if (strlen($xml) > 5 * 1024 * 1024) {
throw new \RuntimeException("Feed exceeds 5MB in-memory limit.");
}
$rows = $encoder->parse($xml)['catalog']['product'] ?? [];
collect(Arr::wrap($rows))->chunk(500)->each(
fn ($chunk) => Product::upsert(
collect($chunk)->map(fn ($r) => [
'sku' => $r['@sku'] ?? $r['sku'],
'price' => (float) $r['price'],
])->all(),
['sku'],
['price'],
)
);
}
Chunk on the array side — you are already in memory, so batching saves DB round-trips, not parse memory.
For feeds, sitemaps, and exports, build a plain array and hand it to the encoder:
$feed = [
'urlset' => [
'@xmlns' => 'http://www.sitemaps.org/schemas/sitemap/0.9',
'url' => $posts->map(fn ($post) => [
'loc' => route('posts.show', $post),
'lastmod' => $post->updated_at->toAtomString(),
'changefreq' => 'weekly',
])->all(),
],
];
return response($encoder->encode($feed['urlset'], 'urlset'), 200, [
'Content-Type' => 'application/xml',
]);
Keep the array shape close to the wire format — do not try to bend a rich domain model into the converter. Assemble DTOs or arrays first, then serialise.
Because upstream test coverage is thin and unverified, own the contract:
public function test_parses_a_partner_order(): void
{
$xml = file_get_contents(base_path('tests/fixtures/order.xml'));
$data = app(XmlEncoder::class)->parse($xml);
$this->assertSame('ORD-10423', $data['order']['id']);
// Guard against silent regressions on the repeated-node shape
$this->assertIsArray(Arr::wrap($data['order']['line']));
}
Store a real payload from the actual partner, not a synthetic one — the edge cases that bite are the ones you did not invent.
The PM and TPM assessments both flag XXE and entity expansion as blockers pending proof, and rightly so. Before this touches any internet-facing endpoint, confirm the underlying parser disables external entity loading:
// What you want to see in the package source (DOMDocument):
$dom = new \DOMDocument();
$dom->loadXML($xml, LIBXML_NONET | LIBXML_NOENT ^ LIBXML_NOENT); // entities off
// and, on PHP 8+, no call to libxml_disable_entity_loader (deprecated) but
// explicit absence of LIBXML_DTDLOAD / LIBXML_NOENT
If the source does not explicitly set flags, wrap the call and add a defensive check of your own: strip <!DOCTYPE and <!ENTITY from untrusted input, or reject payloads containing them outright. For regulated or internet-facing ingestion, treat this as a blocker until proven.
Array round-trips cannot preserve everything XML can express. Expect to lose or flatten:
soap: body and a namespace-less payload), inspect the output before building on it.@attr for attributes and plain keys for elements — but if an element has both an attribute and a child of the same name, one may shadow the other. Check your real payload.<item> may come back as an array; two items as a list of arrays; zero items as absent. Always Arr::wrap() before iterating.toArray() will not cast "3" to 3 or "true" to true. Cast explicitly at the DTO boundary, and never feed the raw array straight into a numeric validation rule without casting first — numeric on "1,234" will fail, and integer on "03" may surprise you.
The common failure mode is a UTF-8 file with a BOM or a declared encoding="ISO-8859-1" that mismatches the actual bytes. Symptoms are SimpleXMLElement::__construct(): Entity: line 1: parser error at offset 0, or mojibake in a single field. Handle by normalising before parse:
$xml = ltrim($xml, "\xEF\xBB\xBF"); // strip UTF-8 BOM
if (! mb_check_encoding($xml, 'UTF-8')) {
$xml = mb_convert_encoding($xml, 'UTF-8', 'ISO-8859-1');
}
Also watch whitespace-only text nodes: pretty-printed input can produce spurious empty-string keys, so trim the payload with preg_replace('/>\s+/', '>', $xml) if you see unexpected keys.
toXml(['root' => $data]) and toXml($data, root: 'root') are easy to confuse. Test the actual output for the root tag, XML declaration, and xmlns attribute you expect — a missing declaration or a doubled root is the most common silent bug when generating a response. Sniff it in a test:
$this->assertStringStartsWith('<?xml', $xml);
$this->assertStringContainsString('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', $xml);
The converter loads the whole document into memory; there is no streaming surface. Set an explicit byte cap on all untrusted input before calling toArray(). Without one, a 200MB "statement" from a partner becomes a fatal Allowed memory size exhausted in the queue worker at 3am. For anything larger, switch to XMLReader with a pull-parser loop — the package is the wrong tool there and no amount of chunking will fix it.
When the output looks wrong, work down this list before touching the package source:
$request->getContent() on the failing endpoint — you are often testing a payload that was already consumed or mangled upstream.xmllint --noout payload.xml catches well-formedness problems the converter will simply throw on.SimpleXML. A ten-line script dumping simplexml_load_string($xml) alongside you toArray() output tells you whether the divergence is the parser or the converter.assertSame against it; regressions in namespace or repeated-node handling will surface immediately.With 32 stars, zero dependents, and a single maintainer, expect the upstream to go quiet. In practice:
"require": { "noki/laravel-xml-converter": "1.*" }
Pin the major, run composer audit in CI, and keep the adapter from the Implementation Patterns section so a replacement is a one-file change. If the package is final on the facade and you need behaviour it lacks — a custom attribute convention, a root-node option, entity hardening — fork it into app/Support/Xml and own the code. The MIT licence makes that trivial, and at this size a fork is cheaper than waiting for a PR.
Before assuming it is closed, look in src/ for a config-driven mapper or a parser-configurable constructor. Several thin converters expose a $depth, $options, or $parserFlags argument — if noki does, that is your hook for hardening DOMDocument flags without forking. If it doesn't, the wrapper is the extension point.
How can I help you explore Laravel packages today?