You open a cough syrup on a pharmacy site. You read the dosage, check the price, close the tab. Two days later you're back on the same page. Then again that evening.
A good pharmacist behind a counter would notice. They'd say "still thinking about that one? I can do it a bit cheaper." Most online stores don't notice anything, or they notice in the creepiest possible way: an ad for the same syrup following you around the internet for a month.
I built Zovita+, an online pharmacy, on my own with Laravel, React and MySQL. One of the things I wanted from it was a store that notices like the pharmacist does: quietly, inside the store, with offers that are small, time-limited and explainable. This is how that works, why it's rules and not a model, and where the hard parts actually were. Spoiler: the hard part was never deciding on the discount. It was making sure the customer is charged exactly what they were shown, exactly once.
Why rules, not a model
The first question people ask is "is it AI?". It isn't, on purpose.
A pharmacy is a bad place for a black box. If a customer asks "why did I get 8% off this?", the answer has to be a sentence, not a feature-importance chart. If a pharmacist asks "could this ever discount something by 40%?", the answer has to be "no, and here is the line of code that stops it". And with a few hundred test shoppers, there is no data to train anything on.
So the store learns in the plainest possible way: it counts things, and a handful of rules turn those counts into offers. Every offer carries the reason in plain words ("You've been looking at Prospan Cough Syrup 120Ml"), and the whole system is about 400 lines of PHP with seven tests.

Step 1: one key for everyone
Personalisation has to work for guests, because most people browse a pharmacy without signing in. So the first piece is a Visitor service that gives every request a single string:
u:42when someone is signed in, org:9f1c…for a guest, from a UUID in a long-lived, HTTP-only, first-party cookie (zv_vid).
Every table that personalises (product_interactions, offers, experiment_events) stores that string in a visitor column, with user_id alongside it when there is one. Nothing downstream needs to care whether the visitor is signed in. They just ask $visitor->key().
The cookie is first-party, set by the store itself, and never shared with anyone. That is the line between "the shop remembers you" and "the internet follows you".
Step 2: count, don't track
product_interactions has one row per visitor and product, with four counters and two timestamps:
| Column | Bumped when |
|---|---|
views |
the product page is opened |
dwell_seconds |
the page reports how long it was actually visible |
cart_adds |
the product goes into the bag |
purchases |
an order containing it is placed |
last_seen_at, last_purchased_at |
the last view and the last purchase |
Dwell time comes from the browser: the product page measures how long it was visible (not just open in a background tab) and posts it to /signals/dwell. That endpoint is the one place where the client gets a say, so it gets the least trust. The product must exist, the seconds must be an integer, the route has its own rate limit, and anything above 600 seconds is clamped. One report can never be worth more than ten minutes of attention, so a tab left open overnight, or a script posting huge numbers, can't push a product over the "you've been looking at this" line on its own.
There is deliberately nothing else in that table. No referrer, no search terms, no list of pages in order. Counters are enough for the rules, and data you don't store can't leak.
Step 3: six rules
OfferEngine reads the counters and creates offers. The whole rulebook fits in a table:
| Rule | When | Offer |
|---|---|---|
| hesitation | viewed 3+ times, or 45+ seconds in total, without buying | 8% on that product, 72 hours |
| cart rescue | added to the bag more than a day ago, never bought | 5% on that product, 48 hours |
| regular | bought 2+ times | 10% on that product, 7 days |
| welcome | signed up in the last 60 days, no orders yet | 5% off the order, 14 days |
| loyalty | 3+ orders (8+ for the bigger one) | 5% or 10% off the order, 30 days |
| comeback | has ordered, but not in 45 days | 7% off the order, 10 days |

The rules are the easy part. The interesting code is the set of guards around them, each of which exists because of a case I could picture going wrong:
- Never duplicate a live offer. Before creating anything, the engine loads the visitor's live offers and skips a rule whose offer already exists for that product. Otherwise every visit would mint a fresh 8% coupon.
- At most four product offers at a time. Someone who opens forty products shouldn't get forty discounts. The engine walks products in order of dwell time, so the four they lingered on longest win.
- Only in-stock products with a real photo. An offer on something sold out is a tease, and an offer card with a placeholder image looks broken.
- A cool-down after redemption. If you just bought something with an offer, you don't get another offer on it for 14 days. Without that, "hesitation" would fire again on your next visit and teach you to wait for a discount.
- Throttled evaluation. Running the rules on every request would be wasteful, so
evaluate()takes a cache lock (Cache::add) that expires after five minutes and returns early if it's already taken. Placing an order or signing in clears that, so the rules re-run when something important has changed.
Step 4: one function prices both the bag and the charge
This is the part I'd tell anyone building discounts to copy.
There are two moments when a discount is calculated: when the customer looks at their bag, and when the order is created. If those are two different pieces of code, they will drift. Someone will add a cap to one and forget the other, round differently, or apply the order-wide discount before the product discount in one place and after it in the other. Then the bag says one total and the receipt says another, which is exactly what I promised myself would never happen.
So there is exactly one function, Pricing::apply($lines, $offers), and both moments call it:
- Each line gets the best live offer for that product.
- The best order-wide offer then applies to what's left.
- The total personal discount is capped at 15% of the payable amount.
The cap lives in that function and nowhere else, as Pricing::MAX_SHARE = 0.15. A "regular" 10% plus a "loyalty" 10% could add up to 19%; the cap brings it back to 15%. There is a test for exactly that ("stacked offers are capped"), and another that places an order and checks the charged total equals what the bag showed.
Step 5: redeem it once, under a lock
Now the concurrency problem. An offer is a small amount of money with a "use once" rule attached, which means two browser tabs can race for it.
Picture it: the customer has the bag open in two tabs and presses Place order in both. Without care, both checkouts read the same live offer, both apply it, both create an order, and both mark it redeemed. The discount was used twice.
In Zovita+, offers are handled inside the same database transaction that locks stock:
$offers = Offer::where('visitor', $visitor)->active()->lockForUpdate()->get();
$personal = Pricing::apply($lines, $offers);
// … create the order with $personal['discount'] …
Offer::whereKey($personal['used'])->update(['redeemed_at' => now(), 'order_id' => $order->id]);lockForUpdate() means the second checkout waits at that line until the first one commits. When it gets the rows, the first checkout has already set redeemed_at, so the active() scope (not expired, not redeemed) no longer returns that offer and the second order is priced without it. (Each checkout page also carries its own one-time token under a unique index, which stops a double-click in one tab from creating two orders. Two tabs have two different tokens, so for them the lock is what keeps the offer honest.)
The offer also records which order used it (order_id), so the admin can see exactly where every discount went.
Step 6: guests become customers without losing anything
A guest browses for a week, builds up history, earns a hesitation offer, and then signs up to check out. If personalisation forgot all of that at sign-in, the offer they were shown would vanish at the worst possible moment.
So signing in runs Interactions::mergeGuestInto() in one transaction. For each of the guest's interaction rows (locked), it either adds the counters into the customer's existing row for that product or simply re-labels the row from g:… to u:42. Then the guest's live offers move across too, and both visitors' cached offers are cleared. There's a test for it: "guest history follows the customer into their account".
Step 7: is any of this working? A/B tests
Personalisation needs to be measured, so the same visitor key powers a minimal A/B system. Experiments live in config (for example the home page's main button: "browse the shop" or "start from a symptom"), and a visitor's variant is:
$variants[crc32($visitor.'|'.$experiment) % count($variants)];The hash makes the first assignment deterministic, and adding the experiment name means one visitor's buckets in different experiments are independent. The result is then stored in experiment_assignments, because the hash alone has a flaw I describe below. Events (exposure, click, add to cart, purchase) go into experiment_events with insertOrIgnore, so each visitor counts once per event, and the admin dashboard shows unique conversion rates per variant.

The assistant reads the same data
Zovita+ has a small assistant, and it's another consumer of all this. It has no free-text box: the shopper picks a question like "Do I have any discounts?" and the server answers from their live offers, their bag and their orders. Because the answer is assembled from the same OfferEngine::active() the bag uses, the assistant can't promise a discount the checkout won't honour.

What I'd be honest about
- Rules only find what I thought of. They're explainable and testable, but they won't discover that people who buy antacids on Fridays also buy rehydration salts. A real store with real traffic could add a learned "pairs well with" without touching the offer rules.
- A/B buckets used to change at sign-in. The hash uses the visitor key, and that key changes from
g:…tou:…when a guest signs in, so a guest could see variant A and then variant B as a customer, and be counted in both. I fixed it after the first version of this article: assignments are now stored, and signing in carries the guest's variant into the account in the same locked transaction as the interaction merge, unless the account already has one from another device. Nobody switches buckets, and nobody is counted twice. - Clearing cookies resets a guest. That's a privacy feature as much as a limitation, but it means "hesitation" only works within one browser until someone signs in.
- Dwell time is self-reported. It's clamped, rate-limited and only ever worth an 8% offer capped at 15% of an order, which is why it's safe to trust a little. I wouldn't let it drive anything more valuable.
What it taught me
Personalisation sounds like a data-science problem, and the rules really were the easy part: six if statements. Almost all of the engineering went into keeping the money honest: one function that prices the bag and the order, a cap in one place, offers locked and redeemed inside the checkout transaction, and history that survives a guest signing in. A store that notices you is nice. A store that notices you and then charges you something different from what it showed you is worse than one that never noticed at all.
The code is on GitHub under the MIT licence. The full build story, including the 3D body map, the admin panel and the bugs, is in the Zovita+ case study.



