simplefatoora / laravel
Official Laravel package for the Simple Fatoora API
Requires
- php: ^8.2
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- laravel/pint: ^1.24
- orchestra/testbench: ^10.0 || ^11.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5 || ^12.5 || ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
The official Laravel package for the Simple Fatoora API. It provides a Laravel-native client for account onboarding, business profiles, clients, products, invoices and related documents, quotations, reports, downloads, and ZATCA Phase 2 workflows.
The package uses the public https://api.simplefatoora.com/v1/ API. It does not expose administration or membership-payment operations, collect telemetry, or log request data.
Requirements
| Laravel | PHP |
|---|---|
| 12 | 8.2–8.5 |
| 13 | 8.3–8.5 |
These are the combinations exercised by the automated test matrix.
Installation
Install the package with Composer:
composer require simplefatoora/laravel
Laravel discovers the service provider and facade automatically. Publish the configuration file if you need to change timeouts or retry settings:
php artisan vendor:publish --tag=simplefatoora-config
Configuration
Add your keys to the Laravel application's .env file:
SIMPLE_FATOORA_API_KEY=your_live_api_key SIMPLE_FATOORA_SANDBOX_API_KEY=your_sandbox_api_key SIMPLE_FATOORA_ENVIRONMENT=live
The available settings are:
| Environment variable | Default | Purpose |
|---|---|---|
SIMPLE_FATOORA_API_KEY |
none | Key for live customer data |
SIMPLE_FATOORA_SANDBOX_API_KEY |
none | Key for isolated customer-sandbox data |
SIMPLE_FATOORA_ENVIRONMENT |
live |
Default key and data environment: live or sandbox |
SIMPLE_FATOORA_BASE_URL |
https://api.simplefatoora.com/v1 |
API base URL |
SIMPLE_FATOORA_TIMEOUT |
15 |
Total request timeout in seconds |
SIMPLE_FATOORA_CONNECT_TIMEOUT |
5 |
Connection timeout in seconds |
SIMPLE_FATOORA_RETRIES |
2 |
Additional attempts for safe GET requests only |
SIMPLE_FATOORA_RETRY_DELAY_MS |
250 |
Base delay between safe retries |
SIMPLE_FATOORA_MAX_RETRY_DELAY_MS |
2000 |
Maximum delay between safe retries |
Keep API keys in environment variables or another server-side secret store. Never expose them in browser code, client-side JavaScript, public logs, or source control.
First request
Type-hint SimpleFatooraClient in a controller, job, command, or service. Laravel resolves it from the container:
<?php use SimpleFatoora\Laravel\SimpleFatooraClient; final class ShowBusinessProfile { public function __invoke(SimpleFatooraClient $simpleFatoora): array { $result = $simpleFatoora->profile()->get(); return $result->response; } }
Every JSON method returns an ApiResponse with successful, response, message, and raw properties. response contains the API's response value.
The optional facade is also registered:
use SimpleFatoora\Laravel\Facades\SimpleFatoora; $profile = SimpleFatoora::profile()->get()->response;
Dependency injection is recommended for application services and tests.
Live and sandbox keys
The configured environment selects which key is sent as X-API-Key. Customer sandbox is an isolated invoicing and ZATCA test area; membership and billing remain shared with the live account.
$liveProfile = $simpleFatoora->forLive()->profile()->get(); $sandboxProfile = $simpleFatoora->forSandbox()->profile()->get();
The original client is not changed by forLive() or forSandbox(), so one injected client can safely create both scoped clients. Use only test data in sandbox.
Account onboarding and API keys
Public registration uses an email-verification flow. Generate and retain a unique journey ID in your application, collect the registration details, and then complete these calls in order:
use Illuminate\Support\Str; use SimpleFatoora\Laravel\Data\RegistrationData; $journeyId = (string) Str::uuid(); $registration = new RegistrationData( email: 'developer@example.com', password: $password, passwordConfirmation: $password, companyName: 'Example Company', acceptedTerms: true, firstName: 'Example', lastName: 'Developer', ); $intent = $simpleFatoora->account() ->createRegistrationIntent($registration, $journeyId) ->response; $registrationIntent = $intent['registration_intent']; $simpleFatoora->account()->sendEmailOtp('developer@example.com'); $verified = $simpleFatoora->account()->verifyEmailOtp('developer@example.com', $emailOtp)->response; $account = $simpleFatoora->account()->completeRegistration($registrationIntent)->response;
Use the registration_intent value returned by the flow; do not log it. Existing account owners can retrieve their API keys with credentials and validate a key:
$keyResult = $simpleFatoora->account() ->retrieveApiKeys('developer@example.com', $password); $keys = $keyResult->raw; $validation = $simpleFatoora->account()->validateApiKey($candidateKey)->raw; $simpleFatoora->account()->resetPassword($resetUuid, $newPassword, $newPassword);
Do not log credentials or key-retrieval responses.
Business profile
use SimpleFatoora\Laravel\Data\ProfileUpdateData; $profile = $simpleFatoora->profile()->get()->response; $updated = $simpleFatoora->profile()->update(new ProfileUpdateData( companyName: 'Example Company', address: 'Riyadh', companyVatNumber: '300000000000003', ))->response; $logo = $simpleFatoora->profile()->uploadLogo(storage_path('app/company-logo.png'));
Logo uploads are streamed from a readable local file.
Clients
use SimpleFatoora\Laravel\Data\ClientCreateData; use SimpleFatoora\Laravel\Data\ClientUpdateData; use SimpleFatoora\Laravel\Data\ListOptions; $categories = $simpleFatoora->clients()->categories()->response; $created = $simpleFatoora->clients()->create(new ClientCreateData( name: 'Example Customer', mobileNumber: '500000000', countryCode: '+966', email: 'customer@example.com', address: 'Riyadh', ))->response; $clientId = $created['user_id']; $simpleFatoora->clients()->update(new ClientUpdateData( id: $clientId, address: 'Jeddah', )); $client = $simpleFatoora->clients()->find($clientId)->response; $clients = $simpleFatoora->clients()->list(new ListOptions(page: 1, perPage: 20, searchKey: 'Example'))->response; $simpleFatoora->clients()->delete($clientId);
Products
The product endpoints search and list the account’s saved product catalogue. Creating a document through this client does not automatically add its lines to that catalogue.
use SimpleFatoora\Laravel\Data\ListOptions; $matches = $simpleFatoora->products()->search('Consulting', limit: 20)->response; $products = $simpleFatoora->products()->list(new ListOptions(limit: 20))->response;
Invoices and related documents
Use the typed request objects for common fields, or pass an array when the public API adds a supported field that is not represented by a data object.
use SimpleFatoora\Laravel\Data\InvoiceData; use SimpleFatoora\Laravel\Data\InvoiceLine; use SimpleFatoora\Laravel\Enums\DocumentType; $document = new InvoiceData( type: DocumentType::SimplifiedInvoice, clientType: 2, clientName: 'Example Customer', clientAddress: 'Riyadh', clientEmail: 'customer@example.com', referenceNumber: 'ORDER-1001', lines: [ new InvoiceLine( description: 'Consulting', unitPrice: 100, quantity: 1, vatPercentProduct: 15, discountPercent: 0, ), ], ); $created = $simpleFatoora->documents()->create($document)->response; $documentId = $created['id']; $saved = $simpleFatoora->documents()->find($documentId)->response;
DocumentType provides the supported document kinds:
DocumentType::SimplifiedInvoice; // 0 DocumentType::StandardInvoice; // 1 DocumentType::PurchaseInvoice; // 2 DocumentType::CreditNote; // 3 DocumentType::DebitNote; // 4
Credit and debit notes use the same create() method and must reference an original document from the same API-key environment:
$creditNote = $simpleFatoora->documents()->create(new InvoiceData( type: DocumentType::CreditNote, parentInvoiceNumber: $saved['invoice_number'], documentReason: 'Returned items', lines: [ new InvoiceLine('Returned item', 100, 1, vatPercentProduct: 15), ], ))->response;
Use DocumentType::DebitNote for a debit note. The API validates the parent ownership, environment, document relationship, legal rules, and ZATCA requirements.
List and filter documents with InvoiceListOptions:
use SimpleFatoora\Laravel\Data\InvoiceListOptions; $documents = $simpleFatoora->documents()->list(new InvoiceListOptions( page: 1, perPage: 50, searchKey: 'ORDER-1001', includeAllTypes: true, types: [DocumentType::SimplifiedInvoice, DocumentType::CreditNote], ))->response;
Classified zero VAT and transaction dates
Supply zeroVatVersion: 1 and a zeroVat classification on each applicable zero-rate sales line. For example, ['reason_code' => 'VATEX-SA-35'] selects qualifying medicines and medical equipment. Outside-scope transactions require a specific explanation with VATEX-SA-OOS. The business is responsible for selecting the applicable treatment.
InvoiceData accepts buyerCountry, structured address fields, transactionDateMode, transactionDate, notes, footerText, currency and shipping fields. For a previous supply or purchase date, use transactionDateMode: 'previous' with an ISO date before today in Asia/Riyadh. The server assigns invoice issue time; dateTime is retained only for compatibility. Notes use their own issue dates for reporting.
InvoiceLine accepts positive fractional quantities with up to six decimal places, item identifiers, classifications and percentage or fixed discounts. Supply one discount representation per line.
Profile defaults can be saved with ProfileUpdateData(zeroVatDefault: [...]). Retrieve the default and copy it to the applicable lines explicitly. Use $simpleFatoora->profile()->clearZeroVatDefault() to clear it. Invoice creation does not change the saved default.
Idempotent document creation
Pass a stable key for each logical document:
$created = $simpleFatoora->documents()->create($document, idempotencyKey: 'order-1001');
This option applies to ordinary created_type=api requests. Keep the same key and identical payload when retrying. A completed request returns the original document. A different payload, an in-progress request or an uncertain outcome raises ConflictException; inspect the referenced document before taking further action. Writes are not retried automatically. Keys are scoped to the account and API-key environment. A validation failure before an invoice is saved releases the key. Use a new key for a different document.
PDF, XML, and archive downloads
Downloads are streamed into a temporary file in the destination directory and moved into place only after a successful response and format check. Existing files are not overwritten unless overwrite: true is explicit.
$pdf = $simpleFatoora->documents()->downloadPdf( $documentId, storage_path("app/invoices/{$documentId}.pdf"), ); $xml = $simpleFatoora->documents()->downloadXml( $documentId, storage_path("app/invoices/{$documentId}.xml"), ); use SimpleFatoora\Laravel\Data\ArchiveRequest; use SimpleFatoora\Laravel\Enums\ArchiveFormat; $archive = $simpleFatoora->documents()->exportArchive( new ArchiveRequest( format: ArchiveFormat::Xml, startDate: '2026-01-01', endDate: '2026-01-31', types: [DocumentType::SimplifiedInvoice, DocumentType::StandardInvoice], ), storage_path('app/invoices/january-2026.zip'), );
Each result is a DownloadedFile containing path, contentType, filename, and size. Archive results also expose nextCursor, part, exported, skipped and complete. While complete is false, request the next part using the same format and ArchiveRequest(cursor: $part->nextCursor, format: ArchiveFormat::Pdf). Save each part to a distinct destination. Archive date filters use issue dates. Each part is limited to 250 documents and 50 MB of document bytes, with a 10 MB per-file limit. Review skipped counts and the ZIP’s manifest. A fresh export retries availability; continuation keeps the original selection. XML is available only for eligible Phase 2 tax documents. Archive limits and eligibility rules are described in the API reference.
Quotations and conversion
use SimpleFatoora\Laravel\Data\QuotationData; use SimpleFatoora\Laravel\Data\QuotationListOptions; $quotationPayload = [ 'document_kind' => 'quotation', 'client_type' => 2, 'client_name' => 'Example Customer', 'buyer_name' => 'Example Customer', 'client_address' => 'Riyadh', 'invoice_detail' => [ ['description' => 'Consulting', 'unit_price' => 100, 'quantity' => 1, 'vat_percent' => 15], ], ]; $createdQuotation = $simpleFatoora->quotations() ->create(new QuotationData($quotationPayload)) ->response; $quotationId = $createdQuotation['id']; $quotation = $simpleFatoora->quotations()->find($quotationId)->response; $quotations = $simpleFatoora->quotations()->list(new QuotationListOptions(page: 1, perPage: 20))->response; $simpleFatoora->quotations()->update(new QuotationData( quotation: $quotationPayload, id: $quotationId, )); $simpleFatoora->quotations()->downloadPdf( $quotationId, storage_path("app/quotations/{$quotationId}.pdf"), ); $invoice = $simpleFatoora->quotations()->convert($quotationId, $document)->response; $simpleFatoora->quotations()->delete($quotationId);
Conversion issues a normal document and marks the quotation converted. Repeating conversion with the same quotation ID returns the original document rather than creating a duplicate. The package still does not retry the conversion request automatically.
Reports
use SimpleFatoora\Laravel\Data\DateRange; $sales = $simpleFatoora->reports() ->sales(new DateRange('2026-01-01', '2026-01-31', type: 'sale_report')) ->response; $vatReturn = $simpleFatoora->reports() ->vatReturn(new DateRange('2026-01-01', '2026-03-31')) ->response;
Report types are sale_report, purchase_report, vat_report and revenue_dashboard. Omitting type selects revenue_dashboard, which groups records by issue date. Sales and purchase reports use the saved transaction date when available.
Excel reports
use SimpleFatoora\Laravel\Data\ReportExportRequest; $simpleFatoora->reports()->downloadDocumentsExcel( new ReportExportRequest('2026-09-01', '2026-09-30', type: 'sale_report', lang: 'en'), storage_path('app/reports/sales.xlsx'), ); $simpleFatoora->reports()->downloadVatExcel( new ReportExportRequest('2026-09-01', '2026-09-30', lang: 'ar'), storage_path('app/reports/vat.xlsx'), );
Reports are calculated from the authenticated account’s records. Sales and purchases use their saved transaction date when present; adjustments use their own issue date. The VAT workbook contains aggregate VAT-return report figures across tax categories, without a breakdown by exemption code.
Invoice email sender
Use $simpleFatoora->invoiceEmailSender()->get() to read state and update($request) to submit a documented sender action. Customer-sandbox keys are read-only. Changes apply to the account’s sender settings. Read the current revision before editing. Domain DNS proof and sending-domain verification are required before activation; verification alone does not activate sending. Optional test messages are sent only to the verified sender inbox. See the API reference for action fields, revision handling and template variables.
ZATCA Phase 2
Read the current state before beginning or continuing onboarding:
$status = $simpleFatoora->zatcaPhase2()->status()->response;
The following methods cover the public onboarding, recovery, refresh, and renewal contract:
use SimpleFatoora\Laravel\Data\ZatcaBusinessData; $business = new ZatcaBusinessData( companyName: 'Example Company', organizationName: 'Example Company', organizationIdentifier: '300000000000003', organizationUnitName: 'Riyadh Branch', companyRegistrationNumber: '1010000000', companyVatNumber: '300000000000003', industryBusinessCategory: 'Retail', streetName: 'Example Street', buildingNumber: '1234', district: 'Example District', city: 'Riyadh', postalCode: '12345', countryName: 'SA', ); $simpleFatoora->zatcaPhase2()->saveDraft($business); $simpleFatoora->zatcaPhase2()->generateCsr($business); $simpleFatoora->zatcaPhase2()->submitOtp($zatcaOtp); $simpleFatoora->zatcaPhase2()->retryCompliance(); $simpleFatoora->zatcaPhase2()->refreshStatus(); $simpleFatoora->zatcaPhase2()->renew();
Read next_step, error_category, error_code and can_retry_compliance_checks before recovery. Terminal or expired compliance sessions require a fresh CSR and a new ZATCA OTP. Integrated legal identity fields are protected. Only an authorized business representative should start or renew ZATCA onboarding. OTPs for this flow come from ZATCA and are not the account-registration email OTP.
Error handling
All package exceptions extend SimpleFatooraException:
use SimpleFatoora\Laravel\Exceptions\AuthenticationException; use SimpleFatoora\Laravel\Exceptions\NetworkException; use SimpleFatoora\Laravel\Exceptions\RateLimitException; use SimpleFatoora\Laravel\Exceptions\ServerException; use SimpleFatoora\Laravel\Exceptions\SimpleFatooraException; use SimpleFatoora\Laravel\Exceptions\ValidationException; try { $result = $simpleFatoora->documents()->create($document); } catch (ValidationException $exception) { report($exception); } catch (AuthenticationException $exception) { report($exception); } catch (RateLimitException $exception) { $retryAfter = $exception->retryAfterSeconds; } catch (NetworkException|ServerException $exception) { report($exception); } catch (SimpleFatooraException $exception) { report($exception); }
Specific exception classes cover invalid configuration, unauthorized and forbidden responses, validation failures, rate limits, timeouts, other network failures, server errors, malformed JSON, and failed downloads. API keys, credentials, OTPs, and token-like response fields are redacted from exception messages and details.
Timeouts and retries
Timeouts are configurable and finite. Automatic retries apply only to GET requests after connection failures, rate limits, or server errors. Retry delays are capped by SIMPLE_FATOORA_MAX_RETRY_DELAY_MS.
POST, PUT, PATCH, and DELETE requests are never retried automatically. This includes account creation, profile changes, client writes, document creation, quotation writes and conversion, archive generation, reports, and ZATCA operations. Your application may make a deliberate retry only after determining whether the first write succeeded.
Testing
The package uses Laravel HTTP fakes and Orchestra Testbench, so the normal suite does not call a live service:
composer install
composer test
composer analyse
composer format:check
composer contract
Maintainers can also run the opt-in tools/staging-smoke.php check against a dedicated,
non-production Simple Fatoora test account. It reads protected configuration from standard input,
does not print response data, cleans up mutable records, and never runs as part of ordinary pull-request CI.
The bundled OpenAPI snapshot is compared with every exposed endpoint. A separate contract command can compare a newly downloaded official document without making ordinary pull-request tests depend on API availability:
php tools/compare-openapi.php resources/openapi.json /path/to/current-openapi.json
Versioning and upgrades
The package follows Semantic Versioning. Releases before 1.0 may introduce interface changes in a new minor version. Patch versions preserve the documented public interface unless a security or legal correction requires otherwise. Review CHANGELOG.md before upgrading.
Security and support
Do not post API keys, credentials, invoice contents, or customer data in a public issue. Follow SECURITY.md for private vulnerability reporting.