goetas/xsd2php
goetas/xsd2php converts XML Schema and WSDL definitions into PHP classes, generating typed models and serialization mappings so you can work with SOAP/XML services in PHP without hand-writing boilerplate. It integrates with JMS Serializer and supports common XSD constructs.
goetas/xsd2php is a code generator: you point it at an XSD (or a set of them) and it emits plain PHP classes annotated for JMS Serializer, so you can hydrate them from XML and serialize them back. It is not a runtime XML mapper you call directly — the day-to-day value is in the generation step and the JMS annotations baked into the output.
Minimum setup:
composer require --dev goetas/xsd2php
composer require jms/serializer
The generator binary lands at vendor/bin/xsd2php. It is configured with a small YAML file (keys vary slightly by pinned version — always confirm against the README of the exact tag you install):
xsd2php:
namespaces:
'http://example.com/order/v1':
alias: 'Order'
phpNamespace: 'App\Xml\Order'
destinations:
classes: 'src/Xml/Order'
metadata: 'src/Xml/Order/Metadata'
Then:
vendor/bin/xsd2php convert xsd2php.yml
Where to look first:
@XmlRoot, @XmlNamespace, @Type, @SerializedName, @XmlList annotations. That is the contract you will be working against.Command/ directory to see exactly which CLI options and config keys your version supports.XmlSerializationVisitor docs, because every serialization quirk you hit will actually be a JMS quirk.First use case: pick one small, real schema you already need to consume (an order ack, a status response, a webhook payload), generate classes, and round-trip a fixture:
use JMS\Serializer\SerializerBuilder;
$serializer = SerializerBuilder::create()->build();
$order = $serializer->deserialize($xmlString, \App\Xml\Order\Order::class, 'xml');
$xml = $serializer->serialize($order, 'xml');
If deserialize returns an object and serialize produces XML that passes xmllint --schema schema.xsd, you understand the tool.
Generator as a build step, output as checked-in code. Treat generation like a protobuf or migration step. Commit the generated classes, keep a composer generate-xml script or Makefile target, and regenerate only when the schema changes. This keeps diffs reviewable and removes a fragile dev dependency from CI.
"scripts": {
"generate-xml": "xsd2php convert xsd2php.yml",
"test:xml": "xmllint --noout --schema schema.xsd tests/fixtures/*.xml"
}
One PHP namespace per XML namespace. The namespaces map is the single most important piece of config. Keep the alias identical to the schema's canonical prefix and pin it, because it becomes the @XmlNamespace prefix in output. Renaming an alias later changes serialized XML and is a breaking change.
Keep generated DTOs on the infrastructure boundary. These classes are mechanical and often ugly (auto-generated names for anonymous complex types). Do not let them become your domain model. Wrap them in a small mapper:
final class OrderXmlMapper
{
public function toDomain(\App\Xml\Order\Order $dto): Order { /* ... */ }
public function fromDomain(Order $order): \App\Xml\Order\Order { /* ... */ }
}
Configure the serializer once, in a service provider. Use a filesystem metadata cache so JMS does not re-parse annotations per request:
$serializer = SerializerBuilder::create()
->setCacheDir(storage_path('framework/serializer'))
->setDebug(app()->environment('local'))
->build();
Schema-per-config for large integrations. Do not pile twenty XSDs into one YAML file. Generate each service's schema into its own namespace and run separate convert invocations. Collisions in auto-generated type names are far easier to resolve in isolation.
Test with golden fixtures, both directions. Keep a corpus of real payloads. Assert deserialize → serialize is semantically stable (canonicalize with xmllint --c14n before diffing) and that every fixture validates against the original XSD. This catches the annotation problems that unit tests on object graphs miss.
Soap/envelope wrapping is your job. The generator produces message bodies, not envelopes. If you are talking to a SOAP endpoint, build the <soap:Envelope> yourself or serialize into a pre-built DOM, rather than expecting the generated root element to be a valid request.
Escape hatch: read the XSD directly. When you only need validation or a couple of fields, skip generation and use the underlying goetas-webservices/xsd-reader (a transitive dependency) to introspect the schema. It is often less work than generating a 200-class tree for a two-field response.
each()-era helpers, and annotation-vs-attribute mismatches in modern JMS. Prototype on your actual PHP version before committing to it, and strongly consider the maintained successor package goetas-webservices/xsd2php or a hand-written DTO + Symfony Serializer approach for new work.convert silently overwrites you. If the generator gets something wrong, either fix the schema, post-process with a deterministic script (php-cs-fixer, a small AST rewriter for declare(strict_types=1)), or override by subclassing in your own namespace.xmlns:xsi will be rejected by strict consumers. Inspect the generated @XmlNamespace annotations and, when needed, control prefixes via a JMS SerializationContext.nillable is not modelled faithfully. An xsi:nil="true" element typically deserializes to null and re-serializes as an omitted or empty element, not as xsi:nil. If the consumer requires nil semantics, handle it with a custom JMS handler or post-serialization DOM surgery.<choice>, substitution groups, and xs:any are lossy. Choice becomes a bag of optional properties with no mutual-exclusion enforcement; substitution groups and wildcards are often dropped. Do not rely on generated classes for validation — validate with xmllint or DOMDocument::schemaValidate at the boundary.xs:date/xs:dateTime may map to \DateTime with a fixed format annotation, or stay a string depending on the version. Timezone handling is a frequent silent bug — assert on exact string output, not just object equality.php artisan cache:clear or delete the serializer cache dir into your generate script.$serializer->serialize($obj, 'xml')), then diff against a known-good payload, then validate against the XSD. If deserialization returns an object with all-null fields, the problem is almost always a namespace or @SerializedName mismatch, not the XML itself.xsd2php in a request path. Regeneration on a large schema can take seconds and produce thousands of files; run it in CI or locally and commit the result.How can I help you explore Laravel packages today?