php-soap/encoding
A PHP library for SOAP encoding and decoding, converting PHP values to and from SOAP XML. It provides the encoding layer for SOAP clients and servers, with support for envelopes, headers, faults, and typed values through a clean, framework-agnostic API.
composer require php-soap/encoding
Before you write any code, understand what you're installing: this is a low-level building block, not a client. In practice most projects pull it in transitively via php-soap/engine, php-soap/wsdl, or php-soap/psr18-transport. Install it directly only when you are writing your own engine, a custom transport, or a bespoke encoder for a non-standard XSD type.
Where to look first, in order:
src/Encoder.php — the core contract (encode/decode), usually a tiny interface. Everything else is a composition of this.src/Context.php — an immutable value object carrying the namespace, target type, item type, and other hints down the encoder tree. This is the single most important class to understand; most bugs are context bugs.src/Xml/Node/ElementLookup.php — since 0.36 the single read path for decoding. It separates attributes from child elements and replaces the old lookup arrays. Learn element(), attribute(), fromElement() before you touch a custom decoder.src/Encoder/ — the shipped leaves: scalars (string/int/bool/float), DateTime, arrays (maxOccurs > 1), objects/complex types, enums, nil handling, and the new NullValueAware / NullValueHandling plumbing for object properties.tests/ — this package has thin docs and a fast-moving API; the test suite is the real specification.Minimal first use case — round-trip a value through a leaf encoder to see the shape of the API:
use PhpSoap\Encoding\Context;
$context = Context::create()->withNamespace('https://example.com/ns');
$encoder = /* an encoder produced by wsdl/type inference, or a leaf encoder */;
$encoded = $encoder->encode($value, $context); // usually a DOM node / XML fragment
$decoded = $encoder->decode($encoded, $context); // back to a PHP value
Your second use case should be a real payload from a WSDL you actually call: take one <xs:complexType> (ideally one with a nested object and a repeated element), build the encoder tree for it, and round-trip a fixture XML response you captured with curl or SoapUI. That exercise surfaces 90% of what you need to know about the package. Run it on 0.36 against a large real response too — the release is 40–95% faster on multi-MB payloads, and it is worth knowing how much of your own slowness was the encoder before you go optimising DTO hydration.
If you consume this through php-soap/engine / php-soap/wsdl, the upgrade is mostly a composer update plus a check of the behaviour changes in Gotchas — you do not have to rewrite anything unless you wrote custom lenses.
Compose, don't subclass. Encoders are small and stateless. A complex type is an object encoder whose property map points at other encoders; a repeated element is an array encoder wrapping an item encoder. Resist the urge to write one big OrderEncoder — build it from leaves so each piece is testable.
Treat Context as immutable and thread it everywhere. Typical helpers look like withNamespace(...), withType(...), withItemType(...). Never stash context in a property or global; construct a child context at each level and pass it down. This is what makes the encoders reusable across operations in the same WSDL.
Wrap domain types in an adapter encoder. Rather than teaching your services about SOAP shapes, implement Encoder for your own value objects:
final class MoneyEncoder implements Encoder
{
public function __construct(private Encoder $amount, private Encoder $currency) {}
public function encode(mixed $value, Context $context): mixed
{
return [
'amount' => $this->amount->encode($value->amount(), $context),
'currency' => $this->currency->encode($value->currency(), $context),
];
}
public function decode(mixed $value, Context $context): mixed
{
return new Money(
$this->amount->decode($value['amount'], $context),
$this->currency->decode($value['currency'], $context),
);
}
}
Custom object lenses read through ElementLookup. If you implement ProvidesObjectEncoderLens / ProvidesObjectDecoderLens, your decoder lens now receives a Soap\Xml\Node\ElementLookup (namespace: Soap\Encoding\Xml\Node\ElementLookup) instead of an array:
public function decode(ElementLookup $lookup, Context $context): mixed
{
$amount = $lookup->element('amount'); // child element, never an attribute
$currency = $lookup->attribute('currency', 'https://example.com/ns');
return new Money($amount, $currency);
}
This is the fix for the classic "attribute and child element with the same name got mixed up" bug — read attributes with attribute() and elements with element() and the ambiguity is gone.
Make null-meaning explicit on object properties. 0.36 introduces Feature\NullValueAware and the NullValueHandling enum so an encoder can tell the object encoder what null means for a property: omit the element, or write xsi:nil. If you have a property where the server distinguishes the two, express it there rather than post-processing the DOM.
Decorate for observability. Encoding is a great seam for logging: wrap the encoder, log the pre-encode value and the post-decode value, delegate. Do this at the boundary of your client rather than scattering logger->debug calls in DTOs. The same decorator is the cheapest way to time encode/decode per operation now that big-payload cost is dominated by your own code.
Test by round-trip and by fixture. Two complementary styles:
decode(encode($v)) === $v for scalars, dates, arrays, and nullables. Cheap and catches most regressions.Wire it into a real client. In a typical stack the encoder is not called directly — php-soap/wsdl infers the encoder tree from the WSDL, php-soap/engine builds the request envelope, and the transport sends it. Use php-soap/xml for DOM construction around the pieces this package returns. If you are writing a custom transport, the integration point is: build context from the operation's type → encode the payload → let the engine serialize the envelope.
Reuse encoder trees in long-running workers. Encoders are stateless and safe to build once at boot and share across requests (Octane, RoadRunner, Swoole). Building a tree per request is wasteful but harmless; sharing is the pattern for anything under load. After 0.36 the encode/decode phase is far cheaper, so if a large-payload worker is still slow, look at your own hydration and logging before blaming the encoder.
Namespaces are the number-one failure mode. An encoder with the wrong (or missing) namespace target produces XML that looks right and is rejected by the server with an unhelpful fault. When something fails, dump the DOM and diff the xmlns/prefixes before you touch anything else.
Attributes are now read by exact namespace — and never fall back to a child element. After 0.36, an attribute sent in a different namespace than the WSDL declares decodes to null, and an attribute will no longer sneak into a child element property with the same name (or vice versa). This is strictly more correct, but WSDL-adjacent systems are full of lenient services. If an attribute-backed property suddenly decodes as null, check the namespace on the wire before changing code.
ref attributes are written in their own namespace. Declared-through-ref attributes such as xml:lang and tns:code are now emitted with their namespace prefix, per XSD. This differs from ext-soap for everything except xml:lang, so a byte-comparison against an old ext-soap capture will show differences that are not bugs. Compare semantically, not with diff.
Optional nillable elements are now omitted when the property is not set. In 0.36, an unset optional nillable property is left out of the request entirely; set it explicitly to null if you want xsi:nil="true" on the wire. If a service distinguishes "absent" from "present and nil" you now have that control — but requests that used to carry <foo xsi:nil="true"/> will silently lose it after upgrading. Audit any nillable field the server treats as a signal (partial updates and PATCH-like operations are the usual victims).
xsi:nil, empty element, and absent element remain three different things. Most decoding bugs are a conflation of these. Decide explicitly per property: does a nullable PHP property decode from a missing element, an empty string, or xsi:nil="true"? Decode side got better in 0.36 (nil map values and struct members now decode to null instead of "", typed soap-enc values inside xsd:anyType decode by xsi:type); encode side is now your responsibility via NullValueHandling.
Custom lenses that throw now fail the whole encode/decode. As of 0.36 a lens that throws produces an EncodingException where it used to be swallowed and read as null. Lenses are expected to handle their own misses — use reflecta's optional() / optional_property(). Expect every "my classmap worked on 0.35" bug report to be one of these.
DocumentToLookupArrayReader is gone. Replace with ElementLookup::fromElement($element). If you have a custom decoder that consumed the lookup array, port it to element() / attribute() in the same commit — there is no compatibility shim.
Arrays are ambiguous in one direction. A single occurrence of a maxOccurs > 1 element is indistinguishable from a scalar unless the context carries item type information. Make sure your type inference feeds withItemType(...); hand-built trees frequently forget it and decode one-element arrays as scalars.
Pin dependencies together. 0.36 requires veewee/reflecta ^1.2, veewee/xml ^4.14 and php-soap/wsdl-reader ~0.34; the ref namespace fix lives partly in wsdl-reader. Bumping encoding alone while holding back wsdl-reader is a recipe for confusing half-applied behaviour. Update the stack, then run your fixture tests.
Debug by dumping XML, not objects. var_dump on encoded values is misleading because the meaningful state is the DOM. Write a one-line helper that serializes the node with saveXML() and log that. When a fault comes back from the server, compare your request element-by-element against a known-good capture — and remember that "known-good" may be a pre-0.36 capture with different nil/ref semantics.
Decoding must be defensive; encoding can be strict. Write decode() to tolerate absent keys, empty strings, and whitespace-only text — real services send all three. Write encode() to throw on missing required fields rather than emit half-valid XML. The throwless-decode work in 0.36 makes defensive decoding cheap; it does not make it unnecessary.
Extension points: implement Encoder, or a lens. For custom XSD types (base64 blobs, custom date formats, code lists), implement the two-method Encoder interface and slot it into the tree. For custom object shape mapping (DTOs, classmaps), implement ProvidesObjectEncoderLens / ProvidesObjectDecoderLens against ElementLookup. That is the intended customization path — do not fork the package.
Don't ask this package to do WSDL work. Parsing, type inference, and schema resolution belong to php-soap/wsdl; transport belongs to php-soap/psr18-transport. Keeping that boundary clear makes upgrading either side trivial.
Date/time will bite. Timezones, fractional seconds, and xs:date vs xs:dateTime are all distinct types in XSD. Check what your leaf encoder actually emits against the schema, and normalize to DateTimeImmutable in your own adapter so the rest of your code never sees a string.
Still a 0.x API. The 0.36 changelog is a good illustration: one minor bump removed a public reader, changed a lens signature, and altered encode output. Pin the version, read the CHANGELOG before bumping, and keep a fixture suite that fails loudly on wire-level changes — that suite is what turns an upgrade from a guessing game into a diff.
How can I help you explore Laravel packages today?