we-simply-code/laravel-afas-rest-connector
A Laravel package that wraps the AFAS REST API, offering a fluent interface to query, create, update, and delete AFAS data. Includes configurable authentication, request handling, and integration with Laravel's service container and facades.
Install and publish
composer require we-simply-code/laravel-afas-rest-connector
php artisan vendor:publish --tag=afas-config
Add the credentials to .env (key names may vary slightly per release — open the published config/afas.php and map them):
AFAS_URL=https://12345.rest.afas.online/Profit/ABCDE
AFAS_TOKEN=base64-encoded-token-from-afas-profit
The token is generated in AFAS Profit under Beheer → App connector (or via the user's "token" screen), and it is the value you send as Authorization: AfasToken <token>. It is not a bearer token — don't prefix it with Bearer.
Where to look first
config/afas.php — the only file that matters initially. It holds the base URL and token; every call is relative to it.php artisan about or grep the vendor dir for class Afas). This is your entry point for get/post/put/delete.GET /connectors/{name}.First use case: read one GetConnector
The smallest useful call pulls a GetConnector and returns the results array:
use WeSimplyCode\AfasRestConnector\Facades\Afas;
$response = Afas::get('Profit_Article', [
'take' => 10,
]);
$articles = $response['results'] ?? [];
Run it in php artisan tinker before writing any application code. If you get a 401, the token or URL is wrong; if you get a 404, the connector name is wrong (they are case-sensitive and environment-specific). Getting one happy-path call working in tinker is 80% of the integration effort.
Wrap the facade, don't sprinkle it
Put a single AfasClient service around the package so connector names, retries, logging, and pagination live in one place:
class AfasClient
{
public function articles(int $take = 100): array
{
return $this->fetchAll('Profit_Article', [], $take);
}
protected function fetchAll(string $connector, array $params, int $take): array
{
$skip = 0;
$all = [];
do {
$page = Afas::get($connector, $params + [
'skip' => $skip,
'take' => $take,
])['results'] ?? [];
$all = array_merge($all, $page);
$skip += $take;
} while (count($page) === $take);
return $all;
}
}
The AFAS response envelope contains @totalsize, @count and results. Prefer looping until a page is short over trusting @totalsize — @totalsize is often capped and reflects the filtered set, not what you can actually page through.
Filtering
AFAS filters are positional triplets, which is the single biggest source of confusion:
Afas::get('Profit_Article', [
'filterfieldids' => 'Itemcode,Description',
'filtervalues' => "'ABC-1','Widget'",
'operatortypes' => '1,2', // 1 = equal, 2 = contains (verify in AFAS docs)
'orderbyfieldids' => '-Itemcode',
]);
Treat this as a reusable AfasFilter value object. Build the quoted string from an array of [field, operator, value] tuples so callers never hand-write commas and quotes.
Writes go through Update connectors
Reads use Get connectors (Profit_X); writes use the matching Update connector (Profit_X as well in many cases, but the name differs for some entities). Wrap each write in a small method and always inspect the returned {"fault": ...} payload — a failed update frequently returns HTTP 200 with a per-row error body.
$result = Afas::put('Profit_Article', [
'Element' => ['Fields' => ['Itemcode' => 'ABC-1', 'Description' => 'New']],
]);
if (! empty($result['fault'])) {
throw new AfasUpdateException($result['fault']['errorMessage']);
}
Push syncs to the queue
AFAS is not a fast API. Any job that touches more than a handful of rows belongs in a queued job with $tries = 3, backoff() of 30–60s, and a per-run rate limit (RateLimited middleware or Redis::throttle). Batch updates one record per request; there is no bulk endpoint.
Cache reference data
Connectors like Profit_Department, Profit_Country, or Profit_Administration change weekly, not hourly. Cache::remember('afas.departments', now()->addHours(6), fn () => app(AfasClient::class)->departments()) removes a large chunk of latency and rate-limit pressure.
Test without hitting AFAS
Everything funnels through Laravel's HTTP client, so Http::fake() works:
Http::fake([
'*/connectors/Profit_Article*' => Http::response([
'@count' => 1,
'results' => [['Itemcode' => 'ABC-1']],
]),
]);
Fake the AfasClient, not the facade, in feature tests — the facade resolves lazily and makes assertions awkward.
take limits. AFAS caps page size (commonly 5000, with 100–500 recommended for responsiveness). Asking for too much returns an error or truncates; requesting a sane page size and paginating is both faster and safer.skip/take pagination drifts. If records are inserted while you paginate, you will skip or duplicate rows. For delta syncs, filter on a modified timestamp (filterfieldids=Modified,filtervalues='2024-01-01T00:00:00') instead of relying on offsets.Y-m-d\TH:i:s) and be aware that some connectors expect local time without an offset. Test one record before writing a date-range loop.{"fault": {...}}; some business-rule failures return 200 with a fault in the body. Always check the body, not just the status code. Log the full response on failure — AFAS error messages are terse and the request context is what makes them debuggable.->retry(3, 1000) semantics in your wrapper, and honour Retry-After if present.php artisan config:cache freezes AFAS_TOKEN. If you rotate tokens via a secrets manager at runtime, read from config() only after a cache clear, or inject the token through a custom binding.composer.json and read the diff before bumping — the public surface is small enough that you could vendor the two relevant classes if maintenance stalls.Http::withToken(config('afas.token'))->baseUrl(config('afas.url')) directly. That's supported by design, not a workaround.Http::withOptions(['debug' => ...]) and Guzzle middleware will happily dump the Authorization header into your logs. Scrub it in any custom middleware you add.How can I help you explore Laravel packages today?