jarrin / wefact-php-sdk
Modern, strongly-typed, PSR-compliant PHP client for the WeFact v2 API.
Requires
- php: >=8.3
- guzzlehttp/guzzle: ^7.14
- league/html-to-markdown: ^5.1
- mcp/sdk: ^0.7.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- symfony/console: ^7.4
Requires (Dev)
- ergebnis/composer-normalize: ^2.52
- infection/infection: ^0.34.0
- kevinrob/guzzle-cache-middleware: ^7.0
- larastan/larastan: ^3.10
- laravel/pint: ^1.29
- orchestra/testbench: ^11.1
- pestphp/pest: ^4.7
- pestphp/pest-plugin-type-coverage: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.2
- phpstan/phpstan-strict-rules: ^2.0
- rector/rector: ^2.5
- spatie/guzzle-rate-limiter-middleware: ^2.1
README
A modern, strongly-typed, PSR-compliant PHP 8.3+ client for the WeFact v2 API — the Dutch invoicing / billing / bookkeeping SaaS. Works standalone and under Laravel.
- Typed end-to-end. Every controller is a resource, every response object a readonly DTO, every documented code a backed enum. No associative-array guessing, full IDE autocompletion, PHPStan-max clean.
- One obvious way to call it.
$wefact->debtors()->get(code: 'DB10000')— a lazy accessor per controller, a typed method per action. - Hand-written from the official reference and verified against the live API. Not generated; every endpoint is confirmed by an integration test.
use Jarrin\WeFactApiClient\WeFact; $wefact = new WeFact('your-api-key'); $debtor = $wefact->debtors()->get(code: 'DB10000'); $invoice = $wefact->invoices()->create([ 'DebtorCode' => $debtor->code, 'InvoiceLines' => [ ['ProductCode' => 'P0001', 'Number' => 2], ['Description' => 'Consultancy', 'PriceExcl' => 95.0, 'Number' => 3, 'TaxCode' => 'V21'], ], ]); echo $invoice->code, ' — €', $invoice->amountIncl;
Requirements
- PHP 8.3+
- A WeFact v2 API key (WeFact control panel → Settings → API)
- The calling server's IP must be whitelisted for that key (a non-whitelisted IP returns HTTP 403)
Installation
composer require jarrin/wefact-php-sdk
Quick start (standalone)
Construct with an API key, or with a Config for full control:
use Jarrin\WeFactApiClient\WeFact; use Jarrin\WeFactApiClient\Config; $wefact = new WeFact('your-api-key'); // or: $wefact = new WeFact(new Config( apiKey: 'your-api-key', timeout: 15, ));
Then reach the API through one accessor per controller:
// Fetch, addressed by id OR code (never both). $debtor = $wefact->debtors()->get(id: 42); $debtor = $wefact->debtors()->get(code: 'DB10000'); // List with filtering, sorting and paging. $page = $wefact->debtors()->list(searchAt: 'EmailAddress', searchFor: 'a@b.nl', limit: 20); foreach ($page as $debtor) { echo $debtor->companyName, PHP_EOL; } echo $page->totalResults; // total matching records, not just this page // Create — pass a plain array or a DTO. $product = $wefact->products()->create([ 'ProductName' => 'Hosting', 'PriceExcl' => 10.0, 'TaxCode' => 'V21', ]); // Update, identified by id or code. $wefact->products()->update(['PriceExcl' => 12.5], code: $product->code); // Delete. $wefact->products()->delete(code: $product->code);
The call pattern
$wefact->{resource}()->{action}(named args…): DTO | Collection<DTO> | void
{resource}()— a lazy, memoised accessor per WeFact controller (debtors(),invoices(),products(), …). See the resource map.{action}()— a typed method per API action (get,list,create,update,delete, plus lifecycle actions likeinvoices()->credit()orinvoices()->markAsPaid()).- Reads return a readonly DTO (or a
Collection<DTO>for lists); mutations return the updated DTO orvoid.
Addressing records: id: or code:
Every identifiable record is addressed by either its numeric id: or its
human-readable code: (DebtorCode, InvoiceCode, …) — never both, never neither.
Passing both or neither throws a ValidationException before any request is sent. Some
records (groups, subscriptions, tasks, …) have no code and take only id:.
$wefact->invoices()->get(id: 10); // ok $wefact->invoices()->get(code: 'F2024-0001'); // ok $wefact->invoices()->get(); // ValidationException — missing identifier $wefact->invoices()->get(id: 10, code: 'F…'); // ValidationException — ambiguous
Error handling
Every failure is a typed exception extending Jarrin\WeFactApiClient\Exceptions\WeFactException:
| Exception | When |
|---|---|
ValidationException |
Bad arguments caught locally (ambiguous/missing identifier) — no request sent |
AuthenticationException |
HTTP 403 — blocked key or non-whitelisted IP |
ApiException |
The API returned an error envelope (carries ->errors, ->apiController, ->apiAction) |
TransportException |
Network failure or an undecodable/unexpected response |
use Jarrin\WeFactApiClient\Exceptions\ApiException; use Jarrin\WeFactApiClient\Exceptions\WeFactException; try { $wefact->debtors()->get(code: 'DB-does-not-exist'); } catch (ApiException $e) { report($e->errors); // list<string> of API messages } catch (WeFactException $e) { // any other client failure }
Laravel
The package auto-registers a service provider and a WeFact facade — no manual wiring.
php artisan vendor:publish --tag=wefact-config
// config/wefact.php reads WEFACT_API_KEY / WEFACT_BASE_URI / WEFACT_TIMEOUT from .env use Jarrin\WeFactApiClient\WeFact; use WeFact as WeFactFacade; // the registered facade alias public function handle(WeFact $wefact) // resolved from the container as a singleton { $wefact->invoices()->list(limit: 10); } WeFactFacade::debtors()->get(code: 'DB10000'); // or via the facade
The standalone client never loads Laravel; the Laravel layer is optional and dev-only in this package's own test suite. See docs/configuration.md.
CLI: seeding a test administration
The package ships a small Symfony Console binary for populating a dedicated test administration with a known, reversible fixture set (all through the typed client):
vendor/bin/wefact seed # find-or-create the fixtures (idempotent) vendor/bin/wefact seed --wipe # remove the deletable ones again vendor/bin/wefact seed --fresh # wipe, then re-seed
It reads WEFACT_API_KEY (falling back to API_SECRET) from the environment. See
docs/testing.md.
Development
The entire toolchain runs in one pinned PHP 8.3 Docker image, so contributing needs no
PHP on your host. The bin/dev wrapper runs any command in it:
bin/dev composer test # offline suite bin/dev composer stan # PHPStan (max) bin/dev composer docs:api # regenerate the API reference (phpDocumentor) bin/dev bin/wefact seed # seed a test administration
The pre-commit hook (composer hooks:install) runs the full gate in that image. See
docs/development.md and docs/testing.md.
Documentation
- Getting started
- Configuration
- Resources & the call pattern
- DTOs & enums
- Error handling
- Testing, Docker & seeding
- Development & extending the client
- Generated API reference — per-class, produced by
composer docs:api
License
MIT.