Search by

siberfx / laravel-soap

siberfx

A modern, fluent SOAP client for Laravel 12 & 13 on PHP 8.4+ with config-driven services, events and first-class testing fakes.

Package info

github.com/siberfx/laravel-soap

pkg:composer/siberfx/laravel-soap

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-09-30 18:19 UTC

This package is auto-updated.

Last update: 2026-09-30 18:22:44 UTC


README

Latest Version on Packagist Tests License: MIT

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

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=0 avoids 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_timeout ini setting. Use ini_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. Prefer Soap::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.