siberfx / laravel-soap
A modern, fluent SOAP client for Laravel 12 & 13 on PHP 8.4+ with config-driven services, events and first-class testing fakes.
Requires
- php: ^8.4
- ext-soap: *
- illuminate/contracts: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A modern, fluent SOAP client for Laravel. Register SOAP services once — in config or in code —
and call them anywhere with Soap::call('Service.Method', $arguments). Includes class maps,
SOAP headers, authentication, TLS/proxy options, events, readable exceptions and a
testing fake so your test suite never touches a real SOAP endpoint.
Inspired by notfalsedev/laravel-soap, rewritten for PHP 8.4+ and Laravel 12/13.
Contents
- Requirements
- Installation
- Quick start
- Registering services
- Service options reference
- Calling operations
- Error handling
- Events
- Testing
- Customisation
- Migrating from notfalsedev/laravel-soap
- Changelog
- License
Requirements
| Package | PHP | Laravel |
|---|---|---|
| 1.x | 8.4, 8.5, 8.6 | 12.x, 13.x |
The PHP soap extension must be enabled.
Installation
composer require siberfx/laravel-soap
The service provider and the Soap facade are registered automatically via package discovery.
Publish the configuration file (optional):
php artisan vendor:publish --tag=soap-config
Quick start
use Siberfx\Soap\Facades\Soap; use Siberfx\Soap\Service; Soap::add('Currency', fn (Service $service) => $service ->wsdl('https://currencyconverter.kowabunga.net/converter.asmx?WSDL') ->trace() ); $amount = Soap::call('Currency.GetConversionAmount', [[ 'CurrencyFrom' => 'USD', 'CurrencyTo' => 'EUR', 'RateDate' => '2026-09-30', 'Amount' => '1000', ]]);
Registering services
A service is a named SOAP endpoint. Each service gets its own lazily created, cached
\SoapClient, built the first time you call it.
In code
Register services in a service provider's boot() method (for example AppServiceProvider):
use Siberfx\Soap\Facades\Soap; use Siberfx\Soap\Service; public function boot(): void { Soap::add('Weather', function (Service $service) { $service ->wsdl(config('services.weather.wsdl')) ->soapVersion(SOAP_1_2) ->basicAuth(config('services.weather.user'), config('services.weather.password')) ->connectionTimeout(10) ->cache(WSDL_CACHE_BOTH); }); }
add() also accepts the same array format used in the config file:
Soap::add('Weather', [ 'wsdl' => 'https://example.com/weather?wsdl', 'trace' => true, ]);
Registering the same name twice throws ServiceAlreadyExistsException. Use Soap::forget('Weather')
first if you really need to replace a service. Other helpers:
Soap::has('Weather'); // bool Soap::service('Weather'); // Siberfx\Soap\Service Soap::services(); // array<string, Service> Soap::addMany([ 'Weather' => [...], 'Currency' => fn (Service $s) => $s->wsdl('...'), ]);
You can also inject Siberfx\Soap\SoapWrapper instead of using the facade — it is bound as a
singleton (also aliased as soap).
In config
Services in config/soap.php are registered automatically:
'services' => [ 'currency' => [ 'wsdl' => env('CURRENCY_WSDL'), 'trace' => true, 'classmap' => [ 'GetConversionAmount' => App\Soap\Types\GetConversionAmount::class, ], 'login' => env('CURRENCY_USER'), 'password' => env('CURRENCY_PASSWORD'), ], ],
Supported keys: wsdl, location, uri, soap_version, trace, cache (or cache_wsdl),
classmap, typemap, headers, stream_context, verify_ssl, client and options.
Any other key is passed as-is to the native \SoapClient (e.g. login, password,
authentication, proxy_host, local_cert, user_agent, keep_alive). null values are ignored,
so unset environment variables fall back to the defaults.
Global defaults
soap.defaults is applied to every service — from config or code — before the service's own settings:
'defaults' => [ 'trace' => (bool) env('SOAP_TRACE', false), 'soap_version' => SOAP_1_1, 'cache' => (int) env('SOAP_WSDL_CACHE', WSDL_CACHE_BOTH), 'connection_timeout' => (int) env('SOAP_CONNECTION_TIMEOUT', 30), 'verify_ssl' => (bool) env('SOAP_VERIFY_SSL', true), ],
| Env variable | Default | Description |
|---|---|---|
SOAP_TRACE |
false |
Keep raw request/response XML for every service. |
SOAP_WSDL_CACHE |
3 |
0 none, 1 disk, 2 memory, 3 both. |
SOAP_CONNECTION_TIMEOUT |
30 |
Seconds to wait while connecting. |
SOAP_VERIFY_SSL |
true |
Set to false only for local/self-signed hosts. |
SOAP_EVENTS |
true |
Dispatch request/response events. |
Tip: during development
SOAP_WSDL_CACHE=0avoids stale WSDLs after a remote change.
Non-WSDL mode
For services without a WSDL, provide location and uri instead:
Soap::add('Legacy', fn (Service $s) => $s ->location('https://legacy.example.com/soap') ->uri('urn:legacy-service') );
A service with neither a WSDL nor both location and uri throws
InvalidServiceConfigurationException when its client is built.
Service options reference
All methods return the service, so they can be chained.
| Method | \SoapClient option / effect |
|---|---|
wsdl(?string $wsdl) |
WSDL URL or local path (null = non-WSDL mode) |
location(string $url) |
location — endpoint, overrides the WSDL endpoint |
uri(string $namespace) |
uri — target namespace (non-WSDL mode) |
soapVersion(int $version) |
soap_version — SOAP_1_1 or SOAP_1_2 |
trace(bool $trace = true) |
trace — keep the last request/response |
cache(int $mode) |
cache_wsdl — WSDL_CACHE_NONE/DISK/MEMORY/BOTH |
classMap(array $map) / classmap() |
classmap — WSDL type ⇒ PHP class (merged) |
typeMap(array $map) |
typemap (merged) |
header($ns, $name, $data, $mustUnderstand, $actor) |
Adds a \SoapHeader sent with every call |
header(SoapHeader $header) / customHeader() |
Adds a prebuilt \SoapHeader |
headers(iterable $headers) |
Adds several \SoapHeaders |
basicAuth(string $login, string $password) |
login, password, SOAP_AUTHENTICATION_BASIC |
digestAuth(string $login, string $password) |
login, password, SOAP_AUTHENTICATION_DIGEST |
certificate(string $path, ?string $passphrase) |
local_cert, passphrase — client certificate (mTLS) |
proxy(string $host, int $port, ?$login, ?$password) |
proxy_host, proxy_port, proxy_login, proxy_password |
connectionTimeout(int $seconds) |
connection_timeout |
userAgent(string $agent) |
user_agent |
compression(int $flags) |
compression, e.g. SOAP_COMPRESSION_ACCEPT | SOAP_COMPRESSION_GZIP |
keepAlive(bool $keepAlive = true) |
keep_alive |
features(int $flags) |
features, e.g. SOAP_SINGLE_ELEMENT_ARRAYS |
encoding(string $encoding) |
encoding |
streamContext(array|resource $context) |
stream_context — array is turned into a context |
verifySsl(bool $verify = true) |
false disables peer/host verification |
client(string $class) |
Use a custom \SoapClient subclass |
option(string $key, mixed $value) |
Any raw option |
options(array $options) |
Several raw options — these win over everything else |
exceptions is always true: failures are reported as exceptions, never as return values.
Read timeouts: PHP's SOAP extension reads responses using the
default_socket_timeoutini setting. Useini_set('default_socket_timeout', 60)for long-running operations.
The configured values are readable (but not writable) as properties, e.g.
Soap::service('Weather')->wsdl, ->trace, ->classMap, ->headers, and
->toOptions() returns the exact options array passed to \SoapClient.
Calling operations
$result = Soap::call('Service.Method', $arguments, $options, $inputHeaders, $outputHeaders);
| Parameter | Description |
|---|---|
$target |
"Service.Method". The last dot separates the method, so service names may contain dots (acme.billing.GetInvoice). |
$arguments |
Array passed to \SoapClient::__soapCall(). |
$options |
Per-call options: location, uri, soapaction. |
$inputHeaders |
\SoapHeader or array of headers for this call only. |
$outputHeaders |
Passed by reference, filled with the response headers. |
About $arguments: most WSDL services use the document/literal wrapped style, where an
operation takes a single parameter object. Pass that object as the only element of the array:
// document/literal: one wrapper parameter Soap::call('Currency.GetConversionAmount', [ ['CurrencyFrom' => 'USD', 'CurrencyTo' => 'EUR', 'RateDate' => '2026-09-30', 'Amount' => '1000'], ]); // rpc style / non-WSDL: positional parameters Soap::call('Calculator.add', [2, 3]);
Reading response headers requires a by-reference argument, which PHP cannot pass through a
facade — call the injected SoapWrapper instead:
use Siberfx\Soap\SoapWrapper; public function transfer(SoapWrapper $soap, array $payload): mixed { $result = $soap->call('Bank.Transfer', [$payload], outputHeaders: $headers); $sessionId = $headers['SessionId'] ?? null; return $result; }
Class maps
Map WSDL complex types to your own classes to get typed requests and responses:
namespace App\Soap\Types; class GetConversionAmount { public function __construct( public string $CurrencyFrom, public string $CurrencyTo, public string $RateDate, public string $Amount, ) {} } class GetConversionAmountResponse { public string $GetConversionAmountResult; }
Soap::add('Currency', fn (Service $s) => $s ->wsdl('https://currencyconverter.kowabunga.net/converter.asmx?WSDL') ->classMap([ 'GetConversionAmount' => GetConversionAmount::class, 'GetConversionAmountResponse' => GetConversionAmountResponse::class, ]) ); /** @var GetConversionAmountResponse $response */ $response = Soap::call('Currency.GetConversionAmount', [ new GetConversionAmount('USD', 'EUR', '2026-09-30', '1000'), ]); $response->GetConversionAmountResult;
SOAP headers
Headers added on the service are sent with every call:
Soap::add('Secure', fn (Service $s) => $s ->wsdl('https://example.com/secure?wsdl') ->header('http://example.com/auth', 'AuthHeader', [ 'Username' => config('services.secure.user'), 'Password' => config('services.secure.password'), ], mustUnderstand: true) );
Headers for a single call:
Soap::call('Secure.GetOrders', [$request], inputHeaders: [ new SoapHeader('http://example.com/tracing', 'CorrelationId', (string) Str::uuid()), ]);
Working with the client directly
Soap::client($name) returns the cached client — a Siberfx\Soap\SoapClient (an extended
\SoapClient) unless you configured another class:
$client = Soap::client('Currency'); $client->functions(); // operations described by the WSDL $client->types(); // types described by the WSDL $client->call('GetCurrencies'); $client->GetCurrencies(); // native magic calls work too
Pass a closure to receive the client and return a value:
$rates = Soap::client('Currency', fn (SoapClient $client) => $client->call('GetCurrencies'));
Calls made directly on the client bypass the wrapper, so they are not faked, recorded, converted into
SoapCallExceptions or reported through events. PreferSoap::call().
Debugging requests
Enable trace() on a service (or SOAP_TRACE=true for all of them) and inspect the raw XML:
Soap::call('Currency.GetCurrencies'); $client = Soap::client('Currency'); $client->lastRequest(); $client->lastRequestHeaders(); $client->lastResponse(); $client->lastResponseHeaders();
With trace enabled, the XML is also attached to SoapCallException and SoapResponseReceived.
Error handling
All package exceptions extend Siberfx\Soap\Exceptions\SoapException (a RuntimeException).
| Exception | Thrown when |
|---|---|
SoapCallException |
The server returned a SOAP fault or the transport failed during a call. |
ClientCreationException |
The client could not be built (unreachable / invalid WSDL, bad options). |
ServiceNotFoundException |
Calling a service that was not registered. |
ServiceAlreadyExistsException |
Registering a name that is already taken. |
InvalidServiceConfigurationException |
Missing WSDL / location+uri, or a malformed "Service.Method" target. |
StrayCallException |
A faked call had no stub while preventStrayCalls() is enabled. |
SoapCallException gives you everything about the failure:
use Siberfx\Soap\Exceptions\SoapCallException; try { Soap::call('Bank.Transfer', [$payload]); } catch (SoapCallException $e) { $e->service; // "Bank" $e->method; // "Transfer" $e->faultCode(); // e.g. "soap:Server" $e->faultString(); // human readable message from the server $e->detail(); // the <detail> element, if any $e->lastRequest; // raw XML (trace enabled) $e->lastResponse; // raw XML (trace enabled) $e->fault; // the original \SoapFault (also getPrevious()) }
Events
When soap.events is enabled (default), every Soap::call() dispatches:
| Event | Properties |
|---|---|
Siberfx\Soap\Events\SoapRequestSending |
service, method, arguments |
Siberfx\Soap\Events\SoapResponseReceived |
service, method, arguments, response, duration (ms), lastRequest, lastResponse |
Siberfx\Soap\Events\SoapRequestFailed |
service, method, arguments, exception (SoapCallException), duration (ms) |
Example: log slow or failing calls.
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use Siberfx\Soap\Events\SoapRequestFailed; use Siberfx\Soap\Events\SoapResponseReceived; Event::listen(function (SoapResponseReceived $event) { if ($event->duration > 2000) { Log::warning("Slow SOAP call {$event->service}.{$event->method}", ['ms' => $event->duration]); } }); Event::listen(function (SoapRequestFailed $event) { Log::error($event->exception->getMessage(), [ 'request' => $event->exception->lastRequest, 'response' => $event->exception->lastResponse, ]); });
Arguments may contain credentials or personal data — scrub them before logging.
Testing
Call Soap::fake() and no request leaves your application. Every call is recorded so you can
assert on it. Services do not even need to be registered while faked.
use Siberfx\Soap\Facades\Soap; use Siberfx\Soap\Testing\RecordedCall; public function test_it_converts_currency(): void { Soap::fake([ 'Currency.GetConversionAmount' => (object) ['GetConversionAmountResult' => '921.50'], ]); $this->post('/convert', ['from' => 'USD', 'to' => 'EUR', 'amount' => 1000]) ->assertOk() ->assertSee('921.50'); Soap::assertCalled('Currency.GetConversionAmount', fn (RecordedCall $call) => $call->argument('0.CurrencyFrom') === 'USD' ); }
Stubs
| Stub key | Matches |
|---|---|
'Currency.GetRates' |
exactly that call (exact keys always win) |
'Currency.*' |
any method of the service |
'*' |
everything |
| Stub value | Result |
|---|---|
| any value | returned as the response |
Closure |
called with the RecordedCall; its return value is used |
\SoapFault |
converted to SoapCallException (just like a real fault) |
any other Throwable |
thrown as-is (e.g. simulate a timeout) |
Soap::fake([ 'Weather.Forecast' => fn (RecordedCall $call) => "Sunny in {$call->argument('0.city')}", 'Bank.Transfer' => new SoapFault('Server', 'Insufficient funds'), 'Currency.*' => 1.08, ]);
Unmatched calls return null. To make them fail instead:
Soap::fake([...])->preventStrayCalls();
Soap::fake() can be called multiple times; stubs are merged.
Assertions
Soap::assertCalled('Currency.GetConversionAmount'); Soap::assertCalled('Currency.*', fn (RecordedCall $call) => $call->argument('0.Amount') === '1000'); Soap::assertNotCalled('Bank.Transfer'); Soap::assertCalledTimes('Currency.GetConversionAmount', 2); Soap::assertNothingCalled(); Soap::recorded(); // list<RecordedCall> Soap::recorded(fn (RecordedCall $call) => $call->service === 'Bank');
RecordedCall exposes service, method, arguments, target() ("Service.Method") and
argument($key, $default) which supports dot notation into arrays and objects.
Customisation
Custom client class
Extend Siberfx\Soap\SoapClient (or \SoapClient) — for example to sign requests with WS-Security:
use Siberfx\Soap\SoapClient; class SignedSoapClient extends SoapClient { public function __doRequest( string $request, string $location, string $action, int $version, bool $oneWay = false, ?string $uriParserClass = null, // PHP 8.5+, harmless on 8.4 ): ?string { $signed = app(RequestSigner::class)->sign($request); return parent::__doRequest($signed, $location, $action, $version, $oneWay); } } Soap::add('Government', fn (Service $s) => $s->wsdl('...')->client(SignedSoapClient::class));
Custom client factory
All clients are built by Siberfx\Soap\ClientFactory. Bind your own subclass to change how
every client is created:
$this->app->singleton(\Siberfx\Soap\ClientFactory::class, MyClientFactory::class);
Migrating from notfalsedev/laravel-soap
The API is intentionally familiar:
| notfalsedev/laravel-soap | siberfx/laravel-soap |
|---|---|
Artisaninweb\SoapWrapper\SoapWrapper |
Siberfx\Soap\SoapWrapper or the Soap facade |
$soapWrapper->add('Name', fn ($service) => ...) |
Soap::add('Name', fn (Service $service) => ...) |
$service->name('Name') |
not needed — the name is the first add() argument |
->wsdl(), ->trace(), ->classmap(), ->cache(), ->options(), ->certificate(), ->header(), ->customHeader() |
same names |
$soapWrapper->call('Name.Method', [...]) |
Soap::call('Name.Method', [...]) |
$soapWrapper->client('Name', fn ($client) => ...) |
Soap::client('Name', fn ($client) => ...) (returns the callback result) |
$client->call('Method', [...]) |
same |
raw SoapFault |
SoapCallException (original fault via ->fault) |
Development
composer install
composer test
The test suite runs real SOAP round-trips against an in-process SoapServer, so no network
access is required. The soap extension must be enabled.
Changelog
See CHANGELOG.md.
License
The MIT License (MIT). See LICENSE.md.