Article about implementing parallel SOAP requests in PHP using custom HTTP transport and Guzzle Promises
🤖 AI Assistant Note: This article was prepared using an AI agent to ensure technical accuracy, code completeness, and quality translation. The AI agent assisted in content structuring, technical detail verification, and creating a bilingual version of the article.
Introduction
Hello!
Once we faced the need to execute SOAP requests in parallel: there were many requests, they were quite heavy, and the standard SOAP client was estimated to take a week to complete them. Obviously, this was because the requests were executed sequentially, which in the 21st century looks like some kind of anachronism. For example, we really wanted to use Guzzle Promises capabilities, as mentioned in the documentation https://docs.guzzlephp.org/en/stable/quickstart.html#concurrent-requests.
Let's see what we can do about this.
Anatomy of \SoapClient
The \SoapClient
itself is a good example of violating SOLID principles such as Single Responsibility and Open/Close Principle:
- it can both send data via HTTP and perform marshalling/unmarshalling of data and data mapping. Too bad coffee can't do that.
- its internal implementations, such as HTTP transport, are absolutely impossible to change. Well, almost impossible.
But, on the other hand, we cannot fail to note the good level of abstractions for Dependency Inversion Principle: the __doRequest()
and __soapCall()
methods represent interfaces of different abstraction levels: low-level HTTP message transmission interface and high-level object interface.
SOLID Principles Analysis
Let's analyze each SOLID principle in the context of \SoapClient
:
Single Responsibility Principle (SRP) - Violation ❌
\SoapClient
violates the single responsibility principle by performing multiple tasks:
- HTTP Communication: sending requests and receiving responses
- Data Marshalling: converting PHP objects to XML
- Data Unmarshalling: converting XML back to PHP objects
- Type Mapping: converting SOAP types to PHP types
- WSDL Caching: loading and caching service schema
This makes the class difficult to test and modify.
Open/Closed Principle (OCP) - Violation ❌
\SoapClient
is closed for extension but open for modification. Internal components (HTTP transport, parsers) are tightly coupled with the main class. It's impossible to:
- Replace HTTP transport without modifying source code
- Add support for new protocols
- Change caching logic
Liskov Substitution Principle (LSP) - Compliance ✅
\SoapClient
can be inherited and the base class can be replaced without breaking functionality. This is the only principle that is correctly followed.
Interface Segregation Principle (ISP) - Partial Violation ⚠️
\SoapClient
provides a single interface for all operations, but clients may only use part of the functionality. For example, if only HTTP transport is needed, you still have to load all SOAP logic.
Dependency Inversion Principle (DIP) - Compliance ✅
\SoapClient
correctly depends on abstractions through __doRequest()
and __soapCall()
methods, not on concrete implementations. This allows behavior substitution through inheritance.
Analysis of __doRequest() and __soapCall() Interfaces
The __doRequest()
and __soapCall()
methods represent an excellent example of Dependency Inversion Principle application:
// Low-level HTTP communication interface
public function __doRequest(
string $request, // XML request
string $location, // HTTP URL
string $action, // SOAP Action
int $version, // SOAP version
bool $oneWay = false // Whether to expect response
): ?string;
// High-level object interface
public function __soapCall(
string $name, // Method name
array $args, // Arguments
array|null $options, // Options
$inputHeaders, // Input headers
&$outputHeaders // Output headers
): mixed;
This separation allows:
- Testing HTTP layer separately from SOAP logic
- Substituting HTTP transport without changing SOAP logic
Architectural Limitations and Their Consequences
Main problems with \SoapClient
architecture:
- Sequential Execution: All requests are executed synchronously
- Tight Coupling: HTTP transport is built into the class
- No Composition: Impossible to combine different transports
- Limited Extensibility: Difficult to add new functionality
These limitations lead to:
- Low Performance with multiple requests
- Testing Complexity due to tight coupling
- No Reusability of HTTP logic in other contexts
Well, let's not criticize the SOAP
package, it's legacy code and it is what it is, and obviously there were certain reasons to make it that way. Let's better see what we can do about it.
Custom HTTP Transport
Let's create a project for our exercises:
mkdir php-soap-concurrent
cd php-soap-concurrent
Excellent! Since we're working with PHP in the 21st century, there's absolutely no reason not to use PSR standards for HTTP communications, such as PSR-7: HTTP message interfaces and PSR-18: HTTP Client. Let's include them in the project:
composer require psr/http-message psr/http-client psr/http-factories
Or something like that.
PSR Standards Integration
Our solution is fully compatible with modern PSR standards:
PSR-7: HTTP Message Interfaces
PSR-7 defines standard interfaces for HTTP messages, which allows:
- Unifying work with HTTP requests and responses
- Integrating with any PSR-7 compatible libraries
- Testing HTTP layer independently from SOAP logic
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
// Our transport uses PSR-7 interfaces
class GuzzlePromiseTransport implements Transport, RequestFactoryInterface
{
protected RequestInterface $request;
protected ResponseInterface $response;
public function createRequest(string $method, $uri): RequestInterface
{
// Creating PSR-7 compatible request
}
}
PSR-18: HTTP Client
PSR-18 defines a standard interface for HTTP clients, which ensures:
- Interchangeability of HTTP clients
- Consistency of API for all HTTP operations
- Compatibility with various HTTP libraries
use Psr\Http\Client\ClientInterface;
// Guzzle automatically implements PSR-18
$client = new \GuzzleHttp\Client(); // implements ClientInterface
// Our SOAP client works with any PSR-18 client
$soap = new GuzzlePromiseSoapClient($wsdl, $options, $client);
Benefits of PSR Integration
- Standardization: Unified approach to HTTP communications
- Compatibility: Works with any PSR-compatible libraries
- Testability: Easy creation of mocks and stubs
- Extensibility: Simple addition of new HTTP clients
- Performance: Optimization through specialized clients
Excellent! Now we're ready to implement our HTTP transport.
Interface
What should it be? I suggest not reinventing the wheel and taking the already ready \SoapClient::__doRequest()
: this will allow us to easily transfer the logic from the client to the transport
Source: src/Transport.php
<?php
declare(strict_types=1);
namespace AndreiStepanov\Examples\ConcurrentSoap;
interface Transport
{
/** @see \SoapClient::__doRequest() */
public function doRequest(string $request, string $location, string $action, int $version, bool $oneWay = false): ?string;
}
Magnificent! Well, now let's try to use it.
New Old Client
Let's apply another SOLID principle Liskov Substitution Principle and create a descendant class of \SoapClient
with a backward-compatible constructor interface:
Source: src/SoapClient.php
<?php
namespace AndreiStepanov\Examples\ConcurrentSoap;
use \SoapClient as BaseSoapClient;
/** @see \SoapClient */
class SoapClient extends BaseSoapClient
{
protected ?Transport $transport;
/** @see \SoapClient::__construct() */
public function __construct(?string $wsdl, array $options = [], ?Transport $transport = null)
{
if (empty($options['transport']) && $transport === null) {
throw new \InvalidArgumentException('Transport is required');
}
$this->transport = $options['transport'] ?? $transport;
parent::__construct($wsdl, $options);
}
/** @see \SoapClient::__doRequest() */
public function __doRequest(string $request, string $location, string $action, int $version, bool $oneWay = false): ?string
{
if ($this->transport === null) {
return parent::__doRequest($request, $location, $action, $version, $oneWay);
}
$resposne = $this->transport->doRequest($request, $location, $action, $version, $oneWay);
if ($oneWay) {
return null;
} else {
return $resposne;
}
}
}
Excellent! Now we can pass our Transport
implementation to SoapClient
, which we can fully control!
use AndreiStepanov\Examples\ConcurrentSoap;
$transport = new MyTransportImpl();
$soap = new SoapClient(wsdl: $wsdl, options: $options, transport: $transport);
// or for full backward compatibility of the constructor interface
$options['transport'] = $transport;
$soap = new SoapClient(wsdl: $wsdl, options: $options);
Just a little bit left - to add the implementation.
Implementation
Since we already have the excellent Guzzle library https://docs.guzzlephp.org/en/stable/quickstart.html#concurrent-requests, which is also PSR-compatible with our standards, there's absolutely no reason not to use it.
So, our goal is to use Guzzle with the ability to make concurrent requests. For this, Guzzle uses the Promises/A+ implementation.
Therefore, our transport should somehow return not an XML response as a string value, but a Promise
object.
HttpMessageTransport - Transport for HTTP Responses
For working with already received HTTP responses, let's create a special transport:
Source: src/HttpMessageTransport.php
<?php
declare(strict_types=1);
namespace AndreiStepanov\Examples\ConcurrentSoap;
use Psr\Http\Message\ResponseInterface;
class HttpMessageTransport implements Transport
{
public ResponseInterface $response;
public function __construct(ResponseInterface $response)
{
$this->response = $response;
}
public function doRequest(string $request, string $location, string $action, int $version, bool $oneWay = false): ?string
{
$this->response->getBody()->rewind();
return $this->response->getBody()->getContents();
}
}
This transport allows working with already received HTTP responses, which is critical for asynchronous processing.
HttpResponseSoapClient - SOAP Client for HTTP Responses
For processing HTTP responses in SOAP context, let's create a specialized client:
Source: src/HttpResponseSoapClient.php
<?php
declare(strict_types=1);
namespace AndreiStepanov\Examples\ConcurrentSoap;
use Psr\Http\Message\ResponseInterface;
class HttpResponseSoapClient extends SoapClient
{
/** @var HttpMessageTransport */
public ?Transport $transport = null;
public function __construct(?string $wsdl, array $options = [], ?ResponseInterface $response = null) {
if (empty($options['response']) && $response === null) {
throw new \InvalidArgumentException('Response is required');
}
parent::__construct(
wsdl: $wsdl,
options: ['uri' => 'http://127.0.0.1', 'location' => 'http://127.0.0.1'],
transport: new HttpMessageTransport($options['response'] ?? $response),
);
}
}
This client is specifically designed for processing HTTP responses in promise callback context.
Data Processing and Promise Integration
The key feature of our solution is integration with Guzzle Promises for asynchronous processing:
// In GuzzlePromiseTransport
$this->promise = $this->client->sendAsync($this->request, $this->getClientOptions())->then(
onFulfilled: function (ResponseInterface $response): mixed {
$this->response = $response;
return (new HttpResponseSoapClient($this->wsdl, $this->options, $response))
->__soapCall(...$this->soapCall);
},
);
This allows:
- Asynchronously processing HTTP responses
- Integrating SOAP logic with Promise callbacks
- Maintaining compatibility with existing SOAP API
Error Handling and Exception Management
Our solution includes comprehensive error handling:
// Error handling in promise chain
$results = Utils::settle($promises)->wait();
foreach ($results as $key => $result) {
if ($result['state'] === PromiseInterface::FULFILLED) {
// Successful processing
echo "✅ {$key}: OK\n";
} else {
// Error handling
echo "❌ {$key}: ERROR\n";
$exception = $result['reason'];
// Logging or exception handling
}
}
This ensures reliable handling of both successful and failed requests.
Let's see what we can do about this.
Result
What did we get in the end? We got the ability to create batch requests to SOAP
services in this form:
Source: test.php
<?php
require_once 'vendor/autoload.php';
use AndreiStepanov\Examples\ConcurrentSoap\GuzzlePromiseSoapClient;
use GuzzleHttp\Client;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Promise\Utils;
$start = microtime(true);
$wsdl = 'http://webservices.oorsprong.org/websamples.countryinfo/CountryInfoService.wso?WSDL';
$options = [
'trace' => true,
'version' => \SOAP_1_2,
'cache_wsdl' => \WSDL_CACHE_BOTH,
];
$client = new Client();
// Create multiple SOAP client instances
$soap1 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
$soap2 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
$soap3 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
$soap4 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
$soap5 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
$soap6 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
$soap7 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
$soap8 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
// Initiate all requests concurrently (non-blocking)
$promises = [
'CapitalCity_US' => $soap1->__soapCall('CapitalCity', [['sCountryISOCode' => 'US']]),
'CapitalCity_GB' => $soap2->__soapCall('CapitalCity', [['sCountryISOCode' => 'GB']]),
'CapitalCity_FR' => $soap3->__soapCall('CapitalCity', [['sCountryISOCode' => 'FR']]),
'CapitalCity_DE' => $soap4->__soapCall('CapitalCity', [['sCountryISOCode' => 'DE']]),
'CapitalCity_IT' => $soap5->__soapCall('CapitalCity', [['sCountryISOCode' => 'IT']]),
'CapitalCity_ES' => $soap6->__soapCall('CapitalCity', [['sCountryISOCode' => 'ES']]),
'CapitalCity_PT' => $soap7->__soapCall('CapitalCity', [['sCountryISOCode' => 'PT']]),
'CapitalCity_NL' => $soap8->__soapCall('CapitalCity', [['sCountryISOCode' => 'NL']]),
];
// Wait for all promises to complete (success or failure)
$results = Utils::settle($promises)->wait();
// Process results - check state for each
foreach ($results as $key => $result) {
if ($result['state'] === PromiseInterface::FULFILLED) {
// Success - process the SOAP response
echo "✅ {$key}: OK\n";
} else {
// Failure - handle the exception
echo "❌ {$key}: ERROR\n";
}
}
$end = microtime(true);
echo "Time taken: " . ($end - $start) . " seconds\n";
Additional Resources
PSR Standards
- PSR-7: HTTP Message Interfaces - Standard interfaces for HTTP messages
- PSR-18: HTTP Client - Standard interface for HTTP clients
- PSR-17: HTTP Factories - Factories for creating HTTP messages
Guzzle Documentation
- Guzzle HTTP Client - Official documentation
- Concurrent Requests - Parallel requests with Guzzle
- Promises/A+ - Promises/A+ specification
Related Articles
- PSR Standards Best Practices - Best practices for using PSR standards
Conclusion
You can find all the source code examples for this article in my GitHub repository https://github.com/andrei-stsiapanau/examples-php-soapclient-transports
Top comments (0)