← Blog

E-commerce

Listing-agent voor marketplaces: Groningse case study

Hoe een tweedehands marketplace uit Groningen met 19 mensen een chat-agent voor zijn 9 jaar oude Laravel-catalogus zette, zonder het publish endpoint aan te raken.

Jacob Molkenboer· Oprichter · A Brand New Company· 28 okt 2025· 9 min
Drie pakjes met linnentouw op ivoor papier, groene kaart onder het kleinste, koperen weegschaal erachter, donkere ruimte.

Om 22:47 op een dinsdag in Groningen uploadt een verkoper een vintage trenchcoat vanaf haar telefoon. De foto's zijn donker, de omschrijving luidt "echte Burberry trenchcoat, prachtige staat," en het EAN-veld is leeg. Ze drukt op publiceren. Op de oude versie van deze marketplace was de listing live gegaan, had het moderatieteam hem de volgende ochtend opgemerkt, was de brand-protection mail uit Londen op vrijdag binnengekomen, en had een takedown drie uur uit iemands week opgevreten.

Dat gebeurt nu niet. Een chat-agent vóór het listing-publish endpoint pikt de merknaam op, stelt de verkoper twee verduidelijkende vragen, besluit dat het antwoord niet goed genoeg is, en parkeert het concept in een moderatie-queue met een nette reden-code. De verkoper ziet een vriendelijk Nederlandstalig bericht. De moderator ziet een gestructureerde regel. Niets bereikt de publieke catalogus voordat een mens akkoord geeft.

De marketplace is een tweedehands platform van 19 mensen waar we de afgelopen zeven maanden mee hebben gewerkt. De catalogus draait op een custom Laravel 5.5-codebase uit 2017. Het team verwerkt ongeveer 1.680 verkoper-uploads per week. Dit is het verhaal van hoe we een agent voor die pipeline zetten zonder de applicatie te herschrijven, en wat dat kostte aan latency, nauwkeurigheid en engineeringtrots.

De catalogus die niemand aan wilde raken

Laravel 5.5 verloor zijn security-support in 2018. De applicatie die we overnamen draait al in productie door drie PHP-upgrades, een migratie van MySQL 5.7 naar 8.0, en een gedeeltelijke overstap van server-side Blade-rendering naar een Vue 2 front end. De code is niet slecht. Hij is alleen oud, eigenwijs, en heeft een categorie-taxonomie die sinds 2017 organisch is gegroeid.

De eerste vraag van de CTO was een terechte. Waarom niet herschrijven? Het antwoord is hetzelfde als we elke klant met een werkende catalogus geven: omdat de catalogus werkt, het team hem kent, en de search-index negen jaar aan click-through data in zijn rankings heeft zitten. Een rewrite is een jaar risico in ruil voor schonere code. Een agent voor het listing-publish endpoint is zes weken afgebakend werk dat de catalogus veiliger maakt op de dag dat hij live gaat.

We hebben het listing-model, de categorisatie-logica en het publish endpoint zelf niet aangeraakt. We zetten er een service voor. Vanuit Laravel's perspectief is de agent een middleware die over HTTP een Python-sidecar aanroept, een gestructureerd verdict accepteert, en het request ofwel doorlaat, ofwel een 422 retourneert met een leesbare reden.

De agent ontwerpen rondom het publish endpoint

Het publish endpoint was de enige deur naar de catalogus. Verkopers gebruikten het vanuit de React Native-app, moderators vanuit de admin-tool, de bulk-import CSV-cron voor de kleine groep geverifieerde power sellers. Als we de agent op dat ene endpoint kregen, ging elke listing erdoorheen.

De middleware ziet er ongeveer zo uit.

// app/Http/Middleware/AgentReview.php
public function handle($request, Closure $next)
{
    if (! $this->shouldReview($request)) {
        return $next($request);
    }

    $verdict = $this->agent->review([
        'title'       => $request->input('title'),
        'description' => $request->input('description'),
        'category_id' => $request->input('category_id'),
        'ean'         => $request->input('ean'),
        'price_cents' => $request->input('price_cents'),
        'seller_id'   => $request->user()->id,
    ]);

    if ($verdict->status === 'park') {
        ModeratorQueue::park($request, $verdict);
        return response()->json([
            'status'  => 'pending_review',
            'message' => $verdict->seller_message_nl,
        ], 202);
    }

    if ($verdict->status === 'reject') {
        return response()->json([
            'errors' => [$verdict->seller_message_nl],
        ], 422);
    }

    return $next($request);
}

Drie statussen, geen grijstinten. De agent laat de listing door, parkeert hem voor een moderator, of wijst hem af met een bericht dat de verkoper daadwerkelijk begrijpt. We hebben elke verleiding weerstaan om een vierde status toe te voegen. Elke extra branch is een plek waar een verkoper om 22:47 in de war raakt en support mailt.

De EAN-lookup pipeline

Ongeveer een derde van de binnenkomende listings bevat een EAN, de 13-cijferige GS1-barcode die op de meeste consumentenproducten staat. Voor een tweedehands marketplace is een EAN goud. Hij vertelt je het merk, het model, de oorspronkelijke adviesprijs, en vaak genoeg metadata om het halve listing-formulier automatisch te vullen. Het is ook de goedkoopste, snelste manier om een listing te markeren die beweert een Burberry te zijn maar als Primark scant.

De catch is dat EAN-lookups traag zijn. De publieke GS1 verified-source database is rate-limited. Commerciële product-data providers reageren op een goede dag in 800 tot 1.400 ms. De verkoper staart naar een loading spinner op een telefoon in een kringloopwinkel. Als onze middleware er meer dan een seconde latency bovenop legt, drukt de verkoper op terug, probeert opnieuw, en vult de moderatie-queue zich met duplicaten.

Ons budget voor het hele EAN-bewuste pad was 600 ms. We haalden het met een drielaagse cache.

  • L1: in-process LRU. De Python-sidecar houdt de meest recente 50.000 EAN-naar-product-record entries in geheugen. Een hit komt in minder dan 5 ms terug. Ongeveer 41% van de requests raakt L1 in productie.
  • L2: Redis. Gedeeld tussen sidecar-instances, TTL van 14 dagen, gemiddelde response 12 ms. Vangt nog eens 34% van de requests af.
  • L3: provider-API. Alleen de resterende 25% van de lookups gaat naar de betaalde provider. De sidecar capt de provider-call op 450 ms en valt terug op "geen EAN-data" bij een timeout, in plaats van het publiceren te blokkeren.

De p95 latency over de hele middleware, inclusief het redeneerwerk van de agent voor merkcontrole, zit op 580 ms. De p50 is 140 ms.

Kernpunt

Een agent voor een verouderd endpoint hoeft niet snel te zijn. Hij heeft een latency-budget nodig, hij moet erbinnen blijven, en een moderatie-queue die de missers kan opvangen.

De queue voor merkrecht-twijfel

Het interessante werk zit niet in de EAN-match. Het zit in wat er gebeurt als de EAN ontbreekt, de omschrijving een merk noemt, en de foto's dubbelzinnig zijn. Dat is het moment waarop een namaaklisting door de deur probeert te komen, en het moment waarop een echte tweedehands Burberry-jas óók door die deur probeert te komen, en de agent ze met dezelfde bewijslast uit elkaar moet houden.

De agent probeert geen forensisch authenticator te zijn. Hij draait een klein policy.

  1. Haal merkvermeldingen uit de titel en omschrijving, getoetst aan een gecureerde lijst van ongeveer 1.200 merken waar het platform eerder merkrecht-correspondentie over heeft gehad.
  2. Controleer of de verkoper eerder geverifieerde listings van hetzelfde merk had.
  3. Controleer of de EAN, als die er is, scant als het genoemde merk.
  4. Controleer of de prijs meer dan twee standaarddeviaties onder de mediaan van het platform ligt voor die merk-categorie combinatie.
  5. Als twee of meer signalen elkaar tegenspreken: parkeren.

"Parkeren" is het verdict dat ertoe doet. We laten de agent geen listing afwijzen op enkel merkrecht-gronden. Afwijzen is een menselijke beslissing. De agent mag een listing een paar uur vertragen. Hij mag het werk van een verkoper niet weggooien.

De verkoper ziet een Nederlandstalig bericht dat in essentie zegt: "je listing wordt beoordeeld door een collega, je hoeft niets te doen, je hoort binnen 24 uur van ons." De moderator ziet een regel in een Filament-gebaseerd adminpaneel met de gestructureerde signalen, de redenering van de agent, de geschiedenis van de verkoper, en twee knoppen: goedkeuren of afwijzen met template.

Let op

Als jouw agent listings direct kan afwijzen op merkrecht-gronden, sta je één false positive verwijderd van een sommatie. Parkeren, niet afwijzen. De rapporten van het EUIPO-observatorium over namaak lezen niet vrolijk, maar maken ook duidelijk dat geautomatiseerde takedowns zonder menselijke review juridisch wankel zijn.

De sidecar aansluiten zonder de monoliet te breken

De sidecar is een kleine FastAPI-service. De agent gebruikt een structured-output schema en roept een interne tool aan om de verkoper-geschiedenis op te halen, en een andere om de product-data provider te queryen. We hebben het oppervlak bewust smal gehouden.

# sidecar/review.py
class ReviewResult(BaseModel):
    status: Literal["pass", "park", "reject"]
    seller_message_nl: str
    reason_code: str
    signals: dict[str, Any]
    latency_ms: int

@app.post("/review", response_model=ReviewResult)
async def review(payload: ListingPayload) -> ReviewResult:
    started = time.perf_counter()
    ean_data = await ean_lookup(payload.ean, budget_ms=450)
    brand_hits = brand_scan(payload.title, payload.description)
    seller = await seller_history(payload.seller_id)

    verdict = decide(
        payload=payload,
        ean_data=ean_data,
        brand_hits=brand_hits,
        seller=seller,
    )

    return ReviewResult(
        **verdict,
        latency_ms=int((time.perf_counter() - started) * 1000),
    )

Let op wat er niet staat. Geen retries op de provider-call binnen het request-pad. Geen streaming. Geen exception die teruggaat naar Laravel. De sidecar geeft altijd een verdict terug, ook als elke externe call faalde. Het fallback-verdict is "pass met voorbehoud" voor verkopers met geverifieerde geschiedenis, en "park" voor verkopers zonder. Die asymmetrie is het hele veiligheidsverhaal in één regel.

Cijfers uit de eerste acht weken

Het team was bang dat we ofwel de queue zouden overspoelen, ofwel te veel zouden doorlaten. De eerste acht weken in productie vertelden een rustiger verhaal.

  • Ongeveer 13.440 listings beoordeeld.
  • 92,1% ging direct door.
  • 6,8% geparkeerd voor een moderator.
  • 1,1% direct afgewezen (prijs onder €0,50, verboden categorieën, geblokkeerde verkopers).
  • Van de geparkeerde listings keurden moderators er 71% alsnog goed bij review.
  • De mediaan beslissingstijd van moderators daalde van 4 minuten 20 seconden naar 1 minuut 50 seconden, omdat de gestructureerde signalen van de agent het "open de listing, lees de omschrijving, zoek het merk op" loopje vervingen.
  • Brand-protection takedown-verzoeken daalden van gemiddeld 11 per week vóór de agent naar 2 per week erna.

Het favoriete cijfer van de CTO is het laatste. De besparing aan juridische administratie alleen al verdiende het project binnen drie maanden terug.

Wat we anders zouden doen

Twee dingen, met de wijsheid van achteraf.

Ten eerste hadden we het moderator-adminpaneel twee weken eerder live moeten zetten. We bouwden eerst de agent en daarna het paneel. Tien dagen lang beoordeelden de moderators geparkeerde listings in een half afgemaakt Filament-scherm, en ze hadden er een hekel aan. De les, die we keer op keer opnieuw leren: de ervaring van de operator is belangrijker dan het algoritme. Een agent die werk in een queue zet is alleen zo goed als die queue.

Ten tweede hadden we de merkenlijst vanaf dag één bewerkbaar moeten maken voor de eigenaar. We begonnen met een YAML-bestand in de sidecar-repository en een "ping ons op Slack om een merk toe te voegen"-workflow. De moderatie-lead vroeg na drie dagen al om een CRUD-scherm. Ze had gelijk. De merkenlijst is geen engineering-kennis. Het is operationele kennis die sneller beweegt dan onze deploy-cadans.

Toen we de listing-agent voor dit tweedehands platform bouwden, liepen we steeds vast op het gat tussen het vertrouwen van de agent en het gezag van de moderator. We hebben dat opgelost door de agent als router te zien, niet als rechter: hij stuurt werk door, de mens beslist. Wil je hetzelfde doen bovenop je eigen catalogus, dan is dat het werk dat wij doen onder AI-agents die op verouderde PHP-stacks worden ingeplugd.

De audit van vijf minuten voor je eigen marketplace: lijst elk endpoint op dat naar je publieke catalogus schrijft, vind degene met de meeste upstream callers, en zet er één middleware voor die het verdict van de agent een week lang logt (zonder te blokkeren). Lees het log op een vrijdagmiddag. De vorm van het werk is op maandag duidelijk.

Kern

Een agent voor een verouderd endpoint hoeft niet snel te zijn. Hij heeft een latency-budget nodig, hij moet erbinnen blijven, en een moderatie-queue die de missers kan opvangen.

FAQ

Waarom de Laravel 5.5-catalogus niet gewoon herschrijven?

Een werkende catalogus draagt jaren aan search-ranking signaal en team-kennis. Een middleware voor het publish endpoint levert veiligheid in zes weken, in plaats van een jaar risico op een rewrite.

Kan de agent zelfstandig listings afwijzen?

Hij kan afwijzen op harde regels, zoals verboden categorieën of onmogelijke prijzen. Voor merkrecht-twijfels kan hij de listing alleen parkeren voor een menselijke moderator.

Wat gebeurt er als de EAN-provider een timeout geeft?

De sidecar capt de provider-call op 450 ms en valt terug op een no-EAN-data verdict. Verkopers met geverifieerde geschiedenis komen door, nieuwe verkopers zonder geschiedenis worden geparkeerd voor review.

Hoeveel latency voegt de middleware toe aan publiceren?

P50 latency over het hele agent-pad is 140 ms en p95 is 580 ms, inclusief merkcontrole en EAN-lookup. Het budget voor het hele pad was 600 ms.

ai agentschat agentse-commercelegacy sitesphpcase study

Iets bouwen?

Start een project