robrichards/wse-php
robrichards/wse-php is a PHP library that adds WS-Security to SOAP requests, offering XML signing, encryption and decryption, security tokens and timestamp support for secure, interoperable web service integrations.
Install and requirements
composer require robrichards/wse-php
ext-soap, ext-dom, ext-openssl, and (for current versions) robrichards/xmlseclibs as a transitive dependency.RobRichards\WsePhp (not RobRichards\WsePhp\... legacy names). If you're porting old blog snippets, they often use the pre-2.0 class names — check the version you installed.php -m | grep -E 'soap|dom|openssl' before debugging anything else; a missing ext-soap produces confusing "class not found" errors.Where to look first
src/WSSESoap.php — the class you'll spend 90% of your time with: addTimestamp(), addUserToken(), addBinaryToken(), signSoapDoc(), encryptSoapDoc(), saveXML().src/WSSESoapServer.php — inbound side: processHeaders(), validateTimestamp(), validateUserToken(), verifySignature(), decryptSoapDoc().src/WSASoap.php — WS-Addressing headers (addAction(), addTo(), addMessageID(), addReplyTo()).examples/ — the repo's client/server/encryption samples are the fastest reference for correct call order.First use case: a signed outbound request
Most integrations fail because a vendor requires a wsse:Security header with a Timestamp, a UsernameToken, and a signature over the body. The canonical pattern is overriding SoapClient::__doRequest(), because ext/soap gives you no hook to mutate the outgoing envelope:
use RobRichards\WsePhp\WSSESoap;
use RobRichards\XMLSecLibs\XMLSecurityKey;
class SignedSoapClient extends \SoapClient
{
public function __construct($wsdl, array $options, private string $cert, private string $key, private string $pass) {
parent::__construct($wsdl, $options);
}
public function __doRequest($request, $location, $action, $version, $oneWay = false): ?string
{
$doc = new DOMDocument();
$doc->preserveWhiteSpace = true;
$doc->loadXML($request);
$wsse = new WSSESoap($doc);
$wsse->addTimestamp(300); // valid 5 minutes
$wsse->addUserToken($this->user, $this->pass); // usually non-digest for legacy vendors
$key = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, ['type' => 'private']);
$key->loadKey($this->key, true, $this->pass);
$wsse->signSoapDoc($key); // must be the LAST mutation
return parent::__doRequest($wsse->saveXML(), $location, $action, $version, $oneWay);
}
}
If you get InvalidSecurity, Security header missing, or Signature verification failed from a partner on the first attempt, that's normal — proceed to the gotchas section before changing code.
Wrap it in a service class, not in the controller
Inject config (cert path, key path, passphrase, endpoint, credentials) from config/services.php, backed by env vars. Keep the SoapClient subclass thin and construct one instance per request/tenant. Laravel's container can bind a factory that resolves the right certificate per tenant or environment.
// config/services.php
'acme' => [
'wsdl' => env('ACME_WSDL'),
'cert' => storage_path('app/certs/acme.crt'),
'key' => storage_path('app/certs/acme.key'),
'passphrase' => env('ACME_KEY_PASSPHRASE'),
'username' => env('ACME_USER'),
'password' => env('ACME_PASS'),
],
Store certs in storage/app/certs, not in the repo; set 0600 permissions; never let a passphrase leak into logs.
Sign-then-encrypt for confidential payloads
Typical enterprise ordering: signature over body, timestamp, and username token, then encrypt the body only:
$wsse->addTimestamp(300);
$wsse->addUserToken($user, $pass);
$wsse->signSoapDoc($privateKey);
$recipientKey = new XMLSecurityKey(XMLSecurityKey::RSA_OAEP_MGF1P, ['type' => 'public']);
$recipientKey->loadKey($theirCertPem, false);
$wsse->encryptSoapDoc($recipientKey, [
'encrypt_signature' => false,
'encrypt_certificate' => false,
'algorithm' => XMLSecurityKey::AES256_CBC,
]);
Don't encrypt the signature unless the receiver explicitly expects a signed-and-encrypted Security header; some stacks (older WCF, SAP PI) reject it.
Inbound verification
For a partner calling you, read raw XML from php://input (do not let ext/soap deserialize first) and process:
$server = new \RobRichards\WsePhp\WSSESoapServer(file_get_contents('php://input'));
if (!$server->processHeaders()) { abort(400, 'Malformed WS-Security'); }
if (!$server->validateTimestamp()) { abort(400, 'Stale timestamp'); }
// verifySignature() against the partner's uploaded cert / or the embedded BinarySecurityToken
$server->verifySignature($trustedCertPath);
$xml = $server->saveXML();
Then hand the verified XML to your own parser or a local SoapServer with SoapServer::setClass(). Keep processHeaders() as the first operation; it populates the internal state everything else depends on.
WS-Addressing
Some endpoints (BizTalk, older Oracle SOA) require wsa:Action, wsa:MessageID, and wsa:To. Use WSASoap on the same DOMDocument before signing — the addressing headers become part of the signed set if your signature covers all headers.
$wsa = new WSASoap($doc);
$wsa->addAction('urn:SubmitOrder');
$wsa->addTo($endpoint);
$wsa->addMessageID(\Ramsey\Uuid\Uuid::uuid4()->toString());
$doc = $wsa->saveXML(); // re-load into WSSESoap
Testing
DOMXPath + the wsse/ds namespaces: exactly one Timestamp, one UsernameToken, one Signature, Reference URI="#..." pointing at the body wsu:Id.setTestNow) so timestamp assertions don't flake.Retries and idempotency
Never reuse a signed envelope. Each retry must rebuild timestamp, nonce (for PasswordDigest), and signature. Cache the WSDL (soap.wsdl_cache) but not the signed payload.
Sign last — always. addTimestamp(), addUserToken(), addBinaryToken(), and WSASoap mutations must precede signSoapDoc(). Touching the DOM afterward (even formatOutput = true) invalidates canonicalization and the receiver rejects the signature with a generic failure.
Canonicalization is fragile. Load the raw request with preserveWhiteSpace = true and don't reformat, pretty-print, or round-trip through SimpleXML. If the vendor uses inclusive C14N instead of exclusive, you'll need to switch canonicalization method explicitly; mismatched C14N is the #1 cause of "signature invalid" on an otherwise correct envelope.
__getLastRequest() lies. It returns the envelope ext/soap built, before your __doRequest() override rewrote it. Log the string inside __doRequest() (before and after signing) to see what actually went on the wire. This trips up nearly everyone debugging signature failures.
__doRequest() signature changed in PHP 8.1+. Match the parent exactly (: ?string, bool $oneWay = false) or you'll get a fatal "declaration must be compatible" error after a PHP upgrade.
BinarySecurityToken format. addBinaryToken() expects a certificate it can parse; PEM headers must be stripped or converted to DER internally. If your vendor sends X509SubjectKeyIdentifier references instead of BinarySecurityToken, the default signing path won't match their expectation — check which wsse:Reference value/ValueType they require.
PasswordDigest vs PasswordText. addUserToken($u, $p, true) produces a digest (nonce + created + password, base64 SHA-1). Legacy .NET and SAP endpoints often want plain text, and some want digest with a specific nonce encoding. When auth fails but the signature verifies, this flag is usually the culprit.
Timestamps and skew. addTimestamp(300) = 5 minutes of validity; servers with clock drift reject at 60 seconds. Also, the wsu:Created value must be UTC ISO-8601 (2024-01-01T00:00:00Z), not local time — PHP's default timezone will silently break this.
Certificate/key mismatches. XMLSecurityKey::loadKey() throws opaque OpenSSL errors if the passphrase is wrong or the key is encrypted in an unsupported format. Validate at boot: openssl_x509_read(file_get_contents($cert)) and openssl_pkey_get_private(...). On the encryption side, you can encrypt to a public cert but you cannot decrypt without the matching private key — a common source of "works in test, fails in prod" when test uses a self-signed pair.
Header ordering. Some stacks require wsse:Security to be the first child of soap:Header. If so, pass ['insertBefore' => true] (or reorder after construction, before signing) — but reordering after signing breaks the signature.
Server-side processing mutates the document. processHeaders() rewrites $this->doc (strips the Security header, decrypts the body). Call saveXML() immediately after and don't hold a reference to the original XML string expecting it to match.
SOAP 1.1 vs 1.2. Namespace constants differ (NS_SOAP vs NS_SOAP12); the library detects them from the envelope, but your SoapClient options (soap_version) and the vendor's expectations must agree. Mismatched versions produce signature references that resolve to nothing.
No MTOM/SwA support. Attachments must be base64-inlined. If the partner requires MTOM, this library is the wrong tool — you'll need a lower-level implementation.
Extension points. Subclass WSSESoap to customize header assembly or to add proprietary tokens; override WSSESoapServer::processHeaders() to support custom token types or to hook audit logging. For signature reference manipulation (multiple body parts, wsu:Id naming), drop down to XMLSecurityDSig from xmlseclibs directly and use WSSESoap's constants for namespaces.
Debug with external tools. xmlsec1 --verify --pubkey-cert-pem cert.pem signed.xml will tell you definitively whether your signature is valid, removing the partner from the loop. SoapUI's WS-Security configuration is a good second opinion on header shape.
Pin the version and read the changelog. The API has shifted across major versions (namespaces, signSoapDoc() signature, encryption options). A composer update in a working integration is a genuine risk; pin with ~3.0 (or whatever your installed major is) and test upgrades against the vendor sandbox.
How can I help you explore Laravel packages today?