deployecommerce / module-order-status-transition
Detects Magento order status transitions reliably and dispatches a single canonical event other modules can consume.
Package info
github.com/DeployEcommerce/module-order-status-transition
Type:magento2-module
pkg:composer/deployecommerce/module-order-status-transition
Requires
- php: ~8.2.0||~8.3.0||~8.4.0
- magento/framework: >=103.0.0 <104
- magento/module-authorization: >=100.4.0 <101
- magento/module-integration: >=100.4.0 <101
- magento/module-sales: >=103.0.0 <104
- magento/module-store: >=101.1.0 <102
- magento/module-user: >=101.2.0 <102
Requires (Dev)
- mockery/mockery: ^1.6
- pestphp/pest: ^2.35 || ^3.7
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 withisImplicit() - 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.