corecave / laravel-zatca
Laravel package for ZATCA e-invoicing integration (Saudi Arabia Phase 2)
Requires
- php: ^8.1
- guzzlehttp/guzzle: ^7.0|^8.0
- illuminate/database: ^13.0
- illuminate/support: ^13.0
- phpseclib/phpseclib: ^3.0
- robrichards/xmlseclibs: ^3.1
- sabre/xml: ^4.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- pestphp/pest: ^2.0|^3.0
- pestphp/pest-plugin-laravel: ^2.0|^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A comprehensive Laravel package for integrating with Saudi Arabia's ZATCA (Zakat, Tax and Customs Authority) e-invoicing system (FATOORA platform).
This package supports Phase 2 requirements including:
- CSR generation and certificate management
- Invoice XML generation (UBL 2.1 compliant)
- Digital signing with ECDSA (secp256k1)
- QR code generation (9 TLV tags)
- Invoice reporting (B2C simplified invoices)
- Invoice clearance (B2B standard invoices)
- Credit and debit note handling
- Sandbox, simulation, and production environments
Requirements
- PHP 8.3+, which Laravel 13 needs
- Laravel 13.x
- OpenSSL extension
- GMP extension (recommended for better performance)
Installation
The package is on Packagist:
composer require corecave/laravel-zatca:^2.1
Publish the configuration file:
php artisan vendor:publish --tag=zatca-config
The package ships no migrations. Each workshop keeps its own certificates, invoice counter and hash chain in its own schema, so the host app keeps the two table migrations with its workshop migrations (database/migrations/tenant in Syanat) and runs them per workshop:
php artisan tenants:migrate
An app whose one seller keeps its device centrally, while a tenant's connection is the default, keeps the two tables with its central migrations instead and names its central connection in ZATCA_DB_CONNECTION: the certificates, the chain and the chain's lock all use it.
Configuration
Add these environment variables to your .env file:
# Environment: sandbox, simulation, or production ZATCA_ENVIRONMENT=simulation # Seller Information ZATCA_SELLER_NAME="Your Company Name" ZATCA_SELLER_NAME_AR="اسم شركتك" ZATCA_VAT_NUMBER=300000000000003 ZATCA_REGISTRATION_NUMBER=1234567890 # Seller Address ZATCA_SELLER_STREET="Main Street" ZATCA_SELLER_BUILDING="1234" ZATCA_SELLER_CITY="Riyadh" ZATCA_SELLER_DISTRICT="Al Olaya" ZATCA_SELLER_POSTAL_CODE="12345" # CSR Configuration ZATCA_CSR_ORGANIZATION="Your Company Name" ZATCA_CSR_ORGANIZATION_UNIT="Main Branch" ZATCA_CSR_COMMON_NAME="Your Company Name" ZATCA_INVOICE_TYPES=1100 # Database connection for the certificates and the chain (optional; empty is the default connection) ZATCA_DB_CONNECTION= # Debug (optional) ZATCA_DEBUG_ENABLED=true ZATCA_DEBUG_PATH=zatca/debug
Environments
The package supports three environments:
| Environment | Endpoint | Certificate Issuer | Purpose |
|---|---|---|---|
sandbox |
developer-portal | CN=eInvoicing (mock) |
Basic development testing |
simulation |
simulation | CN=TSZEINVOICE-SubCA-1, DC=extgazt, DC=gov, DC=local |
Real testing with ZATCA |
production |
core | CN=PEZEINVOICESCA2-CA, DC=extgazt, DC=gov, DC=local |
Live production |
Important:
- The
sandboxenvironment returns mock certificates that will NOT pass ZATCA's official validators - Use
simulationfor testing with the ZATCA validator at https://sandbox.zatca.gov.sa/Compliance - Use
productiononly when ready to go live
Onboarding Process
Step 1: Generate CSR
php artisan zatca:generate-csr --save
Step 2: Get Compliance CSID
- Log in to the FATOORA portal
- Generate an OTP for your EGS unit
- Run the compliance command:
php artisan zatca:compliance --otp=123456
Step 3: Get Production CSID
After passing compliance checks:
php artisan zatca:production-csid
Usage
Creating a Simplified Invoice (B2C)
use Corecave\Zatca\Facades\Zatca; use Corecave\Zatca\Invoice\InvoiceBuilder; use Corecave\Zatca\Enums\VatCategory; // Build the invoice $invoice = InvoiceBuilder::simplified() ->setInvoiceNumber('INV-2024-001') ->setIssueDate(now()) ->addLineItem([ 'name' => 'Product Name', 'quantity' => 2, 'unit_price' => 100.00, 'vat_category' => VatCategory::STANDARD, ]) ->addLineItem([ 'name' => 'Service', 'quantity' => 1, 'unit_price' => 250.00, 'vat_category' => VatCategory::STANDARD, ]) ->build(); // Report to ZATCA $result = Zatca::report($invoice); if ($result->isSuccess()) { $qrCode = $result->getQrCode(); $signedXml = $result->getSignedXml(); // Store or display the QR code }
Creating a Standard Invoice (B2B)
For standard B2B invoices, buyer information is required and must include complete address details for Saudi Arabian buyers.
use Corecave\Zatca\Facades\Zatca; use Corecave\Zatca\Invoice\InvoiceBuilder; $invoice = InvoiceBuilder::standard() ->setInvoiceNumber('INV-2024-002') ->setIssueDate(now()) ->setBuyer([ 'name' => 'Buyer Company LLC', 'vat_number' => '300000000000003', // Used for TIN scheme if no registration_number 'registration_number' => '1234567890', // CRN - Commercial Registration Number 'registration_scheme' => 'CRN', // Optional: CRN, MOM, MLS, SAG, 700, OTH 'address' => [ 'street' => 'King Fahd Road', 'building' => '1234', // Required for SA buyers (KSA-18) 'additional_number' => '5678', // Optional (KSA-23) 'city' => 'Riyadh', 'district' => 'Al Olaya', // Required for SA buyers (KSA-4) 'postal_code' => '12345', 'country' => 'SA', ], ]) ->addLineItem([ 'name' => 'Consulting Services', 'quantity' => 10, 'unit_price' => 1000.00, 'vat_category' => VatCategory::STANDARD, ]) ->build(); // Clear with ZATCA (must be done BEFORE issuing to customer) $result = Zatca::clear($invoice); if ($result->isSuccess()) { // ZATCA adds cryptographic stamp and QR code $clearedXml = $result->getClearedXml(); $qrCode = $result->getQrCode(); }
Buyer Identification Schemes
For the registration_scheme field, ZATCA accepts:
| Scheme ID | Description |
|---|---|
CRN |
Commercial Registration Number |
MOM |
Momra License |
MLS |
MLSD License |
SAG |
Sagia License |
NAT |
National ID (10 digits) |
GCC |
GCC ID |
IQA |
Iqama Number |
TIN |
Tax Identification Number (VAT) |
700 |
700 Number |
OTH |
Other ID |
Required Buyer Address Fields for Saudi Arabia (BR-KSA-63)
When the buyer's country is SA, these fields are mandatory:
| Field | XML Element | Description |
|---|---|---|
street |
cbc:StreetName |
Street name (BT-50) |
building |
cbc:BuildingNumber |
Building number - 4 digits (KSA-18) |
postal_code |
cbc:PostalZone |
Postal code - 5 digits (BT-53) |
city |
cbc:CityName |
City name (BT-52) |
district |
cbc:CitySubdivisionName |
District name (KSA-4) |
country |
cbc:IdentificationCode |
Country code (BT-55) |
Creating Credit/Debit Notes
use Corecave\Zatca\Invoice\InvoiceBuilder; // Credit note for a standard invoice $creditNote = InvoiceBuilder::creditNote(simplified: false) ->setInvoiceNumber('CN-2024-001') ->setOriginalInvoice('INV-2024-002') // Reference original invoice ->setReason('Returned goods') ->setBuyer([/* buyer details */]) ->addLineItem([ 'name' => 'Returned Product', 'quantity' => 1, 'unit_price' => 100.00, 'vat_category' => VatCategory::STANDARD, ]) ->build(); // Process (automatically clears for standard, reports for simplified) $result = Zatca::process($creditNote);
Auto-Processing Invoices
The process() method automatically determines whether to report or clear:
// Automatically uses report() for simplified invoices // and clear() for standard invoices $result = Zatca::process($invoice); if ($result->wasReported()) { // B2C invoice was reported } if ($result->wasCleared()) { // B2B invoice was cleared }
VAT Categories
use Corecave\Zatca\Enums\VatCategory; use Corecave\Zatca\Enums\VatExemptionReason; // Standard rate (15%) ->addLineItem([ 'name' => 'Product', 'quantity' => 1, 'unit_price' => 100.00, 'vat_category' => VatCategory::STANDARD, ]) // Zero-rated ->addLineItem([ 'name' => 'Export Product', 'quantity' => 1, 'unit_price' => 100.00, 'vat_category' => VatCategory::ZERO_RATED, 'vat_exemption_reason' => VatExemptionReason::EXPORT, ]) // Exempt ->addLineItem([ 'name' => 'Financial Service', 'quantity' => 1, 'unit_price' => 100.00, 'vat_category' => VatCategory::EXEMPT, 'vat_exemption_reason' => VatExemptionReason::FINANCIAL_SERVICES, ])
Working with Certificates
use Corecave\Zatca\Facades\Zatca; // Check if production certificate exists if (Zatca::certificate()->hasActiveProductionCertificate()) { // Ready to issue invoices } // Get certificate details $cert = Zatca::certificate()->getActive('production'); $expiresAt = $cert->getExpiresAt(); $isExpiringSoon = $cert->isExpiringSoon(30); // Get certificates expiring soon $expiring = Zatca::certificate()->getExpiringSoon(30); // Get certificate issuer (for debugging) $issuer = $cert->getFormattedIssuer(); // Should be: CN=TSZEINVOICE-SubCA-1, DC=extgazt, DC=gov, DC=local (simulation) // NOT: CN=eInvoicing (mock/sandbox)
Manual XML Generation
use Corecave\Zatca\Facades\Zatca; // Generate XML without submitting $xml = Zatca::generateXml($invoice); // Validate XML $isValid = Zatca::validateXml($xml); // Sign XML $signedXml = Zatca::signXml($xml);
Hash Chain Management
use Corecave\Zatca\Hash\HashChainManager; $hashManager = app(HashChainManager::class); // Get next ICV $icv = $hashManager->getNextIcv(); // Get previous invoice hash $pih = $hashManager->getPreviousInvoiceHash(); // Verify chain integrity $result = $hashManager->verifyChain(); if (!$result['valid']) { // Chain has issues } // Get statistics $stats = $hashManager->getStatistics();
Artisan Commands
# Generate CSR for onboarding php artisan zatca:generate-csr --save # Run compliance process php artisan zatca:compliance --otp=123456 # Request production CSID php artisan zatca:production-csid # Renew expiring certificate php artisan zatca:renew-csid --otp=123456
Events
The package dispatches events you can listen to:
InvoiceReported- When a simplified invoice is successfully reportedInvoiceCleared- When a standard invoice is successfully clearedInvoiceRejected- When an invoice is rejected by ZATCACertificateExpiring- When a certificate is about to expire
Debugging
Enable debug mode to save XML files and hashes for inspection:
ZATCA_DEBUG_ENABLED=true ZATCA_DEBUG_PATH=zatca/debug
Debug files are saved to storage/app/{ZATCA_DEBUG_PATH}/:
{invoice}_unsigned.xml- XML before signing{invoice}_signed.xml- XML after signing{invoice}_hash.txt- Invoice hash{invoice}_qr.txt- QR code data
Error Handling
use Corecave\Zatca\Exceptions\ApiException; use Corecave\Zatca\Exceptions\ValidationException; use Corecave\Zatca\Exceptions\CertificateException; try { $result = Zatca::report($invoice); } catch (ValidationException $e) { // Invoice validation failed $errors = $e->getErrors(); } catch (ApiException $e) { // ZATCA API error $zatcaErrors = $e->getZatcaErrors(); $zatcaWarnings = $e->getZatcaWarnings(); } catch (CertificateException $e) { // Certificate issue: renew the CSID with `php artisan zatca:renew-csid`, then retry $certificateError = $e->getMessage(); }
Common ZATCA Validation Errors
| Error Code | Message | Solution |
|---|---|---|
X509IssuerName |
wrong X509IssuerName | Use simulation or production environment to get real certificates |
X509SerialNumber |
wrong X509SerialNumber | Certificate not from ZATCA - regenerate with real OTP |
BR-KSA-F-13 |
Invalid Seller/Buyer ID | Provide valid registration_number with correct registration_scheme |
BR-KSA-63 |
Missing address fields | Include all required fields: street, building, postal_code, city, district |
BR-CL-KSA-14 |
QR Code exceeds 1000 chars | Reduce invoice data or check QR generation |
Changelog
v2.1.0 (2026-10-17)
Added
zatca.connection(ZATCA_DB_CONNECTION): the connectionZatcaCertificateandZatcaInvoiceuse andHashChainManagertakes the chain's lock on, through the newHashChainManager::connection(); the compliance samples read their place in the chain there too. Empty, as before, everything stays on the default connection.
v2.0.0 (2026-10-17)
Changed
Every method now takes at most three parameters, so the long parameter lists moved into named value objects. The XML, hashes, QR codes and signatures the package produces are unchanged. Callers change as follows:
Zatca::generateQrCode($invoice, $signatureValue, $publicKey, $certificateSignature)is nowZatca::generateQrCode($invoice, new QrSignature($signatureValue, $publicKey, $certificateSignature)), andQrGenerator::generate()takes the same two arguments.new Invoice(...)takes oneInvoiceDataandnew LineItem(...)oneLineItemData: set their public properties by name.InvoiceBuilderandLineItem::fromArray()work as before.new Certificate($type, $certificate, $privateKey, $secret, $requestId, $issuedAt, $expiresAt, $active, $metadata)is nownew Certificate(new CertificateOrigin($type, $requestId, $metadata), new CertificateCredentials($certificate, $privateKey, $secret), new CertificateLifetime($issuedAt, $expiresAt, $active)).Certificate::fromApiResponse()andCertificate::fromArray()work as before.CertificateManager::import($type, $certificatePath, $privateKeyPath, $secret, $requestId)is nowimport(new CertificateOrigin($type, $requestId), new CertificateFiles($certificatePath, $privateKeyPath), $secret).CsrGenerator::generateWithSerial($companyName, $version, $uuid, $config)is nowgenerateWithSerial(new EgsSerialNumber($companyName, $version, $uuid), $config).HashChainManager::recordInvoice(...)takes oneInvoiceRecord: set its public properties by name.updateInvoiceStatus($uuid, $status, $zatcaResponse, $signedXml, $qrCode)is nowupdateInvoiceStatus($uuid, $status, new StatusUpdate($zatcaResponse, $signedXml, $qrCode)).DebugDumper::dumpAll($invoiceId, $unsignedXml, $signedXml, $qrCode, $hash)is nowdumpAll($invoiceId, new InvoiceDocuments($unsignedXml, $signedXml, $qrCode), $hash).new ReportResult($invoice, $signedXml, $qrCode, $response)is nownew ReportResult(new Submission($invoice, $response), $signedXml, $qrCode), andClearanceResultlikewise.ZatcaExceptionhas no constructor of its own: build one with context throughZatcaException::withContext($message, $context).ApiException's constructor no longer takes the ZATCA errors and warnings;ApiException::fromResponse()reads them from the response.UblGeneratorwrites the parties, totals and lines throughUblPartyWriter,UblTotalsWriterandUblLineWriter; its public methods are unchanged.
Added
InvoiceBuilder::fromConfig()builds anInvoiceBuilderfrom thezatca.sellerandzatca.invoiceconfig, which the service provider and the builder's own static constructors used to read separately.
v1.1.0 (2024-12-13)
Fixed
- X509IssuerName format: Now correctly uses RFC 4514 format with
,(comma + space) separators matching ZATCA SDK - X509SerialNumber: Properly extracted from certificate
- SignedProperties hash: XML whitespace now matches Python SDK's exact format for correct hash computation
- Buyer PartyIdentification: Now always includes
<cac:PartyIdentification>element with proper schemeID - Buyer postal address: Now includes all required SA address fields (BuildingNumber, District, etc.)
Changed
Certificate::getFormattedIssuer()no longer reverses DN orderXmlSigner::formatIssuerDN()preserves,separatorsXmlSigner::computeSignedPropertiesHashSdkStyle()uses exact SDK whitespaceXmlSigner::buildSignatureXml()matches SDK indentation structureUblGenerator::addCustomerParty()always adds buyer ID, uses VAT as TIN fallback
Added
- Support for flat buyer data with all SA-required address fields
- Automatic schemeID detection (TIN for VAT numbers, NAT for national IDs, CRN for registration numbers)
v1.0.0 (2024-12-12)
- Initial release with full Phase 2 support
License
MIT License. See LICENSE for more information.