phpro/soap-client
A general-purpose SOAP client for PHP with WSDL-based code generation, type-safe wrappers, PSR-18 transport, middleware, and extensible event-driven customization for clean, maintainable integrations.
Prerequisites first. This package is a thin, well-behaved layer on top of ext-soap — it does not replace it. Confirm php -m | grep soap before anything else, and check the PHP constraint of the release you install (current majors target PHP 8.1+). WS-Security and NTLM also pull in extra dependencies/extension requirements (robrichards/wse-php, ext-curl with NTLM support).
composer require phpro/soap-client
php -m | grep -i soap
Decide your entry point. There are two ways to use it, and the choice determines everything else:
Phpro\SoapClient\Client\AbstractClient, wire a SoapClient engine + a HandlerStack. Fine for one or two operations.phpro/soap-client-code-generator-style rules to emit typed DTOs, an assembler, a classmap and a factory from the WSDL.Where to look first (generated path).
config/soap-client.php — your generator config: WSDL path, destination, namespace, and the rules you enable (TypeRule, ClassmapRule, ClientRule, ClientFactoryRule, AssemblerRule).ClientFactory / Client — this is the seam you actually code against.src/.../Types/*.php — the generated request/response DTOs. Read these to learn the real shape of the API, not the WSDL.Classmap.php — the WSDL-type → PHP-class map. If a response comes back as a raw array instead of a DTO, the classmap is the first suspect.Generate and run.
vendor/bin/soap-client generate --config=config/soap-client.php
Commit the output. Then your first call looks roughly like:
$client = MyApiClientFactory::factory(/* PSR-18 client + PSR-17 factories, or SoapClientFactory, depending on version */);
$response = $client->getCustomer(new GetCustomer(['id' => 123]));
$customer = $response->getCustomerResult(); // generated DTO
The exact factory() signature has changed across majors (older versions take a SoapClientFactory + HandlerStack; newer ones take PSR-18/PSR-17 implementations). Read the generated factory rather than copying a snippet from a blog post.
First realistic use case: pick the single simplest operation with a scalar in and a DTO out, get it working end-to-end against the real endpoint, and only then add auth, logging and error handling. SOAP debugging is painful enough without three unknowns at once.
Wrap the generated client immediately. Do not let generated DTOs and Client leak into controllers. Put the generated client behind a service that maps to your domain objects:
final class CustomerGateway
{
public function __construct(private Generated\MyApiClient $client) {}
public function find(int $id): ?Customer
{
try {
$dto = $this->client->getCustomer(new GetCustomer(['id' => $id]))->getCustomerResult();
} catch (\SoapFault $e) {
if ($e->faultcode === 'Client.NotFound') { return null; }
throw $e;
}
return new Customer($dto->getId(), $dto->getName());
}
}
This gives you one place to change when the WSDL drifts and regenerates.
Handler stack + middleware is the extension model. Auth, correlation IDs, logging, retries — all middleware. Order matters: the first pushed is the outermost (sees the request first, the response last). A practical stack:
$stack = new HandlerStack();
$stack->push(new CollectLastRequestInfoMiddleware()); // log bodies/headers as sent
$stack->push(new WsseMiddleware('/path/to/cert.pem'));
$stack->push(new CorrelationIdMiddleware($uuid));
Custom middleware implements MiddlewareInterface, and you must handle the body stream carefully:
final class CorrelationIdMiddleware implements MiddlewareInterface
{
public function process(RequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$request = $request->withHeader('X-Correlation-Id', $this->id);
return $handler->handle($request);
}
}
Events vs middleware. Use middleware when you need to modify the request/response. Use the client's event listeners (addRequestListener, addResponseListener, addFaultListener) for observability that shouldn't touch the message — metrics, timers, structured logs. Events get you the WSDL operation, which is handy for per-operation dashboards.
Typed responses via TypeConverter. The generated Assembler + Classmap does the array-to-DTO mapping. For hand-rolled clients, implement TypeConverter yourself; without one you get stdClass trees and every type assumption becomes a runtime gamble.
Testing. Two layers:
LastRequestInfo or your logging middleware, and assert the parsed DTOs. Keep one real-endpoint smoke test in a separate, non-blocking CI job.Retries belong in middleware, and only for idempotent operations. Check for RequestException (transport) versus SoapFault (server said no) — retrying a SoapFault is almost always wrong.
Multi-service apps: one generated namespace + factory per WSDL, each with its own config file and its own handler stack. Don't try to share a stack between services with different auth.
The WSDL is fetched at construction time. That means slow cold starts, hard failures in air-gapped environments, and surprising behaviour when the remote WSDL changes. Download the WSDL and its imported XSDs to disk, point wsdl at the local file, and verify relative imports still resolve. If you keep remote fetching, set cache_wsdl => WSDL_CACHE_BOTH — and remember to bust that cache after a WSDL update, or you'll debug a schema that no longer exists.
trace => true vs LastRequestInfo. Prefer CollectLastRequestInfoMiddleware. It works with the middleware pipeline, gives you request and response, and doesn't depend on ext-soap's internal trace buffers. Log both bodies plus the HTTP status on failure — a bare SoapFault message is usually useless alone.
Middleware ordering silently breaks auth headers in logs. Push the collector before the auth middleware if you want to see the header that was actually sent; push it after and your logs will be missing Authorization and you'll waste an afternoon.
PSR-7 stream bodies are single-read. Casting (string) $request->getBody() consumes the stream in some implementations. Rewind if seekable, or replace the body with a fresh stream — otherwise you ship an empty SOAP envelope and get a baffling server-side parse error.
Empty elements are a classic SOAP rejection. null vs '' vs element-absent are three different things to a strict server. Normalize explicitly; if the package's empty-node removal middleware is available, use it — but be aware it will also drop elements that are required to be present but empty, which some schemas demand.
Single-element arrays. A repeated element with maxOccurs="unbounded" can encode as a struct rather than a list when there's exactly one item. Always test with 0, 1 and n items. This is the number-one "works in dev, breaks in prod" bug in SOAP clients.
Classmap keys must match namespaces exactly — trailing backslash, casing, no leading slash. A mismatch is silent: you get arrays instead of DTOs, and nothing throws.
Catch both exception families. Transport problems (DNS, TLS, timeout, 500) surface as Phpro\SoapClient\Exception\RequestException; application-level rejections as \SoapFault. A single catch (\Exception) in your gateway hides the distinction and makes retry logic impossible.
Never hand-edit generated code. Regeneration overwrites it. Extend or decorate instead, and run generation in CI with a git diff --exit-code check to catch WSDL drift before it hits production.
Timeouts are two different settings. ext-soap's connection_timeout and PHP's default_socket_timeout govern the engine; any PSR-18 client you wire in has its own timeout config. Set both, or you'll get 60-second hangs nobody can explain.
Debugging checklist when a call fails: (1) reproduce with a raw \SoapClient to isolate engine vs middleware, (2) log the exact XML envelope being sent, (3) confirm which WSDL URL/path is in use, (4) clear the WSDL cache, (5) check namespace prefixes — some legacy servers are strict about prefix conventions even though the spec says they shouldn't be.
Version pinning matters. The middleware interface shape (promise-returning vs ResponseInterface-returning) and the factory constructor arguments differ between v1 and v2+. Read the upgrade guide when bumping a major; a lot of the Stack Overflow answers you'll find are for v1 and won't type-check.
How can I help you explore Laravel packages today?