Implementing two-way catalog synchronization with MoySklad
MoySklad is a Russian cloud-based commerce management system. When integrated with an online store, synchronization typically covers three data flows: products and inventory (from MoySklad to site), orders (from site to MoySklad), statuses and tracking (from MoySklad back to site). Two-way synchronization is significantly more complex than one-way — you need to handle conflicts, maintain ID mapping, and manage source of truth priorities.
Architecture: sources of truth
Before development, clearly define which system is authoritative for each entity:
| Entity | Source of Truth | Note |
|---|---|---|
| Products (name, description, price) | MoySklad | Managers edit there |
| Inventory | MoySklad | Updated on receipt/sale |
| Product images | Site | Uploaded via CMS |
| SEO fields (meta, slug) | Site | Don't exist in MoySklad |
| Orders | Site → MoySklad | Created on site, sent to MoySklad |
| Order statuses | MoySklad → Site | Manager changes in MoySklad |
MoySklad API
MoySklad uses REST API with JSON:API-like structure. Authentication — Basic Auth or Bearer token.
class MoiSkladClient
{
private string $baseUrl = 'https://api.moysklad.ru/api/remap/1.2';
private function headers(): array
{
return [
'Authorization' => 'Bearer ' . config('services.moysklad.token'),
'Content-Type' => 'application/json;charset=utf-8',
'Accept-Encoding' => 'gzip',
];
}
public function get(string $path, array $params = []): array
{
return Http::withHeaders($this->headers())
->get("{$this->baseUrl}/{$path}", $params)
->throw()
->json();
}
public function post(string $path, array $data): array
{
return Http::withHeaders($this->headers())
->post("{$this->baseUrl}/{$path}", $data)
->throw()
->json();
}
}
Fetching products with inventory
class ProductSyncService
{
public function fetchProducts(int $offset = 0, int $limit = 100): array
{
return $this->ms->get('entity/product', [
'offset' => $offset,
'limit' => $limit,
'expand' => 'productFolder,images',
'filter' => 'archived=false',
]);
}
public function fetchStocks(): array
{
// Inventory across all warehouses
return $this->ms->get('report/stock/all/current', [
'stockType' => 'stock',
'includeRelated' => false,
]);
}
public function syncToSite(): void
{
$offset = 0;
$limit = 100;
do {
$response = $this->fetchProducts($offset, $limit);
$products = $response['rows'];
foreach ($products as $msProduct) {
$this->upsertProduct($msProduct);
}
$offset += $limit;
} while ($offset < $response['meta']['size']);
// Update inventory separately
$stocks = $this->fetchStocks();
foreach ($stocks as $stock) {
Product::whereExternalId($stock['assortmentId'])
->update(['stock' => max(0, (int)$stock['stock'])]);
}
}
private function upsertProduct(array $msProduct): void
{
$msId = $msProduct['id'];
// Map MoySklad fields → site
$data = [
'external_id' => $msId,
'name' => $msProduct['name'],
'article' => $msProduct['article'] ?? null,
'price' => $this->parsePrice($msProduct['salePrices'][0]['value'] ?? 0),
'description' => $msProduct['description'] ?? '',
'ms_updated_at'=> Carbon::parse($msProduct['updated']),
];
$product = Product::updateOrCreate(['external_id' => $msId], $data);
// SEO and images are NOT overwritten — managed on site
}
private function parsePrice(int $msPrice): float
{
// MoySklad stores prices in kopecks (multiplied by 100)
return $msPrice / 100;
}
}
Sending orders to MoySklad
public function pushOrder(Order $order): string
{
$positions = [];
foreach ($order->items as $item) {
$positions[] = [
'assortment' => [
'meta' => [
'href' => "{$this->baseUrl}/entity/product/{$item->product->external_id}",
'type' => 'product',
],
],
'quantity' => $item->quantity,
'price' => $item->price * 100, // in kopecks
];
}
$msOrder = $this->ms->post('entity/customerorder', [
'name' => "Order #{$order->id}",
'organization' => [
'meta' => [
'href' => "{$this->baseUrl}/entity/organization/" . config('services.moysklad.org_id'),
'type' => 'organization',
],
],
'agent' => $this->getOrCreateCounterparty($order->customer),
'positions' => $positions,
'description' => "Source: website\nEmail: {$order->customer->email}",
'attributes' => [
[
'meta' => ['href' => $this->orderIdAttributeHref()],
'value' => (string)$order->id,
],
],
]);
$order->update(['ms_order_id' => $msOrder['id']]);
return $msOrder['id'];
}
Webhook from MoySklad: status updates
MoySklad supports outgoing webhooks on entity change events:
public function handleMsWebhook(Request $request): Response
{
$events = $request->json('events', []);
foreach ($events as $event) {
if ($event['meta']['type'] === 'customerorder') {
$msOrderId = basename($event['meta']['href']);
SyncOrderStatusJob::dispatch($msOrderId);
}
}
return response()->noContent();
}
class SyncOrderStatusJob implements ShouldQueue
{
public function handle(MoiSkladClient $ms): void
{
$msOrder = $ms->get("entity/customerorder/{$this->msOrderId}");
$order = Order::where('ms_order_id', $this->msOrderId)->first();
if (!$order) return;
// Map MoySklad statuses → site statuses
$statusMap = [
'New' => 'pending',
'In work' => 'processing',
'Sent' => 'shipped',
'Delivered' => 'completed',
'Cancelled' => 'cancelled',
];
$msStatus = $msOrder['state']['name'] ?? '';
$siteStatus = $statusMap[$msStatus] ?? null;
if ($siteStatus && $order->status !== $siteStatus) {
$order->update(['status' => $siteStatus]);
$order->customer->notify(new OrderStatusChanged($order));
}
}
}
Conflict resolution
With two-way synchronization, conflicts may arise: a product is changed both on site and in MoySklad simultaneously. The strategy depends on the field:
- Price, inventory, article — always MoySklad priority
- SEO fields, site description — site only
- Name — MoySklad priority with change history preservation
For fields with competing updates, store ms_updated_at and site_updated_at, during sync take the more recent value.
Schedule and frequency
// routes/console.php
Schedule::job(new SyncProductsJob)->everyTenMinutes();
Schedule::job(new SyncStocksJob)->everyFiveMinutes(); // inventory more frequent
Schedule::job(new SyncOrderStatusesJob)->everyFiveMinutes();
Update inventory more frequently — it's critical for "in stock/out of stock" display.
Timeline
One-way synchronization (MoySklad → site, products + inventory): 2–3 working days. Full two-way (+ orders, + statuses, + webhook, + conflict resolution): 6–8 working days. Time increases with non-standard catalog structure (variants, bundles, services).







