Een AFAS-koppeling die om 02:00 's nachts 14.000 medewerkers ophaalt, faalt niet op de data. Hij faalt op het tweede getal: 14.000 records in één GetConnector-call, zonder paging, zonder retry.
Laravel + AFAS: HRM en CRM data synchronisatie
Een AFAS-koppeling die om 02:00 's nachts 14.000 medewerkers ophaalt, faalt niet op de data. Hij faalt op het tweede getal: 14.000 records in één GetConnector-call, zonder paging, zonder retry. AFAS geeft een timeout, je queue-job crasht, en de volgende ochtend mist je portaal de helft van het personeel.
Wij bouwen AFAS Profit-integraties in Laravel voor HRM- en CRM-flows. De techniek is niet ingewikkeld. De valkuilen wel: throttling, idempotentie, datamodel-mapping en foutafhandeling. Dit artikel laat zien hoe je het in productie betrouwbaar krijgt.
AFAS Profit: GetConnector en UpdateConnector
AFAS Profit werkt met twee soorten connectoren. Je moet het verschil snappen voordat je een regel code schrijft.
- GetConnector — lezen. Je definieert in AFAS een view (een query) en krijgt JSON terug. Voor het ophalen van medewerkers, organisaties, contactpersonen, verlofsaldi.
- UpdateConnector — schrijven. Je stuurt XML of JSON met een vaste structuur die AFAS valideert tegen het datamodel. Voor het aanmaken van een nieuwe medewerker, het muteren van een adres, het loggen van een verkoopkans.
De REST API draait op https://{omgeving}.rest.afas.online/profitrestservices. Authenticatie gaat via een AFAS-token in een Base64-encoded header. Genereer dat token in AFAS Profit zelf en bewaar het in je .env — nooit in code.
// config/afas.php
return [
'base_url' => env('AFAS_BASE_URL'),
'token' => env('AFAS_TOKEN'), // <token><version>1</version><data>XXXX</data></token>, base64
];
$response = Http::withHeaders([
'Authorization' => 'AfasToken ' . config('afas.token'),
])->get(config('afas.base_url') . '/connectors/Medewerkers', [
'skip' => 0,
'take' => 100,
]);
Let op skip en take. Die twee parameters zijn het belangrijkste van de hele GetConnector. Zonder paging haalt AFAS standaard maximaal 100 records op. Vraag je er meer, dan moet je expliciet pagineren.
Datamodel-mapping: de AFAS-velden zijn cryptisch
AFAS retourneert veldnamen zoals EmId, Nm, EmAd, Bd. Geen mens leest dat. Mapping is geen luxe, het is een vereiste.
Wij gebruiken altijd een aparte mapping-laag tussen de ruwe AFAS-response en je domeinmodel. Een Data Transfer Object dwingt structuur af.
class AfasMedewerker
{
public function __construct(
public string $afasId, // EmId
public string $voornaam, // VoNm
public string $achternaam, // LaNm
public ?string $email, // EmAd
public ?Carbon $indienst, // DaEm
) {}
public static function fromGet(array $row): self
{
return new self(
afasId: $row['EmId'],
voornaam: $row['VoNm'] ?? '',
achternaam: $row['LaNm'] ?? '',
email: $row['EmAd'] ?? null,
indienst: isset($row['DaEm']) ? Carbon::parse($row['DaEm']) : null,
);
}
}
Drie regels die wij hard hanteren bij mapping:
- Map nooit één-op-één naar je database. AFAS-veldnamen veranderen als iemand de GetConnector-view aanpast. Een DTO vangt dat op één plek op.
- Documenteer de view-definitie. De GetConnector-output hangt 100% af van welke velden in AFAS aan de view zijn toegevoegd. Zet die definitie in je repo, niet alleen in AFAS.
- Behandel ontbrekende velden defensief. Een leeg veld komt soms niet terug in de JSON in plaats van als
null. Gebruik??.
Voor UpdateConnectors is mapping nog strenger. AFAS valideert je payload tegen het datamodel en weigert alles wat niet klopt. De structuur is genest:
$payload = [
'KnEmployee' => [
'Element' => [
'Fields' => ['EmId' => 'EMP-2026-001'],
'Objects' => [
'KnPerson' => [
'Element' => [
'Fields' => [
'FiNm' => 'Jan',
'LaNm' => 'de Vries',
'EmAd' => '[email protected]',
],
],
],
],
],
],
];
Eén verkeerd genest veld en je krijgt een HTTP 400 met een AFAS-foutmelding die je letterlijk moet lezen om de oorzaak te vinden. Bouw deze payloads daarom met een builder-class, niet met losse arrays door je codebase.
Throttling: AFAS knijpt af, plan ervoor
AFAS heeft rate limits. In de praktijk lopen wij tegen twee grenzen aan: gelijktijdige requests en datavolume per call. Negeer je die, dan krijg je HTTP 429 of timeouts.
De oplossing zit in drie lagen.
1. Pagineer altijd in batches van 100 tot 1.000. Loop metskip/take tot je een lege response krijgt.
$skip = 0;
$take = 500;
do {
$rows = $this->afas->getConnector('Medewerkers', $skip, $take);
foreach ($rows as $row) {
ProcessMedewerker::dispatch(AfasMedewerker::fromGet($row));
}
$skip += $take;
} while (count($rows) === $take);
RateLimited middleware of Redis::throttle voorkomt dat je 50 jobs tegelijk op AFAS afvuurt.
public function middleware(): array
{
return [new RateLimited('afas')];
}
// In een service provider
RateLimiter::for('afas', fn () => Limit::perMinute(60));
delay() ertussen. Wij draaien zware syncs tussen 01:00 en 05:00 en houden overdag alleen delta-updates aan.
Een delta-sync — alleen records die sinds de laatste run zijn gewijzigd — verlaagt je volume met 90%+. Voeg een Gewijzigd op-veld toe aan je GetConnector-view en filter daarop.
Idempotentie: dezelfde sync twee keer mag niets stukmaken
Een queue-job kan opnieuw draaien. Bij een timeout, een deploy, een crash. Als je sync niet idempotent is, krijg je dubbele medewerkers of dubbele CRM-contacten.
Idempotentie betekent: dezelfde input geeft hetzelfde eindresultaat, ongeacht hoe vaak je hem draait. Twee technieken werken in de praktijk.
Upsert op de AFAS-id. GebruikEmId als unieke sleutel, niet je eigen auto-increment.
Medewerker::updateOrCreate(
['afas_id' => $dto->afasId],
[
'voornaam' => $dto->voornaam,
'achternaam' => $dto->achternaam,
'email' => $dto->email,
]
);
$hash = md5(json_encode($payload));
if ($medewerker->last_afas_hash === $hash) {
return; // geen wijziging, sla over
}
$this->afas->update('KnEmployee', $payload);
$medewerker->update(['last_afas_hash' => $hash]);
Voor CRM-flows is idempotentie nog belangrijker. Een dubbel aangemaakte verkoopkans of organisatie vervuilt je AFAS-data direct, en dat zien je salesmensen meteen. Check altijd op een bestaande match voordat je een nieuw record aanmaakt.
Foutafhandeling: AFAS-fouten zijn geen HTTP-fouten
De grootste denkfout: aannemen dat HTTP 200 betekent dat alles goed ging. Bij UpdateConnectors kan AFAS een 200 teruggeven met een foutmelding in de body. Lees altijd de response, niet alleen de statuscode.
Wij delen AFAS-fouten in drie categorieën in, elk met een eigen strategie.
| Type | Voorbeeld | Strategie |
|---|---|---|
| Tijdelijk | 429, 503, timeout | Retry met backoff |
| Validatie | 400, "veld X verplicht" | Niet retryen, loggen, alert |
| Auth | 401, token verlopen | Stop, alert direct |
Retry met exponential backoff vangt de tijdelijke fouten op:
public int $tries = 5;
public function backoff(): array
{
return [10, 30, 60, 120, 300]; // seconden
}
Validatiefouten retryen heeft geen zin. Een verplicht veld dat ontbreekt, ontbreekt bij elke poging. Vang die apart af, log de volledige AFAS-response en stuur een alert naar Slack of mail. Laat de job daarna falen, niet eindeloos retryen.
$response = $this->afas->update('KnEmployee', $payload);
if ($response->failed() || str_contains($response->body(), 'error')) {
Log::channel('afas')->error('AFAS update mislukt', [
'afas_id' => $dto->afasId,
'status' => $response->status(),
'response' => $response->body(),
]);
throw new AfasUpdateException($response->body());
}
Log altijd de request én de response. Zonder de exacte payload kun je een AFAS-validatiefout nooit reproduceren. Wij houden een aparte afas-logkanaal aan met retentie van minstens 30 dagen.
Architectuur in Laravel: hoe wij het opzetten
Een AFAS-koppeling die je over een jaar nog snapt, heeft een vaste structuur. Dit is de opzet die wij standaard hanteren bij Laravel maatwerk.
- Een
AfasClient-service — wrapt de HTTP-calls, auth en paging. Eén plek voor alle AFAS-communicatie. - DTO's per entiteit —
AfasMedewerker,AfasOrganisatie,AfasContactpersoon. Mapping op één plek. - Queue-jobs per sync —
SyncMedewerkers,PushVerkoopkans. Met throttling-middleware en backoff. - Een scheduler —
app/Console/Kernel.phpplant de delta- en full-syncs. - Een audit-tabel — log elke sync met tijdstip, aantallen en fouten. Onmisbaar bij support.
// Console scheduling
$schedule->job(new SyncMedewerkersDelta)->everyThirtyMinutes();
$schedule->job(new SyncMedewerkersFull)->dailyAt('02:30');
Deze aanpak is dezelfde die wij gebruiken voor andere ERP- en boekhoudkoppelingen. De principes — DTO-mapping, throttling, idempotentie — komen één-op-één terug in onze Laravel-Exact Online koppeling. Verschillende API's, identieke discipline.
Wanneer een AFAS-koppeling NIET de juiste keuze is
Eerlijk: niet elke integratie hoort via een directe API-koppeling. Soms is het overkill.
- Eenmalige migratie. Verhuis je één keer data uit AFAS? Doe een CSV-export en import. Geen live koppeling bouwen.
- Zeer laag volume, lage frequentie. Vijf nieuwe contacten per maand handmatig overtikken kost minder dan onderhoud op een koppeling.
- AFAS als bron én bestemming tegelijk. Bidirectionele sync zonder duidelijke "source of truth" wordt een conflictenmachine. Bepaal eerst welk systeem leidend is per veld.
Wij zeggen dit liever vooraf dan dat je achteraf een koppeling onderhoudt die niemand nodig had. Twijfel je of een AFAS-integratie loont voor jouw situatie? Neem contact op — we kijken eerst of het überhaupt zinvol is voordat we iets bouwen.
Checklist voor een productiewaardige AFAS-koppeling
Voordat een AFAS-koppeling van ons live gaat, loopt hij langs deze punten:
- Paging via
skip/takein elke GetConnector-call. - Throttling op queue-niveau, niet vertrouwen op AFAS' goedheid.
- Delta-sync waar mogelijk, full sync alleen 's nachts.
- Idempotente upserts op AFAS-id, hash-check voor schrijfacties.
- Retry met backoff voor tijdelijke fouten, hard falen bij validatiefouten.
- Volledige logging van request én response, 30 dagen retentie.
- Audit-tabel met sync-resultaten voor support.
Krijg je deze zeven punten op orde, dan draait je koppeling jaren zonder dat iemand er 's nachts wakker van ligt.
Wil je een AFAS-koppeling die mutaties betrouwbaar verwerkt, ook bij 14.000 records? Onze senior developers bouwen het zonder tussenlagen. Plan een gesprek.
Veelgestelde vragen
Wat is het verschil tussen een GetConnector en een UpdateConnector in AFAS?
Een GetConnector leest data uit AFAS Profit op basis van een vooraf gedefinieerde view en geeft JSON terug. Een UpdateConnector schrijft data terug naar AFAS via een vaste, geneste payload-structuur die tegen het AFAS-datamodel wordt gevalideerd. Lezen doe je met Get, muteren met Update.
Hoe ga ik om met de rate limits van AFAS in Laravel?
Combineer drie technieken: pagineer GetConnector-calls in batches van 100 tot 1.000 records via skip/take, throttle je queue-jobs met Laravel's RateLimited-middleware of Redis::throttle, en spreid zware full syncs over de nacht. Gebruik delta-syncs overdag om het volume laag te houden.
Hoe maak ik mijn AFAS-synchronisatie idempotent?
Gebruik de AFAS-id (zoals EmId) als unieke sleutel en werk met updateOrCreate in plaats van blind aanmaken. Voor schrijfacties richting AFAS bereken je een hash van de payload en sla je die op; bij een gelijke hash stuur je niets. Zo levert dezelfde sync, ook bij een retry, altijd hetzelfde eindresultaat.
Waarom betekent een HTTP 200 van AFAS niet dat de mutatie geslaagd is?
AFAS kan bij een UpdateConnector een HTTP 200 teruggeven terwijl de body een foutmelding bevat. Controleer daarom altijd de inhoud van de response, niet alleen de statuscode. Log zowel de request als de response, zodat je een validatiefout later kunt reproduceren.
Hoe lang duurt het bouwen van een AFAS-koppeling in Laravel?
Een eenvoudige eenrichtings-sync (bijvoorbeeld medewerkers ophalen) bouwen we doorgaans in enkele dagen. Een bidirectionele HRM- of CRM-koppeling met throttling, idempotentie en foutafhandeling kost meer, afhankelijk van het aantal entiteiten en de complexiteit van het datamodel. We bepalen de scope altijd eerst samen, zodat je niet betaalt voor wat je niet nodig hebt.

Geschreven door Ruthger Idema
15+ jaar ervaring in e-commerce development. Gespecialiseerd in Magento, Shopify en Laravel maatwerk.
Meer over ons team →