diff --git a/.github/workflows/benchmarks.yaml b/.github/workflows/benchmarks.yaml
new file mode 100644
index 0000000..027e757
--- /dev/null
+++ b/.github/workflows/benchmarks.yaml
@@ -0,0 +1,38 @@
+name: Benchmarks
+
+on: [pull_request]
+jobs:
+ run:
+ runs-on: ubuntu-latest
+ name: Performance regression check
+ steps:
+ - name: Checkout base branch
+ uses: actions/checkout@master
+ with:
+ ref: ${{ github.base_ref }}
+ - name: Install PHP
+ uses: shivammathur/setup-php@master
+ with:
+ php-version: '8.5'
+ tools: 'composer:v2'
+ extensions: mbstring, dom, soap
+ coverage: none
+ - name: Install dependencies (base)
+ run: composer update --prefer-dist --no-progress --no-suggest
+ - name: Benchmark baseline (base branch)
+ run: |
+ vendor/bin/phpbench run benchmarks/ \
+ --tag=baseline \
+ --store \
+ --report=aggregate
+ - name: Checkout PR branch
+ uses: actions/checkout@master
+ with:
+ clean: false
+ - name: Install dependencies (PR)
+ run: composer update --prefer-dist --no-progress --no-suggest
+ - name: Benchmark PR branch vs baseline
+ run: |
+ vendor/bin/phpbench run benchmarks/ \
+ --ref=baseline \
+ --report=aggregate
diff --git a/.gitignore b/.gitignore
index a6c661f..2f2cea7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ vendor
composer.lock
.phpunit.cache
.php-cs-fixer.cache
+.phpbench
diff --git a/benchmarks/BenchSoapClient.php b/benchmarks/BenchSoapClient.php
new file mode 100644
index 0000000..882b6ca
--- /dev/null
+++ b/benchmarks/BenchSoapClient.php
@@ -0,0 +1,21 @@
+mockResponse;
+ }
+}
diff --git a/benchmarks/ComplexTypeBenchTrait.php b/benchmarks/ComplexTypeBenchTrait.php
new file mode 100644
index 0000000..fac22d7
--- /dev/null
+++ b/benchmarks/ComplexTypeBenchTrait.php
@@ -0,0 +1,254 @@
+ */
+ protected array $items;
+ protected SoapResponse $literalResponse;
+ protected SoapResponse $encodedResponse;
+
+ protected BenchSoapClient $extSoapLiteral;
+ protected BenchSoapClient $extSoapEncoded;
+ protected string $minimalResponse;
+ protected string $fullResponse;
+
+ public function setUp(): void
+ {
+ $this->singleItem = (object) [
+ 'id' => 42,
+ 'name' => 'Widget Pro',
+ 'description' => 'A high-quality widget for professional use',
+ 'price' => 29.99,
+ 'quantity' => 100,
+ 'active' => true,
+ 'sku' => 'WDG-PRO-001',
+ 'category' => 'Electronics',
+ 'address' => (object) [
+ 'street' => '123 Main St',
+ 'city' => 'Springfield',
+ 'zip' => '62701',
+ ],
+ ];
+
+ $this->items = [];
+ for ($i = 0; $i < 500; $i++) {
+ $this->items[] = (object) [
+ 'id' => $i,
+ 'name' => 'Widget ' . $i,
+ 'description' => 'Description for widget ' . $i,
+ 'price' => 9.99 + $i,
+ 'quantity' => $i * 10,
+ 'active' => $i % 2 === 0,
+ 'sku' => 'WDG-' . str_pad((string) $i, 5, '0', STR_PAD_LEFT),
+ 'category' => 'Category ' . ($i % 10),
+ 'address' => (object) [
+ 'street' => $i . ' Elm St',
+ 'city' => 'City ' . ($i % 50),
+ 'zip' => str_pad((string) ($i % 99999), 5, '0', STR_PAD_LEFT),
+ ],
+ ];
+ }
+
+ // php-soap/encoding
+ $this->literalDriver = $this->buildPhpSoapDriver('literal');
+ $this->encodedDriver = $this->buildPhpSoapDriver('encoded');
+
+ $this->literalResponse = new SoapResponse(
+ $this->literalDriver->encode('test', [$this->singleItem])->getRequest()
+ );
+ $this->encodedResponse = new SoapResponse(
+ $this->encodedDriver->encode('test', [$this->singleItem])->getRequest()
+ );
+
+ // ext-soap
+ $this->extSoapLiteral = $this->buildExtSoapClient('literal');
+ $this->extSoapEncoded = $this->buildExtSoapClient('encoded');
+
+ $this->minimalResponse = ''
+ . ''
+ . ''
+ . '';
+
+ $this->fullResponse = ''
+ . ''
+ . ''
+ . '42Widget Pro'
+ . 'A high-quality widget for professional use'
+ . '29.99100true'
+ . 'WDG-PRO-001Electronics'
+ . '123 Main StSpringfield62701'
+ . ''
+ . '';
+
+ // Warmup ext-soap
+ $this->extSoapLiteral->mockResponse = $this->minimalResponse;
+ $this->extSoapLiteral->__soapCall('test', ['testParam' => $this->singleItem]);
+ $this->extSoapEncoded->mockResponse = $this->minimalResponse;
+ $this->extSoapEncoded->__soapCall('test', ['testParam' => $this->singleItem]);
+ }
+
+ private function buildPhpSoapDriver(string $use): Driver
+ {
+ $encodingStyle = $use === 'encoded'
+ ? ' encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"'
+ : '';
+
+ $wsdl = <<
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ WSDL;
+
+ $wsdlObject = (new Wsdl1Reader(
+ new CallbackLoader(static fn (): string => $wsdl)
+ ))('file.wsdl');
+
+ $registry = EncoderRegistry::default();
+ $metadata = (new Wsdl1MetadataProvider($wsdlObject))->getMetadata();
+
+ return Driver::createFromMetadata($metadata, $wsdlObject->namespaces, $registry);
+ }
+
+ private function buildExtSoapClient(string $use): BenchSoapClient
+ {
+ $encodingStyle = $use === 'encoded'
+ ? ' encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"'
+ : '';
+
+ $wsdl = <<
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+WSDL;
+
+ $wsdlFile = tempnam(sys_get_temp_dir(), 'wsdl_') . '.wsdl';
+ file_put_contents($wsdlFile, $wsdl);
+
+ $client = new BenchSoapClient($wsdlFile, [
+ 'trace' => true,
+ 'cache_wsdl' => WSDL_CACHE_NONE,
+ ]);
+
+ unlink($wsdlFile);
+
+ return $client;
+ }
+}
diff --git a/benchmarks/DecodeBench.php b/benchmarks/DecodeBench.php
new file mode 100644
index 0000000..1ace581
--- /dev/null
+++ b/benchmarks/DecodeBench.php
@@ -0,0 +1,99 @@
+literalDriver->decode('test', $this->literalResponse);
+ }
+
+ #[Groups(['literal', 'php-soap', 'x500'])]
+ public function benchLiteral_x500(): void
+ {
+ $response = $this->literalResponse;
+ foreach ($this->items as $item) {
+ $this->literalDriver->decode('test', $response);
+ }
+ }
+
+ // -- php-soap/encoding: encoded --
+
+ #[Revs(100)]
+ #[Groups(['encoded', 'php-soap', 'x1'])]
+ public function benchEncoded_x1(): void
+ {
+ $this->encodedDriver->decode('test', $this->encodedResponse);
+ }
+
+ #[Groups(['encoded', 'php-soap', 'x500'])]
+ public function benchEncoded_x500(): void
+ {
+ $response = $this->encodedResponse;
+ foreach ($this->items as $item) {
+ $this->encodedDriver->decode('test', $response);
+ }
+ }
+
+ // -- ext-soap: literal (includes encode overhead) --
+
+ #[Revs(100)]
+ #[Groups(['literal', 'ext-soap', 'x1'])]
+ public function benchExtSoapLiteral_x1(): void
+ {
+ $this->extSoapLiteral->mockResponse = $this->fullResponse;
+ $this->extSoapLiteral->__soapCall('test', ['testParam' => $this->singleItem]);
+ }
+
+ #[Groups(['literal', 'ext-soap', 'x500'])]
+ public function benchExtSoapLiteral_x500(): void
+ {
+ $this->extSoapLiteral->mockResponse = $this->fullResponse;
+ foreach ($this->items as $item) {
+ $this->extSoapLiteral->__soapCall('test', ['testParam' => $item]);
+ }
+ }
+
+ // -- ext-soap: encoded (includes encode overhead) --
+
+ #[Revs(100)]
+ #[Groups(['encoded', 'ext-soap', 'x1'])]
+ public function benchExtSoapEncoded_x1(): void
+ {
+ $this->extSoapEncoded->mockResponse = $this->fullResponse;
+ $this->extSoapEncoded->__soapCall('test', ['testParam' => $this->singleItem]);
+ }
+
+ #[Groups(['encoded', 'ext-soap', 'x500'])]
+ public function benchExtSoapEncoded_x500(): void
+ {
+ $this->extSoapEncoded->mockResponse = $this->fullResponse;
+ foreach ($this->items as $item) {
+ $this->extSoapEncoded->__soapCall('test', ['testParam' => $item]);
+ }
+ }
+}
diff --git a/benchmarks/EncodeBench.php b/benchmarks/EncodeBench.php
new file mode 100644
index 0000000..668e2a1
--- /dev/null
+++ b/benchmarks/EncodeBench.php
@@ -0,0 +1,104 @@
+literalDriver->encode('test', [$this->singleItem]);
+ }
+
+ #[Groups(['literal', 'x500'])]
+ public function benchLiteral_x500(): void
+ {
+ foreach ($this->items as $item) {
+ $this->literalDriver->encode('test', [$item]);
+ }
+ }
+
+ // -- php-soap/encoding: encoded --
+
+ #[Revs(100)]
+ #[Groups(['encoded', 'x1'])]
+ public function benchEncoded_x1(): void
+ {
+ $this->encodedDriver->encode('test', [$this->singleItem]);
+ }
+
+ #[Groups(['encoded', 'x500'])]
+ public function benchEncoded_x500(): void
+ {
+ foreach ($this->items as $item) {
+ $this->encodedDriver->encode('test', [$item]);
+ }
+ }
+
+ // -- ext-soap: literal --
+
+ #[Revs(100)]
+ #[Groups(['literal', 'ext-soap', 'x1'])]
+ public function benchExtSoapLiteral_x1(): void
+ {
+ $this->extSoapLiteral->mockResponse = $this->minimalResponse;
+ $this->extSoapLiteral->__soapCall('test', ['testParam' => $this->singleItem]);
+ }
+
+ #[Groups(['literal', 'ext-soap', 'x500'])]
+ public function benchExtSoapLiteral_x500(): void
+ {
+ $this->extSoapLiteral->mockResponse = $this->minimalResponse;
+ foreach ($this->items as $item) {
+ $this->extSoapLiteral->__soapCall('test', ['testParam' => $item]);
+ }
+ }
+
+ // -- ext-soap: encoded --
+
+ #[Revs(100)]
+ #[Groups(['encoded', 'ext-soap', 'x1'])]
+ public function benchExtSoapEncoded_x1(): void
+ {
+ $this->extSoapEncoded->mockResponse = $this->minimalResponse;
+ $this->extSoapEncoded->__soapCall('test', ['testParam' => $this->singleItem]);
+ }
+
+ #[Groups(['encoded', 'ext-soap', 'x500'])]
+ public function benchExtSoapEncoded_x500(): void
+ {
+ $this->extSoapEncoded->mockResponse = $this->minimalResponse;
+ foreach ($this->items as $item) {
+ $this->extSoapEncoded->__soapCall('test', ['testParam' => $item]);
+ }
+ }
+}
diff --git a/benchmarks/scripts/ext_soap_comparison.php b/benchmarks/scripts/ext_soap_comparison.php
new file mode 100644
index 0000000..ec5f9c3
--- /dev/null
+++ b/benchmarks/scripts/ext_soap_comparison.php
@@ -0,0 +1,337 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+WSDL;
+
+$wsdlFile = tempnam(sys_get_temp_dir(), 'wsdl_') . '.wsdl';
+file_put_contents($wsdlFile, $wsdl);
+
+// ---------------------------------------------------------------------------
+// Test data
+// ---------------------------------------------------------------------------
+
+// For php-soap/encoding (accepts plain objects)
+$plainItem = (object) [
+ 'id' => 42,
+ 'name' => 'Widget Pro',
+ 'description' => 'A premium widget for demanding environments',
+ 'price' => 29.99,
+ 'quantity' => 100,
+ 'active' => true,
+ 'sku' => 'WDG-001',
+ 'category' => 'Electronics',
+ 'address' => (object) ['street' => '123 Main Street', 'city' => 'Springfield', 'zip' => '62701'],
+];
+
+// For ext-soap: nested objects need explicit type hints via SoapVar
+$extSoapObj = clone $plainItem;
+$extSoapObj->address = new SoapVar(
+ (object) ['street' => '123 Main Street', 'city' => 'Springfield', 'zip' => '62701'],
+ SOAP_ENC_OBJECT,
+ 'addressType',
+ 'http://test.benchmark/'
+);
+$extSoapItem = new SoapParam($extSoapObj, 'item');
+
+// ---------------------------------------------------------------------------
+// Setup: ext-soap (override __doRequest to avoid network)
+// ---------------------------------------------------------------------------
+
+class BenchSoapClient extends SoapClient
+{
+ public ?string $lastRequest = null;
+ public ?string $mockResponse = null;
+
+ public function __doRequest(string $request, string $location, string $action, int $version, bool $oneWay = false): ?string
+ {
+ $this->lastRequest = $request;
+ return $this->mockResponse;
+ }
+}
+
+$extSoap = new BenchSoapClient($wsdlFile, [
+ 'trace' => true,
+ 'cache_wsdl' => WSDL_CACHE_NONE,
+]);
+
+// ---------------------------------------------------------------------------
+// Setup: php-soap/encoding
+// ---------------------------------------------------------------------------
+
+$wsdlObject = (new Wsdl1Reader(new CallbackLoader(static fn (): string => $wsdl)))('file.wsdl');
+$registry = EncoderRegistry::default();
+$metadata = (new Wsdl1MetadataProvider($wsdlObject))->getMetadata();
+$driver = Driver::createFromMetadata($metadata, $wsdlObject->namespaces, $registry);
+
+// ---------------------------------------------------------------------------
+// Warmup both
+// ---------------------------------------------------------------------------
+
+// For ext-soap, __doRequest must return a valid SOAP response.
+// Use a minimal empty response for encode-only benchmarks.
+$minimalResponse = ''
+ . ''
+ . ''
+ . '';
+
+// Full response for decode benchmarks
+$fullResponse = ''
+ . ''
+ . ''
+ . ''
+ . '42Widget Pro'
+ . 'A premium widget for demanding environments'
+ . '29.99100true'
+ . 'WDG-001Electronics'
+ . '123 Main StreetSpringfield62701'
+ . ''
+ . ''
+ . '';
+
+// ext-soap warmup
+$extSoap->mockResponse = $minimalResponse;
+$extSoap->__soapCall('test', ['item' => $plainItem]);
+$extSoapEncoded = $extSoap->lastRequest;
+
+// php-soap/encoding warmup
+$driver->encode('test', [$plainItem]);
+$phpSoapEncoded = $driver->encode('test', [$plainItem])->getRequest();
+
+echo "=== COMPARISON: php-soap/encoding vs ext-soap ===\n";
+printf("Items per run: %s\n", number_format($N));
+printf("php-soap/encoding XML: %d bytes\n", strlen($phpSoapEncoded));
+printf("ext-soap XML: %d bytes\n\n", strlen($extSoapEncoded));
+
+// ---------------------------------------------------------------------------
+// Benchmark: ENCODE
+// ---------------------------------------------------------------------------
+
+echo "--- ENCODE ---\n";
+
+// ext-soap encode (minimal response to avoid decode overhead)
+$extSoap->mockResponse = $minimalResponse;
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) {
+ $extSoap->__soapCall('test', ['item' => $plainItem]);
+}
+$extEncUs = (hrtime(true) - $t0) / 1e3 / $N;
+printf("ext-soap: %6.1f us/item\n", $extEncUs);
+
+// php-soap/encoding encode
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) {
+ $driver->encode('test', [$plainItem]);
+}
+$phpEncUs = (hrtime(true) - $t0) / 1e3 / $N;
+printf("php-soap/encoding: %6.1f us/item\n", $phpEncUs);
+printf("Ratio: %.1fx slower\n\n", $phpEncUs / $extEncUs);
+
+// ---------------------------------------------------------------------------
+// Benchmark: DECODE
+// ---------------------------------------------------------------------------
+
+echo "--- DECODE ---\n";
+
+// ext-soap decode (full response with nested objects)
+$extSoap->mockResponse = $fullResponse;
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) {
+ $extSoap->__soapCall('test', ['item' => $plainItem]); // __doRequest returns fullResponse, ext-soap parses it
+}
+$extDecUs = (hrtime(true) - $t0) / 1e3 / $N;
+printf("ext-soap: %6.1f us/item (encode+decode combined)\n", $extDecUs);
+
+// For ext-soap, we can't easily separate encode from decode since __soapCall does both.
+// The decode portion is roughly: $extDecUs - $extEncUs
+$extDecOnlyUs = max(0, $extDecUs - $extEncUs);
+printf("ext-soap decode: ~%.1f us/item (estimated: combined - encode)\n", $extDecOnlyUs);
+
+// php-soap/encoding decode
+$response = new SoapResponse($fullResponse);
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) {
+ $driver->decode('test', $response);
+}
+$phpDecUs = (hrtime(true) - $t0) / 1e3 / $N;
+printf("php-soap/encoding: %6.1f us/item\n", $phpDecUs);
+if ($extDecOnlyUs > 0) {
+ printf("Ratio: %.1fx slower\n\n", $phpDecUs / $extDecOnlyUs);
+} else {
+ printf("Ratio: n/a (ext-soap decode too fast to estimate)\n\n");
+}
+
+// ---------------------------------------------------------------------------
+// Summary
+// ---------------------------------------------------------------------------
+
+echo "--- SUMMARY ---\n";
+printf("Encode: php-soap/encoding is %.1fx slower than ext-soap (%.1f vs %.1f us)\n", $phpEncUs / $extEncUs, $phpEncUs, $extEncUs);
+printf("Decode: php-soap/encoding is ~%.1fx slower than ext-soap (~%.1f vs ~%.1f us)\n",
+ $extDecOnlyUs > 0 ? $phpDecUs / $extDecOnlyUs : 0,
+ $phpDecUs,
+ $extDecOnlyUs
+);
+printf("\nNote: ext-soap is a C extension; this comparison shows the overhead of a\n");
+printf("pure PHP implementation. The gap is expected and acceptable for most use cases.\n");
+
+// ---------------------------------------------------------------------------
+// Large payload benchmark (encode-only, both implementations)
+// ---------------------------------------------------------------------------
+
+$targetMB = (int) ($argv[2] ?? 50);
+$targetBytes = $targetMB * 1024 * 1024;
+
+// Calibrate using ext-soap XML size
+$extBytesPerItem = strlen($extSoapEncoded);
+$phpBytesPerItem = strlen($phpSoapEncoded);
+$largeN = (int) ceil($targetBytes / max($extBytesPerItem, $phpBytesPerItem));
+
+printf("\n=== LARGE PAYLOAD (%d MB, %s items) ===\n\n", $targetMB, number_format($largeN));
+
+// ext-soap large encode
+$extSoap->mockResponse = $minimalResponse;
+$extTotalBytes = 0;
+$t0 = hrtime(true);
+for ($i = 0; $i < $largeN; $i++) {
+ $extSoap->__soapCall('test', ['item' => $plainItem]);
+ $extTotalBytes += $extBytesPerItem;
+}
+$extLargeEncSec = (hrtime(true) - $t0) / 1e9;
+$extLargeEncMB = $extTotalBytes / 1024 / 1024;
+printf("ext-soap encode:\n");
+printf(" Total: %.1f MB in %.3f s\n", $extLargeEncMB, $extLargeEncSec);
+printf(" Throughput: %.1f MB/s\n", $extLargeEncMB / $extLargeEncSec);
+printf(" Per item: %.1f us\n", $extLargeEncSec / $largeN * 1e6);
+printf(" Peak mem: %.1f MB\n\n", memory_get_peak_usage(true) / 1024 / 1024);
+
+// php-soap/encoding large encode
+$phpTotalBytes = 0;
+$t0 = hrtime(true);
+for ($i = 0; $i < $largeN; $i++) {
+ $xml = $driver->encode('test', [$plainItem])->getRequest();
+ $phpTotalBytes += strlen($xml);
+}
+$phpLargeEncSec = (hrtime(true) - $t0) / 1e9;
+$phpLargeEncMB = $phpTotalBytes / 1024 / 1024;
+printf("php-soap/encoding encode:\n");
+printf(" Total: %.1f MB in %.3f s\n", $phpLargeEncMB, $phpLargeEncSec);
+printf(" Throughput: %.1f MB/s\n", $phpLargeEncMB / $phpLargeEncSec);
+printf(" Per item: %.1f us\n", $phpLargeEncSec / $largeN * 1e6);
+printf(" Peak mem: %.1f MB\n\n", memory_get_peak_usage(true) / 1024 / 1024);
+
+printf("Encode ratio at scale: %.1fx slower (%.1f vs %.1f MB/s)\n",
+ $phpLargeEncSec / $extLargeEncSec,
+ $phpLargeEncMB / $phpLargeEncSec,
+ $extLargeEncMB / $extLargeEncSec
+);
+
+// ext-soap large decode
+$extSoap->mockResponse = $fullResponse;
+$t0 = hrtime(true);
+for ($i = 0; $i < $largeN; $i++) {
+ $extSoap->__soapCall('test', ['item' => $plainItem]);
+}
+$extLargeDecSec = (hrtime(true) - $t0) / 1e9;
+// Subtract encode time to estimate decode-only
+$extLargeDecOnlySec = max(0, $extLargeDecSec - $extLargeEncSec);
+$extLargeDecMB = $largeN * strlen($fullResponse) / 1024 / 1024;
+
+printf("\next-soap decode (estimated):\n");
+printf(" Throughput: ~%.1f MB/s\n", $extLargeDecOnlySec > 0 ? $extLargeDecMB / $extLargeDecOnlySec : 0);
+printf(" Per item: ~%.1f us\n", $extLargeDecOnlySec / $largeN * 1e6);
+
+// php-soap/encoding large decode
+$response = new SoapResponse($fullResponse);
+$t0 = hrtime(true);
+for ($i = 0; $i < $largeN; $i++) {
+ $driver->decode('test', $response);
+}
+$phpLargeDecSec = (hrtime(true) - $t0) / 1e9;
+$phpLargeDecMB = $largeN * strlen($fullResponse) / 1024 / 1024;
+
+printf("\nphp-soap/encoding decode:\n");
+printf(" Throughput: %.1f MB/s\n", $phpLargeDecMB / $phpLargeDecSec);
+printf(" Per item: %.1f us\n", $phpLargeDecSec / $largeN * 1e6);
+
+if ($extLargeDecOnlySec > 0) {
+ printf("\nDecode ratio at scale: ~%.1fx slower (%.1f vs ~%.1f MB/s)\n",
+ $phpLargeDecSec / $extLargeDecOnlySec,
+ $phpLargeDecMB / $phpLargeDecSec,
+ $extLargeDecMB / $extLargeDecOnlySec
+ );
+}
+
+unlink($wsdlFile);
diff --git a/benchmarks/scripts/large_payload_bench.php b/benchmarks/scripts/large_payload_bench.php
new file mode 100644
index 0000000..8887953
--- /dev/null
+++ b/benchmarks/scripts/large_payload_bench.php
@@ -0,0 +1,241 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+EOXML;
+
+$wsdl = <<
+
+
+
+
+ {$schema}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+EOF;
+
+$wsdlObject = (new Wsdl1Reader(new CallbackLoader(static fn (): string => $wsdl)))('file.wsdl');
+$registry = EncoderRegistry::default();
+$metadata = (new Wsdl1MetadataProvider($wsdlObject))->getMetadata();
+$driver = Driver::createFromMetadata($metadata, $wsdlObject->namespaces, $registry);
+
+// ---------------------------------------------------------------------------
+// Generate realistic item data
+// ---------------------------------------------------------------------------
+
+$streets = [
+ '742 Evergreen Terrace', '221B Baker Street', '1600 Pennsylvania Avenue',
+ '350 Fifth Avenue', '10 Downing Street', '1 Infinite Loop',
+ '4059 Mount Lee Drive', '1060 West Addison Street',
+];
+$cities = [
+ 'Springfield', 'London', 'Washington', 'New York', 'Manchester',
+ 'Cupertino', 'Los Angeles', 'Chicago', 'Portland', 'Seattle',
+];
+$categories = [
+ 'Electronics', 'Home & Garden', 'Sporting Goods', 'Automotive Parts',
+ 'Books & Media', 'Health & Beauty', 'Office Supplies', 'Industrial Equipment',
+ 'Clothing & Apparel', 'Food & Beverages',
+];
+$adjectives = [
+ 'Premium', 'Professional', 'Heavy-Duty', 'Lightweight', 'Industrial',
+ 'Compact', 'Deluxe', 'Standard', 'Advanced', 'Ultra',
+];
+$nouns = [
+ 'Widget', 'Gadget', 'Component', 'Module', 'Assembly',
+ 'Adapter', 'Connector', 'Bracket', 'Sensor', 'Controller',
+];
+$descriptions = [
+ 'Designed for demanding environments and continuous operation under heavy loads',
+ 'Precision-engineered component manufactured to exacting tolerances',
+ 'Versatile solution suitable for both commercial and residential applications',
+ 'High-performance unit featuring advanced thermal management system',
+ 'Ergonomic design with reinforced housing for extended service life',
+ 'Cost-effective replacement part compatible with all major brands',
+ 'Next-generation technology delivering improved efficiency and reliability',
+ 'Rugged construction with IP67 rating for outdoor installations',
+];
+
+function buildItem(int $i): object
+{
+ global $streets, $cities, $categories, $adjectives, $nouns, $descriptions;
+
+ return (object) [
+ 'id' => $i,
+ 'name' => $adjectives[$i % count($adjectives)] . ' ' . $nouns[$i % count($nouns)] . ' MK-' . $i,
+ 'description' => $descriptions[$i % count($descriptions)] . ' (ref #' . $i . ')',
+ 'price' => round(4.99 + ($i % 500) * 0.73, 2),
+ 'quantity' => 1 + ($i % 9999),
+ 'active' => $i % 3 !== 0,
+ 'sku' => 'SKU-' . strtoupper(dechex($i + 0xA000)) . '-' . str_pad((string) ($i % 10000), 4, '0', STR_PAD_LEFT),
+ 'category' => $categories[$i % count($categories)],
+ 'address' => (object) [
+ 'street' => ($i * 7 % 9999) . ' ' . $streets[$i % count($streets)],
+ 'city' => $cities[$i % count($cities)],
+ 'zip' => str_pad((string) (10000 + $i % 89999), 5, '0', STR_PAD_LEFT),
+ ],
+ ];
+}
+
+// ---------------------------------------------------------------------------
+// Calibrate: encode one item to measure XML size, then derive N for ~500MB
+// ---------------------------------------------------------------------------
+
+$sampleItem = buildItem(0);
+
+// Warmup
+$driver->encode('test', [$sampleItem]);
+
+$sampleXml = $driver->encode('test', [$sampleItem])->getRequest();
+$bytesPerItem = strlen($sampleXml);
+
+$targetMB = (int) ($argv[1] ?? 50);
+$targetBytes = $targetMB * 1024 * 1024;
+$N = (int) ceil($targetBytes / $bytesPerItem);
+
+printf("XML size per item: %d bytes\n", $bytesPerItem);
+printf("Items needed: %s (targeting %d MB)\n", number_format($N), $targetBytes / 1024 / 1024);
+printf("Expected total XML: %.1f MB\n\n", ($N * $bytesPerItem) / 1024 / 1024);
+
+// ---------------------------------------------------------------------------
+// Benchmark: Encode (generate items on the fly to avoid memory exhaustion)
+// ---------------------------------------------------------------------------
+
+echo "=== ENCODE ===\n";
+$totalEncodeBytes = 0;
+$encodedXmls = [];
+
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) {
+ $xml = $driver->encode('test', [buildItem($i)])->getRequest();
+ $totalEncodeBytes += strlen($xml);
+ if ($i < 1000) {
+ $encodedXmls[] = $xml;
+ }
+}
+$encodeNs = hrtime(true) - $t0;
+
+$encodeMs = $encodeNs / 1e6;
+$encodeSec = $encodeNs / 1e9;
+$encodeMB = $totalEncodeBytes / 1024 / 1024;
+
+printf("Total XML produced: %.1f MB\n", $encodeMB);
+printf("Encode time: %.3f s\n", $encodeSec);
+printf("Throughput: %.1f MB/s\n", $encodeMB / $encodeSec);
+printf("Per item: %.1f us\n", $encodeNs / 1e3 / $N);
+printf("Peak memory: %.1f MB\n\n", memory_get_peak_usage(true) / 1024 / 1024);
+
+// ---------------------------------------------------------------------------
+// Benchmark: Decode
+// ---------------------------------------------------------------------------
+
+// For decode, we cycle through the stored subset of encoded XMLs.
+// This measures decode throughput over the same ~500MB volume without
+// needing to keep all encoded strings in memory.
+
+echo "=== DECODE ===\n";
+$totalDecodeBytes = 0;
+$subsetCount = count($encodedXmls);
+
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) {
+ $xml = $encodedXmls[$i % $subsetCount];
+ $response = new SoapResponse($xml);
+ $driver->decode('test', $response);
+ $totalDecodeBytes += strlen($xml);
+}
+$decodeNs = hrtime(true) - $t0;
+
+$decodeSec = $decodeNs / 1e9;
+$decodeMB = $totalDecodeBytes / 1024 / 1024;
+
+printf("Total XML decoded: %.1f MB\n", $decodeMB);
+printf("Decode time: %.3f s\n", $decodeSec);
+printf("Throughput: %.1f MB/s\n", $decodeMB / $decodeSec);
+printf("Per item: %.1f us\n", $decodeNs / 1e3 / $N);
+printf("Peak memory: %.1f MB\n\n", memory_get_peak_usage(true) / 1024 / 1024);
+
+// ---------------------------------------------------------------------------
+// Summary
+// ---------------------------------------------------------------------------
+
+echo "=== SUMMARY ===\n";
+printf("Items: %s\n", number_format($N));
+printf("Encode throughput: %.1f MB/s (%.3f s)\n", $encodeMB / $encodeSec, $encodeSec);
+printf("Decode throughput: %.1f MB/s (%.3f s)\n", $decodeMB / $decodeSec, $decodeSec);
+printf("Peak memory: %.1f MB\n", memory_get_peak_usage(true) / 1024 / 1024);
diff --git a/benchmarks/scripts/profile_encode.php b/benchmarks/scripts/profile_encode.php
new file mode 100644
index 0000000..beb43c8
--- /dev/null
+++ b/benchmarks/scripts/profile_encode.php
@@ -0,0 +1,223 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+EOXML;
+
+$wsdl = <<
+
+
+
+
+ {$schema}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+EOF;
+
+// Setup
+$wsdlObject = (new Wsdl1Reader(new CallbackLoader(static fn (): string => $wsdl)))('file.wsdl');
+$registry = EncoderRegistry::default();
+$metadata = (new Wsdl1MetadataProvider($wsdlObject))->getMetadata();
+$driver = Driver::createFromMetadata($metadata, $wsdlObject->namespaces, $registry);
+
+$item = (object) [
+ 'id' => 42, 'name' => 'Widget', 'description' => 'A widget',
+ 'price' => 29.99, 'quantity' => 100, 'active' => true,
+ 'sku' => 'WDG-001', 'category' => 'Electronics',
+ 'address' => (object) ['street' => '123 Main', 'city' => 'Springfield', 'zip' => '62701'],
+];
+
+$N = (int) ($argv[1] ?? 1000);
+
+// Warmup
+$driver->encode('test', [$item]);
+$xml = $driver->encode('test', [$item])->getRequest();
+$response = new SoapResponse($xml);
+$driver->decode('test', $response);
+
+echo "=== END-TO-END ($N iterations) ===\n";
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) $driver->encode('test', [$item]);
+printf("Encode: %.1fms total, %.1fus/item\n", ($t = (hrtime(true) - $t0) / 1e6), $t / $N * 1000);
+
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) $driver->decode('test', $response);
+printf("Decode: %.1fms total, %.1fus/item\n\n", ($t = (hrtime(true) - $t0) / 1e6), $t / $N * 1000);
+
+// ---- Break down ENCODE ----
+echo "=== ENCODE BREAKDOWN ($N iterations) ===\n";
+
+// Step 1: Encoder setup (method lookup, iso creation)
+$method = $metadata->getMethods()->fetchByName('test');
+$methodContext = new \Soap\Encoding\Encoder\Method\MethodContext($method, $metadata, $registry, $wsdlObject->namespaces);
+$requestEncoder = new \Soap\Encoding\Encoder\Method\RequestEncoder();
+// Pre-build iso once to separate setup from execution
+$encIso = $requestEncoder->iso($methodContext);
+
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) {
+ $requestEncoder->iso($methodContext);
+}
+printf("RequestEncoder->iso(): %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N);
+
+// Step 2: iso->to (the actual encode)
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) {
+ $encIso->to([$item]);
+}
+printf("iso->to([\$item]): %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N);
+
+// Step 3: Just the inner encoder (type encoder, not envelope)
+$paramType = first($method->getParameters())->getType();
+$encContext = $methodContext->createXmlEncoderContextForType($paramType);
+$typeEncoder = $registry->detectEncoderForContext($encContext);
+$typeIso = $typeEncoder->iso($encContext);
+
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) {
+ $typeIso->to($item);
+}
+printf("typeIso->to(\$item) (no envelope): %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N);
+
+// Step 4: Just XsdTypeXmlElementWriter
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) {
+ (new \Soap\Encoding\Xml\Writer\XsdTypeXmlElementWriter())(
+ $encContext,
+ \VeeWee\Xml\Writer\Builder\value('test')
+ );
+}
+printf("XsdTypeXmlElementWriter (simple): %6.1fus/call\n\n", (hrtime(true) - $t0) / 1e3 / $N);
+
+// ---- Break down DECODE ----
+echo "=== DECODE BREAKDOWN ($N iterations) ===\n";
+
+// Step 1: SOAP envelope parse
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) {
+ (new \Soap\Encoding\Xml\Reader\SoapEnvelopeReader())($xml);
+}
+printf("SoapEnvelopeReader (parse+fault): %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N);
+
+// Step 2: OperationReader (envelope + extract parts)
+$methodMeta = $method->getMeta();
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) {
+ (new OperationReader($methodMeta))($xml);
+}
+printf("OperationReader (full): %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N);
+
+// Step 3: Just the type decoder (from Element, no envelope parse)
+$parts = (new OperationReader($methodMeta))($xml);
+$partElement = first($parts->elements());
+
+$returnType = $method->getReturnType();
+$decContext = $methodContext->createXmlEncoderContextForType($returnType);
+$decContext = new Context(
+ $returnType,
+ $metadata,
+ $registry,
+ $wsdlObject->namespaces,
+ $methodMeta->outputBindingUsage()
+ ->map(BindingUse::from(...))
+ ->unwrapOr(BindingUse::LITERAL)
+);
+$typeDecoder = $registry->detectEncoderForContext($decContext);
+$decIso = $typeDecoder->iso($decContext);
+
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) {
+ $decIso->from($partElement);
+}
+printf("typeIso->from(\$element) (no parse): %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N);
+
+// Step 4: Just DocumentToLookupArrayReader
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) {
+ (new \Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader())($partElement);
+}
+printf("DocumentToLookupArrayReader: %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N);
+
+// Step 5: object_data from (properties_set)
+$objectDataIso = \VeeWee\Reflecta\Iso\object_data(\stdClass::class);
+$propData = ['id' => 42, 'name' => 'Widget', 'description' => 'A widget', 'price' => 29.99, 'quantity' => 100, 'active' => true, 'sku' => 'WDG-001', 'category' => 'Electronics', 'address' => null];
+$t0 = hrtime(true);
+for ($i = 0; $i < $N; $i++) {
+ $objectDataIso->from($propData);
+}
+printf("object_data->from (properties_set): %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N);
+
+echo "\nPeak memory: " . round(memory_get_peak_usage(true) / 1024 / 1024, 1) . "MB\n";
diff --git a/composer.json b/composer.json
index 3b24f95..37023f1 100644
--- a/composer.json
+++ b/composer.json
@@ -10,7 +10,8 @@
},
"autoload-dev": {
"psr-4": {
- "Soap\\Encoding\\Test\\": "tests/"
+ "Soap\\Encoding\\Test\\": "tests/",
+ "Soap\\Encoding\\Benchmarks\\": "benchmarks/"
}
},
"authors": [
@@ -37,7 +38,8 @@
"php-standard-library/psalm-plugin": "^2.3",
"php-soap/engine-integration-tests": "^1.10",
"php-soap/psr18-transport": "^1.8",
- "guzzlehttp/guzzle": "^7.8"
+ "guzzlehttp/guzzle": "^7.8",
+ "phpbench/phpbench": "^1.6"
},
"config": {
"allow-plugins": {
diff --git a/phpbench.json b/phpbench.json
new file mode 100644
index 0000000..4b124e9
--- /dev/null
+++ b/phpbench.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://phpbench.readthedocs.io/en/latest/configuration.html",
+ "runner.bootstrap": "vendor/autoload.php",
+ "runner.path": "benchmarks",
+ "runner.file_pattern": "*Bench.php",
+ "runner.php_config": {
+ "xdebug.mode": "off"
+ }
+}