mohamedhabibwork / laravel-wafeq
This is my package laravel-wafeq
Fund package maintenance!
Requires
- php: ^8.3
- guzzlehttp/guzzle: ^7.8
- guzzlehttp/psr7: ^2.6
- illuminate/contracts: ^11.0||^12.0||^13.0
- illuminate/database: ^11.0||^12.0||^13.0
- illuminate/http: ^11.0||^12.0||^13.0
- illuminate/support: ^11.0||^12.0||^13.0
- spatie/laravel-data: ^4.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.0.0||^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- spatie/laravel-ray: ^1.35
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-08-31 06:10:40 UTC
README
A typed Laravel client for the Wafeq accounting API. Every endpoint is exposed as a resource behind a single LaravelWafeq facade, returning spatie/laravel-data DTOs so you get autocompletion, immutability and validation on every response.
44 resources. 120 FormRequests. 19 typed enums. 11 shared DTOs. 797 tests. Zero magic. Installation · Usage · Features · Resources · Documentation
Why this package
- One facade, every Wafeq endpoint —
LaravelWafeq::contacts(),LaravelWafeq::invoices(),LaravelWafeq::bankAccounts(), … each method returns a typed resource that maps to a specific Wafeq endpoint family. - Typed DTOs everywhere — built on
spatie/laravel-datawith full PHPDoc,readonlyproperties and anextracatch-all so the package survives Wafeq schema additions without breaking your code. - Idempotent by default — every mutating call (
POST/PUT/PATCH/DELETE) automatically attaches a UUID idempotency header, so retries are safe. - Built on Laravel's HTTP client — retries (
429,503), timeouts, logging, and any custom middleware slot into the standardHttp::pipeline. - Spatie Data name mapping — DTO properties are camelCase but Wafeq's wire format is snake_case; the package transparently maps between them via Spatie Data's
SnakeCaseMapper. - Localized fields handled —
{en, ar}Wafeq payloads hydrate into a dedicatedDualLangDatavalue object that implements Spatie Data'sCastableso bare strings, arrays, or existing instances all work. - Typed enum casts — every enum (
Currency,BillStatus, …) implementsSpatie\LaravelData\Casts\CastableviaSafeEnumCastable, so unknown wire values fall back tonullinstead of throwing. - FormRequest layer out of the box — every mutating endpoint (
create/update/partialUpdate/markAs*/taxAuthorityReport/bulkSend/invoice/bill/preview*/endEarly*) ships a typedFormRequestunderHWafeq\LaravelWafeq\Requeststhat wires Laravel validation directly to the matchingspatie/laravel-dataDTO. Validate →->toDto()→ hand off to the resource. - Eloquent bridge — pass any
Modelinto the*Model()overloads (or mixHasWafeqResourcein and call$customer->wafeq()->retrieve()) and the package resolves the Wafeq id and builds the payload for you. - First-class test helpers —
FakesWafeqtrait +WafeqFakehelper stub every endpoint without touching the network. - Typed events — every successful resource call dispatches a
WafeqEventsubclass you can listen to for syncing, notifications, audit logs, etc.
Requirements
- PHP ^8.3
- Laravel ^11.0 || ^12.0 || ^13.0
- A Wafeq API key from https://app.wafeq.com/c/api-keys
Installation
Install the package via composer:
composer require mohamedhabibwork/laravel-wafeq
The service provider and LaravelWafeq facade are auto-discovered — no manual registration needed.
Optionally, publish the config file:
php artisan vendor:publish --tag="laravel-wafeq-config"
Add the API key to your .env (the only required setting):
WAFEQ_ENVIRONMENT=sandbox # or "production" for live traffic WAFEQ_API_KEY=your-key-here # https://app.wafeq.com/c/api-keys
That's it — LaravelWafeq::contacts()->list() will now work.
Usage
A 30-second tour
use HWafeq\LaravelWafeq\Facades\LaravelWafeq; use HWafeq\LaravelWafeq\Enums\Currency; // List contacts — returns PaginatedData<ContactData> $page = LaravelWafeq::contacts()->list(['limit' => 50]); foreach ($page->results as $contact) { echo $contact->name; // typed property access } // Retrieve one — returns ContactData $contact = LaravelWafeq::contacts()->retrieve('ct_123'); // Create one — returns ContactData, sends an idempotency key automatically $contact = LaravelWafeq::contacts()->create([ 'name' => 'Acme Inc.', 'type' => 'business', 'email' => '[email protected]', 'currency' => Currency::SAR->value, // 'SAR' ]); // Update (PUT) / partial update (PATCH) $contact = LaravelWafeq::contacts()->update('ct_123', [/* full body */]); $contact = LaravelWafeq::contacts()->partialUpdate('ct_123', ['phone' => '+966500000000']); // Delete — returns bool LaravelWafeq::contacts()->destroy('ct_123');
Resource with extras
Some resources expose extra endpoints beyond plain CRUD:
// Invoices: download the PDF (returns Illuminate\Http\Client\Response) $pdf = LaravelWafeq::invoices()->download('inv_123'); return response()->streamDownload( fn () => print($pdf->body()), 'invoice.pdf', ); // Invoices: file a tax-authority report (ZATCA / FTA) $report = LaravelWafeq::invoices()->taxAuthorityReport('inv_123', [ 'reporting_period' => '2026-Q3', ]); // Quotes: convert to an invoice $invoice = LaravelWafeq::quotes()->invoice('qt_123'); // Purchase orders: convert to a bill $bill = LaravelWafeq::purchaseOrders()->bill('po_123'); // Expenses: flip the DRAFT ↔ POSTED lifecycle LaravelWafeq::expenses()->markAsDraft('exp_1'); // POSTED → DRAFT LaravelWafeq::expenses()->markAsPosted('exp_1'); // DRAFT → POSTED
Validated payloads with FormRequests
Every mutating endpoint ships a typed FormRequest that derives its rules straight from wafeq-docs/<resource>_<action>.md. Drop one into a controller and Laravel takes care of validation, attribute names, and the typed DTO hand-off:
use HWafeq\LaravelWafeq\Data\ExpenseData; use HWafeq\LaravelWafeq\Facades\LaravelWafeq; use HWafeq\LaravelWafeq\Requests\Expenses\CreateExpenseRequest; class ExpenseController { public function store(CreateExpenseRequest $request) { /** @var ExpenseData $expense */ $expense = LaravelWafeq::expenses()->create($request->toDto()->toArray()); return response()->json($expense, 201); } }
Every FormRequest exposes:
rules()— Laravel validation rules derived from the matching OpenAPI schema (required fields,string/numeric/date_format:Y-m-d/in:v1,v2,...etc.; server-managedreadOnlyfields are excluded).attributes()— human-friendly keys for nicer validation error messages.dto(): class-string<Data>— the matchingspatie/laravel-dataDTO.toDto(): Data— materialises the DTO from the validated payload in one call.
The full catalogue (120 FormRequests across every endpoint that takes a body — create, update, partialUpdate, plus special actions like markAsDraft, markAsPosted, taxAuthorityReport, bulkSend, bill, invoice, previewCreate, endEarly, previewEndEarly) lives under HWafeq\LaravelWafeq\Requests. All extend HWafeq\LaravelWafeq\Requests\WafeqFormRequest, which provides the dto() / toDto() plumbing.
Nested resources
A few resources are scoped to a parent (e.g. a bank account's ledger transactions, a payslip's pay items). Their methods take the parent id as the first argument:
// Every call takes the parent bank account id first $txns = LaravelWafeq::bankLedgerTransactions()->list('ba_123'); $txn = LaravelWafeq::bankLedgerTransactions()->create('ba_123', [...]); $txn = LaravelWafeq::bankLedgerTransactions()->retrieve('ba_123', 'lt_456'); $txn = LaravelWafeq::bankLedgerTransactions()->update('ba_123', 'lt_456', [...]); // Same pattern for: LaravelWafeq::bankStatementTransactions()->list('ba_123'); LaravelWafeq::payslipsPayItems()->list('pay_123');
Eloquent bridge
Pass an Eloquent model directly — the package reads the Wafeq id off it and builds the right payload:
$contact = LaravelWafeq::contacts()->createFromModel($customer); $contact = LaravelWafeq::contacts()->retrieveModel($customer); $contact = LaravelWafeq::contacts()->updateModel($customer, $payload); $contact = LaravelWafeq::contacts()->partialUpdateModel($customer, $payload); LaravelWafeq::contacts()->destroyModel($customer);
Or mix HasWafeqResource into your model and call directly on the instance:
use HWafeq\LaravelWafeq\Concerns\HasWafeqResource; use Illuminate\Database\Eloquent\Model; class Customer extends Model { use HasWafeqResource; public static function wafeqResourceName(): string { return 'contacts'; } } $customer = Customer::find(1); $contact = $customer->wafeq()->retrieve();
The model-side and resource-side APIs share the same id-resolution precedence — see Eloquent integration.
Events
Every successful resource call dispatches a typed event you can listen to:
use HWafeq\LaravelWafeq\Events\Contacts\ContactCreated; use Illuminate\Support\Facades\Event; Event::listen(ContactCreated::class, function (ContactCreated $event) { logger()->info('contact created', [ 'id' => $event->id, 'name' => $event->data->name, 'payload' => $event->payload, ]); });
The full event inventory lives under HWafeq\LaravelWafeq\Events — one event per endpoint × resource (e.g. ContactCreated, ContactListed, ContactRetrieved, ContactUpdated, ContactPartiallyUpdated, ContactDestroyed).
Error handling
Non-2xx responses are converted into typed exceptions under HWafeq\LaravelWafeq\Exceptions:
| Status | Exception | Notes |
|---|---|---|
| 401/403 | AuthenticationException |
Bad or missing API key. |
| 404 | NotFoundException |
Resource doesn't exist. |
| 422 | ValidationException |
context['errors'] holds the field-level error map from Wafeq. |
| 429 | RateLimitException |
context['retry_after'] holds the Retry-After header value. |
| 5xx | ServerException |
Wafeq itself failed — Laravel HTTP client retried before throwing. |
| other | WafeqException |
Catch-all. |
See Error handling for the full hierarchy and context payloads.
Features
Resource map (44 resources)
The package exposes 44 resources grouped by what they manage:
| Group | Resources |
|---|---|
| Org | organization |
| Contacts & People | contacts, employees, beneficiaries, branches |
| Sales | invoices, api-invoices, simplified-invoices, credit-notes, api-credit-notes, quotes |
| Purchases | bills, debit-notes, purchase-orders, expenses, payment-requests |
| Payments | payments, payslips |
| Banking | bank-accounts, bank-ledger-transactions, bank-statement-transactions |
| Chart of accounts | accounts, tax-rates, cost-centers, warehouses, projects, custom-fields, units-of-measure, item-units-of-measure, items |
| Line items | invoices-line-items, bills-line-items, credit-notes-line-items, debit-notes-line-items, quotes-line-items, purchase-orders-line-items, simplified-invoices-line-items, journal-line-items, payslips-pay-items |
| Accounting | manual-journals, amortizations, revenue-recognitions |
| Files & Reports | files, reports |
Every resource is exposed via a dedicated interface under HWafeq\LaravelWafeq\Contracts\*ResourceContract, so you can type-hint the resource you actually use:
use HWafeq\LaravelWafeq\Contracts\InvoicesResourceContract; class BillingService { public function __construct(private InvoicesResourceContract $invoices) {} public function resend(string $invoiceId): void { $this->invoices->partialUpdate($invoiceId, ['status' => 'open']); } }
Shared DTOs
Cross-resource types live under HWafeq\LaravelWafeq\Data\Shared:
| Shared DTO | Used by |
|---|---|
AccountRefData |
Line items that point at an account |
AddressData |
Postal addresses |
BranchRefData |
References to branches |
ContactRefData |
Line items / payments that point at a contact |
DimensionRefData |
Cost center / project references |
DualLangData |
{en, ar} value objects (warehouses, branches, contacts, accounts…) |
ItemRefData |
Line items that point at an item |
TaxRateRefData |
Line items that point at a tax rate |
UserRefData |
Wafeq user / employee references |
WarehouseRefData |
References to a Wafeq warehouse |
DualLangData is special — it implements Spatie Data's Castable contract so any property typed as ?DualLangData will accept the Wafeq wire format ({en: ..., ar: ...}) or a bare string (wrapped as {en: $string, ar: null}) automatically.
Enums
The package ships 19 typed enums in HWafeq\LaravelWafeq\Enums. All Wafeq-facing enums are sourced directly from the official wafeq-docs and implement Spatie Data's Castable so unknown wire values fall back to null instead of throwing.
use HWafeq\LaravelWafeq\Enums\Currency; use HWafeq\LaravelWafeq\Enums\ChargeType; use HWafeq\LaravelWafeq\Enums\PaymentRequestStatus; LaravelWafeq::paymentRequests()->create([ 'amount' => '100.00', 'currency' => Currency::AED->value, // 'AED' 'charge_type' => ChargeType::Beneficiary->value, // 'BEN' ]); $status = LaravelWafeq::paymentRequests()->retrieve('abc')->status; $status === PaymentRequestStatus::Processed->value; // 'PROCESSED'
Default currency
Set WAFEQ_CURRENCY (or config/wafeq.php → currency) to your organisation's base currency. When the value is null, the package fetches the base currency from GET /organization/ and caches it for the rest of the request lifecycle.
WAFEQ_CURRENCY=AED
Tag DTO properties with #[WithCurrency] to auto-fill them from the default when the wire payload is missing or unknown:
use HWafeq\LaravelWafeq\Attributes\WithCurrency; use HWafeq\LaravelWafeq\Enums\Currency; class InvoiceData extends Data { public function __construct( public string $id = '', #[WithCurrency] public ?Currency $currency = null, // ... ) {} }
A wire response shaped { "id": "inv_123" } now hydrates with Currency::AED automatically. A response shaped { "id": "inv_123", "currency": "USD" } becomes Currency::USD. The fill runs once after every response hydration (via HandlesResponses::toData()) and is a no-op when the property already has a value.
$client = app(\HWafeq\LaravelWafeq\Contracts\ClientContract::class); $default = $client->defaultCurrency(); // returns the resolved Currency (or null)
Idempotency
Every mutating call (create, update, partialUpdate, destroy, plus resource-specific extras like markAsPosted, invoice, bill, taxAuthorityReport, bulkSend, previewCreate, etc.) automatically attaches a UUID X-Wafeq-Idempotency-Key header. Retries are safe — see Idempotency.
The header name is configurable via the idempotency_header config key.
FormRequest layer
Every endpoint that accepts a request body ships a typed Laravel FormRequest under HWafeq\LaravelWafeq\Requests. The validation rules are derived directly from the matching wafeq-docs/<resource>_<action>.md OpenAPI schema, so the package and the docs stay in lock-step:
| Endpoint family | FormRequests |
|---|---|
*_create.md |
Create<Resource>Request (one per resource) |
*_update.md |
Update<Resource>Request |
*_partial_update.md |
PartialUpdate<Resource>Request (every field becomes sometimes + nullable) |
| Special actions | CreateMarkAsDraftExpenseRequest, CreateMarkAsPostedExpenseRequest, CreateInvoiceTaxAuthorityReportRequest, CreateApiInvoiceBulkSendRequest, CreateQuoteInvoiceRequest, CreatePurchaseOrderBillRequest, CreateAmortizationPreviewRequest, CreateAmortizationEndEarlyRequest, CreateRevenueRecognitionPreviewRequest, … |
120 FormRequests total (one per endpoint in wafeq-docs/ that takes a request body), plus the WafeqFormRequest base class.
All 121 requests extend a single base class:
namespace HWafeq\LaravelWafeq\Requests; abstract class WafeqFormRequest extends \Illuminate\Foundation\Http\FormRequest { public function authorize(): bool { return true; } /** @return array<string, array<int, mixed>> */ abstract public function rules(): array; /** @return class-string<\Spatie\LaravelData\Data> */ abstract public function dto(): string; public function toDto(): \Spatie\LaravelData\Data { /** @var class-string<\Spatie\LaravelData\Data> $dto */ $dto = $this->dto(); return $dto::from($this->validated()); } }
JSON Schema → Laravel rules
The mapping rules are consistent across every FormRequest (see requests.md for the full table):
| OpenAPI schema | Laravel rule |
|---|---|
{"type": "string", "maxLength": 255} |
['nullable', 'string', 'max:255'] |
{"type": "number", "format": "double"} |
['required', 'numeric'] (or nullable) |
{"type": "string", "format": "date"} |
'date_format:Y-m-d' |
{"type": "string", "format": "date-time"} |
'date_format:Y-m-d\TH:i:sP' |
{"type": "array", "items": {"type": "string"}} |
['sometimes', 'array'] + ['KEY.*', 'string'] |
$ref to CurrencyEnum / ClassificationEnum |
read the enum under src/Enums/ to derive in:v1,v2,... |
{"readOnly": true} |
EXCLUDE (server-managed only) |
default: "X", field optional |
['sometimes', ...] |
partial_update.md body |
every field becomes ['sometimes', 'nullable', ...] |
Bodyless endpoint (mark_as_*, tax_authority_report_*, bulk_send_*, bill_create, invoice_create, preview_*, end_early_*) |
rules() returns []; dto() still wired |
Wire-format caveats
- snake_case on input, camelCase on the DTO — the package's
SnakeCaseMapperhandles the bidirectional translation, so you can pass either form totoDto(). - Dual-language fields (branches / warehouses
name,city,district,address): wire format{en: required, ar: nullable}. The matching FormRequest validatesparent.array+name.enrequired+name.arnullable; the array is then hydrated intoDualLangDataby the DTO's cast. - Bodyless endpoints ship an empty
rules()array but still implementdto()sotoDto()->toArray()always returns a usable payload.
Testing
composer test
The test suite uses Pest and ships an FakesWafeq trait that wraps Laravel's Http::fake() so you can stub every endpoint without touching the network:
use HWafeq\LaravelWafeq\Facades\LaravelWafeq; use HWafeq\LaravelWafeq\Tests\Pests\Concerns\FakesWafeq; uses(FakesWafeq::class); it('creates an invoice', function () { $this->fakeWafeq('/invoices/', ['id' => 'inv_1', 'total' => '100.00']); $invoice = LaravelWafeq::invoices()->create(['contact' => 'ct_1']); expect($invoice->id)->toBe('inv_1'); });
FakesWafeq also exposes fakeWafeqPage() (paginated list bodies), fakeNotFound(), fakeValidationError(), fakeRateLimit(), fakeServerError(), and fakeAuthError() for every HTTP failure the API returns. See Testing for the full surface.
Run the test suite yourself:
./vendor/bin/pest # 797 passed (2075 assertions) ./vendor/bin/phpstan analyse # no errors ./vendor/bin/pint # clean
Configuration
The package reads from config/wafeq.php. The defaults:
return [ // Sandbox for local development, production for live traffic. 'environment' => env('WAFEQ_ENVIRONMENT', 'sandbox'), // Required. Generate one at https://app.wafeq.com/c/api-keys 'api_key' => env('WAFEQ_API_KEY'), 'base_urls' => [ 'sandbox' => env('WAFEQ_SANDBOX_BASE_URL', 'https://api-sandbox.wafeq.com/v1'), 'production' => env('WAFEQ_PRODUCTION_BASE_URL', 'https://api.wafeq.com/v1'), ], 'http' => [ 'timeout' => (int) env('WAFEQ_HTTP_TIMEOUT', 30), 'connect_timeout' => (int) env('WAFEQ_HTTP_CONNECT_TIMEOUT', 10), 'retry' => [ 'times' => (int) env('WAFEQ_RETRY_TIMES', 3), 'delay' => (int) env('WAFEQ_RETRY_DELAY', 250), 'when' => [429, 503], ], 'log' => env('WAFEQ_HTTP_LOG', false), ], 'idempotency_header' => env('WAFEQ_IDEMPOTENCY_HEADER', 'X-Wafeq-Idempotency-Key'), ];
At minimum, set WAFEQ_API_KEY (and WAFEQ_ENVIRONMENT=production when you're ready for live traffic). See Configuration for the full reference.
Documentation
The package ships comprehensive documentation under docs/:
| Page | What it covers |
|---|---|
| Getting started | Install, env, first call. |
| Architecture | Connector → Client → Resource → DTO pipeline; HandlesResponses, InteractsWithModels, HoldsWafeqModel traits. |
| Configuration | Every config key and env var explained. |
| Idempotency | How the package protects mutating calls. |
| Error handling | Exception hierarchy and status-code mapping. |
| Eloquent integration | Model-aware overloads, payload building, id resolution. |
| Events | Typed Laravel events dispatched from every Resource method. |
| Enums | Every typed enum the package ships. |
| DTOs | Data Transfer Object conventions, extra catch-all, paginated envelopes, shared DTOs. |
| Requests | The 120 FormRequest classes — JSON Schema → validation rules → typed DTO conversion. |
| Testing | The FakesWafeq trait and WafeqFake helper. |
| Resources | One page per resource family with curl-equivalent examples. |
Changelog
Please see CHANGELOG.md for more information on what has changed recently.
Contributing
Please see CONTRIBUTING.md for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.