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

Laravel Zatca Laravel Package

corecave/laravel-zatca

A Laravel package for integrating ZATCA Saudi e-invoicing, with tools for compliant invoice generation, QR codes, XML/UBL output, and Phase 1/2 workflows to simplify VAT reporting and API connectivity.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and publish

composer require corecave/laravel-zatca
php artisan vendor:publish --provider="Corecave\Zatca\ZatcaServiceProvider"

Pin the exact version in composer.json ("corecave/laravel-zatca": "1.2.0") rather than a caret range — you do not want a silent minor bump changing XML output. At this dependency count the package is effectively unreviewed by the wider community, and v1.2.0 adds a second third-party dependency (see below), so your lockfile is now doing more work than you think.

If you plan to render QR codes (new in 1.2.0), also install:

composer require simplesoftwareio/simple-qrcode

This is optional at install time but required the moment you touch qr_code_image or qr_code_svg.

2. Read these three things before writing any code

  • The README (rewritten in 1.2.0). This is the big practical improvement of the release: it now carries an accurate end-to-end example, the corrected VAT number format (3XXXXXXXXXX0003 — 15 digits, starts and ends with 3), the clarification that production CSID does not require an OTP, and the full set of cleanup command options. Read it first; it will save you an hour in src/.
  • config/zatca.php — endpoint/environment switch (sandbox, simulation, production), certificate and secret paths, VAT number, company name, invoice prefix/serial settings. This file is the contract; most "it doesn't work" issues are a wrong environment or a stale CSID here.
  • src/ — specifically whatever handles CSR/onboarding, the invoice/XML builder, and the HTTP client. The README is now good, but the source is still authoritative for anything the README glosses over, particularly how the QR payload is assembled.

3. Configure .env

Typical shape (names will match the config file):

ZATCA_ENV=sandbox
ZATCA_VAT_NUMBER=3xxxxxxxxxxxxx3
ZATCA_ORG_NAME="Your Company"
ZATCA_CERT_PATH=/secure/zatca/cert.pem
ZATCA_PRIVATE_KEY_PATH=/secure/zatca/key.pem
ZATCA_SECRET=...

Keep certificates outside the repo, in a path not served by the web server. Never commit them; production CSIDs are live signing credentials.

4. Onboard once, in sandbox

The ZATCA Phase 2 lifecycle is: generate CSR + key pair → request a Compliance CSID (this step does use an OTP) → submit the six compliance-check sample invoices → request a Production CSID (no OTP, per the 1.2.0 README correction). Run it against sandbox first, store the resulting CSID and secret somewhere durable (DB or secret manager), and only then point ZATCA_ENV at production. Treat onboarding as a one-time deployment task, not a runtime code path.

5. First real use case: a simplified (B2C) tax invoice, with a rendered QR

The shortest end-to-end path that proves your integration works is a simplified invoice: build it, generate the QR, render it as an image for a receipt, and report it to ZATCA within 24 hours. In sandbox, dump the raw XML the package produces, validate it against the ZATCA SDK validator, and decode the QR payload to confirm it carries the Phase 2 extended TLV set — not just the Phase 1 five tags. Once that round-trips cleanly, standard (B2B) invoices with clearance are a variation on the same flow.


Implementation Patterns

Wrap the package behind your own service. With this dependency count, the risk is not that the package is wrong — it's that the API surface moves or you outgrow it. Put a thin ZatcaInvoiceService in your app that takes your domain models and returns a result object; call the package only inside that class. Swapping to a fork or a hand-rolled UBL/XAdES implementation then costs you one file.

final class ZatcaInvoiceService
{
    public function __construct(private InvoiceRepository $invoices) {}

    public function issue(Order $order): ZatcaResult
    {
        $invoice = $this->invoices->createFromOrder($order); // domain record, pending

        $xml = Zatca::invoice()          // builder entry point per the package
            ->type('388')                // tax invoice
            ->subtype($order->isB2B() ? '0100000' : '0200000')
            ->seller(...)
            ->buyer($order->customer)
            ->lines($order->lines->map(fn ($l) => [...])->all())
            ->issueDate(now('Asia/Riyadh'))
            ->toXml();

        return $this->invoices->attachXml($invoice, $xml);
    }
}

Split standard vs simplified into two dispatch paths. They are genuinely different flows:

  • Standard (B2B, subtype 0100000) — clearance. You must send the signed invoice to ZATCA before delivering it to the buyer; the returned XML (with the stamp) is the artefact you archive and email. Clearance is synchronous and blocking-ish, so keep it in the request path only if your latency budget allows; otherwise queue with an immediate retry and don't release the PDF until cleared.
  • Simplified (B2C, subtype 0200000) — reporting. You issue the invoice immediately (with QR) and report within 24 hours. This maps naturally to a queued job. If the queue is down, you are silently non-compliant, so monitor it.

Model the invoice as a state machine. Persist: UUID, invoice counter value (ICV), previous invoice hash (PIH), invoice hash, signed XML, the QR payload itself (base64 TLV string, not just a rendered image), ZATCA response JSON, status enum (pending, submitted, cleared/reported, rejected, warning), and attempt count. ZATCA returns HTTP 200 with validationResults.errorMessages populated for rejections — status must be derived from the body, not the status code.

Cache rendered QR output, don't regenerate it on every render. The 1.2.0 accessors are convenient but they run QR encoding each time you read them. For a receipt shown to thousands of customers, or an invoice emailed more than once:

// In your own model / accessor layer, not at the call site
public function qrPng(): string
{
    return Cache::rememberForever(
        "zatca:qr:png:{$this->uuid}",
        fn () => $this->zatcaInvoice->qr_code_image
    );
}

Store the source QR payload in your DB and treat the PNG/SVG as derived artefacts. That way a change in your rendering library never forces you to re-derive anything from ZATCA, and you can re-render historical invoices without a network call. Also verify: the QR bytes must correspond to the signed invoice. If you regenerate a QR after any amendment, the hash inside the TLV changes and the code becomes invalid.

Use SVG for the web, PNG for PDF/email. qr_code_svg is a raw <svg> string you embed directly; qr_code_image is base64 PNG that you interpolate into a data URI (as the release notes show). Don't put the raw base64 in an <img src> without the data:image/png;base64, prefix — a common copy-paste error that yields a broken image with no error.

Chain the ICV/PIH atomically. Every invoice increments an invoice counter and references the previous invoice's hash. Two concurrent sales must not get the same ICV:

$seq = DB::transaction(function () use ($invoice) {
    $last = ZatcaInvoice::where('company_id', $invoice->company_id)
        ->orderByDesc('icv')->lockForUpdate()->first();

    return ['icv' => ($last?->icv ?? 0) + 1, 'pih' => $last?->invoice_hash ?? self::GENESIS_HASH];
});

The genesis hash for the first invoice is a base64-encoded SHA-256 of 0 — verify the package handles first-invoice state correctly rather than emitting an empty PIH.

Multi-tenant / multi-company. If you operate more than one VAT registration, you need a certificate and ICV chain per company. Most packages bind config at boot; if so, override at runtime before each call:

config([
    'zatca.certificate' => $company->zatca_cert,
    'zatca.secret'      => decrypt($company->zatca_secret),
    'zatca.vat_number'  => $company->vat_number,
]);

Be aware this is global mutable state — reset it, or you will report company A's invoice under company B's credentials. Prefer a scoped service instance if the package supports it.

Test with Http::fake() and fixtures. Capture real sandbox request/response pairs once, store the JSON as fixtures, and assert your state machine against them — including the rejection shape. Never let CI touch sandbox; the compliance/production endpoints are rate-limited and stateful. Add one fixture for a decoded QR payload so a future bump can't silently downgrade you to Phase 1 tags.


Gotchas and Tips

Environment drift is the #1 time sink. Sandbox, simulation, and production use different base URLs and different CSIDs. A production certificate against the sandbox host returns an auth error that looks like a signature failure. Log the resolved base URL with every submission at debug level.

Certificates expire. Production CSIDs are time-limited (renewable). A scheduled job should check expiry and alert — a silently expired cert turns every invoice into a rejection, and for simplified invoices you discover it 24 hours later when you're already out of the reporting window.

The 24-hour reporting window is not a retry budget. A failed simplified-invoice report does not fail loudly in your app; it fails at ZATCA. Build a daily reconciliation job that queries for invoices still in pending and surfaces them. Same for clearance: an uncleared standard invoice should block fulfilment/PDF delivery.

The new QR accessors add a runtime dependency. v1.2.0 delegates rendering to simplesoftwareio/simple-qrcode. If you don't install it, reading qr_code_image or qr_code_svg will throw — potentially inside a Blade template, mid-receipt. Two ways to handle it: require the package unconditionally in composer.json (simplest, keeps the dependency graph honest), or guard every read behind class_exists(\SimpleSoftwareIO\QrCode\Facades\QrCode::class) and degrade to a text payload. Don't leave it to chance.

PNG generation and image extensions. simple-qrcode renders SVG without image extensions but raster PNG output depends on the underlying encoder and typically wants ext-imagick on the image. This is the same class of Dockerfile trap as the secp256k1 issue below, and it will bite you on php:*-alpine or slim Debian images. If PNG is failing with a vague renderer error, switch to qr_code_svg for the web and test PNG separately in the target image — don't assume the code is wrong.

QR is not the Phase 1 five tags. Phase 2 simplified invoices need the extended TLV set (including invoice hash, signature, and public key), base64-encoded. The 1.2.0 release makes it easier to render a QR, which makes it easier to render the wrong one: if your POS QR only scans into seller/VAT/total/timestamp, someone wired up a Phase 1 helper (or hand-rolled it). Decode one payload and check the tag count before shipping.

Confirm you're installing the code you audited. The v1.2.0 changelog links to nadyshalaby/laravel-zatca, while the Composer vendor is corecave. That may be a legitimate rename, a fork of a fork, or a copy-paste slip in the notes — but for a package that handles live signing credentials, verify the repository URL in composer.json matches the one you reviewed, and diff v1.1.0...v1.2.0 yourself rather than trusting the summary. The new simple-qrcode dependency widens the supply-chain surface of an already-unreviewed package.

"Production CSID does not require OTP" — read the correction precisely. The OTP is still required for the compliance CSID step. If you built your onboarding flow around the old (wrong) assumption that you skip OTP everywhere, or that production also needs one, revisit it. Store the compliance and production credentials as distinct records; they are not interchangeable.

Know what the cleanup commands delete before running them in production. The 1.2.0 README documents all cleanup options, which is useful — but these commands almost certainly remove invoices and/or certificate state. Run --help and read the source of the command before pointing it at a live database, and never put one on a schedule as a "tidy up".

Hash and signature are strict. ZATCA validates the canonical XML, the XAdES signature, and the QR TLV fields. Symptoms of a mismatch are unhelpful "invalid signature" messages. Debug by: (1) dumping the exact XML string the package sends, (2) verifying you are not pretty-printing or re-indenting it after signing (whitespace changes invalidate the hash), and (3) running the XML through ZATCA's own SDK validator rather than guessing. If the package re-serializes XML anywhere between signing and sending, that is a bug worth reporting or forking.

openssl support for secp256k1 varies. ZATCA uses the secp256k1 curve. Some distro OpenSSL builds and some Alpine/php:*-alpine images lack it, producing CSR/signing failures that have nothing to do with the package. Confirm on the target image early — this is a Dockerfile problem, not a code problem.

Timezone and date formats. Issue timestamps must be in Saudi local time (Asia/Riyadh) in the exact format ZATCA expects, and APP_TIMEZONE in a US/EU default will quietly produce invoices that fail validation on business-day rules. Set timezone explicitly when building the invoice, never rely on now() with the app default.

Rounding. VAT is computed and validated per line, then summed; rounding the total independently of the lines produces a mismatch ZATCA will flag. Keep money in integer minor units or use bcmath and round only at the line level.

HTTP 200 ≠ success. Parse validationResults — errorMessages means rejected, warningMessages means accepted-with-warnings and usually still needs human review (they often indicate missing Arabic names, non-standard unit codes, or address fields). Log warnings to a queue someone actually reads.

Idempotency. Retrying a submission must not create a duplicate at ZATCA. Key retries on invoice UUID and check the persisted status before re-sending; wrap the submission job in WithoutOverlapping keyed on the invoice ID. The same applies to rendering: rendering a QR is not a submission and must never be mistaken for one.

Debugging recipe. Reproduce the call outside the framework first — a plain curl to the sandbox endpoint with the same body — to determine whether the failure is in payload generation or transport. Then compare to the package's output. In config/zatca.php, enable whatever request logging exists; if there isn't any, wrap the package's HTTP client with a Laravel middleware or a Http::globalRequestMiddleware in a debug-only service provider.

Extension points to look for. The pieces you'll most likely need to replace: the XML/UBL builder (your line-item shapes will not match the defaults), the certificate resolver (you want yours from a DB or secrets manager), the QR renderer (you may want your own cache layer, or a different encoder for print), and the HTTP transport (you want retries, logging, and timeouts on your terms). If the package binds these as interfaces in its service provider, override them there — cleaner than config() mutation. If it hard-codes them, that is the strongest argument for the wrapper-service approach above.

Maturity check before go-live. Small install base, zero dependents, and now two transitive dependencies: there is no community safety net — no Stack Overflow answers, no third-party bug reports confirming edge cases. Before production, write an integration test suite that covers: first invoice (genesis PIH), credit note and debit note types (381/383 with the original invoice reference), zero-rated and exempt lines, rejection handling, and a decoded assertion on the QR TLV set. If the package fails those, budget time to fork — the ZATCA spec is public and the XML is inspectable, so forking is genuinely cheaper than fighting an opaque dependency during a compliance audit.

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.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin