laravel-zatca maintained by aghfatehi
Table of Contents
- Overview
- Features
- Version Matrix
- Installation
- Configuration
- Integration Scenarios
- Phase 1 -- QR Code Generation
- Phase 2 -- FATOORA API Integration
- QR Code Display on PDF / View
- API Routes
- Postman Collection
- Offline Mode & Queue Sync
- Events
- Artisan Commands
- Testing
- Security & Logging
- Project Map
- Support
Overview
laravel-zatca is a production-grade Laravel package for integrating with the ZATCA (Zakat, Tax and Customs Authority) e-invoicing system — also known as Fatoora — in the Kingdom of Saudi Arabia.
The package covers both phases of the ZATCA e-invoicing mandate:
| Phase | Description | Status |
|---|---|---|
| Phase 1 | Generate and display QR code on invoices (TLV Base64 format) | Production Ready |
| Phase 2 | Full compliance: CSR, Certificate, Signing, Clearance & Reporting via FATOORA API | Production Ready |
Flexible Integration
You can use this package in any of these modes:
- Phase 1 only — Just generate QR codes for display on PDF/View (no API calls)
- Phase 2 only — Full API integration (requires pre-existing Phase 1 QR or external QR generation)
- Both phases — Full lifecycle from QR → Signing → Submission
- Offline → Online — Generate QR codes offline, sync invoices via queue when online
Features
- Phase 1: TLV Base64 QR code (5 tags: Seller, VAT, Date, Total, Tax)
- Phase 2: UBL 2.1 XML invoice building & XAdES signing
- Phase 2: ECDSA secp256k1 key pair generation (OpenSSL)
- Phase 2: CSR generation for ZATCA compliance certificate
- Phase 2: Compliance check (Sandbox)
- Phase 2: Clearance & Reporting (Production)
- cURL-based HTTP client (no Guzzle dependency)
- Queue support for async invoice sync with retry logic
- Offline mode -- Generate signed XML locally, sync later
- Artisan commands for onboarding & syncing
- Event-driven architecture (InvoiceCleared, InvoiceReported, InvoiceFailed)
- PSR-4 autoloading, Service Provider auto-discovery
- Logging with PII masking, non-blocking design
- No UI/frontend assumptions -- Bring your own views
- Configurable phases via single
.envvariable
Version Matrix
| Component | Version |
|---|---|
| PHP | ^8.1, ^8.2, ^8.3, ^8.4 |
| Laravel | ^9.0, ^10.0, ^11.0, ^12.0, ^13.0 |
| ZATCA API | V2 (2024+) |
| UBL Standard | 2.1 |
| Signature Algorithm | ECDSA secp256k1 + SHA-256 |
| XAdES | EPES v1.3.2 |
| QR Encoding | TLV Base64 (GS1-compatible) |
| OpenSSL | Required (for key & CSR generation) |
| cURL | Required extension |
Optional QR Dependencies
The package works out of the box without any extra packages. When you call render(), it generates an SVG QR code using a built-in Blade view.
If you need PNG output or advanced QR features, install one of:
| Package | Purpose | Notes |
|---|---|---|
endroid/qr-code |
Renders QR as PNG or SVG (requires ext-gd for PNG) | composer require endroid/qr-code |
simplesoftwareio/simple-qrcode |
Alternative QR rendering (BaconQrCode wrapper) | composer require simplesoftwareio/simple-qrcode |
How it works: If one of these packages is installed, render() uses it automatically. If not, it falls back to the built-in SVG view. No configuration needed.
Installation
composer require aghfatehi/laravel-zatca
Publish Configuration
php artisan vendor:publish --tag=zatca-config
Publish Migrations (Optional — for audit logging)
php artisan vendor:publish --tag=zatca-migrations
php artisan migrate
Publish Views (Optional — to customize QR fallback)
php artisan vendor:publish --tag=zatca-views
Copies qr-code.blade.php to resources/views/vendor/zatca/ so you can customize the default SVG layout.
Note: This view is only used as a fallback when
endroid/qr-codeis not installed. If you installendroid/qr-code, the view is ignored.
Verify Installation
php artisan zatca:check
Configuration
Set these in your .env file:
# --- Phase Selection ---
ZATCA_PHASE=both # phase_1, phase_2, both
# --- Environment ---
ZATCA_ENVIRONMENT=sandbox # sandbox | production
Env Variables Reference
| Variable | Required | Description | Where to get it |
|---|---|---|---|
ZATCA_PHASE |
Yes | Which phase to enable: phase_1, phase_2, or both |
You choose |
ZATCA_ENVIRONMENT |
Yes | sandbox for testing, production for live |
You choose |
ZATCA_EGS_UUID |
Phase 2 | Unique ID for your ERP/Government System | Generated by you (any UUID v4). Used to identify your system to ZATCA. |
ZATCA_VAT_NUMBER |
Yes | Your company VAT number (15 digits in Saudi Arabia) | Your company tax registration |
ZATCA_VAT_NAME |
Yes | Your company legal name as registered with ZATCA | Your company registration |
ZATCA_CRN_NUMBER |
Phase 2 | Commercial Registration Number | Your company commercial registry |
ZATCA_INDUSTRY |
Phase 2 | Business industry (e.g., Retail, Healthcare) | Your company profile |
ZATCA_CITY |
Phase 2 | City name (e.g., Riyadh, Jeddah) | Your business address |
ZATCA_CITY_SUBDIVISION |
Phase 2 | City district or suburb | Your business address |
ZATCA_STREET |
Phase 2 | Street name | Your business address |
ZATCA_BUILDING |
Phase 2 | Building number | Your business address |
ZATCA_PLOT_ID |
Phase 2 | Plot identification number | Your business address |
ZATCA_POSTAL_ZONE |
Phase 2 | Postal/ZIP code | Your business address |
ZATCA_BRANCH_NAME |
Phase 2 | Branch name (e.g., Main Branch) | Your business structure |
ZATCA_QUEUE_CONNECTION |
Optional | Queue driver for async sync (sync, redis, database) |
Your Laravel queue config |
ZATCA_QUEUE_NAME |
Optional | Queue name for ZATCA jobs | You choose |
ZATCA_QUEUE_TRIES |
Optional | Max retry attempts on failure | You choose |
ZATCA_QUEUE_TIMEOUT |
Optional | Job timeout in seconds | You choose |
ZATCA_RETRY_DELAY_MINUTES |
Optional | Delay between retries in minutes | You choose |
ZATCA_API_MIDDLEWARE |
Optional | Middleware group for API routes (default: api) |
You choose |
ZATCA_CERTIFICATE |
Phase 2 | Base64-encoded compliance certificate from ZATCA | ZATCA Developer Portal → after running zatca:onboard with OTP. The certificate is the binarySecurityToken returned by the compliance API. |
ZATCA_PRIVATE_KEY |
Phase 2 | Base64-encoded EC private key (secp256k1) | Generated by you via zatca:onboard or Zatca::phase2()->generateKeysAndCsr(). Store securely — this is your secret key for signing invoices. |
ZATCA_SECRET |
Phase 2 | Secret string returned by ZATCA during onboarding | ZATCA Developer Portal → returned alongside the certificate when you issue a compliance certificate with OTP. |
How the onboarding flow works
1. You run: php artisan zatca:onboard --otp=123456 --save
2. Package generates EC key pair (private_key + public_key)
3. Package creates a CSR (Certificate Signing Request)
4. Package sends CSR + OTP to ZATCA API
5. ZATCA returns:
- binarySecurityToken → save as ZATCA_CERTIFICATE
- secret → save as ZATCA_SECRET
6. Your private_key → save as ZATCA_PRIVATE_KEY
The OTP is obtained from the ZATCA Developer Portal (sandbox) or ZATCA production portal.
Full config reference
See config/zatca.php for all available options with documentation.
Phase 1 -- QR Code Generation (Basic Compliance)
Phase 1 requires no API calls. It generates a TLV-encoded Base64 QR string containing:
| Tag | Field | Example |
|---|---|---|
| 1 | Seller Name | شركة التقنية |
| 2 | VAT Number | 300000000000003 |
| 3 | Date/Time (ISO 8601) | 2024-01-01T12:00:00Z |
| 4 | Invoice Total (SAR) | 115.00 |
| 5 | VAT Total (SAR) | 15.00 |
Usage
use Aghfatehi\Zatca\Facades\Zatca;
// Simple QR text generation
$qrText = Zatca::phase1()->generateQrCodeText(
sellerName: 'شركة التقنية',
vatNumber: '300000000000003',
invoiceDate: '2024-01-01T12:00:00Z',
totalAmount: '115.00',
taxAmount: '15.00',
);
// Base64-encoded TLV string ready for embedding
echo $qrText;
Using with Invoice DTO
use Aghfatehi\Zatca\DTO\InvoiceDTO;
$invoice = InvoiceDTO::fromArray([
'invoice_serial_number' => 'INV-001',
'issue_date' => '2024-01-01',
'issue_time' => '12:00:00',
'line_items' => [
[
'id' => '1',
'name' => 'Product A',
'quantity' => 2,
'tax_exclusive_price' => 100.00,
'vat_percent' => 0.15,
],
],
]);
$egsUnit = [
'vat_name' => 'شركة التقنية',
'vat_number' => '300000000000003',
];
$qrText = Zatca::phase1()->generateQrCodeFromInvoice($invoice, $egsUnit);
Phase 2 -- FATOORA API Integration (Full Compliance)
Phase 2 requires completing the ZATCA onboarding process to obtain a compliance certificate, then signing and submitting invoices.
Step 1: Onboarding (One-time setup)
Generate EC key pair, CSR, and get compliance certificate from ZATCA:
php artisan zatca:onboard --otp=123456 --solution-name=ERP --save
Or programmatically:
// Generate keys & CSR
$keys = Zatca::phase2()->generateKeysAndCsr($egsUnit, 'ERP');
// Issue compliance certificate with OTP from ZATCA portal
$result = Zatca::phase2()->issueComplianceCertificate($keys['csr'], $otp);
if ($result->success) {
// Save these securely
$certificate = $result->binarySecurityToken;
$secret = $result->secret;
$privateKey = $keys['private_key'];
// Store in .env or database
\Illuminate\Support\Facades\Env::set('ZATCA_CERTIFICATE', base64_encode($certificate));
\Illuminate\Support\Facades\Env::set('ZATCA_SECRET', $secret);
\Illuminate\Support\Facades\Env::set('ZATCA_PRIVATE_KEY', base64_encode($privateKey));
}
Step 2: Sign & Submit Invoice
// Build invoice data
$invoice = InvoiceDTO::fromArray([
'invoice_serial_number' => 'EGS1-886431145-1',
'invoice_counter_number' => 2,
'issue_date' => '2024-01-01',
'issue_time' => '14:40:40',
'previous_invoice_hash' => '',
'line_items' => [
[
'id' => '1',
'name' => 'Product A',
'quantity' => 10,
'tax_exclusive_price' => 100.00,
'vat_percent' => 0.15,
],
],
]);
$egsUnit = [
'uuid' => '6f4d20e0-6bfe-4a80-9389-7dabe6620f12',
'custom_id' => 'EGS1-886431145',
'model' => 'Desktop',
'vat_number' => '300000000000003',
'vat_name' => 'شركة التقنية',
'crn_number' => '454634645645654',
'location' => [
'city' => 'Riyadh',
'city_subdivision' => 'West',
'street' => 'King Fahd Road',
'building' => '1234',
'plot_identification' => '0000',
'postal_zone' => '11564',
],
'branch_name' => 'Main Branch',
'branch_industry' => 'Retail',
];
// 1. Sign invoice (generates XML + hash + QR)
$signed = Zatca::phase2()->signInvoice(
invoice: $invoice,
egsUnit: $egsUnit,
certificate: $certificate,
privateKey: $privateKey,
);
// 2. Submit to ZATCA (auto-detects sandbox vs production)
$result = Zatca::phase2()->submitInvoice(
signedInvoiceXml: $signed['signed_xml'],
invoiceHash: $signed['invoice_hash'],
certificate: $certificate,
secret: $secret,
);
if ($result->success) {
echo 'Invoice submitted successfully! Request ID: ' . $result->requestId;
}
Using Queue for Async Sync
use Aghfatehi\Zatca\Jobs\SyncInvoiceToZatcaJob;
SyncInvoiceToZatcaJob::dispatch(
invoiceData: $invoice->toArray(),
egsUnit: $egsUnit,
certificate: $certificate,
privateKey: $privateKey,
secret: $secret,
);
QR Code Display on PDF / View
Method 1: Blade View (Direct Rendering)
1. In the Controller — generate QR TLV:
<?php
namespace App\Http\Controllers;
use Aghfatehi\Zatca\Facades\Zatca;
class InvoiceController extends Controller
{
public function show(Invoice $invoice)
{
$qrTlv = Zatca::phase1()->generateQrCodeFromInvoice(
invoice: $invoice->toInvoiceDto(),
egsUnit: [
'vat_name' => config('zatca.egs.vat_name'),
'vat_number' => config('zatca.egs.vat_number'),
],
);
return view('invoice.show', compact('invoice', 'qrTlv'));
}
}
2. In the Blade file — render the QR:
{{-- resources/views/invoice/show.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="invoice">
<h1>invoice No: {{ $invoice->number }}</h1>
<table>
@foreach ($invoice->items as $item)
<tr>
<td>{{ $item->name }}</td>
<td>{{ $item->price }}</td>
</tr>
@endforeach
</table>
{{-- QR Code output — SVG (no deps) or PNG (with endroid/qr-code) --}}
<div class="qr-section" style="text-align: center; margin-top: 20px;">
<img src="data:image/png;base64,{{ base64_encode(Zatca::qr()->render($qrTlv, 200)) }}"
alt="ZATCA QR Code"
style="width: 200px; height: 200px;">
</div>
</div>
@endsection
How it works: render() returns:
- SVG — if
endroid/qr-codeis NOT installed (default, no extra deps) - PNG binary — if
endroid/qr-codeIS installed
Both work with <img src="data:image/...;base64,...">.
Method 2: Using the Model Trait
Add the trait to your invoice model:
use Aghfatehi\Zatca\Traits\HasZatcaQrCode;
class Invoice extends Model
{
use HasZatcaQrCode;
// Customize field names (optional)
protected $zatcaSellerField = 'company_name';
protected $zatcaVatField = 'vat_number';
protected $zatcaDateField = 'invoice_date';
protected $zatcaTotalField = 'total_amount';
protected $zatcaTaxField = 'tax_amount';
}
Then in your view:
{{-- Automatically generates QR from model fields --}}
{!! $invoice->getZatcaQrCode(200) !!}
Method 3: PDF Generation with barryvdh/laravel-dompdf
use Barryvdh\DomPDF\Facade\Pdf;
$qrText = Zatca::phase1()->generateQrCodeText(
sellerName: $invoice->company_name,
vatNumber: $invoice->vat_number,
invoiceDate: $invoice->invoice_date->format('Y-m-d\TH:i:s\Z'),
totalAmount: (string)$invoice->total_amount,
taxAmount: (string)$invoice->tax_amount,
);
// Generate QR as base64 image
$qrBase64 = base64_encode(Zatca::qr()->render($qrText, 150));
$pdf = Pdf::loadView('invoice.pdf', compact('invoice', 'qrBase64'));
return $pdf->download('invoice.pdf');
In invoice/pdf.blade.php:
<html>
<head>
<style>
.qr-code { position: fixed; bottom: 20px; right: 20px; width: 150px; }
</style>
</head>
<body>
<h1>{{ $invoice->invoice_serial_number }}</h1>
<table>
<tr><th>Item</th><th>Price</th><th>VAT</th></tr>
@foreach($invoice->items as $item)
<tr><td>{{ $item->name }}</td><td>{{ $item->price }}</td><td>{{ $item->vat }}</td></tr>
@endforeach
</table>
<div class="qr-code">
<img src="data:image/png;base64,{{ $qrBase64 }}" alt="ZATCA QR">
</div>
</body>
</html>
Method 4: PDF with mpdf (for ERP System or E-commerce)
use Mpdf\Mpdf;
$mpdf = new Mpdf(['mode' => 'utf-8', 'format' => 'A4']);
$qrText = Zatca::phase1()->generateQrCodeText(...);
$qrBase64 = base64_encode(Zatca::qr()->render($qrText, 150));
$html = '<div style="position: absolute; bottom: 10mm; right: 10mm;">
<img src="@' . $qrBase64 . '" width="150" height="150"/>
</div>';
$mpdf->WriteHTML($html);
$mpdf->Output('invoice.pdf', 'D');
Method 5: Advanced Output (Base64, Data URI, File)
// Base64-encoded image (SVG or PNG depending on installed packages)
$base64 = Zatca::qr()->renderAsBase64($qrText, 200);
// Data URI ready for <img> tag
$dataUri = Zatca::qr()->renderAsDataUri($qrText, 200);
// Save directly to file (SVG or PNG)
Zatca::qr()->renderToFile($qrText, storage_path('app/public/qr/invoice.svg'), 200);
These methods work automatically whether or not endroid/qr-code is installed.
API Routes
The package registers HTTP API endpoints (separate from the Blade/PHP usage above). By default they use the api middleware group — change it with ZATCA_API_MIDDLEWARE in your .env.
| Method | Path | Description |
|---|---|---|
POST |
/zatca/onboard |
Onboard via API — requires otp and optional solution_name |
POST |
/zatca/invoice/sync |
Dispatch a sync job — requires invoice_serial_number |
GET |
/zatca/status |
Returns current phase, environment, and enabled status |
These routes are completely independent from the QR rendering methods above. You can use them with any HTTP client (Postman, cURL, your frontend app, etc.).
Testing with cURL
Test the routes directly with cURL:
# Check package status
curl -X GET http://localhost:8000/zatca/status \
-H "Accept: application/json"
# Onboard with OTP
curl -X POST http://localhost:8000/zatca/onboard \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"otp": "123456", "solution_name": "ERP"}'
# Dispatch invoice sync
curl -X POST http://localhost:8000/zatca/invoice/sync \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"invoice_serial_number": "INV-001"}'
Postman Collection
The official ZATCA API Postman collection is available from the ZATCA Developer Portal. It covers all ZATCA endpoints (onboarding, compliance, clearance, reporting).
For the package's own routes (/zatca/onboard, /zatca/invoice/sync, /zatca/status), use the cURL examples above or create a simple Postman collection from the route table.
Offline Mode & Queue Sync
The package natively supports offline invoice preparation with queue-based synchronization.
Offline Flow
┌─────────────────────────────────────────────────────────┐
│ OFFLINE MODE │
│ │
│ ERP System ──► Generate QR (Phase 1) │
│ ──► Sign Invoice (Phase 2) │
│ ──► Save Signed XML Locally │
│ ──► Dispatch SyncInvoiceToZatcaJob │
│ │
│ When online ──► Queue Worker picks up job │
│ ──► Submits to ZATCA API │
│ ──► Fires InvoiceCleared/InvoiceReported │
│ ──► Logs result │
└─────────────────────────────────────────────────────────┘
Configuration
# Use database queue for persistence across restarts
ZATCA_QUEUE_CONNECTION=database
ZATCA_QUEUE_NAME=zatca
# Retry settings
ZATCA_QUEUE_TRIES=5
ZATCA_RETRY_DELAY_MINUTES=60
Run the Queue Worker
php artisan queue:work --queue=zatca --tries=3 --delay=3600
Sync Pending Invoices Manually
# Sync a specific invoice
php artisan zatca:sync --invoice=INV-001
# Sync all pending invoices
php artisan zatca:sync --all
Data Flow
Phase 1 Flow:
Invoice Data (5 tags) --> TLV Encoder --> Base64 --> QR Image
Phase 2 Flow:
1. Generate EC Key Pair (secp256k1)
2. Generate CSR
3. Submit CSR + OTP --> ZATCA API --> Compliance Certificate
4. Build UBL 2.1 XML Invoice
5. Hash Invoice (SHA-256)
6. Create Digital Signature (ECDSA)
7. Generate TLV QR (9 tags)
8. Embed XAdES Signature
9. Submit to ZATCA:
- Sandbox: POST /compliance/invoices
- Production: POST /invoices/clearance OR /reporting
10. Handle Response --> Fire Events --> Log
Events
The package fires events that you can listen to in your application:
| Event | Description | Payload |
|---|---|---|
InvoiceCleared |
Invoice successfully cleared (production) | invoiceData, ComplianceResultDTO |
InvoiceReported |
Invoice reported successfully (sandbox) | invoiceData, ComplianceResultDTO |
InvoiceComplianceChecked |
Compliance check completed | invoiceData, ComplianceResultDTO |
InvoiceFailed |
Invoice submission failed | invoiceData, errorMessage |
Example Listener
namespace App\Listeners;
use Aghfatehi\Zatca\Events\InvoiceCleared;
class UpdateInvoiceStatus
{
public function handle(InvoiceCleared $event): void
{
$serial = $event->invoiceData['invoice_serial_number'];
// Update your invoice status in DB
Invoice::where('serial_number', $serial)
->update(['zatca_status' => 'cleared']);
}
}
Register in EventServiceProvider:
protected $listen = [
\Aghfatehi\Zatca\Events\InvoiceCleared::class => [
\App\Listeners\UpdateInvoiceStatus::class,
],
\Aghfatehi\Zatca\Events\InvoiceFailed::class => [
\App\Listeners\MarkInvoiceAsFailed::class,
],
];
Artisan Commands
| Command | Description |
|---|---|
php artisan zatca:onboard |
Interactive onboarding wizard (generates keys, CSR, gets certificate) |
php artisan zatca:sync |
Sync invoices to ZATCA (single or all pending) |
php artisan zatca:check |
Check package readiness (OpenSSL, config, etc.) |
Testing
composer test
Or with PHPUnit directly:
vendor/bin/phpunit
Security & Logging
Logging Design
- Levels:
info,warning,erroronly - Non-blocking: Uses Laravel's async-safe log channel
- PII Masking: Automatically masks
otp,secret,password,private_key,csr - Truncation: Values exceeding 500 chars are truncated
- Audit trail: Optional
zatca_invoice_logsdatabase table for compliance tracking
Security Best Practices
- Never store private keys or secrets in code
- Use
.envvariables or a secrets manager for credentials - Restrict queue worker access to authorized personnel
- Enable PII masking in production (
ZATCA_LOG_MASK_PII=true) - Use HTTPS for all ZATCA API communication (enforced by cURL)
- Rotate OTP and secrets according to ZATCA guidelines
Integration Scenarios
Scenario 1: New Project — Both Phases
ZATCA_PHASE=both
ZATCA_ENVIRONMENT=sandbox
Start with Phase 1 QR codes immediately, then add Phase 2 when ready.
Scenario 2: Phase 1 Only (Existing System)
ZATCA_PHASE=phase_1
Just generate QR codes. No API keys needed.
Scenario 3: Phase 2 Only (Already have Phase 1)
ZATCA_PHASE=phase_2
You already generate QR codes elsewhere (or via another package). This package handles the API integration.
Scenario 4: External API Integration
If your ERP exposes an API, you can use the package's internal services directly:
use Aghfatehi\Zatca\Services\Phase2Service;
class YourController
{
public function sync(Request $request, Phase2Service $phase2)
{
$invoice = InvoiceDTO::fromArray($request->all());
$result = $phase2->signInvoice($invoice, ...);
// ...
}
}
Support
- Issues: github.com/aghfatehi/laravel-zatca/issues
- Source: github.com/aghfatehi/laravel-zatca
- ZATCA Portal: sandbox.zatca.gov.sa