deployecommerce/module-order-status-transition

Detects Magento order status transitions reliably and dispatches a single canonical event other modules can consume.

Maintainers

Package info

github.com/DeployEcommerce/module-order-status-transition

Homepage

Type:magento2-module

pkg:composer/deployecommerce/module-order-status-transition

Transparency log

Statistics

Installs: 14

Dependents: 2

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-07 11:27 UTC

This package is auto-updated.

Last update: 2026-08-07 11:28:04 UTC


README

Detects Magento order status changes reliably and dispatches a single canonical event for other modules to consume.

Detecting a status change sounds trivial and is not. This module exists so that the awkward parts — duplicate detection, statuses Magento rewrites by itself, and working out who was responsible — are solved once rather than in every module that needs them.

composer require deployecommerce/module-order-status-transition
bin/magento module:enable DeployEcommerce_OrderStatusTransition
bin/magento setup:upgrade

Consuming the event

<!-- etc/events.xml -->
<event name="deployecommerce_order_status_transition">
    <observer name="my_module_react_to_status_change"
              instance="My\Module\Observer\ReactToStatusChange"/>
</event>
use DeployEcommerce\OrderStatusTransition\Api\Data\TransitionInterface;

public function execute(Observer $observer): void
{
    /** @var \Magento\Sales\Model\Order $order */
    $order = $observer->getEvent()->getData('order');
    /** @var TransitionInterface $transition */
    $transition = $observer->getEvent()->getData('transition');

    if ($transition->getTo() !== 'dispatched') {
        return;
    }

    // ...
}

TransitionInterface carries the order id, increment id and store id, the status and state either side of the change, when it was observed, and two flags: isPlacement() for an order's first status, and isImplicit() for a status Magento rewrote itself.

Ask for ActorResolverInterface to find out who was responsible:

$actor = $this->actorResolver->resolve();
$actor->getLabel();    // 'admin:jbloggs#7' | 'integration:Warehouse#3' | '[cron:cron:run]' | '[system]'
$actor->getArea();     // 'adminhtml' | 'frontend' | 'webapi_rest' | 'crontab' | '[unset]'
$actor->getIp();       // '203.0.113.9' | '[system]'
$actor->getTrigger();  // 'sales_order_addComment' | 'POST:/V1/order/12/ship' | 'cron:run'

Where observers run

The event is raised from sales_order_save_after, which puts your observer inside the order's database transaction and before Magento persists the order's relations. Two consequences worth knowing:

  • Anything you attach to the order — a status history row, for instance — is written atomically with the order and rolled back if the save fails. You do not need, and must not issue, a second save.
  • Never call $order->save() from your observer. This module guards against re-entry, but a nested save inside an in-flight save is a bad idea regardless.

Consumers that throw are caught and logged. An extension reacting to a status change must never be able to break a checkout or an admin action.

Configuration

Stores → Configuration → Advanced → Order Status Transitions

Setting Default Purpose
Enabled Yes Master switch. Turning it off silences every consumer.
Detect Column-Level Status Writes Yes Also watch saveAttribute(), which writes named columns straight to the order row. Core only uses it for email flags, but third-party code can route a status through it.

What it catches

Everything that goes through a normal order save, which is almost everything:

  • Admin status changes and order comments, including mass hold, unhold and cancel
  • OrderManagementInterface — cancel, hold, unhold, addComment
  • Shipments, invoices, credit memos and refunds
  • Every REST and SOAP order endpoint
  • Payment webhooks and any other code calling OrderRepositoryInterface::save()
  • Statuses Magento rewrites itself in Handler\State, flagged with isImplicit()
  • Column-level writes via saveAttribute(), when that setting is on

What it does not catch

Anything that writes to the sales_order table without going through the model layer. In practice that means raw SQL, and bulk import extensions that use insertOnDuplicate directly — some order import tools do exactly this, which is why they ship their own grid re-indexer.

No event-based approach can see those writes. If you need to cover them, either hook the importer's own extension points or reconcile periodically against the sales_order table.

Notes on the IP address

Behind a CDN or load balancer the recorded address is only as trustworthy as Magento's remote_addresses/trusted_proxies configuration. With no trusted proxies configured Magento takes the first X-Forwarded-For entry, which the client controls. Record it; do not treat it as evidence.

Development

composer install
composer test

Tests are written with Pest and need no Magento installation. Magento's own packages are pulled from the public Mage-OS mirror, so no Adobe credentials are required. That repositories entry only applies when this package is the root package — Composer ignores it when the module is installed as a dependency.

Licence

MIT. See LICENSE.md.