Skip to content

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

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.

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.

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:

  1. The product: draft status, pricing_engine set to decoration, a name from the feed’s text rule set (Naming\ProductNamer), the source’s gallery style and supplier company.
  2. buildProduct(), then variants (SupplierProductVariants::build), costs, decoration pricing and content.
  3. Categories, then branding method links, as rules can depend on categories.
  4. 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.

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.

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:

  1. refresh() the staging rows. A failure here stops the run.
  2. 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 sets is_discontinued, one back clears it, a new one becomes a variant. Known SKUs’ options and staff’s is_hidden are 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.
  3. 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.

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.

  • SupplierProduct to Product is therefore one to many. A split-off product stores a products.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 by SplitRules, 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).

Say a supplier called Acme publishes one JSON list of products.

  1. Client. Add src/Suppliers/Acme/AcmeClient.php with its own exception. Give it a feed_path so tests and development read a saved copy. Keep credentials out of messages.

  2. Config. Add catalog.suppliers.acme to config/catalog.php, reading env vars, and bind the client scoped in CatalogServiceProvider.

  3. Source. Add AcmeSource implements SupplierSource with HANDLE = 'acme'. Start with records(), 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().

  4. Register. Add it to the SupplierSources singleton in CatalogServiceProvider.

  5. Variants and costs. Map SKUs to SupplierVariants, keyed by our option handles (SupplierVariant::COLOUR, SIZE and so on), and return SupplierCosts. Sizes should use our names, so values are shared with other feeds.

  6. Decorations. Return a SupplierDecorations whose for() gives the shape in pricing and decoration, or null for blanks (see PenCarrieDecorations, whose garments are priced from a decoration sheet instead). DecorationPricingValidator refuses a wrong shape. Expand supplier pricing rules here, so the engine stays free of supplier code.

  7. Photos, documents, content, naming. Fill in the rest, and declare any quirks().

  8. Settings. Add the feed to SupplierSourceSettingsSeeder: supplier company, margins, minimums. Without a margin its products are quote required, and SupplierSyncSchedule only sees feeds with a settings row.

  9. 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.

  10. Tests. Follow tests/Feature/Suppliers/UneekSourceTest.php and UneekVariantsTest.php, with a small fixture feed. If the business gives worked orders, add a worked examples test too.