E-commerce
Marketplace listing agents: a Groningen thrift case study
How a 19-person Groningen thrift marketplace put a chat agent in front of its 9-year-old Laravel catalogue without touching the publish endpoint.

At 22:47 on a Tuesday in Groningen, a seller uploads a vintage trench coat from her phone. The photos are dim, the description reads "echte Burberry trenchcoat, prachtige staat," and the EAN field is blank. She hits publish. On the old version of this marketplace the listing would have gone live, the moderation team would have spotted it the next morning, the brand-protection email would have arrived from London by Friday, and a takedown would have eaten three hours of someone's week.
That is not what happens now. A chat agent in front of the listing-publish endpoint catches the brand mention, asks the seller two clarifying questions, decides the answer is not good enough, and parks the draft in a moderator queue with a clean reason code. The seller sees a polite message in Dutch. The moderator sees a structured row. Nothing reaches the public catalogue until a human signs off.
The marketplace is a 19-person tweedehands platform we have worked with for the last seven months. The catalogus runs on a custom Laravel 5.5 codebase from 2017. The team handles roughly 1,680 verkoper-uploads a week. This is the story of how we put an agent in front of that pipeline without rewriting the application, and what it cost in latency, accuracy, and engineering pride.
The catalogus nobody wanted to touch
Laravel 5.5 reached end of security support in 2018. The application we inherited has been running in production through three PHP upgrades, a MySQL 5.7 to 8.0 migration, and a partial move from server-side Blade rendering to a Vue 2 front end. The code is not bad. It is just old, opinionated, and has a category taxonomy that grew organically since 2017.
The CTO's first question to us was a fair one. Why not rewrite? The answer is the same one we give every client with a working catalogue: because the catalogue works, the team knows it, and the search index has nine years of click-through data baked into its rankings. A rewrite is a year of risk in exchange for cleaner code. An agent in front of the listing-publish endpoint is six weeks of bounded work that makes the catalogue safer the day it ships.
We did not touch the listing model, the categorisation logic, or the publish endpoint itself. We put a service in front of it. From Laravel's perspective the agent is a middleware that calls out to a Python sidecar over HTTP, accepts a structured verdict, and either lets the request through or returns a 422 with a human-readable reason.
Designing the agent around the publish endpoint
The publish endpoint was the only door into the catalogue. Sellers used it from the React Native app, moderators used it from the admin tool, the bulk-import CSV cron used it for the small handful of verified power sellers. If we could put the agent on that one endpoint, every listing went through it.
The middleware looks roughly like this.
// 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);
}Three statuses, no shades of grey. The agent either lets the listing through, parks it for a moderator, or rejects it with a message the seller actually understands. We resisted every temptation to add a fourth status. Every extra branch is a place where a 22:47 seller gets confused and emails support.
The EAN lookup pipeline
Roughly a third of incoming listings include an EAN, the 13-digit GS1 barcode printed on most consumer products. For a tweedehands marketplace, the EAN is gold. It tells you the brand, the model, the original RRP, and often enough metadata to populate half the listing form automatically. It is also the cheapest, fastest way to flag a listing that claims to be a Burberry but scans as a Primark.
The catch is that EAN lookups are slow. The public GS1 verified-source database is rate-limited. Commercial product-data providers respond in 800 to 1,400 ms on a good day. The seller is staring at a loading spinner on a phone in a thrift shop. If our middleware adds more than a second of latency to publish, the seller hits back, retries, and the moderator queue fills up with duplicates.
Our budget for the entire EAN-aware path was 600 ms. We hit it with a three-layer cache.
- L1: in-process LRU. The Python sidecar keeps the most recent 50,000 EAN-to-product-record entries in memory. A hit returns in under 5 ms. About 41% of requests hit L1 in production.
- L2: Redis. Shared across sidecar instances, 14-day TTL, average response 12 ms. Catches another 34% of requests.
- L3: provider API. Only the remaining 25% of lookups hit the paid provider. The sidecar caps the provider call at 450 ms and falls through to "no EAN data" if it times out, rather than blocking the publish.
The p95 latency across the entire middleware, including the agent's brand-check reasoning, sits at 580 ms. The p50 is 140 ms.
An agent in front of a legacy endpoint does not need to be fast. It needs a latency budget, a hit on it, and a moderator queue that can absorb the misses.
The trademark-doubt queue
The interesting work is not the EAN match. It is what happens when the EAN is missing, the description mentions a brand, and the photos are ambiguous. This is the moment a counterfeit listing tries to walk through the door, and it is the moment a real second-hand Burberry coat also tries to walk through the door, and the agent has to tell them apart with the same evidence.
The agent does not try to be a forensic authenticator. It runs a small policy.
- Extract any brand mentions from the title and description, against a curated list of roughly 1,200 brands the platform has had trademark-correspondence about.
- Check whether the seller has prior verified listings of the same brand.
- Check whether the EAN, if present, scans as the named brand.
- Check whether the price is more than two standard deviations below the platform's median for that brand-category combination.
- If two or more signals disagree, park.
"Park" is the verdict that matters. We do not let the agent reject a listing on trademark grounds alone. Rejection is a human decision. The agent is allowed to slow a listing down by a few hours. It is not allowed to delete a seller's work.
The seller sees a Dutch-language message that says, in effect, "your listing is being reviewed by a colleague, you do not need to do anything, you will hear back within 24 hours." The moderator sees a row in a Filament-based admin panel with the structured signals, the agent's reasoning, the seller's history, and two buttons: approve or reject with template.
If your agent can reject listings outright on trademark grounds, you are one false positive away from a small-claims letter. Park, do not reject. The EUIPO observatory reports on counterfeits make grim reading, but they also make clear that automated takedown without human review is legally fragile.
Wiring the sidecar without breaking the monolith
The sidecar is a small FastAPI service. The agent uses a structured-output schema and calls an internal tool to fetch the seller history and another to query the product-data provider. We deliberately kept the surface area narrow.
# 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),
)Notice what is not there. No retries on the provider call inside the request path. No streaming. No exception that propagates back to Laravel. The sidecar always returns a verdict, even if every external call failed. The fallback verdict is "pass with caveats" for sellers with verified history, "park" for sellers without. That asymmetry is the entire safety story in one rule.
Numbers from the first eight weeks
The team had been worried that we would either flood the queue or let too much through. The first eight weeks in production told a calmer story.
- Roughly 13,440 listings reviewed.
- 92.1% passed straight through.
- 6.8% parked for a moderator.
- 1.1% rejected outright (price under €0.50, prohibited categories, banned sellers).
- Of the parked listings, moderators approved 71% on review.
- Median moderator decision time dropped from 4 minutes 20 seconds to 1 minute 50 seconds, because the agent's structured signals replaced the moderator's "open the listing, read the description, search the brand" loop.
- Brand-protection takedown requests dropped from an average of 11 per week before the agent to 2 per week after.
The CTO's favourite number is the last one. The legal-administration time savings alone paid for the project in under three months.
What we would do differently
Two things, with the benefit of hindsight.
First, we should have shipped the moderator admin panel two weeks earlier. We built the agent first and the panel second. For ten days the moderators were reviewing parked listings in a half-finished Filament screen, and they hated it. The lesson, which we will keep relearning, is that the operator's experience matters more than the algorithm. An agent that puts work into a queue is only as good as the queue.
Second, we should have made the brand list owner-editable from day one. We started with a YAML file in the sidecar repository and a "ping us in Slack to add a brand" workflow. The moderation lead asked for a CRUD screen three days in. She was right. The brand list is not engineering knowledge. It is operational knowledge that moves faster than our deploy cadence.
When we built the listing agent for this tweedehands platform, the thing we kept running into was the gap between the agent's confidence and the moderator's authority. We solved it by treating the agent as a router, not a judge: it routes work, the human decides. If you want to do the same thing on top of your own catalogue, that is the work we do under AI agents wired into legacy PHP stacks.
The five-minute audit for your own marketplace: list every endpoint that writes to your public catalogue, find the one with the most upstream callers, and put a single middleware in front of it that logs (does not block) the agent's verdict for a week. Read the log on a Friday afternoon. The shape of the work will be obvious by Monday.
Key takeaway
An agent in front of a legacy endpoint does not need to be fast. It needs a latency budget, a hit on it, and a moderator queue that can absorb the misses.
FAQ
Why not just rewrite the Laravel 5.5 catalogue?
A working catalogue carries years of search-ranking signal and team knowledge. A middleware in front of the publish endpoint ships safety in six weeks instead of risking a year-long rewrite.
Can the agent reject listings on its own?
It can reject for hard rules like prohibited categories or impossible prices. For trademark concerns it can only park the listing for a human moderator to decide.
What happens if the EAN provider times out?
The sidecar caps the provider call at 450 ms and falls through to a no-EAN-data verdict. Sellers with verified history pass; new sellers without history get parked for review.
How much latency does the middleware add to publish?
P50 latency across the full agent path is 140 ms and p95 is 580 ms, including brand check and EAN lookup. The budget for the whole path was 600 ms.