php-soap/ext-soap-engine
A SOAP engine for php-soap that builds clients from WSDL using PHP's native ext-soap extension. It generates type-safe client classes, handles requests and responses, and lets you swap engines without changing your application code.
Requirements: PHP 8.x with the native ext-soap extension enabled (php -m | grep soap). There are no extra runtime dependencies — the package just adapts \SoapClient behind php-soap's Engine interface.
Install:
composer require php-soap/ext-soap-engine
# optional, but this is what most projects actually talk to:
composer require php-soap/client
Look here first:
Soap\ExtSoapEngine\ExtSoapEngineFactory — entry point, builds an Engine from options.Soap\ExtSoapEngine\ExtSoapOptions — fluent wrapper around ext-soap options array.Soap\ExtSoapEngine\ExtSoapDriver — the adapter that owns the underlying \SoapClient.ExtSoapClientInterface — the small surface used internally (call + last request/response).Minimal first call (engine level):
use Soap\ExtSoapEngine\ExtSoapEngineFactory;
use Soap\ExtSoapEngine\ExtSoapOptions;
$engine = ExtSoapEngineFactory::fromOptions(
Ext soapOptions::defaults('https://example.com/service?wsdl', [
'trace' => true,
'exceptions' => true,
])
);
$result = $engine->request('Add', ['intA' => 1, 'intB' => 2]);
Minimal first call (DX client):
use Soap\Client\Client;
$client = new Client($engine);
$result = $client->Add(['intA' => 1, 'intB' => 2]); // magic __call
// or explicitly: $client->call('Add', ['intA' => 1, 'intB' => 2]);
First use case: swapping an existing new \SoapClient($wsdl, $options) while keeping the same options array — then progressively adding a classmap, typed DTOs, middleware, and test doubles without changing the call sites.
Bind one engine per remote service in the container. Engines are cheap wrappers, but WSDL parsing is not — build once and reuse:
$this->app->singleton('soap.crm', fn () => ExtSoapEngineFactory::fromOptions(
ExtSoapOptions::defaults(storage_path('app/wsdl/crm.wsdl'), [
'trace' => config('app.debug'),
'exceptions' => true,
'cache_wsdl' => WSDL_CACHE_DISK,
])
));
Prefer a local, versioned WSDL in production. Download it at build/deploy time and point defaults() at the file path. This removes a network hop, makes behaviour reproducible, and avoids "the vendor changed the WSDL" incidents. Override the endpoint separately via the location option.
Map WSDL types to your own DTOs instead of working with stdClass chains:
$options = ExtSoapOptions::defaults($wsdl)
->withClassMap(App\Soap\ClassMap::class)
->withTypeMap(App\Soap\TypeMap::class);
Then $result contains your domain objects; keep those classes dumb (public props, no constructor args required) so deserialization works.
Non-WSDL mode is supported — pass null as the WSDL and supply location + uri in the options array. Use this for tiny internal services where WSDL parsing cost outweighs the benefit. You lose metadata/autocompletion and must know the exact operation names.
Decorate the Engine for cross-cutting concerns. This is the cleanest extension point because it sits above ext-soap and works identically across engines:
final class LoggingEngine implements \Soap\Engine\Engine
{
public function __construct(
private \Soap\Engine\Engine $inner,
private \Psr\Log\LoggerInterface $logger,
) {}
public function request(string $operation, array $arguments): mixed
{
$start = microtime(true);
try {
return $this->inner->request($operation, $arguments);
} finally {
$this->logger->info('soap.call', [
'operation' => $operation,
'ms' => (int) ((microtime(true) - $start) * 1000),
]);
}
}
public function getMetadata(): \Soap\Engine\Metadata\Metadata
{
return $this->inner->getMetadata();
}
}
This is where retries, circuit breakers, metrics, and argument scrubbing belong.
Inspect metadata for discovery/validation via $engine->getMetadata(). Useful for building an admin screen that lists operations and their parameter types, or for asserting at boot that the WSDL actually contains the operation you're about to call.
Testing: don't mock \SoapClient (it's final-ish and awkward). Implement Soap\Engine\Engine with a stub engine returning canned responses, or capture a real (sanitized) response and replay it. Assert on the arguments your code passed. This is the single biggest day-to-day benefit of using php-soap abstractions over raw ext-soap.
php.ini. ext-soap enabled in web but missing in php artisan / queue workers is the most common first-hour bug. Check both.exceptions => true. With the ext-soap default (false), failures come back as SoapFault objects rather than exceptions, which silently breaks php-soap's error model.trace => true is required for debugging. Only then can you read __getLastRequest() / __getLastResponse() off the underlying SoapClient (accessible through ExtSoapDriver). Never leave it on in high-throughput production paths — it buffers full payloads in memory.soap.wsdl_cache_enabled / soap.wsdl_cache_ttl still apply. Use WSDL_CACHE_NONE plus ExtSoapOptions::disableWsdlCache() in dev and tests, and an on-disk cache in prod. Stale-cache issues after a vendor WSDL update are classic "it works on my machine".connection_timeout is a connect timeout only. There's no per-request read timeout in ext-soap options; enforce overall timeouts at the stream context (stream_context option, e.g. http.timeout) or above the engine.stream_context, not through Guzzle/cURL options — for example verify_peer, verify_peer_name, cafile. Corporate proxies also need proxy_host / proxy_port.null will blow up during deserialization — prefer nullable types. Also be wary of readonly/promoted constructor properties.features => SOAP_SINGLE_ELEMENT_ARRAYS and normalize at the boundary. SOAP_USE_XSI_ARRAY_TYPE matters if the server validates request types strictly.Engine decorator or to ext-soap's own options (login/password, stream_context).SoapHeader by hand.__setSoapHeaders is sticky state. If you set headers on a shared client instance, they leak into subsequent calls (and across tenants in long-running workers/Octane). Scope engines per request, or reset headers explicitly.SoapFault gives you faultcode/faultstring and little else; log the last request/response (requires trace) or you'll be debugging blind. SoapUI/Insomnia against the same WSDL is still the fastest way to confirm whether the problem is your payload or the server.trace plus large responses add up. Cap memory_limit awareness, stream where possible, and avoid logging full payloads in production.Engine decorator, (2) ExtSoapOptions subclass for shared defaults, (3) custom TypeMap/ClassMap converters, (4) stream_context. If you find yourself patching \SoapClient behaviour, reconsider whether ext-soap is the right engine for that integration.How can I help you explore Laravel packages today?