Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Psr18 Transport Laravel Package

php-soap/psr18-transport

PSR-18 transport for php-soap, letting you drive SOAP clients through any PSR-18 HTTP client. Swap in your preferred HTTP library, keep full control of requests and middleware, and integrate SOAP cleanly with modern PHP HTTP stacks.

View on GitHub
Deep Wiki
Context7

Getting Started

The transport is deliberately thin: it turns the SOAP XML string that php-soap/engine produces into a PSR-7 request, hands that to your PSR-18 client, and returns the response body. Everything interesting (auth, logging, retries, timeouts) lives in the HTTP client, not in the package.

Install the transport plus a real HTTP client and PSR-7/PSR-17 factories:

composer require php-soap/psr18-transport guzzlehttp/guzzle nyholm/psr7

You need both a PSR-18 client and a PSR-17 factory implementation. The transport has to build requests, and Psr18Transport::createForClient() relies on PSR-17 discovery — installing only Guzzle is the single most common first-run failure.

Wire it up:

use GuzzleHttp\Client as GuzzleClient;
use Soap\Engine\Engine;
use Soap\Psr18Transport\Psr18Transport;

$psr18 = new GuzzleClient([
    'http_errors' => false,   // see Gotchas — critical for SOAP faults
    'timeout'         => 10,
    'connect_timeout' => 3,
]);

$transport = Psr18Transport::createForClient($psr18);

$engine = Engine::default()->withTransport($transport);
$client = new MyGeneratedSoapClient($engine);

If your php-soap version doesn't expose Engine::withTransport(), pass the transport as the transport argument of the generated client instead — the generated client constructor accepts (Engine, ?Transport).

Where to look first, in order:

  1. Psr18Transport::createForClient() — the only factory you normally need.
  2. Your generated client's constructor — to see how the transport is injected.
  3. Your PSR-18 client's docs — this is where 90% of the work happens.

First use case: swap the default ext-soap socket transport for Guzzle so you can attach a SOAPAction/Authorization header, log the raw XML, and assert on requests in tests. Start by logging one real request/response pair — it immediately demystifies what the engine does and doesn't do.

Implementation Patterns

Bind it once in the container

The transport is stateless and cheap; bind it as a singleton in a service provider so all generated clients share one HTTP client (connection reuse, one middleware stack).

$this->app->singleton(Transport::class, function ($app) {
    $psr18 = new GuzzleClient([
        'http_errors' => false,
        'timeout' => config('soap.timeout', 10),
        'handler' => HandlerStack::create(), // stack shared, middleware pushed below
    ]);

    return Psr18Transport::createForClient($psr18);
});

$this->app->bind(SomeServiceClient::class, fn ($app) =>
    new SomeServiceClient(Engine::default(), $app->make(Transport::class))
);

If different services need different endpoints/credentials, bind per-client tokens (SomeServiceTransport, BillingTransport) rather than one global transport.

Authentication lives in the middleware stack

Do not try to authenticate inside the SOAP envelope unless the service actually uses WS-Security. HTTP auth, bearer tokens, and API keys belong in the PSR-18 client:

$stack->push(Middleware::mapRequest(function (RequestInterface $request) {
    return $request
        ->withHeader('Authorization', 'Bearer '.$token)
        ->withHeader('X-Correlation-Id', (string) Str::uuid());
}));

WS-Security / signed bodies are a different layer — add those headers/XML before the transport (a request-mapping middleware that rewrites the body), and keep the two concerns separate in your code.

Logging: capture request and response as strings

$stack->push(Middleware::tap(
    fn ($req) => Log::debug('soap.request', ['body' => (string) $req->getBody()]),
    fn ($res) => Log::debug('soap.response', ['body' => (string) $res->getBody()])
));

Always cast the stream to (string) for logging — but be aware you've now consumed the stream. Guzzle's Middleware::history (and casting before returning) is the safer pattern if you still need the body downstream; rewind with $body->rewind().

Retries — narrowly

Retry only transport-level failures (connection reset, timeout) and idempotent-safe statuses (429, 503 with Retry-After). SOAP calls are frequently non-idempotent; a blind retry can create duplicate orders. Wire retries via Guzzle's RetryMiddleware or Symfony's RetryableHttpClient — never inside the SOAP layer.

Custom headers and correlation IDs

Add a request-mapping middleware that stamps SOAPAction (the engine sets it, but middleware that rebuilds the request can drop it — re-add it explicitly) and your tracing headers. This is the cheapest useful thing you get from the package.

Testing: fake the PSR-18 client

The whole point of decoupling is that you never touch a network in unit tests. Implement a 20-line fake:

final class FakePsr18Client implements ClientInterface
{
    /** @var RequestInterface[] */
    public array $requests = [];

    public function __construct(private string $responseBody, private int $status = 200) {}

    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        $this->requests[] = $request;
        return new Response($this->status, ['Content-Type' => 'text/xml'], $this->responseBody);
    }
}

Then assert the SOAPAction header, the endpoint URL, and the envelope contents. Because the transport is a drop-in replacement for the default one, you can run the exact production wiring in tests with only the client swapped.

Per-call client swapping

For multi-tenant endpoints, construct a transport per request scope (Laravel scoped() binding) rather than mutating a shared client's base URI. Generated clients are cheap; shared mutable HTTP clients are not.

Gotchas and Tips

Guzzle throws on SOAP faults. A SOAP fault is delivered as HTTP 500. With Guzzle's default http_errors => true you get a RequestException and lose the fault envelope entirely, so real fault codes and messages vanish. Set 'http_errors' => false — or catch RequestException and unpack $e->getResponse()->getBody(). Symfony's Psr18Client does not throw on 4xx/5xx, so behavior differs by client; standardize on one and document it.

PSR-17 discovery fails without a factory implementation. Psr18Transport::createForClient() needs request/stream factories. Install nyholm/psr7 (or pass factories explicitly if the factory method accepts them). The error message ("No PSR-17 request factory found") is not obvious unless you know the transport has two dependencies, not one.

The WSDL fetch is not necessarily routed through the transport. Depending on how your engine is configured, ext-soap may still grab the WSDL over its own socket layer (and ignore your Guzzle timeouts, proxies, and auth). Pre-fetch and cache the WSDL to disk and point the engine at the local file; this also removes a per-boot network call and makes cold starts deterministic. Use php-soap/wsdl caching if you want it handled for you.

Relative soap:address location in the WSDL. The transport builds an absolute request URI from the endpoint the engine gives it. If the WSDL uses a relative location (common behind a reverse proxy), you'll get an invalid-URI exception from the PSR-18 client. Override the endpoint explicitly on the client/engine config.

Timeouts are entirely your responsibility. The transport sets none. ext-soap's default_socket_timeout no longer applies. Set timeout and connect_timeout on the HTTP client — otherwise a hung SOAP endpoint blocks a worker until PHP's max execution time.

Don't use Http:: (Laravel facade) as the PSR-18 client. It is not PSR-18 compliant. Use GuzzleHttp\Client or wrap Symfony\Component\HttpClient\Psr18Client. Do bind whichever you choose behind Psr\Http\Client\ClientInterface so tests and future swaps stay trivial.

Middleware that rebuilds requests can silently drop SOAPAction and Content-Type. SOAP 1.1 expects text/xml; charset=utf-8 with a quoted SOAPAction header; SOAP 1.2 uses application/soap+xml; action="…" as a Content-Type parameter, not a header. If you create a new request in a middleware, copy all headers from the original. Servers typically answer a missing/incorrect SOAPAction with an unhelpful generic fault.

Retries on non-idempotent operations create duplicates. SOAP has no idempotency key convention. Restrict retries to connection-establishment failures and 429/503, and think hard before retrying CreateOrder-style calls.

Session-based SOAP needs a cookie jar. If the service issues a Set-Cookie session, you must persist cookies at the HTTP client level (Guzzle cookies => true with a shared CookieJar). The transport is stateless and won't do this for you.

Large MTOM attachments and memory. Bodies are buffered as PSR-7 streams and typically cast to strings for logging. If you're shipping base64 attachments, avoid (string) $body logging on production traffic and check that the stream is rewound before being re-read.

Debugging checklist when a call fails: (1) log the raw request XML and the raw response body; (2) confirm the endpoint URL is absolute and correct; (3) confirm SOAPAction/Content-Type survive your middleware; (4) confirm http_errors => false so you can see the fault; (5) check the WSDL is the version you think it is (stale cached WSDL is a frequent culprit).

Extension points are all on the HTTP side. Custom middleware, event dispatchers, mock handlers, proxy config, TLS settings, HTTP/2 — all come from the PSR-18 client. The only extension point this package exposes is the factory that builds the transport, which is why keeping that construction in one place (a service provider method) pays off.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core