Skip to content

Product Feed

A product feed for Magento 2 in the Google Shopping format (RSS 2.0 XML) — read as it is by Google Merchant Center and Meta (Facebook and Instagram), and accepted by TikTok and Pinterest catalogues — that is built to finish on any catalogue size, sends prices exactly as the storefront shows them, exports configurable products as proper variants, and gives you custom labels you can actually build campaigns on.

  • Finishes on any catalogue. Products are read and written in batches of 500 and streamed to disk, so memory stays flat however many products you have: measured on 75,879 items (50,000 simple products and 1,000 configurables with 24 variants each), memory in use stayed at about 115 MB from the first batch to the last, and the run took under three minutes. Feeds that load the whole catalogue at once need gigabytes at that size and fail on a standard memory_limit.
  • Never stalls the store. The feed runs in its own cron group, in a separate process, so a long run does not hold up emails or indexers. One run per store view at a time (lock). Generate Now in the admin queues the run for cron instead of running it inside the web request, where large feeds hit the server’s time limit.
  • Fails safe. A product that cannot be built is logged and left out; the rest of the feed is still published. A run that produces no item keeps the previous file — platforms delete every product missing from a fetched feed. The file is replaced in one atomic rename, so a platform never fetches a half-written feed.
  • Prices as on the product page. Regular and sale price from Magento’s price index — special prices, catalog price rules — with the sale window when the special price has an end date. Tax is added or removed with Magento’s own tax calculation when the catalogue is entered differently from what the feed must show (Europe: including tax; United States and Canada: excluding), and amounts are converted to the store view’s display currency.
  • Stock that matches checkout. Salability comes from Magento Inventory (MSI), reservations of unshipped orders included — the last unit of a product already ordered is not advertised as in stock.
  • Configurable products as variants. One item per size and colour, grouped under the parent SKU with item_group_id, item_group_title and variant_option, a distinguishing title (“Hoodie - M, Blue”), color and size. Each variant links to the parent page with its options preselected, so the price and stock a shopper lands on are the variant’s — on Hyvä and Luma alike.
  • Bundle products, optionally. One item per bundle, priced by Magento’s own price model (the price index keeps 0 for bundles), out of stock as soon as a required item is, described by its items’ categories when it only sits in a “Bundles” category, and sent with is_bundle when your bundles are a main product with add-ons.
  • Square images. Images are padded onto a white 1:1 canvas, so square ad placements show the whole product instead of a crop. The storefront images are not touched; squares are created within a time budget per run and by a background job.
  • Categories you can target. Every category branch as product_type; the product’s main branch as category level labels; merchandising branches (Sale, New In, Brands) left out; a Feed Category attribute on the product to choose the main branch when a product sits in several.
  • Custom labels from the admin. Choose what fills each of the five custom labels: category level 1–3, all categories, any product attribute, on sale, or a discount band read from the feed’s own prices.
  • Identifiers done right. GTIN only with a valid check digit (EAN, UPC, ISBN, GTIN-14); MPN; brand from an attribute or a fixed default; identifier_exists=no only when the product truly has none.
  • One feed per store view, in its language and currency; one element per line (Meta rejects XML lines over 5 MB); optional gzip.
  • Any theme. No storefront code: works the same on Hyvä and Luma.
  • Extensible. Every element of an item comes from a modifier in a pool; add or override elements with one class and a few lines of di.xml.
Magento Open Source / Adobe Commerce 2.4.8 (tested on 2.4.8-p5)
Magento Inventory (MSI) enabled (Magento_InventorySalesApi)
PHP 8.3 – 8.4, with the gd, xmlwriter and zlib extensions
Cron Magento cron running (bin/magento cron:run every minute)

Add the repository you received with your license to composer.json (once), then:

Terminal window
composer require slova/module-product-feed:^1.0
bin/magento module:enable Slova_ProductFeed
bin/magento setup:upgrade
bin/magento setup:di:compile # production mode
bin/magento cache:flush

Then enable the feed for your store views (Stores > Configuration > Slova > Product Feed > General > Enable Feed) and generate it once:

Terminal window
bin/magento slova:feed:images # large catalogue: square every image now
bin/magento slova:feed:generate
Terminal window
composer update slova/module-product-feed
bin/magento setup:upgrade
bin/magento setup:di:compile # production mode
bin/magento cache:flush

Read CHANGELOG.md first; settings are kept across versions.

Terminal window
bin/magento module:uninstall Slova_ProductFeed --remove-data

--remove-data deletes the module’s settings, run reports and the Feed Category product attribute with its values. The module creates no tables. Feed files (pub/media/feed/) and square images (pub/media/slova-productfeed/) are left in place, so platforms still fetching them do not drop your catalogue at once; delete them by hand when the platforms no longer use them.

Stores > Configuration > Slova > Product Feed. Settings marked global apply to every store view; the others can be set per store view.

A table with one row per store view: the feed URL, the last run (time, duration, items, products left out, errors, file size) and a Generate Now button. Generate Now queues the feed; cron starts it within a minute.

Setting Default
Enable Feed No Each enabled store view gets its own file.
Schedule (global) 40 */4 * * * Cron expression: every 4 hours at minute 40.
Setting Default
Simple Products Yes Enabled simple products visible in the catalog or search.
Configurable Products Yes One item per enabled variant (see Configurable products).
Variant Links Preselect Options Yes Variant links open the parent page with the variant’s options chosen.
Bundle Products No One item per visible bundle (see Bundle products).
Send Bundles as g:is_bundle Yes Yes for a main product with add-ons; No for sets of equal products.
Leave Out Out-of-Stock Products No No is recommended: out-of-stock items are sent as out_of_stock, so platforms stop advertising them but keep them in the catalogue, and tracking events from their pages still match.
Setting Default
GTIN — Attribute holding the EAN/UPC/ISBN, or SKU when your SKUs are GTINs.
MPN — Manufacturer part number, for products without a GTIN.
Brand —
Default Brand — Sent when a product has no brand, e.g. your own brand.
Color / Size color / size
Google Product Category — ID or full path from the Google taxonomy, sent for every item.
Setting Default
Send Category Paths Yes Every category branch as g:product_type, the main branch first.
Category Root ID store root Only categories below it count; levels are numbered from it.
Leave Out These Branches — Merchandising branches (Sale, New In, Brands) kept out of category paths and category labels.
Primary Category Attribute Feed Category Product attribute holding the main category id (see Category labels).

Each of g:custom_label_0 to 4 takes one source:

Source Value
Category, Level 1 / 2 / 3 The main branch’s category name at that level.
All Categories Every category name of the product, deepest first, joined with “ | “.
Product Attribute The attribute’s value(s), joined with “ | “.
On Sale sale when the item has a sale price, else regular.
Discount Band sale 1-19%, sale 20-39%, sale 40-59%, sale 60%+, or regular.

Defaults: labels 0–2 category levels 1–3, label 3 all categories, label 4 On Sale. Google Ads splits campaigns by custom labels (it cannot split by price), and Meta product sets filter on them. When every product carries a standard discount, use Discount Band: On Sale would mark the whole catalogue.

Setting Default
Tax in Feed Prices As Displayed on the Storefront Or force Including Tax / Excluding Tax.
Setting Default
Square Images Yes
Square Size (px) 1080 Longest side; smaller images are not enlarged. Changing it gives every image a new URL.
Time for New Images per Feed Run (s) 60 Missing squares are created during the run for this long; then the original image is linked until the square exists.
Background Image Job Time (s) 240 Every 10 minutes, a cron job creates missing squares for this long. 0 turns it off.
Setting Default
File Name google-shopping-{store}.xml In pub/media/feed/; {store} is replaced by the store view code.
Compress (gzip) No Writes .xml.gz: 3 to 10 times smaller, depending on how repetitive the text is (3× measured on a bookshop with long, unique descriptions). Google Merchant Center and TikTok accept compressed feeds; Meta does not list them among its formats, so leave this off for a feed Meta reads. The URL changes.

Copy the feed URL from the Feeds table and add it to each platform as a product data source fetched from a URL on a schedule — in Google Merchant Center as a product source from a file link, in Meta Commerce Manager as a scheduled data feed of the catalogue, and likewise in TikTok and Pinterest catalogues. The menus differ between platforms and change over time; their help pages name the current path.

Schedule the fetch shortly after a feed run (with the default schedule: at minute 50, every 4 hours).

  • Cron runs the scheduled feed, the Generate Now queue (every minute) and the background image job (every 10 minutes), in the cron group slova_productfeed.

  • Command line:

    Terminal window
    bin/magento slova:feed:generate # every enabled store view
    bin/magento slova:feed:generate --store=en # one store view, even when disabled
    bin/magento slova:feed:generate --all-images # no time limit for square images in this run
    bin/magento slova:feed:images # create every missing square image
  • Log: var/log/slova_productfeed.log — one report per run, and the SKU and reason of every product left out after an error (the first 20 per run).

Each enabled variant of a visible configurable product is an item of its own, as Google requires:

<g:id>MH01-M-Orange</g:id>
<g:title>Chaz Kangeroo Hoodie - M, Orange</g:title>
<g:link>https://example.com/chaz-kangeroo-hoodie.html?size=168&amp;color=56#142=168&amp;93=56</g:link>
<g:item_group_id>MH01</g:item_group_id>
<g:item_group_title>Chaz Kangeroo Hoodie</g:item_group_title>
<g:variant_option><g:name>Size</g:name><g:value>M</g:value></g:variant_option>
<g:variant_option><g:name>Color</g:name><g:value>Orange</g:value></g:variant_option>
<g:color>Orange</g:color>
<g:size>M</g:size>

The link carries the options twice: as a query (attribute codes, read by Hyvä and by Luma swatches) and as a fragment (attribute ids, read by Hyvä and by both Luma renderers). A variant without an image of its own uses its parent’s; description and categories come from the parent. A simple product that is also visible on its own appears once, as a variant.

With Bundle Products on, each visible, enabled bundle is an item of its own, with its own title, description, image and link.

  • Price. Magento’s price index keeps 0 as the regular and final price of most bundles, so the price comes from Magento’s price model, the one the product page renders: the price of the bundle’s lowest configuration — for a bundle whose options are all required with a single choice, simply its price — taxed the way the page taxes it (item by item, each at its own tax class, for a dynamic bundle). By default a struck-through price is sent only when the bundle itself is discounted (special price, catalog price rule), not the sum of its items’ separate prices: Google shows its sale annotation only when the regular price was the product’s price for 30 of the past 200 days, which a bundle’s item total never was. A store whose bundle pages show that total struck through can send it too, with a preference (below); the feed then matches the page, and the ad shows the bundle’s price, with or without Google’s annotation.
  • Stock. From Magento Inventory, which marks a bundle unsalable as soon as one of its required items is.
  • Categories. A bundle that only sits in a merchandising category (“Bundles”, “Sets”) outside the category root is described by the categories of its items.
  • is_bundle. Google asks for is_bundle=yes on a merchant-made bundle of a main product with add-ons (a camera with a bag and a card), and treats a set of equal products (a series of books, a hat and scarf) as an ordinary product. Choose with Send Bundles as g:is_bundle.
  • Identifiers. A bundle without a GTIN of its own is sent with identifier_exists=no.

To price bundles another way, add a preference for Slova\ProductFeed\Api\BundlePriceInterface; it returns the regular and final price in the base currency, already including or excluding tax as the feed asks ($includingTax). The feed only converts the currency.

Feed prices were checked against the prices rendered on product pages — a simple product, one reduced by a catalog price rule and a dynamic bundle — with prices entered with and without tax, shown with and without tax, and shown in another currency than the base one.

A category label holds one value, so a product in several branches is filed under one main branch, and all its level labels come from that same branch — never level 1 from one branch and level 2 from another.

  1. If the product’s Feed Category (in the Product Feed group of the product form) is set, the main branch is the one through that category. Pick a domain (“Fiction”) or a precise category (“Fiction > Historical”); it counts even when the product is not assigned to it.
  2. Otherwise, the first branch alphabetically by its full path. No branch is more important than another in general, so this rule is deliberately arbitrary; what matters is that it is stable from run to run.

The other branches stay in g:product_type and in the All Categories label. A product assigned both to a category and to its parent is described by the deeper one.

Every element of an item is written by a modifier. Implement Slova\ProductFeed\Api\ItemModifierInterface and add it to the pool; modifiers run in ascending sortOrder, and a later one can add elements or overwrite earlier ones:

class PreOrder implements \Slova\ProductFeed\Api\ItemModifierInterface
{
public function getAttributeCodes(\Magento\Store\Api\Data\StoreInterface $store): array
{
return ['is_preorder', 'release_date']; // added to the product query
}
public function modify(array $item, \Magento\Catalog\Model\Product $product, \Slova\ProductFeed\Model\Feed\ItemContext $context): ?array
{
if ($product->getData('is_preorder')) {
$item['availability'] = 'preorder';
$item['availability_date'] = (new \DateTime($product->getData('release_date')))->format('c');
}
return $item; // null leaves the product out
}
}
<type name="Slova\ProductFeed\Model\Feed\ItemBuilder">
<arguments>
<argument name="modifiers" xsi:type="array">
<item name="preorder" xsi:type="array">
<item name="class" xsi:type="object">Vendor\Module\Model\Feed\PreOrder</item>
<item name="sortOrder" xsi:type="number">35</item>
</item>
</argument>
</arguments>
</type>

Built-in modifiers: basic (10), image (20), availability (30), price (40), identifiers (50), variant (60), taxonomy (70), custom_labels (80). An item is an ordered map of element names (without g:) to a string, a list of strings (repeated element) or a list of maps (group element such as variant_option). Leave Out Out-of-Stock Products is applied after every modifier, so a modifier that turns an item into a pre-order keeps it in the feed.

Defaults in your own module’s etc/config.xml: an empty element does not clear a value set by this module (Magento keeps the earlier value when merging configuration files). Use none to switch off an attribute or a custom label there, e.g. <color_attribute>none</color_attribute>.

Image directory: squares are written to pub/media/slova-productfeed/. To keep the URLs of squares made by another tool with the same layout (<dir>/jpg/square-<side>-v1/<path>.jpg), set the cacheDirectory argument of Slova\ProductFeed\Model\Image\SquareImage.

  • The feed is not generated, or Generate Now stays queued. Magento cron is not running, or not for the slova_productfeed group. Check cron_schedule for the slova_productfeed_* jobs.
  • A product is missing. It must be enabled, assigned to the store view’s website, visible (simple products) and have a price in the price index — after mass imports, reindex catalog_product_price. Products left out after an error are listed in the log.
  • Prices differ from the product page. Check Tax in Feed Prices and, for a store view in another currency, the currency rates.
  • Images are the originals, not squares. The run’s image budget was used up; the background job squares the rest, or run bin/magento slova:feed:images.
  • Production mode, after installing or upgrading. Run setup:di:compile; without it, new classes are not wired.
  • Grouped, virtual and downloadable products are not exported.
  • A bundle with optional or alternative items is sent at the price of its cheapest configuration (the “From” price of its page).
  • Requires Magento Inventory (MSI).
  • One price per item: the NOT LOGGED IN customer group’s price; tier and customer group prices are not sent.
  • Only the base image is sent (no additional_image_link).
  • Shipping and returns are not part of the feed; set them in each platform’s account settings.

Proprietary software, licensed per the agreement accepted at purchase — see LICENSE. Support: through the channel listed in your license.