In short
Zazopoulos SA supplies other shops as well as selling direct. We built the network that makes that work: the supplier publishes a curated catalogue for each reseller, every reseller shop pulls its catalogue overnight, and orders placed in those shops go straight back to the supplier for fulfilment. One June morning the status check on a reseller shop decided that 2,228 of its 2,228 products should be switched off. Nothing happened, because the code refused. This is how the sync is put together and why that refusal was designed in from the start. Our Zazopoulos B2B portal case study covers the supplier's own shop.
How the network fits together
There are two halves. On the supplier side, a plugin exposes seven endpoints: products, active and inactive products, product images, price changes, send order and cancel order. On each reseller shop, a second plugin consumes them.
Every reseller authenticates with its own token and sees only the products the supplier has switched on for it, with that reseller's B2B and promotional prices. The feed is paged at 250 products per page.
Supplier shop Reseller shop (one of many) ------------- --------------------------- per-reseller catalogue --products--> clone:site 01:00-06:00, every 30 min status list --status----> clone:status 06:00 daily price change log <--prices---- clone:price 08:00 and 19:00 real order created <--orders---- on payment, retried hourly
Every scheduled command runs with withoutOverlapping, and smaller jobs handle resume (every 5 minutes), health checks (every 15) and failed order retries (hourly). A single reseller carries a little over 2,200 supplier products.
Resuming a sync the host killed
The shops run on hosting that kills long PHP workers, so a nightly import of thousands of products will be interrupted. We stopped trying to prevent that and made it cheap instead.
After every product the job writes a checkpoint: the last SKU processed and how many are done. A resume command looks for pages with no progress for 10 minutes, restarts them on the same row and skips forward to the saved SKU. A separate progress marker lets it tell a page that's slow from one that's stuck. The retry counter resets whenever the SKU moves, and a page is only abandoned after five retries with no progress at all.
Getting this right took a few rounds. An early version only resumed pages after page 3, which left the first three pages stranded forever. Another let a counter from one page leak into the next, and one run reported 345 processed products on a page that only had 196. Both are fixed, and both are the kind of bug you only find by reading the logs of real overnight runs.
Skipping what hasn't changed
Most products don't change from one night to the next, so re-saving all of them wastes most of the run. Each product carries two fingerprints: an MD5 of about 25 data fields (translations and categories included) and one of its images. If both match, the product is skipped outright. If only the data changed, the existing image files are reused.
That is what keeps a full re-sync inside a worker budget of roughly two minutes per chunk on this host. Each product is written inside a database transaction, and images are downloaded before the transaction opens, so a slow image download never holds a lock.
Images come either from a CDN or as base64 through the API. We cache the result of every image request for the length of the run, failures included. Brand logos were being requested hundreds of times per sync before that cache existed.
The safety cap
The daily status check asks the supplier which products are no longer available and switches them off in the reseller shop. That is the most dangerous job in the system. If the supplier's answer is wrong, empty or half-built, a naive implementation takes a whole shop offline before anyone is awake.
Three rules keep it safe. It only ever touches products flagged as coming from the supplier, never the reseller's own. Deactivation is soft: products go to pending and nothing is deleted. And if a run would deactivate more than half of the supplier products in that shop, it aborts and changes nothing.
ABORT clone:status - would deactivate 2228/2228 clone products (100.0%)
That's the line from the log on 24 June. The next run, with a sane response, deactivated 35 products that really had been withdrawn. The threshold is configurable per shop through the environment.
The soft delete rule came from an earlier mistake. The first version deleted products outright, and an audit in May found about 1,065 orphaned metadata rows left behind. A one-off migration cleaned them up, and deleting products has been off the table since.
Orders going back to the supplier
When an order in a reseller shop is paid, it's forwarded to the supplier automatically if the shop owner has that switched on. The hook runs after the shop's own ERP order hook, so both systems see the order in a predictable sequence. Only the supplier's lines are sent, as SKU and quantity.
Forwarding is idempotent. The supplier's response is stored on the order, and an order that already has one is never sent again. Failures are recorded with the reason, an hourly job retries them and gives up after five attempts, and on the supplier side the result is a normal order with a history note saying it came from the reseller API.
Prices travel the other way. When a reseller edits a price, the change is recorded, and twice a day the last 24 hours of changes are posted back to the supplier, who keeps a per-reseller price check table. The supplier always knows what its products are actually selling for.
Small fixes that mattered
- The service provider used to stop the whole application if the API token was missing, which took down every Laravel process on the shop. It now logs a warning and the sync simply doesn't run.
- Category sync used to delete and recreate categories, causing churn and a race condition. It now updates in place, hides categories that end up empty, and brings them back automatically. Categories the shop owner created are never touched.
- The host blocks background processes started from the web, so admin buttons drop a marker file and a per-minute cron runs the requested job from an allowlist.
- Every run is recorded as running, success, failed or partial, and a janitor marks anything stuck for two hours as failed, so the admin page never shows a sync that's been running for three days.
If you supply other businesses and want them selling your range without spreadsheets and emailed price lists, that's the kind of custom software and integration work we do. Talk to us.