greenter/ubl-validator
Validate UBL (OASIS Universal Business Language) XML documents against official XSD schemas. Automatically resolves the UBL version, locates the matching schema, and reports validation errors. Supports custom XSD directories for other UBL versions.
composer require greenter/ubl-validator
The package is framework-agnostic and has no Laravel service provider, so there is no config/ubl-validator.php to publish. Everything is a plain PHP class you instantiate where you need it.
use Greenter\Ubl\UblValidator;
$xml = file_get_contents(storage_path('app/invoices/20000000001-01-F001-1.xml'));
$validator = new UblValidator();
if ($validator->isValid($xml)) {
// schema is valid against the UBL XSD
} else {
// $validator->getError() returns a LibXML error string
report(new \RuntimeException($validator->getError()));
}
isValid() does three things internally: it reads <cbc:UBLVersionID> from the document, maps that version to an XSD bundled under src/xsd, then runs DOMDocument::schemaValidate. Nothing else — no business rules, no SUNAT totals, no catalog checks. Treat it as a syntax/shape gate, not a compliance gate.
Greenter\Ubl\UblValidator (entry point) and Greenter\Ubl\Resolver\UblPathResolver (the only config knob: baseDirectory).vendor/greenter/ubl-validator/src/xsd/ — inspect this directory early. The README explicitly warns not every UBL schema ships with the package, and knowing which versions are present tells you whether you need a custom directory on day one.Validate an inbound invoice at the API boundary so malformed files never reach persistence or your SUNAT/DIAN/etc. submission pipeline:
use Greenter\Ubl\UblValidator;
public function store(StoreInvoiceRequest $request)
{
$xml = $request->file('invoice')->get();
$validator = new UblValidator();
if (! $validator->isValid($xml)) {
return response()->json([
'message' => 'Invalid UBL document',
'detail' => $validator->getError(),
], 422);
}
Invoice::create(['xml' => $xml, 'status' => 'received']);
return response()->json(['status' => 'accepted'], 201);
}
That single call is the 80% case. Everything below is about making it robust and reusable.
Keep the construction in one place so the XSD directory and any defaults are consistent across controllers, jobs, and commands:
namespace App\Providers;
use Greenter\Ubl\UblValidator;
use Greenter\Ubl\Resolver\UblPathResolver;
use Illuminate\Support\ServiceProvider;
class UblServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(UblValidator::class, function () {
$validator = new UblValidator();
$validator->pathResolver = new UblPathResolver();
$validator->pathResolver->baseDirectory = config('ubl.xsd_path', storage_path('ubl-xsd'));
return $validator;
});
}
}
Prefer bind over singleton unless you have verified that a single instance is safe to reuse after a failed validation — the error state lives on the object, and bind sidesteps any stale-error surprises for free.
This gives you $request->validated() semantics and consistent 422 responses without touching controllers:
namespace App\Rules;
use Greenter\Ubl\UblValidator;
use Illuminate\Contracts\Validation\Rule;
class UblXmlRule implements Rule
{
private string $error = '';
public function passes($attribute, $value): bool
{
$xml = $value instanceof \Illuminate\Http\UploadedFile
? $value->get()
: (string) $value;
$validator = app(UblValidator::class);
if ($validator->isValid($xml)) {
return true;
}
$this->error = $validator->getError();
return false;
}
public function message(): string
{
return 'The document is not valid UBL: '.$this->error;
}
}
Note the $value instanceof UploadedFile check — in Laravel the rule may receive either a string body or an uploaded file, and getting this wrong is one of the most common first-day bugs.
XSD validation on multi-megabyte XML is CPU-bound and will block a request worker. For anything user-facing at scale — supplier onboarding, SFTP intake, ERP backfill — push it onto a queue:
namespace App\Jobs;
use Greenter\Ubl\UblValidator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
class ValidateUblDocument implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 120;
public function __construct(
public string $disk,
public string $path,
public int $invoiceId,
) {}
public function handle(UblValidator $validator): void
{
$xml = Storage::disk($this->disk)->get($this->path);
$valid = $validator->isValid($xml);
$error = $valid ? null : $validator->getError();
Invoice::whereKey($this->invoiceId)->update([
'validation_status' => $valid ? 'valid' : 'invalid',
'validation_error' => $error,
]);
}
}
Pass the path, not the XML string. Serializing a large XML blob into the job payload bloats the queue table and hits payload size limits on Redis/SQS.
This is the package's only extension point, and you'll likely need it — the bundled set is deliberately partial, and regional addenda (e.g. SUNAT's Perception with ExceptionalIndicator) sometimes require updated schemas.
$validator = new UblValidator();
$validator->pathResolver = new UblPathResolver();
$validator->pathResolver->baseDirectory = storage_path('app/ubl-xsd');
The directory must follow this exact layout — the resolver builds paths from the UBL version string:
app/ubl-xsd/
├─2.0/
│ ├─ common/
│ └─ maindoc/
├─2.1/
│ ├─ common/
│ └─ maindoc/
Download the schemas from oasis-open.org and commit them to the repo or ship them in a deploy step. Do not fetch them at runtime.
Schema validity is necessary but never sufficient. Chain it with your own checks and keep the two concerns separate:
class InvoiceValidator
{
public function __construct(private UblValidator $xsd) {}
public function validate(string $xml): array
{
$errors = [];
if (! $this->xsd->isValid($xml)) {
return ['Schema' => $this->xsd->getError()];
}
$doc = new \DOMDocument();
$doc->loadXML($xml);
$xpath = new \DOMXPath($doc);
$xpath->registerNamespace('cbc', 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2');
// e.g. totals consistency, required catalog codes, RUC format...
$total = (float) $xpath->evaluate('string(//cbc:PayableAmount)');
// ...
return $errors;
}
}
Reuse the same pattern for supplier onboarding (supplier_validations table), legacy ERP migration loops, and pre-submission gates before calling a tax authority API.
Put a handful of real provider samples in tests/fixtures/ — one valid per version, plus deliberately broken ones:
public function test_real_invoice_passes_ubl_schema(): void
{
$xml = file_get_contents(base_path('tests/fixtures/invoice-2.1.xml'));
$this->assertTrue(app(UblValidator::class)->isValid($xml));
}
These catch schema-directory misconfiguration in CI, which is otherwise only discovered in production.
UBLVersionID is mandatory and must resolveThe resolver reads <cbc:UBLVersionID> to pick the XSD. If the element is missing, misspelled, or carries an unmapped version, validation fails in a way that looks unrelated to the real cause. Before debugging anything else, dump it:
preg_match('/<cbc:UBLVersionID[^>]*>([^<]+)</', $xml, $m);
dump($m[1] ?? 'MISSING');
The bundled XSD set is intentionally incomplete and only covers specific UBL versions. Check vendor/greenter/ubl-validator/src/xsd/ for the versions actually present before assuming support. Lock your expectations with a test and pin the package version — releases add schemas (e.g. a newer Despatch XSD, or SUNAT 2026 ExceptionalIndicator in Perception) but do not retroactively widen coverage.
getError() returns raw LibXML outputExpect strings like Element '{...}Invoice': Missing child element(s)… at line numbers of the XML file. Two implications:
substr:class UblError
{
public static function summarize(?string $error): string
{
if (! $error) {
return 'Unknown UBL validation error';
}
return str_contains($error, 'failed to load')
? 'Schema unavailable for this UBL version'
: mb_substr(preg_replace('/\s+/', ' ', $error), 0, 240);
}
}
isValid() in a try/catchMalformed or empty input can surface as a LibXML warning/failure rather than a clean false. Guard the boundary:
try {
$valid = $validator->isValid($xml);
} catch (\Throwable $e) {
Log::warning('UBL parse failure', ['exception' => $e->getMessage()]);
$valid = false;
}
And if you want granular diagnostics, enable LibXML's internal error buffer around the call — but clear it afterwards, since it's global process state:
libxml_use_internal_errors(true);
try {
$valid = $validator->isValid($xml);
foreach (libxml_get_errors() as $e) {
Log::debug('libxml', ['line' => $e->line, 'msg' => trim($e->message)]);
}
} finally {
libxml_clear_errors();
libxml_use_internal_errors(false);
}
UBL is XML-Schema strict. <Invoice> must declare the correct default namespace (urn:oasis:names:specification:ubl:schema:xsd:Invoice-2) and every cbc:/cac: element must be prefixed and cased exactly. A hand-built XML string that omits prefixes, or substitutes <CBC:ID>, fails even though it "looks right." Prefer generating XML through a builder (e.g. greenter/xml in the same ecosystem) rather than string concatenation.
XSD validation is not incremental. Files in the tens of MB can exhaust max_execution_time or memory. Set an explicit ceiling and enqueue beyond it:
if (strlen($xml) > 10 * 1024 * 1024) {
ValidateUblDocument::dispatch($disk, $path, $invoice->id);
return;
}
Also raise pcre.backtrack_limit / memory_limit only if you have measured a real need — blind tuning hides the actual bottleneck.
UblPathResolver composes {baseDirectory}/{version}/{common|maindoc}/.... A flat directory, a missing common/ folder, or a trailing slash in baseDirectory produces misleading "failed to load" errors that look like schema errors. Validate the layout on boot in a test:
$this->assertDirectoryExists(config('ubl.xsd_path').'/2.1/common');
$this->assertDirectoryExists(config('ubl.xsd_path').'/2.1/maindoc');
There is no extra.laravel block, so app(UblValidator::class) only works because of your provider (or because Laravel can autowire the no-arg constructor — which then silently ignores your custom baseDirectory). If custom XSDs "work in the test but not the app," this is almost always why: the new UblValidator() in a job bypassed the configured binding. Consistently resolve from the container.
UblValidator exposes pathResolver publicly and little else. For anything beyond schema validation — richer error objects, per-version result caching, metrics — subclass it rather than forking:
class InstrumentedUblValidator extends UblValidator
{
public function isValid(string $xml): bool
{
$start = microtime(true);
$result = parent::isValid($xml);
Metrics::timing('ubl.validate', microtime(true) - $start, ['valid' => $result]);
return $result;
}
}
Bind the subclass in the container and every call site inherits instrumentation. Treat the package as a focused XSD gate and layer your compliance rules on top — trying to make it do more is where these integrations get brittle.
How can I help you explore Laravel packages today?