vitalybaev/google-merchant-feed
Generate Google Merchant Center product feeds in Laravel with this package. It builds valid XML feeds from your products, supports required attributes, and integrates cleanly into existing Eloquent models and scheduled jobs.
Install
composer require vitalybaev/google-merchant-feed
Requires PHP 7.1+ and ext-dom / ext-simplexml (both ship with most PHP builds; php -m | grep -E 'dom|simplexml').
First look
README.md — one working example gets you 90% of the way.src/Feed.php — the container: addProduct(), getProducts(), build().src/Product.php and src/BaseProduct.php — the attribute setters (setId, setTitle, setPrice, setAvailability, setBrand, setGtin, …) plus addCustomAttribute() for anything the package doesn't model explicitly.Minimal working feed
use Vitalybaev\GoogleMerchant\Feed;
use Vitalybaev\GoogleMerchant\Product;
$feed = new Feed('My Store', 'https://example.com', 'A great store');
$product = new Product();
$product->setId('SKU-123');
$product->setTitle('Blue Widget');
$product->setDescription('A widget that is blue.');
$product->setLink('https://example.com/p/sku-123');
$product->setImageLink('https://example.com/img/sku-123.jpg');
$product->setAvailability('in stock');
$product->setPrice('19.99', 'USD');
$product->setBrand('Acme');
$product->setGtin('00012345600012');
$product->setCondition('new');
$feed->addProduct($product);
$xml = $feed->build(); // or echo / write to file
First real use case: an Artisan command (php artisan merchant:feed) that writes the XML to storage/app/merchant/feed.xml, plus a route that serves it with Content-Type: application/xml. Register the URL in Google Merchant Center as a scheduled fetch. That single loop — model → Product → build() → file — is the core of every deployment of this package.
Mapper class per model
Don't scatter setTitle() calls through controllers. Extract a ProductMapper:
class ProductMapper
{
public function fromModel(Item $item): Product
{
$p = new Product();
$p->setId($item->sku);
$p->setTitle(Str::limit($item->name, 150, ''));
$p->setDescription(Str::limit(strip_tags($item->description), 5000, ''));
$p->setLink(route('product.show', $item));
$p->setImageLink($item->primaryImageUrl());
$p->setAvailability($item->stock > 0 ? 'in stock' : 'out of stock');
$p->setPrice(number_format($item->price, 2, '.', ''), 'USD');
$p->setBrand($item->brand);
$p->setGtin($item->gtin);
$p->setCondition('new');
if ($item->sale_price) {
$p->setSalePrice(number_format($item->sale_price, 2, '.', ''), 'USD');
}
$p->addCustomAttribute('google_product_category', $item->google_category);
$p->addCustomAttribute('product_type', $item->product_type);
return $p;
}
}
Chunked iteration for large catalogs
Feed holds every Product in memory until build(). For 50k+ SKUs, use chunkById and build multiple feed files (Google accepts multiple feed files per target country), or bypass build() and stream with XMLWriter past a threshold.
Item::query()->chunkById(5000, function ($items) use ($feed) {
foreach ($items as $item) {
$feed->addProduct($this->mapper->fromModel($item));
}
});
Serve vs. generate
build() → response) for small catalogs.Route::get('/merchant/feed.xml', function () {
return response($feed->build(), 200, ['Content-Type' => 'application/xml']);
});
Or write once and let nginx serve it:
Storage::disk('local')->put('merchant/feed.xml', $feed->build());
Custom attributes for spec coverage gaps
Google's spec has hundreds of attributes; this package models the common ones. When Google adds a new field, or you need one the package doesn't implement, addCustomAttribute('name', 'value') emits <g:name> without waiting for a package release. That's your forward-compatibility escape hatch.
Testing
$xml = $feed->build();
$this->assertXmlStringEqualsXmlString(
'<?xml version="1.0"?><rss xmlns:g="http://base.google.com/ns/1.0">...</rss>',
$xml
);
assertXmlStringEqualsXmlString ignores whitespace, which saves you from brittle assertions over the indentation build() uses.
Wrapper service
Bind a MerchantFeedBuilder in the container that composes Feed + mappers. If the package is eventually abandoned (it's quiet — last release Dec 2022), you swap the implementation inside the service without touching call sites.
API drift between versions. Older releases used Offer + addItem() + getXml(); newer ones use Product + addProduct() + build(). If a tutorial doesn't match, check composer.lock. Pin the version in composer.json; the docs on the repo reflect the current tag, not whatever you installed two years ago.
Always pass price as a string with an explicit decimal point. setPrice(19.99, 'USD') may render 19.99 USD fine in one locale and 19,99 in another depending on how the float is cast. Use number_format($price, 2, '.', ''). Same for sale price, shipping, and tax.
Required minimum for Google. At minimum: id, title, description, link, image_link, availability, price, and either brand + gtin/mpn, or identifier_exists=false. Missing any of these and Merchant Center silently disables the item — the feed still generates valid XML, so nothing fails until diagnostics show up in the dashboard. Validate against the Google Merchant Center "Diagnostics" after every schema change.
Enum values are case- and space-sensitive. availability must be exactly in stock, out of stock, preorder, or backorder. condition must be new, refurbished, or used. Title-cased "In Stock" silently rejects.
XML escaping is handled, but not normalization. SimpleXML escapes &, <, >, but it does not strip newlines, control characters, or HTML from your description. strip_tags() and preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $text) before setting descriptions — Google rejects whole feeds over invalid characters.
No schema validation. The package builds well-formed XML but has no idea whether it's valid Google XML. If you want a safety net, validate once in CI with xmllint --schema against Google's XSD, or run the generated file through a merchant-center sanity script.
Memory. Feed keeps Product objects alive until build(). Each Product holds a SimpleXMLElement (or attribute array depending on version) — budget roughly a few KB per SKU. At 100k SKUs you'll hit PHP's default memory_limit; set -d memory_limit=1G on the CLI job or stream instead. Google's own limits are ~1GB uncompressed / 1M items per feed file, so plan file splitting around that.
Duplicate IDs. If two Products share an id, Google discards one; the package won't warn you. Assert uniqueness in the mapper or a test.
Shipping and tax are nested. Multi-shipment products need repeated setShipping(...) style calls (each produces a <g:shipping> block). Read Product.php before assuming single-argument helpers. Verify the emitted XML has one <g:shipping> per shipment — silent overwrites are common.
Currency and units. setPrice takes the currency as an ISO 4217 code; it is not inferred from the store's locale. If you support multiple storefronts, generate one feed per target country/currency rather than mixing.
Date attributes need ISO 8601. sale_price_effective_date, availability_date, expiration_date all expect YYYY-MM-DDThh:mm:ss±hh:mm (or a start/end range for effective dates). Use Carbon::toIso8601String() — not toDateTimeString().
Extension point: subclass Product. For repeated attributes across every SKU, subclass and populate defaults in the constructor. Cleaner than a map() loop and easier to test in isolation.
Debugging an unexpectedly rejected feed: dump the XML, open it in a browser or xmllint --format, and confirm the <g: namespace declaration is present on <rss>. If a caller has manually string-manipulated the output, the namespace can get lost and Google treats every attribute as invalid.
How can I help you explore Laravel packages today?