carlos-veizaga / xml-flow
A fluent, agnostic XML builder with native support for xsi:nil and complex namespaces.
Requires
- php: >=8.2
- ext-dom: *
- ext-openssl: *
- ext-xmlwriter: *
- robrichards/xmlseclibs: ^3.1.5
Requires (Dev)
- phpunit/phpunit: ^11.5
README
A fluent PHP library for building well-formed XML documents, validating them against XSD schemas, and signing them with RSA-SHA256 digital signatures — purpose-built for electronic invoicing systems and any integration that demands strict schema compliance.
Features
- Fluent builder — chainable API from root declaration to final XML string
- Native
xsi:nilsupport — emit<field xsi:nil="true"/>for absent but schema-required fields - Root element attributes — set any attribute on the root element (e.g.
xsi:noNamespaceSchemaLocation) without string manipulation - Multi-driver architecture — swap rendering backends without touching application code
- XSD validation — validate against any schema with a structured error report
- XMLDSig signing — enveloped RSA-SHA256 signatures via a dedicated, decoupled
XmlSigner - Sequential array collections — arrays of items automatically repeat the parent tag
- Precision-safe decimal formatting —
asDecimal(n)serializes strings without float conversion;transform()lets you bring your own arithmetic (BCMath, GMP, etc.)
Requirements
- PHP 8.2+
- Extensions:
ext-dom,ext-xmlwriter,ext-openssl
Installation
composer require carlos-veizaga/xml-flow
Basic usage
use CarlosVeizaga\XmlFlow\XmlFlow; use CarlosVeizaga\XmlFlow\Node; $xml = XmlFlow::create('invoice') ->namespaces(['xsi' => 'http://www.w3.org/2001/XMLSchema-instance']) ->body([ 'documentId' => 1234567, 'businessName' => 'ACME Inc.', 'line' => [ ['description' => 'Laptop', 'quantity' => 1, 'unitPrice' => 4500], ['description' => 'Mouse', 'quantity' => 2, 'unitPrice' => 120], ], ]) ->toXml();
Output:
<?xml version="1.0" encoding="UTF-8"?> <invoice xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <documentId>1234567</documentId> <businessName>ACME Inc.</businessName> <line> <description>Laptop</description> <quantity>1</quantity> <unitPrice>4500</unitPrice> </line> <line> <description>Mouse</description> <quantity>2</quantity> <unitPrice>120</unitPrice> </line> </invoice>
Power features
Root element attributes — ->attributes()
Some schemas require attributes on the root element that are not namespace declarations — for example xsi:noNamespaceSchemaLocation. Use ->attributes() to set them directly on the builder:
$xml = XmlFlow::create('invoice') ->namespaces(['xsi' => 'http://www.w3.org/2001/XMLSchema-instance']) ->attributes(['xsi:noNamespaceSchemaLocation' => 'invoice.xsd']) ->body([...]) ->toXml();
<invoice xsi:noNamespaceSchemaLocation="invoice.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> ... </invoice>
Multiple calls to ->attributes() are merged, consistent with ->namespaces().
Null fields — Node::nil()
Some XSD schemas require absent fields to appear explicitly with xsi:nil="true" rather than being omitted. XmlFlow handles this natively.
use CarlosVeizaga\XmlFlow\Node; $xml = XmlFlow::create('invoice') ->namespaces(['xsi' => 'http://www.w3.org/2001/XMLSchema-instance']) ->body([ 'documentId' => 1234567, 'discount' => Node::nil('discount'), // <discount xsi:nil="true"/> 'reference' => Node::nil('reference'), // <reference xsi:nil="true"/> ]) ->toXml();
Note: The
xsinamespace must be declared via->namespaces(). XmlFlow does not inject it automatically — neither driver does.
Elements with custom attributes and text content use Node::create():
Node::create('unit') ->value('KGM') ->attributes(['code' => '58']); // <unit code="58">KGM</unit>
Decimal precision — asDecimal() and transform()
PHP silently drops decimal places when casting numbers to strings — (string) 150.0 produces '150', not '150.00'. If your XSD field is xs:decimal, this causes validation failures. asDecimal() fixes it:
Node::create('unitPrice')->value(150.0)->asDecimal(2) // <unitPrice>150.00</unitPrice> Node::create('taxRate')->value(0.13)->asDecimal(4) // <taxRate>0.1300</taxRate> Node::create('total')->value(1234.5)->asDecimal(2) // <total>1234.50</total>
value() accepts string, int, or float. The precision defaults to 2 if omitted.
String values — no float conversion
When value() receives a string, asDecimal() normalizes decimal places via string operations — no (float) cast. This is safe for values that exceed IEEE 754 double precision (e.g. amounts from a BCMath-based SDK):
// Pads missing decimals Node::create('total')->value('9999999.9')->asDecimal(2) // '9999999.90' // Truncates extra decimals (caller owns rounding) Node::create('total')->value('9999999.999')->asDecimal(2) // '9999999.99' // Preserves precision beyond float range Node::create('total')->value('1234567890123456.78')->asDecimal(2) // '1234567890123456.78'
Custom arithmetic — transform(callable)
For rounding, BCMath, GMP, or any other arithmetic strategy, use transform(). The callable receives the raw value and returns the final string. It takes full precedence over asDecimal().
// BCMath round-half-up (consumer provides the logic, library stays dependency-free) Node::create('total') ->value($this->totalPayable) ->transform(fn($v) => bcadd($v, '0.005', 2));
// Any other custom formatter Node::create('rate') ->value($rate) ->transform(fn($v) => MyArithmetic::round($v, 4));
XSD validation
use CarlosVeizaga\XmlFlow\Exceptions\XsdValidationException; try { $xml = XmlFlow::create('invoice') ->namespaces([...]) ->body([...]) ->validate('/schemas/invoice.xsd') ->toXml(); } catch (XsdValidationException $e) { foreach ($e->getErrors() as $error) { echo "[line {$error->line}] {$error->message}"; } }
validate() returns $this, so it composes naturally in the chain. It throws XsdValidationException with the raw LibXMLError[] list on failure.
Digital signing — XMLDSig RSA-SHA256
use CarlosVeizaga\XmlFlow\Security\XmlSigner; use CarlosVeizaga\XmlFlow\Exceptions\SignatureException; $xml = XmlFlow::create('invoice') ->namespaces([...]) ->body([...]) ->toXml(); try { $signedXml = XmlSigner::create()->sign($xml, '/certs/company.p12', 'password'); } catch (SignatureException $e) { // certificate not found, wrong password, or corrupt P12 }
The signature is inserted as the last child of the root element, as required by enveloped XMLDSig. The <ds:Signature> node declares xmlns:ds explicitly to satisfy strict schema validators.
Note:
XmlSigneraccepts any XML string — it is not coupled toXmlFlow. You can sign documents generated by other means.
High-volume generation — useStreamMode()
For batch jobs generating thousands of documents, switch to the StreamDriver. It uses PHP's XMLWriter extension and keeps memory usage O(1) regardless of document size.
$xml = XmlFlow::create('invoice') ->useStreamMode() ->namespaces([...]) ->body([...]) ->toXml();
When to use each driver:
Scenario Driver XMLDSig digital signing DomDriver(default) — signing requires DOM node accessXSD validation via ->validate()DomDriver(default)Batch / high-volume generation (no signing) StreamDrivervia->useStreamMode()
Switching drivers is transparent — both implement XmlDriverInterface and produce semantically identical output.
Architecture
XmlFlow follows SOLID principles throughout:
- Strategy pattern —
XmlDriverInterfacedecouples the rendering backend from the builder. Implement it to add a custom driver without modifying any existing class. - Value Object —
Nodeis immutable by convention; it carries no identity, only data. - Decoupled signing —
XmlSignerdepends onrobrichards/xmlseclibsand is entirely separate from the builder. Applications that do not need signing do not pay any cost for it.
XmlFlow (builder)
└── XmlDriverInterface
├── DomDriver — DOMDocument, supports XMLDSig
└── StreamDriver — XMLWriter, O(1) memory
XmlSigner (independent)
└── SignerInterface
└── XmlSigner — RSA-SHA256 enveloped signature
Running the tests
composer install ./vendor/bin/phpunit
The suite covers the builder, Node semantics, driver parity (DomDriver vs StreamDriver), and cryptographic signature verification.
License
MIT © Carlos Veizaga