goetas-webservices/xsd2php
Generate PHP classes from XSD schemas and WSDL files, converting XML Schema types into ready-to-use PHP models with JMS Serializer metadata support. A practical tool for building SOAP and XML integrations from existing contracts.
1. Split generator from runtime. The generator is a build-time tool; the runtime annotations/handlers ship to production:
composer require goetas-webservices/xsd2php-runtime
composer require --dev goetas-webservices/xsd2php
2. Write a namespace map. Every XSD targetNamespace you consume needs a PHP namespace, otherwise conversion aborts. config/xsd2php.yml:
namespaces:
"http://schemas.example.com/order/v1":
php_ns: ["Api", "Order", "Model"]
alias: "Order" # used for JMS type names / xsi:type resolution
"": # chameleon / no-namespace schemas
php_ns: ["Api", "Common", "Model"]
3. Run the CLI. -d is the PSR-4 root; the PHP namespace becomes the directory path underneath it:
vendor/bin/xsd2php convert config/xsd2php.yml resources/xsd/order.xsd -d src
# -> src/Api/Order/Model/PurchaseOrderType.php
Pass all entry-point XSDs on the command line; imported schemas are pulled in through their schemaLocation.
4. Deserialize a real payload immediately. Don't review generated code class-by-class — prove it works against a sample document from the API docs/partner:
$serializer = \JMS\Serializer\SerializerBuilder::create()->build();
$order = $serializer->deserialize($xml, \Api\Order\Model\PurchaseOrderType::class, 'xml');
echo $serializer->serialize($order, 'xml');
Where to look first: the repo's tests/ fixtures — they pair XSD snippets with the exact PHP/JMS output, which answers “what will xs:choice look like?” faster than any docs. Then bin/xsd2php, which is the canonical wiring of SchemaReader → PhpConverter → Psr4PathGenerator → writer if you need to drive it from PHP instead of the shell.
First use case: a single partner XSD (invoice, order, tracking) that you receive as XML over REST/SOAP. Generate models, deserialize, and stop hand-writing SimpleXMLElement traversal.
Generate on demand, commit the output. Treat generated classes like compiled artifacts you check in: schema changes then show up as reviewable diffs, and CI can run xsd2php convert ... && git diff --exit-code src/Api to catch schemas that changed upstream without a regeneration.
"scripts": {
"xsd2php": "xsd2php convert config/xsd2php.yml resources/xsd/order.xsd -d src"
}
Keep generation out of the hot path. Add the generated tree to PHPStan/PHPCS excludes and to coverage ignores; the docblock-and-public-property style will otherwise drown your own code in noise.
Compose with JMS, not with the SOAP client. The generated classes are plain JMS-serializable models — useful well beyond SOAP. For SOAP transport, goetas-webservices/soap-client consumes the same schema/config and reuses these classes as its message types, so one generation step serves both REST-ish XML endpoints and WSDL services.
Wrap access in a thin service layer. Put mapping quirks (defaults, denormalizing into your domain DTOs, currency/date normalization) in a hand-written service that takes the generated model in and returns a domain object. Generated classes stay regenerable; your business rules stay put.
Round-trip fixtures as your regression suite. Store one representative XML per schema version, deserialize → re-serialize, and compare with DOMDocument::C14N() so element ordering and whitespace don't cause false failures. This is the cheapest way to notice when a schema update silently stopped mapping a field.
Two-pass workflow for inherited types. For xs:extension-heavy schemas, generate once, then grep generated classes for extends to understand the PHP hierarchy before writing polymorphic handling (JMS discriminators key off the configured alias, so pick aliases deliberately — changing one later breaks xsi:type deserialization of stored payloads).
targetNamespace absent from the config throws during conversion. In practice the fix is to add the entry, not to fight the converter.schemaLocations resolve against the XSD's own directory. Flattening or renaming files (or pulling them from vendor/) breaks imports; remote http:// schemaLocations silently need local copies with rewritten imports.alias collisions bite at runtime, not generation time. Two XSD namespaces mapping to the same class short-name produce ambiguous JMS type names, and xsi:type lookups resolve to the wrong class. Distinct aliases + distinct php_ns prevent it.@JMS\XmlList(entry="...") is the element name, not the property name. Rename the PHP property freely; change entry and deserialization returns an empty array with no exception — the classic “my collection is always empty” bug.xs:date → DateTime<'Y-m-d'>, xs:dateTime → a full ISO format. If the remote party insists on a different representation, adjust at the boundary rather than editing the annotation.xs:any / xs:anyType degrade to untyped values and won't round-trip as XML nodes without custom JMS handlers. Identify these early; they often justify a small hand-written class instead.SerializationContext::create()->setSerializeNull(true) when the receiver requires present-but-empty elements, and check xsi:nil handling explicitly — it is not free.doctrine/annotations to still be installed and the annotation reader enabled. If you upgrade the serializer major, verify a generated class still deserializes before regenerating everything.var/cache produces “type not found”/mismatched-property errors that look like converter bugs.@XmlRoot/type names and can break discriminators — prefer services and accessors over subclass behavior.How can I help you explore Laravel packages today?