Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Ubl Validator Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and make the first call

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.

Where to look first

  • The two classes that matter: 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.
  • Your XML fixtures. Before writing any integration code, run a real invoice from your provider through the validator. Failures here almost always mean a namespace or version problem, not a code problem.

First real use case

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.


Implementation Patterns

Bind a shared, configured validator in a service provider

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.

Wrap it in a FormRequest custom rule

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.

Validate asynchronously for uploads and bulk migrations

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.

Configure a custom XSD directory for versions the package doesn't ship

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.

Pair it with a domain validator

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.

Regression-test with real fixtures

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.


Gotchas and Tips

UBLVersionID is mandatory and must resolve

The 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 version the package ships vs. the version you need drift apart

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 output

Expect strings like Element '{...}Invoice': Missing child element(s)… at line numbers of the XML file. Two implications:

  1. Don't show it to end users verbatim — it's noisy and leaks internal structure. Log the full string, return a truncated summary.
  2. Truncation still needs the line number. A wrapper like this is more useful than a blind 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);
    }
}

Wrap isValid() in a try/catch

Malformed 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);
}

Namespaces and casing are non-negotiable

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.

Large documents will time out

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.

Filesystem layout of the XSD directory is load-bearing

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');

No Laravel auto-discovery — remember the provider

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.

Extension points are limited by design

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo
stevenay/myanfont
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc