Setting Up Automatic Product Feed Updates on Schedule
Product feeds — XML or CSV files consumed by Yandex.Market, Google Merchant, Facebook Catalog, partner aggregators. If feed updates manually or once daily via static export — price and inventory actuality is questionable. Automatic schedule solves this systematically.
Feed Formats
Each platform expects different format:
- Yandex.Market — YML (Yandex Market Language), XML extension
-
Google Merchant — RSS 2.0 with
g:namespace or TSV - Facebook/Instagram — CSV or XML with specific fields
- Avito — proprietary XML
Same catalog must export to multiple formats. Architecture must account for this from start.
Generator Structure
// app/Services/Feed/FeedGenerator.php
interface FeedGeneratorInterface
{
public function generate(FeedConfig $config): string;
public function format(): string; // 'yml', 'csv', 'xml'
}
class YandexMarketFeedGenerator implements FeedGeneratorInterface
{
public function format(): string { return 'yml'; }
public function generate(FeedConfig $config): string
{
$products = Product::query()
->where('is_active', true)
->whereHas('stock', fn($q) => $q->where('quantity', '>', 0))
->when($config->category_ids, fn($q, $ids) => $q->whereIn('category_id', $ids))
->with(['category', 'images', 'attributes'])
->cursor(); // cursor() — don't load all in memory
$xml = new \XMLWriter();
$xml->openMemory();
$xml->setIndent(true);
$xml->startDocument('1.0', 'UTF-8');
$xml->startElement('yml_catalog');
$xml->writeAttribute('date', now()->format('Y-m-d H:i'));
$xml->startElement('shop');
$this->writeShopInfo($xml, $config);
$xml->startElement('offers');
foreach ($products as $product) {
$this->writeOffer($xml, $product, $config);
}
$xml->endElement(); // offers
$xml->endElement(); // shop
$xml->endElement(); // yml_catalog
return $xml->outputMemory();
}
private function writeOffer(\XMLWriter $xml, Product $product, FeedConfig $config): void
{
$xml->startElement('offer');
$xml->writeAttribute('id', $product->id);
$xml->writeAttribute('available', $product->stock->quantity > 0 ? 'true' : 'false');
$xml->writeElement('url', route('product.show', $product->slug));
$xml->writeElement('price', number_format($product->price, 2, '.', ''));
$xml->writeElement('currencyId', $config->currency ?? 'RUB');
$xml->writeElement('categoryId', $product->category_id);
$xml->writeElement('name', $product->name);
$xml->writeElement('description', strip_tags($product->description));
foreach ($product->images->take(10) as $image) {
$xml->writeElement('picture', $image->url);
}
$xml->endElement(); // offer
}
}
Feed Config Model
CREATE TABLE feed_configs (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
type VARCHAR(32) NOT NULL, -- 'yandex', 'google', 'facebook'
schedule VARCHAR(64) NOT NULL, -- cron: '*/30 * * * *'
output_path VARCHAR(512) NOT NULL, -- '/public/feeds/yandex.xml'
is_active BOOLEAN DEFAULT true,
last_run_at TIMESTAMPTZ,
last_error TEXT,
options JSONB DEFAULT '{}'
);
Artisan Command for Generation
// app/Console/Commands/GenerateFeed.php
class GenerateFeed extends Command
{
protected $signature = 'feed:generate {feed_id?} {--all}';
protected $description = 'Generate product feed files';
public function handle(): int
{
$configs = $this->option('all')
? FeedConfig::where('is_active', true)->get()
: FeedConfig::whereKey($this->argument('feed_id'))->get();
foreach ($configs as $config) {
$this->generateOne($config);
}
return self::SUCCESS;
}
private function generateOne(FeedConfig $config): void
{
$start = microtime(true);
try {
$generator = FeedGeneratorFactory::make($config->type);
$content = $generator->generate($config);
// Write to tmp, then atomically rename
$tmp = $config->output_path . '.tmp';
file_put_contents(public_path($tmp), $content);
rename(public_path($tmp), public_path($config->output_path));
$config->update([
'last_run_at' => now(),
'last_error' => null,
]);
$this->info(sprintf(
'[%s] %s generated in %.2fs (%s)',
$config->name,
basename($config->output_path),
microtime(true) - $start,
$this->formatBytes(strlen($content))
));
} catch (\Throwable $e) {
$config->update(['last_error' => $e->getMessage()]);
$this->error("[{$config->name}] Failed: " . $e->getMessage());
report($e);
}
}
}
Atomic rename is important: if aggregator downloads feed while writing — it gets complete old version, not truncated.
Laravel Scheduler
// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
// Read schedule from DB — flexible, no deploy on change
FeedConfig::where('is_active', true)->each(function (FeedConfig $config) use ($schedule) {
$schedule->command("feed:generate {$config->id}")
->cron($config->schedule)
->withoutOverlapping(10) // don't run if previous still working
->runInBackground()
->onFailure(function () use ($config) {
// Notify Slack/Telegram
Notification::route('slack', config('services.slack.webhook'))
->notify(new FeedGenerationFailed($config));
});
});
}
Typical schedules:
- Prices and inventory:
*/15 * * * *(every 15 min) - Main catalog with descriptions:
0 * * * *(hourly) - Full export with images:
0 3 * * *(daily at night)
Freshness Monitoring
Yandex.Market blocks shops with feed older than 24h. Check freshness:
FeedConfig::where('is_active', true)->each(function (FeedConfig $config) {
$maxAge = $config->options['max_age_minutes'] ?? 60;
$isStale = $config->last_run_at?->diffInMinutes(now()) > $maxAge;
if ($isStale || $config->last_error) {
// Alert to monitoring
}
});
Basic system with two formats (YML + Google) and web config management — 3–4 business days.







