Supplier feeds
Most of the catalog comes from supplier feeds. A feed does not write directly to products. It fills a
staging table, staff choose what to sell, and a sync keeps the chosen products in line from then on.
Staff see this in Suppliers. All the code is in
app-modules/catalog/src/Suppliers/.
The registered feeds (CatalogServiceProvider, Suppliers\SupplierSources):
| Handle | Source class | Reads from |
|---|---|---|
midocean |
Midocean\MidoceanSource |
API |
umbrellacompany |
UmbrellaCompany\UmbrellaCompanySource |
API price list |
xdconnects |
XdConnects\XdConnectsSource |
Feed downloads |
laltex |
Laltex\LaltexSource |
API |
pfconcept |
PfConcept\PfConceptSource |
Feed downloads |
pencarrie |
PenCarrie\PenCarrieSource |
Export download |
ralawise |
Ralawise\RalawiseSource |
CSV export in a directory |
uneek |
Uneek\UneekSource |
API |
Supplier products: the staging copy
Section titled “Supplier products: the staging copy”Models\SupplierProduct is one row per supplier product, whether we sell it or not. source and
source_id are unique together. A few display fields (name, category_paths, thumbnail_url,
variant_count) let one screen list every feed. The supplier’s own record is kept whole in
payload.
SupplierProducts::refresh($source, $records) is the only writer, with two guarantees:
- All or nothing. The records are read in full first. A feed that throws, lists a code twice or returns nothing writes nothing, so a bad day leaves the last good copy. The writes are one transaction.
- Status is derived.
SupplierProduct::resolveStatus()always works it out, and it is stored only for filtering. Ignored is the one status staff set, and it survives everything. Otherwise: not in the feed is Withdrawn, no catalog product is New, else Imported. So a code that disappears and comes back lands on the right status by itself.
payload_hash covers the payload and what the source read from it, so a change to how a source
reads its feed updates every row once, and an unchanged row only gets last_seen_at.
The feed class
Section titled “The feed class”A feed implements Contracts\SupplierSource:
| Method | Job |
|---|---|
handle(), label() |
Its key and its name for staff |
records() |
The whole feed as SupplierProductRecords |
variants() |
The product’s SKUs as SupplierVariants, read from the payload |
costs() |
Each SKU’s SupplierCost, from the supplier’s price list |
decorations() |
A Contracts\SupplierDecorations read once per run |
photos(), documents(), content() |
What the product should show, from the payload |
namingFields() |
The common fields text rule sets refer to (model, code, brand and so on) |
galleryStyle() |
How its products show photos when first added |
managesVariants() |
Whether the feed decides a product’s variants |
buildProduct() |
Saves the new product (the sources do little more than $product->save()) |
The rule that makes this work: quirks stay in the source. Shared code (SupplierOptionValues,
SupplierProductVariants, SupplierProductContent) never sees a feed’s format, and the decoration
engine reads only our own shape. Text from feeds goes through the Tidy toolkit.
Optional contracts add behaviour:
| Contract | Adds |
|---|---|
SuppliesStock |
A stock feed. Without it, variants don’t track a quantity and are always available. |
SuppliesCustomFields |
Values for product custom fields |
SuppliesCertifications |
Certifications the feed states |
SuppliesCustomisationChoices |
Choices that don’t change the price |
StartsIgnored |
Codes that arrive Ignored |
NamesPositions |
A model’s own print position names |
AddsDecorationSheet |
On the decorations object: a decorator’s sheet added beside its print data |
DeclaresFeedQuirks |
FeedQuirks listed on the feed’s Settings page |
DeclaresFeedQuirks is a discipline, not decoration. Code that treats one feed specially and is not
listed there is a bug in the list. A per-model value that an existing product field can hold is
staff data, not an importer rule.
Clients sit beside each source (LaltexClient, for example). Credentials come from
config/catalog.php under catalog.suppliers.<handle>. Most clients are bound scoped, so one sync
reads each feed once, and most accept a feed_path or feed_directory to read a saved copy and make
no call, which is how development and tests work without credentials. Where the link itself is the
credential (XD Connects, PF Concept), clients keep it out of messages.
Add to catalog
Section titled “Add to catalog”SupplierProducts::addToCatalog() turns New rows into draft products. Staff trigger it from
Suppliers > Supplier products, and Jobs\AddToCatalog runs it on the imports queue in parts of
100, reporting to a background task. catalog:add-supplier {source} does the same for every New row
of a feed by category mapping.
Each row is its own transaction, in this order:
- The product: draft status,
pricing_engineset todecoration, a name from the feed’s text rule set (Naming\ProductNamer), the source’s gallery style and supplier company. buildProduct(), then variants (SupplierProductVariants::build), costs, decoration pricing and content.- Categories, then branding method links, as rules can depend on categories.
- Last, the From price (
refreshFromPrice()), as it needs all of the above. The primary category becomes the preferred canonical term on every site.
Then split rules run, photos and documents are queued on the media queue
(Jobs\DownloadSupplierMedia), and the batch is reindexed once in the background. Publishing is
separate: catalog:publish-supplier publishes a feed’s drafts, then run cms:reindex and
catalog:search-rebuild.
Models\SupplierCategory holds the category mapping: a feed’s category path to our category,
optionally a field set, garment type and position limit. SupplierCategory::placementFor() places a
product, and rows with no mapping are left out and listed unless a fallback is given.
One supplier per product
Section titled “One supplier per product”A product has exactly one supplier. The same item can still appear in several feeds, so sources that
know the maker’s code give an item_key (ItemKeys::make($brand, $makerCode)), and SameItems
finds matches. supplier_source_settings.priority is the order among feeds.
SupplierProducts::whyNotAdded() is what enforces it, with a reason staff see:
- Not New.
- Already in the catalog through another supplier (
SameItems::inCatalogElsewhere()). - A supplier earlier in the order has it New (
SameItems::preferredElsewhere()).
VariantSource is one row per variant, its product’s own source.
The sync
Section titled “The sync”Suppliers\SupplierSync::run($source, $kind, $startedBy) is one recorded run (SupplierSyncRun).
It is started by catalog:sync-supplier {source} [--stock], by staff, or by the schedule.
catalog:run-due-syncs runs every five minutes and SupplierSyncSchedule::due() picks one due feed:
nothing while another sync runs, then the first feed whose products slot has passed, then the first
whose stock is stale. Jobs\RunSupplierSync is unique per feed and tried once.
A products run does this:
refresh()the staging rows. A failure here stops the run.syncCatalog()reads costs and decorations once, then goes through every product of the feed. Each is its own transaction, and one failure is reported without stopping the others:SupplierProductVariants::sync(): a SKU gone setsis_discontinued, one back clears it, a new one becomes a variant. Known SKUs’ options and staff’sis_hiddenare never touched.SupplierProductContent::apply(): see below.SupplierVariantCosts::apply(): a SKU with no cost has its costs cleared, so it fails closed.SupplierDecorationPricing::apply(): decoration pricing is supplier-owned, replaced whole.
- Stock, through
syncStock(), when the feed gives it and manages it.
Staff edits survive. The product remembers what the feed last wrote (supplier_content). A field
still holding that value follows the feed, and one staff changed is left alone. Comparing with what
was written, not with what today’s rules would give, is what lets a rule change reach every untouched
product. A detail staff deleted is not added back.
Prices go stale on purpose. prices_synced_at is set only when both the costs and the decorations
were read. Past the feed’s stop_after_days its products become quote required until a sync succeeds
(SupplierSourceSetting::priceFreshness()).
Runs are replayable. SupplierRunFiles saves what a run read under supplier-syncs/<feed>/<run id>/ on the local disk, and a replay reads those files under today’s rules, leaving stock alone.
Changes are logged under the supplier-sync activity log. catalog:prune-sync-inputs runs daily.
Sync events (FeedSynced, ProductChangedByFeed) are available as triggers for
automations.
Split products
Section titled “Split products”One feed model can become several catalog products, such as a T-shirt and the same T-shirt in white only, each with its own page and From price. Staff guidance is in split products.
SupplierProducttoProductis therefore one to many. A split-off product stores aproducts.variant_scope(VariantScope: conditions on a SKU’s options, matched by name). A product with no scope is the main product and takes every SKU no other product’s scope claims.ProductSplitter::split()moves the variant rows themselves, so quotes and orders still point at them. The new product starts as a draft copy.mergeBack()returns them and redirects the split-off’s addresses to the main product.SplitRule, applied bySplitRules, does this automatically at Add to catalog and by a rule’s Apply. During a sync the rules are never applied:SplitRules::report()only counts them and tells automation rules.
The product page actions are Split off variants and Merge back
(ProductResource/Pages/EditProduct.php).
Adding a feed
Section titled “Adding a feed”Say a supplier called Acme publishes one JSON list of products.
-
Client. Add
src/Suppliers/Acme/AcmeClient.phpwith its own exception. Give it afeed_pathso tests and development read a saved copy. Keep credentials out of messages. -
Config. Add
catalog.suppliers.acmetoconfig/catalog.php, reading env vars, and bind the clientscopedinCatalogServiceProvider. -
Source. Add
AcmeSource implements SupplierSourcewithHANDLE = 'acme'. Start withrecords(), keeping the whole product as the payload:return new SupplierProductRecord(sourceId: $code,name: $row['name'] ?? null,categoryPaths: [$row['category'].' > '.$row['subcategory']],thumbnailUrl: $row['image'] ?? null,variantCount: count($row['items'] ?? []),payload: $row,);Throw when a required field is missing, so a broken feed changes nothing.
buildProduct()can be$product->save(). -
Register. Add it to the
SupplierSourcessingleton inCatalogServiceProvider. -
Variants and costs. Map SKUs to
SupplierVariants, keyed by our option handles (SupplierVariant::COLOUR,SIZEand so on), and returnSupplierCosts. Sizes should use our names, so values are shared with other feeds. -
Decorations. Return a
SupplierDecorationswhosefor()gives the shape in pricing and decoration, or null for blanks (seePenCarrieDecorations, whose garments are priced from a decoration sheet instead).DecorationPricingValidatorrefuses a wrong shape. Expand supplier pricing rules here, so the engine stays free of supplier code. -
Photos, documents, content, naming. Fill in the rest, and declare any
quirks(). -
Settings. Add the feed to
SupplierSourceSettingsSeeder: supplier company, margins, minimums. Without a margin its products are quote required, andSupplierSyncScheduleonly sees feeds with a settings row. -
Mappings. Run a first
catalog:sync-supplier acme, then map categories, methods and positions on the feed’s screens (Suppliers > Feeds). See category mapping and branding setup. -
Tests. Follow
tests/Feature/Suppliers/UneekSourceTest.phpandUneekVariantsTest.php, with a small fixture feed. If the business gives worked orders, add a worked examples test too.