Export Magento 2 Orders to an ERP Without Losing Any
Exporting orders from Magento to an ERP looks simple: when an order is placed, send it. The problems show up later. The ERP was down for ten minutes and three orders never arrived. A retry created a duplicate. An order was sent before payment was confirmed and the warehouse shipped it anyway. Nobody noticed until a customer called.
This article describes an order export design for Magento Open Source and Adobe Commerce 2.4.8 that does not lose orders and does not duplicate them. It is less about a specific ERP and more about the structure around the API call, which is where most integrations fail. For the broader picture, see ERP integration patterns.
Principles
- The database is the source of truth for export status. Each order records whether it was exported, when, and the ERP reference. Events and queues are ways to speed things up, not the record.
- Export is asynchronous. The customer's checkout never waits for the ERP.
- Every export can be retried safely. Sending the same order twice must not create two ERP orders.
- Failures are visible. Someone gets told when an order is stuck, before the customer complains.
Export status on the order
Add columns to sales_order with db_schema.xml:
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="sales_order">
<column xsi:type="varchar" name="erp_export_status" nullable="false" length="20" default="pending" comment="ERP Export Status"/>
<column xsi:type="smallint" name="erp_export_attempts" unsigned="true" nullable="false" default="0" comment="ERP Export Attempts"/>
<column xsi:type="timestamp" name="erp_exported_at" nullable="true" comment="ERP Exported At"/>
<column xsi:type="varchar" name="erp_reference" nullable="true" length="64" comment="ERP Order Reference"/>
<column xsi:type="text" name="erp_last_error" nullable="true" comment="ERP Last Error"/>
<index referenceId="SALES_ORDER_ERP_EXPORT_STATUS" indexType="btree">
<column name="erp_export_status"/>
</index>
</table>
</schema>
When this column is added to an existing store, every historical order becomes pending. Before enabling the export, run a one time update that marks old orders as skipped, or your first run will try to send years of orders to the ERP.
Some teams prefer a separate table keyed by order id instead of new columns on sales_order. That keeps the sales table clean and is easier to remove later. Both work. The important part is that the status is stored and indexed.
When is an order ready to export?
Agree on this with the business before writing code. Common answers:
- When it is placed, for offline payments where the ERP handles payment follow up.
- When it is paid, which in Magento usually means an invoice exists. For online payments with capture at checkout, that is immediate. With authorize only, it happens later.
- When it reaches a specific status, for example after a fraud check.
Write that rule as one method on one class, for example isReadyForExport(OrderInterface $order): bool. Both the event handler and the cron sweep below call it.
Picking the trigger event
A tempting choice is sales_order_place_after. It fires inside Order::place(), before the order is saved. If you publish a message there, a fast consumer can try to load an order that does not exist yet, or that will never exist if the transaction is rolled back.
A safer choice is sales_order_save_commit_after. It is dispatched after the database transaction that saved the order is committed, so the order is really there. It fires on every order save, so the observer must be cheap: check the export status and the readiness rule, and publish only if both say yes.
<?php
declare(strict_types=1);
namespace Vendor\ErpExport\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\MessageQueue\PublisherInterface;
use Vendor\ErpExport\Model\ExportRules;
class PublishOrderForExport implements ObserverInterface
{
public const TOPIC = 'vendor.erp.order.export';
public function __construct(
private readonly PublisherInterface $publisher,
private readonly ExportRules $exportRules
) {
}
public function execute(Observer $observer): void
{
$order = $observer->getEvent()->getOrder();
$status = $order->getData('erp_export_status') ?? 'pending';
if ($status !== 'pending') {
return;
}
if (!$this->exportRules->isReadyForExport($order)) {
return;
}
$this->publisher->publish(self::TOPIC, (string) $order->getEntityId());
}
}
A freshly placed order object does not carry the column default from the database, so a missing value is treated as pending. Register the observer in the global etc/events.xml, so orders created in the admin and through the API are covered too. The message carries only the order id. The consumer always loads fresh data, which avoids exporting a stale copy.
The consumer
The queue topic, consumer and handler are declared in communication.xml, queue_publisher.xml, queue_topology.xml and queue_consumer.xml. RabbitMQ consumers covers those files and how to keep consumers running. The handler is where the export happens:
<?php
declare(strict_types=1);
namespace Vendor\ErpExport\Model\Queue;
use Magento\Framework\App\ResourceConnection;
use Magento\Sales\Api\OrderRepositoryInterface;
use Psr\Log\LoggerInterface;
use Throwable;
use Vendor\ErpExport\Model\ErpClient;
use Vendor\ErpExport\Model\OrderMapper;
class ExportHandler
{
private const MAX_ATTEMPTS = 5;
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly OrderMapper $mapper,
private readonly ErpClient $client,
private readonly ResourceConnection $resource,
private readonly LoggerInterface $logger
) {
}
public function process(string $orderId): void
{
$order = $this->orderRepository->get((int) $orderId);
if ($order->getData('erp_export_status') === 'exported') {
return;
}
try {
$reference = $this->client->sendOrder($this->mapper->map($order));
$this->update((int) $orderId, [
'erp_export_status' => 'exported',
'erp_reference' => $reference,
'erp_exported_at' => gmdate('Y-m-d H:i:s'),
'erp_last_error' => null,
]);
} catch (Throwable $e) {
$attempts = (int) $order->getData('erp_export_attempts') + 1;
$this->update((int) $orderId, [
'erp_export_status' => $attempts >= self::MAX_ATTEMPTS ? 'failed' : 'pending',
'erp_export_attempts' => $attempts,
'erp_last_error' => mb_substr($e->getMessage(), 0, 1000),
]);
$this->logger->error('ERP export failed', ['order_id' => $orderId, 'exception' => $e]);
}
}
private function update(int $orderId, array $data): void
{
$connection = $this->resource->getConnection('sales');
$connection->update(
$this->resource->getTableName('sales_order', 'sales'),
$data,
['entity_id = ?' => $orderId]
);
}
}
The status update writes directly to the table instead of saving the order through the repository. Saving the full order would fire sales_order_save_commit_after again, run every other observer and plugin attached to order saves, and risk overwriting changes made by an admin in the meantime. A targeted update of our own columns avoids all of that.
Idempotency
The handler checks the status before sending, but that only protects against duplicates inside Magento. The real protection has to be in the ERP call. Send a unique key with every order, normally the increment id, and make the ERP side reject or return the existing record when the key already exists. Many ERPs support an external reference field for exactly this.
Without that, a timeout is dangerous. The ERP may have created the order and failed only to answer. Magento sees an error, retries, and creates a second order. With an idempotent key, the retry returns the existing reference and Magento marks the order as exported.
The cron sweep
Events and queues fail in quiet ways: a consumer that is not running, a message lost during a broker restart, an order created by a direct database import. A cron job every few minutes closes the gap. It selects orders with erp_export_status = 'pending', ready by the business rule, and older than a few minutes so it does not race the queue, and publishes them again.
Keep it bounded. Select a limited batch per run, ordered by entity_id, and let the next run continue. Use a lock so two runs never overlap. The cron jobs guide covers both.
Visibility and operations
- Admin grid column. Add the export status to the order grid so support can see it without database access. The order grid has its own table,
sales_order_grid, so the column has to be added there and to the grid configuration indi.xml. - Alerts. Notify someone when an order reaches
failed, or when the oldest pending order is older than an agreed limit. - A resend command. A CLI command that resets the status and attempts for a given order and publishes it. It saves a lot of manual database edits.
- Logs with context. Log the order id, the request and the response, with personal data masked where needed.
Mapping the order
Keep the mapping in its own class that takes an OrderInterface and returns an array. It should be a pure function, with no API calls, so it is easy to test with fixture orders. Be explicit about things the ERP cares about: tax per line, discounts per line or per order, shipping as a line or a header field, configurable products where the child carries the SKU, bundle products, and the currency. Most data errors I have seen in exports come from these cases, not from the transport.
FAQ
Should I export orders in an observer during checkout?
No. Calling the ERP during checkout makes the customer wait and loses the order export when the ERP is down. Record the need to export and do it asynchronously.
Which event should trigger the export?
sales_order_save_commit_after, filtered by your export status and readiness rule. sales_order_place_after fires before the order is saved, which is too early for a queue.
Do I need RabbitMQ for this?
No. The status column and the cron sweep alone make a working export. A queue adds speed and spreads the work, and Magento can also use its MySQL queue backend.
How do I avoid duplicate orders in the ERP?
Send a unique key such as the increment id and make the ERP reject or return existing records with that key. Then retries are safe.
I am available for freelance Magento and Adobe Commerce work, including ERP and order integrations. If you need help with this, get in touch.
Related articles
Magento 2 Custom Order Status: States, Statuses and Workflow
Create custom order statuses in Magento 2, map them to states with a data patch, change them from code safely and avoid Magento resetting them on save.
Magento 2 RabbitMQ Consumers That Don't Die Silently
Set up RabbitMQ message queues in Magento 2.4 the right way: queue XML files, consumers run by cron or supervisor, max-messages and how to spot a dead consumer.
Magento 2 Admin Security Hardening Checklist
Harden the Magento 2 admin: custom admin path, IP restriction, 2FA, lockout and session settings, user and role cleanup, API integrations and admin logs.
Magento 2 Admin Two Factor Authentication: Setup and Fixes
Set up Magento 2 admin two factor authentication: providers, forcing a provider, CLI commands to reset users, local development, API tokens and common problems.