Khaled Ahmed
Home Blog Backend & Architecture
Backend & Architecture

ZATCA Phase 2 E-Invoicing in Laravel: A Working Integration Guide

Khaled Ahmed 18 min read

If you run a custom-built store or ERP that issues invoices in Saudi Arabia, ZATCA Phase 2 is not a plugin you install. It is a cryptographic signing pipeline, a certificate lifecycle, and a live API dependency sitting between your checkout and your customer's receipt. I'm Khaled Ahmed, a full stack developer in Cairo, and I've built and repaired this integration inside Laravel applications for Saudi clients. Here is the whole flow, honestly, including the parts that break.

1. Does my custom-built store legally need ZATCA integration?

Short answer: if you are VAT-registered in Saudi Arabia and you issue tax invoices, yes — and "my developer built it, it isn't an accounting product" is not an exemption.

ZATCA regulates the invoice, not the software category. The obligation attaches to the taxable person, and it flows down to whatever system generates their invoices. A Laravel storefront that emails a PDF receipt with a VAT line on it is an E-Invoice Generation Solution (an "EGS unit" in ZATCA's language) whether or not anyone calls it that. So is your point-of-sale. So is the admin panel where your accountant raises a manual invoice.

Two things decide your timing:

  • Are you VAT-registered in KSA? Non-resident taxable persons are outside the e-invoicing obligation, but residents are in.
  • Which integration wave are you in? ZATCA phases Phase 2 in by annual VAT-taxable revenue, and it notifies each group at least six months before its go-live date.

The waves have marched steadily downward. Wave 1 covered taxpayers above SAR 3 billion from 1 January 2023. By the end of 2025 the announced thresholds had fallen to roughly the SAR 1 million level, and further waves have continued into 2026. I am writing this in August 2026 and I will be straight with you: the exact current wave number and threshold is the one fact in this article most likely to be out of date by the time you read it. Do not take it from a blog — mine included. Check the wave lookup on the Fatoora portal against your own VAT number, and confirm with your tax advisor.

What matters strategically is the direction of travel. SAR 3 billion down to roughly SAR 1 million is a factor of three thousand — about three and a half orders of magnitude in three years. If you are a serious online business in Saudi Arabia, you are either in scope now or you will be shortly, and the notification gives you six months — which sounds generous until you discover your invoice table has no immutable counter and your totals are stored as floats.

The practical test: If your system can produce a document that a Saudi customer could hand to their accountant to reclaim VAT, that document is in scope. Build for Phase 2 now, even if your wave letter has not arrived.

2. What is the difference between Phase 1 and Phase 2 compliance?

Phase 1 — "Generation" — has been mandatory for all resident taxpayers since 4 December 2021. It is deliberately undemanding:

  • Invoices must be generated electronically in a structured way. No handwritten invoices, no Word documents, no Excel sheets you edit by hand.
  • Arabic is mandatory on the invoice. You may add English, but Arabic cannot be missing.
  • Simplified (B2C) invoices need a QR code carrying five fields: seller name, seller VAT number, timestamp, total including VAT, and VAT amount, encoded as base64 TLV.
  • No connection to ZATCA at all. Nothing leaves your server.

Phase 2 — "Integration" — is a different animal. It adds four hard requirements on top:

  1. A specific XML format. UBL 2.1 with ZATCA's KSA extensions, not "some XML we invented."
  2. A cryptographic stamp. Every invoice is signed with a private key that lives on your server, using a certificate issued by ZATCA.
  3. A live API dependency. Standard invoices must be cleared by ZATCA before you give them to the buyer. Simplified invoices must be reported within 24 hours.
  4. Tamper resistance. No key export, no invoice counter reset, no clock manipulation, no user-editable stamp.
DimensionPhase 1 (Generation)Phase 2 (Integration)
In force since4 Dec 2021, all resident taxpayers1 Jan 2023, by revenue wave
Invoice formatAny structured electronic formatUBL 2.1 XML with KSA extensions
QR codeSimplified only, 5 TLV tagsSimplified: 9 TLV tags including the signature and public key
Cryptographic signatureNot requiredXAdES enveloped signature, ECDSA over secp256k1, SHA-256
CertificateNoneProduction CSID issued per EGS unit via Fatoora
Contact with ZATCANoneClearance (real time) or reporting (within 24h)
Invoice chainingNot requiredSequential ICV plus previous invoice hash (PIH)
Typical Laravel build effort1–3 days4–8 weeks

The gap between those last two rows is where most budgets get destroyed. Teams quote Phase 2 as if it were "add a QR code, plus an API call." It is neither.

3. Clearance vs reporting: two flows, two very different failure modes

This is the single most important architectural decision, and getting it wrong is expensive because it changes what happens at your checkout.

Standard tax invoices (B2B and B2G) — Clearance

A standard tax invoice is one issued to another VAT-registered business or a government entity. These must be sent to ZATCA synchronously, before the buyer receives them. ZATCA validates the XML, applies its own cryptographic stamp and QR, and returns a cleared document. The cleared version is the legally valid invoice. The one your system generated is not.

That means your invoice issuance is now blocking on a third-party HTTP call. If ZATCA is slow, your invoice is slow. If ZATCA is down, you cannot legally issue a standard invoice at that moment. Your design has to queue, retry, and communicate that state to the user honestly.

Simplified tax invoices (B2C) — Reporting

A simplified invoice is your normal consumer receipt. You issue it to the customer immediately, complete with your own QR code, and you report the XML to ZATCA within 24 hours. This is asynchronous, which is a gift: put it on a queue, retry with backoff, and your checkout never waits on Riyadh.

Most ecommerce stores are overwhelmingly simplified-invoice businesses with a thin B2B tail. If that is you, build the reporting path first and treat clearance as a second, gated flow — the same staged approach I use when planning any custom ecommerce build where one payment or tax path dominates volume.

Design rule: Never call ZATCA inline from an HTTP request that a customer is waiting on — not even for clearance. Persist the invoice, dispatch a job, and let the job own the API conversation. Your checkout latency should not be a function of a government gateway's afternoon.

4. What a compliant invoice actually contains

The document is UBL 2.1. On top of standard UBL, ZATCA defines KSA-specific rules and fields. The ones that trip people up:

  • UUID — a v4 UUID per invoice, distinct from your human-readable invoice number.
  • ICV (Invoice Counter Value) — a strictly sequential integer per EGS unit, starting at 1, that can never be reset. Not your primary key if your primary key can gap or be reused. Not a per-branch counter unless that branch is its own registered EGS unit.
  • PIH (Previous Invoice Hash) — the base64 SHA-256 hash of the previous invoice's canonicalized XML, which chains your invoices together like a small private blockchain. The very first invoice uses ZATCA's defined seed value, and you should hard-code it verbatim: NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==. Look closely at what that constant is, because it is the single most common first-invoice failure: it is the base64 of the 64-character lowercase hex string of SHA-256 over the character 0 — not the base64 of the raw digest bytes. Encoding the raw bytes instead gives you X+zrZv/IbzjZUnhsbWlsecLbwjndTpG0ZynXOif7V+k=, which is a different value and will fail your compliance check. Every subsequent PIH is the base64 of the raw digest, exactly as the hashing code below computes it; only the seed uses the hex-string form.
  • InvoiceTypeCode — the value is the UBL document type (388 tax invoice, 383 debit note, 381 credit note) and the name attribute is a seven-character positional flag string whose first two characters are 01 for standard and 02 for simplified, followed by five flags for third-party, nominal, export, summary and self-billed invoices. So a plain standard invoice is name="0100000" and a plain simplified one is name="0200000". Emit six characters instead of seven and the schema validator rejects the document.
  • Tax amount in SAR — even when the invoice is denominated in another currency, the tax total must also be expressed in SAR.
  • Rounding — two decimals, consistently. Line extension amounts, tax subtotals and the payable amount must reconcile exactly or the validator rejects the document.

That ICV and PIH pair is the reason you cannot bolt this onto a system with a sloppy data layer. You need a durable, gap-free, concurrency-safe counter and a reliable pointer to the last successfully generated invoice. If two workers issue invoice number 4,102 at the same time, your chain is broken and every subsequent invoice inherits the break. I cover the underlying patterns in more depth in my guide to database design for web apps, but the short version for Laravel is: a dedicated counters table, a SELECT ... FOR UPDATE inside a transaction, and never a MAX(id)+1.

// Concurrency-safe ICV allocation for one EGS unit
DB::transaction(function () use ($egsUnitId, $invoice) {
    $counter = DB::table('egs_counters')
        ->where('egs_unit_id', $egsUnitId)
        ->lockForUpdate()
        ->first();

    $icv = $counter->last_icv + 1;

    DB::table('egs_counters')
        ->where('egs_unit_id', $egsUnitId)
        ->update(['last_icv' => $icv]);

    $invoice->update([
        'icv'  => $icv,
        'pih'  => $counter->last_invoice_hash,
        'uuid' => (string) Str::uuid(),
    ]);
});

5. The cryptographic stamp: hashing, signing, and the QR code

This is where implementations actually fail, and where a generic "we do API integrations" agency will burn three weeks of your money.

The hash

The invoice hash is the base64-encoded SHA-256 digest of the canonicalized XML, computed after removing three elements: the ext:UBLExtensions block, the cac:Signature block, and the cac:AdditionalDocumentReference whose cbc:ID is QR. You remove them because they either contain the signature or depend on it.

Canonicalization is where the bodies are buried. ZATCA's specification calls for C14N 1.1, and PHP's DOMDocument::C14N() implements C14N 1.0. In practice, for the element set ZATCA uses, the two produce identical bytes — I have not seen a real divergence — but if your hash mismatches and everything else looks right, this is the first place to look, along with three far more common culprits: formatOutput = true re-indenting your document, a BOM, or Windows line endings sneaking into a template.

$doc = new DOMDocument();
$doc->preserveWhiteSpace = true;
$doc->formatOutput = false;          // never true
$doc->loadXML($invoiceXml);

$xpath = new DOMXPath($doc);
$xpath->registerNamespace('ext', 'urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2');
$xpath->registerNamespace('cac', 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2');
$xpath->registerNamespace('cbc', 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2');

$remove = array_merge(
    iterator_to_array($xpath->query('//ext:UBLExtensions')),
    iterator_to_array($xpath->query('//cac:Signature')),
    iterator_to_array($xpath->query("//cac:AdditionalDocumentReference[cbc:ID='QR']"))
);

foreach ($remove as $node) {
    $node->parentNode->removeChild($node);
}

$canonical   = $doc->C14N(false, false);
$invoiceHash = base64_encode(hash('sha256', $canonical, true));
// this value is the PIH for the next invoice.
// The PIH for the FIRST invoice in the chain is not computed at all —
// it is ZATCA's fixed seed constant, base64 of the hex string:
// NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==

The signature

ZATCA uses ECDSA over the secp256k1 curve with SHA-256, wrapped in a XAdES enveloped signature. The secp256k1 choice surprises people — it is the Bitcoin curve, not the NIST P-256 that most enterprise PKI uses. Before you promise a delivery date, verify your server actually supports it:

openssl ecparam -list_curves | grep secp256k1

Most mainstream OpenSSL builds include it, but some hardened or minimal container images strip it, and discovering that on the day of go-live is a bad day. Check it in your Docker base image, not just on your laptop.

The second notorious trap is the SignedProperties digest. You must hash the canonicalized xades:SignedProperties block exactly as it will appear in the final document — including its indentation. ZATCA's reference implementation emits that block with specific whitespace, and if your XML writer normalises it, your digest will be computed over different bytes than the validator computes over, and you will get a signature failure that tells you almost nothing. If you are debugging a rejected signature and the hash of the invoice body is already correct, this is the first thing to check — dump the exact bytes you hashed and diff them against the bytes in the final document.

The QR code

Phase 2 simplified invoices carry a nine-tag TLV structure, base64-encoded, rendered as a QR image. Tags 1 to 5 are the Phase 1 fields. Tags 6 to 9 are the new ones: the invoice hash, the ECDSA signature, the stamp's public key, and — for simplified invoices — the certificate authority's signature over that public key.

function tlv(int $tag, string $value): string
{
    return chr($tag) . chr(strlen($value)) . $value;
}

// Note: the length is a single byte and ZATCA's TLV specification
// defines no extended-length form, so nothing may exceed 255 bytes.
// In practice nothing comes close: tag 6 is 44 bytes, and tags 7 to 9
// land in the 64-90 byte range. The one that can bite is tag 1, a long
// Arabic seller name in UTF-8. Assert on strlen(), never truncate.
$qr = base64_encode(
      tlv(1, $sellerName)
    . tlv(2, $vatNumber)
    . tlv(3, $timestampUtcIso8601)
    . tlv(4, $totalWithVat)
    . tlv(5, $vatTotal)
    . tlv(6, $invoiceHash)
    . tlv(7, $ecdsaSignature)
    . tlv(8, $publicKeyDer)
    . tlv(9, $certificateSignature)
);

Two details people get wrong: the timestamp must be UTC in ISO 8601, and the amounts are strings formatted to two decimals — not floats, not localised with Arabic-Indic digits.

6. How do I get a CSID and onboard to Fatoora?

Onboarding is a four-step handshake per EGS unit. An EGS unit is one invoice-generating device or instance — a branch till, a server, an application node. If you run one Laravel application issuing all invoices, that is one unit. If you run twelve branch POS terminals, that is twelve.

Step 1 — Generate a CSR and private key

Locally, on the machine that will sign. The CSR carries ZATCA-specific fields: the 15-digit VAT number, which goes in the UID entry of the subjectAltName and not in the organization (O) field, a serial number in the format 1-{ERP name}|2-{model}|3-{device UUID}, the invoice type as a four-digit flag (1100 for a unit that issues both standard and simplified invoices), the country SA, the business category, and a custom certificate-template OID whose value differs per environment.

# openssl.cnf fragment — the template name changes per environment
[req]
prompt             = no
distinguished_name = dn
req_extensions     = v3_req

[dn]
CN = my-egs-unit-01
OU = Riyadh Branch
O  = My Company LLC
C  = SA

[v3_req]
# TSTZATCA-Code-Signing  = sandbox / developer portal
# PREZATCA-Code-Signing  = simulation
# ZATCA-Code-Signing     = production
1.3.6.1.4.1.311.20.2 = ASN1:UTF8String:PREZATCA-Code-Signing
subjectAltName       = dirName:alt_names

[alt_names]
SN = 1-MyERP|2-1.0|3-8f1a2c30-0000-4a00-9f00-1c2d3e4f5a6b
UID = 300000000000003
title = 1100
registeredAddress = King Fahd Road, Riyadh
businessCategory = Retail

The signing key itself is an EC key on the secp256k1 curve, not RSA:

openssl ecparam -name secp256k1 -genkey -noout -out ec-private.pem
openssl req -new -sha256 -key ec-private.pem -config openssl.cnf -out egs.csr

Step 2 — Request a Compliance CSID

Log into the Fatoora portal, choose to onboard a new solution, and it gives you an OTP that is valid for a short window (about an hour, in my experience — treat it as short). Post the base64 CSR with that OTP:

POST {base}/compliance
OTP: 123456
Accept-Version: V2
Content-Type: application/json

{ "csr": "<base64 of egs.csr>" }

You get back a binarySecurityToken (your compliance CSID), a secret, and a requestID. Store all three. From here on, authentication is HTTP Basic where the username is the binary security token and the password is the secret.

Step 3 — Pass the compliance checks

Before ZATCA will issue a production certificate, your unit must successfully submit sample documents to /compliance/invoices — in practice six of them for a unit registered for both types: a standard invoice, credit note and debit note, plus a simplified invoice, credit note and debit note. Each must be correctly signed with the compliance certificate and correctly chained.

This is the real gate. Everything up to here is paperwork; this is where a broken hash or a mangled SignedProperties digest stops you cold.

Step 4 — Request the Production CSID

POST {base}/production/csids
Accept-Version: V2
Authorization: Basic base64(complianceToken:secret)

{ "compliance_request_id": "1234567890123" }

That returns your production CSID and its own secret. Those are the credentials you use for real clearance and reporting. Renewal is a PATCH to the same path with a fresh CSR and a new OTP. Certificates are commonly cited as valid for five years — but rather than trusting that number, read the notAfter field off your actual certificate and put a calendar alert 60 days before it. An expired CSID stops invoicing entirely.

The three environments

EnvironmentBase pathCertificate templateUse it for
Developer portal (sandbox)/e-invoicing/developer-portalTSTZATCA-Code-SigningEarly development. Lenient. Accepts things production will not.
Simulation/e-invoicing/simulationPREZATCA-Code-SigningThe real rehearsal. Same validation rules as production, no legal effect.
Production/e-invoicing/coreZATCA-Code-SigningLive invoices.

All three sit under the gw-fatoora.zatca.gov.sa gateway host. Do not skip simulation. Sandbox is forgiving in ways that will give you false confidence — I have seen invoices sail through the developer portal and get rejected on the first simulation attempt. Budget a full week in simulation with real production-shaped data.

7. Wiring it into an existing Laravel app

The clean architecture is a pipeline with the API call isolated at the very end, behind a queue. In broad strokes:

  1. Persist the invoice first. Your database is the source of truth, not ZATCA's response.
  2. Allocate ICV and PIH inside a locking transaction, as above.
  3. Build the UBL XML from a template, with no pretty-printing.
  4. Hash, sign, build the QR, and store the signed XML on disk or object storage. Store the hash — you need it as the next invoice's PIH.
  5. Dispatch a job that submits to clearance or reporting depending on invoice type.
  6. Record the response verbatim — status, warnings, errors, the cleared XML if any — against the invoice row.

Concretely, the submission looks like this:

// Clearance for a standard (B2B) invoice
$response = Http::withBasicAuth($csid->token, $csid->secret)
    ->withHeaders([
        'Accept-Version'   => 'V2',
        'Accept-Language'  => 'en',
        'Clearance-Status' => '1',
    ])
    ->timeout(30)
    // Retry transport failures and gateway errors ONLY. A bare
    // retry(3, 2000) also retries 4xx, which resubmits a rejected
    // invoice three times and then throws — so you never reach
    // step 6 and never record what ZATCA actually said.
    ->retry(3, 2000, function (Throwable $e) {
        return $e instanceof ConnectionException
            || ($e instanceof RequestException
                && $e->response->serverError());
    }, throw: false)
    ->post($base . '/invoices/clearance/single', [
        'invoiceHash' => $invoice->hash,
        'uuid'        => $invoice->uuid,
        'invoice'     => base64_encode($signedXml),
    ]);

That $when closure is not decoration. Laravel's retry() throws internally between attempts, so the default behaviour treats a 400 NOT_CLEARED — a considered verdict, not a blip — exactly like a dropped connection. And throw: false matters just as much: without it, an exhausted retry chain throws a RequestException, $response is never assigned, and the "record the response verbatim" step below never runs on precisely the invoices you most need the evidence for.

For simplified invoices, the path is /invoices/reporting/single and you drop the Clearance-Status header. The body shape is identical.

A few Laravel-specific notes from doing this repeatedly. Keep the signing logic in a framework-agnostic service class with no Eloquent dependency — you will want to unit-test it against ZATCA's published sample invoices, and you cannot do that comfortably through a model. Use a dedicated queue with a low concurrency so invoices submit in ICV order. And log every request and response body, because when ZATCA rejects something six weeks from now, the error message alone will not tell you what you sent. These are the same discipline points I argue for in my write-up on API design best practices — idempotency keys, verbatim response storage, and a clear separation between "we recorded it" and "they accepted it."

Two more things worth budgeting for. The private key must be protected: it is a legal signing credential, and ZATCA's tamper-resistance requirements mean no user-facing export path and no key sitting in your repository. Encrypt it at rest, restrict filesystem permissions, and keep it out of backups that leave the Kingdom. And KSA has data-residency expectations for invoice archives, with VAT record retention generally running to six years for most records and longer for capital assets and property. That is a hosting decision, not a code decision — verify the current retention rules with your tax advisor before choosing a region. My website security checklist covers the key-handling side in more general terms.

8. What happens if my invoices fail ZATCA validation?

Three outcomes, and the difference between them matters legally.

ResponseMeaningIs the invoice valid?What you should do
200 — CLEARED / REPORTEDAccepted with no issuesYesStore the cleared XML and serve that to the buyer
202 — accepted with warningsAccepted, but ZATCA flagged non-blocking issuesYesStill valid — but fix the warnings. Warnings become errors in future spec versions.
400 — NOT_CLEARED / NOT_REPORTEDRejected on business or schema rulesNoThe document is not a legal invoice. Correct and resubmit.
401Bad or expired CSIDN/ACheck certificate expiry and Basic auth encoding
5xx / timeoutGateway problem, not yoursUnknownRetry with backoff. Never assume rejection.

The 202 case is the one teams mishandle. It looks like a failure in a naive integration and gets retried into a duplicate, or it gets ignored for a year and then becomes a blocking error when ZATCA tightens the rule. Treat warnings as a backlog, not as noise.

The 400 case is the dangerous one for standard invoices. If clearance fails, you have no legally valid invoice to hand the buyer — you cannot send the uncleared version and sort it out later. Your system needs a visible "pending clearance" state and an operational alert, not a silent failed job.

On penalties: the Saudi VAT framework provides for fines for failing to issue or retain invoices correctly, commonly cited in a range from around SAR 5,000 up to SAR 50,000 depending on the violation and whether it is repeated, and ZATCA has historically applied a notice-and-correct approach for first violations. I am not a tax advisor and the penalty schedule has been adjusted more than once — treat those figures as an order of magnitude and get the current position from a Saudi tax professional. The engineering point stands regardless: a broken integration is not a bug that costs you a support ticket, it is a bug that costs you a fine and a customer who cannot reclaim their VAT.

Build a compliance dashboard on day one. Every invoice, its submission status, its response code, and a one-click resubmit. Not a nice-to-have. It is the difference between finding out about a systematic failure in twenty minutes and finding out in the quarterly VAT return.

9. How long does ZATCA integration take on an existing Laravel app?

Assuming a reasonably well-built Laravel application that already issues invoices, has a clean invoice model, and stores totals as decimals rather than floats, my honest estimate is four to eight weeks of focused work — that is 22 to 38 working days, and here is where they go:

  • Discovery and data audit — 3 to 5 days. This is where you find that credit notes were implemented as negative invoices, or that four branches share one invoice sequence.
  • Data model changes — ICV counters, PIH storage, UUID column, XML and response archival — 3 to 5 days.
  • UBL generation and signing — 5 to 10 days. The bulk of it.
  • Onboarding and compliance checks in sandbox and simulation — 5 to 8 days, including waiting on portal access and OTPs.
  • Admin tooling, monitoring, retry handling — 3 to 5 days.
  • Production onboarding and parallel running — 3 to 5 days.

Add substantially to that if any of the following is true: multiple branches or EGS units, a VAT group structure, multi-currency invoicing, an existing invoice numbering scheme with gaps, credit and debit notes that do not reference their original invoice, or a legacy database where the same "invoice" concept lives in three tables. In a multi-tenant SaaS where every tenant needs its own certificate and counter, this becomes a platform feature rather than an integration task, and the timeline roughly doubles.

The variable nobody estimates correctly is portal access. Getting the right person at the client authorised on Fatoora, with the right VAT credentials, to generate an OTP at the moment your developer needs it, routinely takes longer than writing the signing code. Start it in week one.

10. What it costs

Honest ranges, with the assumptions stated. These are the bands I quote from, in SAR with USD equivalents, for work done remotely from Cairo for Saudi clients. Your local agency in Riyadh will typically sit above the top of these ranges; a marketplace freelancer will sit below the bottom and you will usually pay the difference twice.

ScopeTypical range (SAR)Typical range (USD)Assumptions
Phase 1 retrofit only (QR + Arabic + structured generation)4,000 – 9,0001,050 – 2,400Existing clean invoice model, single entity
Phase 2, single EGS unit, clean Laravel app22,000 – 45,0005,900 – 12,000One VAT number, one branch, simplified + standard, decimals not floats
Phase 2, multi-branch or multi-entity50,000 – 110,00013,300 – 29,300Several EGS units, per-unit certificates and counters, VAT group possible
Phase 2 inside a multi-tenant SaaS90,000 – 200,000+24,000 – 53,300+Per-tenant onboarding UX, certificate lifecycle, tenant-level monitoring
Certified third-party middleware (annual)3,000 – 25,000/yr + per-invoice800 – 6,700/yrPlus the integration work to talk to it, usually 5–15 days
Ongoing maintenance and spec updates500 – 2,000/month135 – 535/monthCertificate renewals, spec version bumps, warning cleanup

Those bands assume you are paying for senior work with a fixed scope, not an hourly open-ended engagement. For a broader view of how these numbers compare to general web work across the region, I've written a full breakdown of what websites actually cost in Egypt and the Gulf.

11. Build in-house, use a certified provider, or hire a specialist?

I make money building this, and I will still tell you when not to hire me.

Use a certified ZATCA solution provider if your invoicing is standard, your volume is modest, and you have no unusual product model. You pay a subscription, they own the spec updates, and you integrate against a much friendlier API than ZATCA's. This is the right answer more often than developers like to admit.

Build it in-house or hire a specialist when your invoicing logic is genuinely custom — marketplaces with split settlements, subscription products with proration, mixed B2B and B2C flows, per-tenant tax identities — or when a per-invoice fee at your volume outweighs the build cost within eighteen months. Anything where a generic provider forces you to distort your own domain model is a case for building.

If you build, you do not have to start from an empty file. As of August 2026 there are several actively used PHP packages on Packagist. salla/zatca is by far the most downloaded — roughly 470,000 installs against about 23,000 each for the alternatives — and it is worth correcting its reputation as "the QR package": alongside QR generation it ships an InvoiceSign class for XAdES invoice signing and helpers for CSR generation during onboarding, so it reaches into Phase 2 rather than stopping at Phase 1. What it does not do is build your UBL document or wrap the clearance and reporting endpoints for you. saleh7/php-zatca-xml and sevaske/zatca-api go wider, covering UBL generation, XAdES signing and the API calls. All of them are community-maintained and unofficial. Read the source before you depend on one — check when it was last updated against the current spec version, because a stale signing implementation is worse than none.

Do not hand this to a WordPress generalist or a team whose portfolio is landing pages. The signing pipeline is unforgiving, the error messages are terse, and the debugging loop requires reading a specification, not a Stack Overflow answer. If you need someone who has actually shipped this, that is exactly the scenario I describe on my hire a Laravel developer page.

One more practical note for Saudi and Gulf merchants: ZATCA is rarely the only compliance-shaped integration on the roadmap. It usually lands in the same quarter as the payment stack, and the two interact — refunds create credit notes, and credit notes are ZATCA documents. If that is your situation, read my guide to GCC payment gateway integration alongside this one and sequence them together rather than paying for two separate discovery phases.

12. The mistakes that cost the most

  • Floats for money. If your totals are stored as float, ZATCA's arithmetic validation will reject invoices for one-halala discrepancies you cannot see in the UI. Fix this before anything else; it is a migration, not a patch.
  • Resettable or gapped counters. The ICV must never go backwards or repeat, across deployments, database restores and staging refreshes. Restoring a production backup to staging and letting staging submit invoices has broken more than one chain.
  • Signing in the request cycle. ECDSA signing is fast; the ZATCA call is not. Queue it.
  • Skipping simulation. Covered above. Do not do it.
  • Not storing the cleared XML. For standard invoices, ZATCA's returned document is the legal one. If you only keep yours, your archive is not compliant.
  • One certificate for everything. If you have multiple invoice-issuing devices, each is an EGS unit with its own CSID and its own counter. Sharing one certificate across branches will pass sandbox and fail an audit.
  • Treating Arabic as optional. It was mandatory in Phase 1 and it remains mandatory. Arabic seller name, Arabic on the printed document.
  • No monitoring. A queue worker that dies silently is a compliance incident with a delay fuse.

13. A realistic delivery plan

If you are starting today with a wave deadline in six months, here is the sequence I would run:

  1. Week 1: Fatoora portal access secured and tested. Data audit of the invoice model. Decision on build vs certified provider.
  2. Weeks 2–3: Data model migration — decimals, ICV counters, PIH, UUIDs, XML archive. Deployed to production behind a flag, generating nothing yet.
  3. Weeks 4–6: UBL generation, hashing, signing, QR. Unit-tested against ZATCA's published sample documents before any network call.
  4. Week 7: Sandbox onboarding and the six compliance documents.
  5. Weeks 8–9: Simulation with production-shaped data. This is where you find the real bugs.
  6. Week 10: Production onboarding, parallel run — generate and sign every real invoice, submit them, but keep the old document flow visible internally so you can compare.
  7. Weeks 11–12: Cut over. Monitor daily. Clear the warning backlog.

That leaves three months of buffer against a six-month notification, which is roughly the buffer you want, because the thing that delays this project is almost never the code.

14. Where to go from here

If you're running a custom Laravel store or ERP in Saudi Arabia and you have a wave deadline in front of you, the useful next step is a short technical review of what you already have: your invoice model, your numbering, your credit note handling, and how many EGS units you actually need. That review takes me an hour or two and it is the difference between a project that lands in eight weeks and one that drags past sixteen.

You can see the kind of work I ship on my portfolio, and I'll give you a straight answer about whether you should build this or buy it — including telling you to buy it, if that is the right call.

Send me the details of your setup and I'll come back within 24 hours with a free consultation and a fixed-fee quote. No retainer, no hourly surprises, and a clear statement of what is in scope and what is not.

Frequently asked questions

Does my custom-built store legally need ZATCA integration?
If you are a resident VAT-registered taxpayer in Saudi Arabia and your system issues tax invoices, yes. ZATCA regulates the invoice, not the software category, so a custom Laravel storefront counts as an EGS unit exactly like a commercial accounting product. Your go-live date depends on which revenue wave ZATCA has placed you in, and you are notified at least six months ahead.
What is the difference between Phase 1 and Phase 2 compliance?
Phase 1 requires only that invoices be generated electronically in a structured format, in Arabic, with a five-field QR code on simplified invoices. Phase 2 adds UBL 2.1 XML, a cryptographic stamp signed with a ZATCA-issued certificate, a nine-field QR code, sequential invoice chaining via ICV and PIH, and a live API connection for clearance or reporting.
How do I get a CSID and onboard to Fatoora?
Four steps per EGS unit. Generate a secp256k1 private key and a CSR carrying your 15-digit VAT number in the subjectAltName UID field, plus the device serial. Get an OTP from the Fatoora portal and post the CSR to the compliance endpoint to receive a compliance CSID. Submit six signed sample documents to the compliance-invoices endpoint. Then exchange your request ID for a production CSID.
How long does ZATCA integration take on an existing Laravel app?
Four to eight weeks of focused work — 22 to 38 working days — if the app already issues invoices, has a clean invoice model, and stores money as decimals rather than floats. Multiple branches, VAT groups, multi-currency invoicing, or a legacy schema where invoices span several tables can double that. The most commonly underestimated delay is getting authorised Fatoora portal access at the client.
What happens if my invoices fail ZATCA validation?
A 400 response means NOT_CLEARED or NOT_REPORTED and the document is not a legally valid invoice; you must correct and resubmit, never blindly retry. A 202 means accepted with warnings, which is still valid but should be cleaned up before those warnings become blocking errors. A 5xx is a gateway problem, so retry with backoff rather than treating it as rejection.
What is the correct PIH value for the very first invoice?
It is the fixed constant NWZlY2ViNjZmZmM4NmYzOGQ5NTI3ODZjNmQ2OTZjNzljMmRiYzIzOWRkNGU5MWI0NjcyOWQ3M2EyN2ZiNTdlOQ==. That is the base64 of the 64-character lowercase hex string of SHA-256 over the character 0, not the base64 of the raw digest bytes. Encoding the raw bytes gives X+zrZv/IbzjZUnhsbWlsecLbwjndTpG0ZynXOif7V+k=, which is wrong and fails compliance. Every later PIH is base64 of the raw digest; only the seed differs.
Can I use an existing PHP package instead of building from scratch?
Partly. As of August 2026 the widely used Packagist options are salla/zatca, by far the most downloaded, which covers QR generation plus XAdES invoice signing and CSR generation but not UBL document building, and saleh7/php-zatca-xml and sevaske/zatca-api, which add UBL generation and the clearance and reporting API calls. All are community-maintained and unofficial, so read the source and check the last update against the current spec version before depending on one.
Do I need a separate certificate for each branch?
Each invoice-generating device or instance is a separate EGS unit, and each EGS unit needs its own CSR, its own production CSID, and its own non-resettable ICV counter. One Laravel application issuing all invoices is a single unit. Twelve branch POS terminals are twelve units. Sharing one certificate across branches may pass sandbox but fails an audit.
Tags: ZATCALaravelE-InvoicingSaudi ArabiaComplianceAPI IntegrationEcommercePHP

Ready to apply what you just read?

Free 30-minute consultation, 24-hour response, written fixed-fee quote.

Chat on WhatsApp