laminas/laminas-xmlrpc
Laminas XML-RPC provides a complete PHP implementation of the XML-RPC protocol, including both client and server support for encoding, decoding, and dispatching remote procedure calls.
This is not a Laravel package — there is no service provider, no auto-discovery, no config file. Plan to wire it yourself in about fifteen minutes.
composer require laminas/laminas-xmlrpc:^3.0
Three things to check before writing code:
laminas-http dependency from the client transport and bumped to PHP 8.1+ syntax; 2.x still ships the old Laminas HTTP transport. The constructor signature and timeout knobs differ. Run composer show laminas/laminas-xmlrpc and read the Client source in vendor/ before assuming anything from a 2.x tutorial.$client = new \Laminas\Xmlrpc\Client('https://erp.example.com/xmlrpc');
dump($client->getIntrospector()->listMethods());
dump($client->getIntrospector()->methodHelp('erp.createOrder'));
Undocumented legacy servers often answer system.listMethods even when nothing else is documented.Your first real call:
$result = $client->call('erp.getCustomer', [42]);
// scalars come back native; structs come back as PHP associative arrays
Put the endpoint URL and credentials in config/services.php, bind the client as a singleton in a small XmlRpcServiceProvider, and keep calls behind a gateway class from day one — you will want to fake it in tests.
Bind one client per remote endpoint, not one global client. XML-RPC is a transport, not a domain. Wrap it:
// app/Services/Erp/ErpGateway.php
final class ErpGateway
{
public function __construct(private \Laminas\Xmlrpc\Client $client) {}
public function customer(int $id): CustomerData
{
try {
$raw = $this->client->call('erp.getCustomer', [$id]);
} catch (\Laminas\Xmlrpc\Exception\FaultException $e) {
throw new ErpFault($e->getFault()->getMessage(), $e->getFault()->getCode(), $e);
}
return CustomerData::fromXmlRpcStruct($raw);
}
}
Bind ErpGateway in the container with an explicit factory that resolves the endpoint from config, and ->scoped() or singleton() the client. Never let Laminas\Xmlrpc\Client leak into controllers or jobs.
Hydrate into DTOs immediately. XML-RPC returns untyped arrays of strings/integers; the wire format cannot distinguish "42" from 42 if the server is sloppy. Convert at the gateway boundary and validate with Laravel's Validator or a spatie/laravel-data object so the rest of your app never sees the raw struct.
Queue the slow stuff. Legacy XML-RPC endpoints are frequently 2–15s. Dispatch a job, let the queue handle $tries/$backoff, and give the user a status model. Do not add Laravel's RetryUntil / auto-retry blindly — XML-RPC has no idempotency key, so a retried createOrder may place the order twice. Pass your own idempotency reference in the payload and make the remote side dedupe, or restrict retries to read-only methods.
Use explicit value objects for anything ambiguous. PHP's implicit mapping breaks on the edges:
Laminas\Xmlrpc\Value\DateTime for dateTime.iso8601 fields.Laminas\Xmlrpc\Value\Base64 for binary payloads — passing a binary string as Value\Text will be shredded by the XML encoder.Laminas\Xmlrpc\Value\Struct, Value\Integer, Value\Double, Value\Boolean, Value\Text, Value\Nil when the remote side is picky about types.Server side, if you must build one. Register a single POST route outside the web middleware group:
Route::post('/xmlrpc', XmlRpcController::class)->withoutMiddleware([...]);
Register methods explicitly rather than handing setClass() a class name — setClass() instantiates the class itself with $argv, so your Laravel container is bypassed and constructor injection silently fails. Use addFunction() with container-resolved closures instead:
$server = new \Laminas\Xmlrpc\Server();
$server->addFunction(
fn (int $id) => app(CustomerApi::class)->get($id),
'crm.getCustomer'
);
$server->addFunction(fn () => 'pong', 'system.ping');
addFunction($callable, 'ns.method') is the only clean way to get namespacing plus dependency injection. Cache the introspection result with Laminas\Xmlrpc\Server\Cache::save()/load() — reflection-based system.* responses are built on every request otherwise. Generate the cache file in an artisan command and run it as a deploy step.
Testing. Fake at your gateway interface ($this->mock(ErpGateway::class)), not at the HTTP layer — Http::fake() will not intercept this client, because the package builds its own XML envelope and transport. For a real round-trip test, instantiate a Laminas\Xmlrpc\Server, register a fake method, capture its output with ob_start(), and feed that into a Client response parser.
Server::handle() writes directly to output. It sends headers and echoes the XML, which fights Laravel's response lifecycle and sets headers_sent() — breaking redirects, session cookies, and Terminate middleware afterwards. Buffer it and return a proper response:
$level = ob_get_level();
ob_start();
try {
$server->handle();
$xml = ob_get_clean();
} catch (\Throwable $e) {
while (ob_get_level() > $level) { ob_end_clean(); }
throw $e;
}
return response($xml ?: (string) $server->getResponse(), 200)
->header('Content-Type', 'text/xml; charset=utf-8');
Keep that route free of StartSession, ShareErrorsFromSession, and cookie encryption. If ob_get_clean() returns an empty string in your version, fall back to $server->getResponse().
CSRF and auth will bite you. XML-RPC clients send no CSRF token and no bearer header. Exempt the route from VerifyCsrfToken (Laravel ≤10) or validateCsrfTokens(except: ['xmlrpc']) (Laravel 11+). Since the protocol has no authentication, protect the endpoint with IP allowlisting middleware, a shared-secret header, or network-level rules — otherwise you have published RPC over the internet.
Don't leak exceptions from a server. The server converts thrown exceptions into XML-RPC faults, message and stack context included. A QueryException becomes a free schema disclosure. Wrap your API class methods in try/catch (\\Throwable) and re-throw a sanitized Laminas\Xmlrpc\Exception\FaultException (or a plain exception with a generic message and a logged correlation ID).
Transport failures are not FaultException. FaultException only covers spec-level faults returned by the remote. Timeouts, DNS failures, TLS errors, and non-200 responses throw different exceptions from the underlying transport. Catch Throwable at the gateway and map to your own hierarchy, or you'll see unhandled 500s on transient network blips.
Log getLastRequest() / getLastResponse() — carefully. These return raw XML and are the only sane way to debug a "why did this call fail" ticket. But the raw request includes the full URI and any credentials; redact before writing to a shared log channel, and guard against huge structs blowing up your log disk.
Version-specific landmines. Pin ^3.0.1 or later: 3.0.0 emitted an empty tag for Value\Boolean(false), which remote parsers read as null. And 2.21.0 changed integer serialization to emit the spec-valid int instead of integer — if a very old remote implementation only understands i4, you'll need to adapt.
Harden the XML parsing. The package parses remote XML with libxml. Pass LIBXML_NONET (and avoid LIBXML_NOENT) via the LIBXML_ flag options added in 2.13.0; a malicious or compromised remote endpoint returning a DOCTYPE is otherwise an XXE vector. Never let the parser resolve external entities on data from a third party.
Dates drift. dateTime.iso8601 has no timezone in the spec. The value object round-trips whatever wall-clock time it's given, so a naive now() in Europe/Amsterdam becomes a time the far side reads as UTC. Always ->utc() before wrapping, and convert back on the way in.
No PHP method proxy for dotted names. $client->getProxy()->someMethod() is convenient, but XML-RPC method names legitimately contain dots (erp.getCustomer) and PHP method names don't. Use call('erp.getCustomer', $args) for anything namespaced; the proxy is only usable for flat method names.
Set a timeout. Default transport timeouts are generous enough that a hung legacy server will consume your PHP-FPM workers and take the whole app down with it. Configure the underlying HTTP client (in 2.x via the Laminas HTTP client injection, in 3.x via whatever the rewritten transport accepts — check the constructor) to something like 10s, and make it shorter than your queue worker's retry_after.
It's a niche package. Low star count, thin docs, and XDR/XML-RPC itself is a 1998 protocol. Budget for the possibility that you'll be reading vendor/laminas/laminas-xmlrpc/src/ rather than the documentation, and don't build new service-to-service contracts on it — this is for talking to systems you cannot change.
How can I help you explore Laravel packages today?