Search by

mohamedhabibwork / laravel-wafeq

This is my package laravel-wafeq

Maintainers

Package info

github.com/mohamedhabibwork/laravel-wafeq

pkg:composer/mohamedhabibwork/laravel-wafeq

Transparency log

Fund package maintenance!

Mohamed Habib

Statistics

Installs: 18

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 2

dev-main 2026-08-28 07:23 UTC

This package is auto-updated.

Last update: 2026-08-31 06:10:40 UTC


README

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

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 endpointLaravelWafeq::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-data with full PHPDoc, readonly properties and an extra catch-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 standard Http:: 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 dedicated DualLangData value object that implements Spatie Data's Castable so bare strings, arrays, or existing instances all work.
  • Typed enum casts — every enum (Currency, BillStatus, …) implements Spatie\LaravelData\Casts\Castable via SafeEnumCastable, so unknown wire values fall back to null instead of throwing.
  • FormRequest layer out of the box — every mutating endpoint (create / update / partialUpdate / markAs* / taxAuthorityReport / bulkSend / invoice / bill / preview* / endEarly*) ships a typed FormRequest under HWafeq\LaravelWafeq\Requests that wires Laravel validation directly to the matching spatie/laravel-data DTO. Validate → ->toDto() → hand off to the resource.
  • Eloquent bridge — pass any Model into the *Model() overloads (or mix HasWafeqResource in and call $customer->wafeq()->retrieve()) and the package resolves the Wafeq id and builds the payload for you.
  • First-class test helpersFakesWafeq trait + WafeqFake helper stub every endpoint without touching the network.
  • Typed events — every successful resource call dispatches a WafeqEvent subclass you can listen to for syncing, notifications, audit logs, etc.

Requirements

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-managed readOnly fields are excluded).
  • attributes() — human-friendly keys for nicer validation error messages.
  • dto(): class-string<Data> — the matching spatie/laravel-data DTO.
  • 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.phpcurrency) 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 SnakeCaseMapper handles the bidirectional translation, so you can pass either form to toDto().
  • Dual-language fields (branches / warehouses name, city, district, address): wire format {en: required, ar: nullable}. The matching FormRequest validates parent.array + name.en required + name.ar nullable; the array is then hydrated into DualLangData by the DTO's cast.
  • Bodyless endpoints ship an empty rules() array but still implement dto() so toDto()->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.