sourcetoad/soapy
Soapy is a lightweight Laravel/PHP package that simplifies SOAP integrations. It offers a clean client wrapper for WSDL endpoints, requests, and responses, helping developers connect to legacy SOAP services without boilerplate or complex configuration.
Soapy is a thin, fluent wrapper around PHP's native SoapClient that gives you a Laravel facade, a config file, and a chainable request builder. As of v3.1.0 it supports PHP 8.4 and Laravel 12; there are no API changes from v3.0.0, so this is a drop-in upgrade (composer update sourcetoad/soapy).
Minimum steps:
ext-soap PHP extension. It is not bundled with Laravel and is missing from many slim Docker images. Verify with php -m | grep soap before doing anything else — a missing extension produces confusing "class SoapClient not found" errors rather than a clear dependency message.composer require sourcetoad/soapy
php artisan vendor:publish --tag=soapy-config
This gives you config/soapy.php (WSDL URL, trace, exceptions, timeouts, WSDL cache mode, stream_context, classmap/typemap).php artisan tinker:
use Sourcetoad\Soapy\Facades\Soapy;
$response = Soapy::endpoint(config('soapy.wsdl'))
->call('SomeOperation')
->withArguments(['id' => 123])
->response();
If the WSDL is unreachable or malformed, this throws at endpoint construction time, not at call time — so this single line validates connectivity, auth, and WSDL parsing all at once.Where to look first: the published config/soapy.php (it is the primary integration surface), then the package README for the exact response accessors (asJson() / raw XML) available in your version. Keep 'trace' => true and 'exceptions' => true in local/staging from day one — they cost little and make every subsequent debugging session trivial.
First real use case: wrapping a single external SOAP operation (a lookup, a submission) behind a dedicated App\Services\* class that returns a typed DTO. Do not call the facade from controllers or jobs directly; see below.
Config-driven, per-service endpoints. Don't let the facade be called with hardcoded URLs. Publish the config, then add one entry per upstream system:
// config/soapy.php
'connections' => [
'billing' => [
'wsdl' => env('BILLING_WSDL'),
'trace' => env('APP_DEBUG', false),
'exceptions' => true,
'connection_timeout' => 10,
'cache_wsdl' => env('APP_ENV') === 'production' ? WSDL_CACHE_BOTH : WSDL_CACHE_NONE,
],
],
Service wrapper + DTO boundary. The highest-value pattern: one class per remote system, constructor-injected config, returning your own DTOs — never the package's response object.
final class BillingClient
{
public function charge(ChargeRequest $req): ChargeResult
{
$raw = Soapy::endpoint(config('soapy.connections.billing.wsdl'))
->call('Charge')
->withArguments([
'account' => $req->account,
'amount' => $req->amountMinor / 100,
'currency'=> $req->currency,
])
->response();
return ChargeResult::fromArray((array) $raw);
}
}
This gives you a seam for tests, one place to add retry/logging, and — importantly — insulation from Soapy's response shape if the package changes.
Typed argument objects over arrays. SOAP's wire format is namespace-sensitive; passing plain arrays works for simple operations but silently drops elements whose XSD type the encoder can't infer. For nested or xsd:any content, build SoapVar instances (or configure classmap in config/soapy.php) and pass those as arguments. When in doubt, echo the generated envelope before blaming the remote server.
Auth and SOAP headers. For WS-Security / API-key headers, prefer whatever header helper your version exposes on the builder; if the operation needs anything unusual, drop to the underlying client (->soapClient() or equivalent) and use native SoapHeader objects. Keep header construction in the wrapper class, keyed off config('services.*') — never inline credentials.
Retries and timeouts. SOAP calls are ordinary blocking I/O. Set connection_timeout/timeout in config and wrap writes in retry() only for idempotent operations — there is no generic idempotency key in SOAP, so blind retries on Create/Submit operations will double-post.
Queued jobs for anything slow. A flaky third-party SOAP endpoint should never sit in a web request. Dispatch a job, store the response, and poll — that also gives you a natural place for backoff and dead-letter handling.
Testing. ext-soap cannot be intercepted by Laravel's Http fake. Two reliable approaches: (a) bind a BillingClient interface in the container and swap in a fake, keeping all SOAP assertions out of your feature tests; or (b) record real request/response XML pairs into fixtures and have a thin transport double replay them for contract tests. Prefer (a) for the bulk of tests and reserve (b) for the one test that validates your argument mapping.
ext-soap is invisible until it isn't. Add ext-soap: * to your composer.json require block so CI fails at install time rather than at runtime.trace defaults bite you. If trace is false, last-request/last-response inspection returns empty strings and you'll waste an hour thinking the request was never sent. Enable it in every non-production environment.exceptions => false changes your error handling completely. Faults come back as SoapFault objects instead of throwing, so try/catch silently does nothing. Pick one mode per environment and keep it consistent; production throwing + a single catch at the wrapper boundary is usually cleanest.WSDL_CACHE_NONE in dev/staging, WSDL_CACHE_BOTH in production, and remember to clear after deploys that change config.php artisan config:cache freezes your endpoints. Any env() call must live inside config/soapy.php, not in your service class — otherwise it returns null the moment config is cached in production.getLastRequest() (with trace on) and compare the element prefixes against the WSDL's targetNamespace.['items' => ['item' => [...]]] rather than ['items' => [...]]. If only the first element arrives server-side, that's the reason.null is not "omit". Passing null explicitly serialises an empty element; to omit a field entirely you must leave the key out of the argument array. Some validators reject the former.stdClass graphs. Request only the fields you need where the operation supports filtering, and avoid logging full responses in production.stream_context, not in code. Configure ssl/http context options in config/soapy.php so certificates and proxy settings aren't scattered across service classes.APP_DEBUG.How can I help you explore Laravel packages today?