deployecommerce / module-order-status-audit-log
Records every Magento order status change: what it was, what it became, who changed it, from where, when, and from which IP address.
Package info
github.com/DeployEcommerce/module-order-status-audit-log
Type:magento2-module
pkg:composer/deployecommerce/module-order-status-audit-log
Requires
- php: ~8.2.0||~8.3.0||~8.4.0
- deployecommerce/module-order-status-transition: ~1.0.0
- magento/framework: >=103.0.0 <104
- magento/module-backend: >=102.0.0 <103
- magento/module-config: >=101.2.0 <102
- magento/module-sales: >=103.0.0 <104
- magento/module-store: >=101.1.0 <102
Requires (Dev)
- pestphp/pest: ^2.35 || ^3.7
README
Records every order status change: what it was, what it became, who changed it, from where, when, and from which IP address.
"Why is this order marked complete?" is a question Magento cannot answer. The status history shows that a status changed, sometimes, and never says who did it or how. This module answers it.
composer require deployecommerce/module-order-status-audit-log
bin/magento module:enable DeployEcommerce_OrderStatusAudit
bin/magento setup:upgrade
What an entry looks like
In the order's Comments History:
[STATUS-AUDIT] from=processing to=dispatched by=admin:jbloggs#7 area=adminhtml
ip=203.0.113.9 at=2026-08-07T14:22:31+00:00 trigger=sales_order_shipment_save
state=processing->complete cause=magento
One line, fixed field order, parseable with explode(' ') then explode('=', $part, 2). Values are
stripped of whitespace, = and |, so nothing in a username or a header can forge an extra field.
| Field | |
|---|---|
from / to |
The status either side. [new] when the order had just been placed |
by |
admin:jbloggs#7, integration:Warehouse#3, customer:#42, guest, [cron:cron:run], [cli:sales:clean:quotes], [system] |
area |
adminhtml, frontend, webapi_rest, crontab, … |
ip |
The request address, or [system] under cron and the CLI |
at |
ISO 8601, UTC |
trigger |
The entry point: a controller action, POST:/V1/order/12/ship, or a console command |
state |
Only when the order's state also moved |
cause |
magento when Magento rewrote the status itself; placement for an order's first status |
cause=magento
Magento moves orders between statuses on its own — processing becomes complete once nothing is
left to ship, and either becomes closed once nothing is left to refund. No caller asks for it.
Without distinguishing those, an audit trail tells you an admin set the order to complete when in fact they created a shipment and Magento drew the conclusion. That is the difference between an audit trail and a list of events.
Where entries are written
Two places, independently switchable, both on by default.
Order comments. Where admins already look. They are written inside the order's own database transaction, so an entry exists if and only if the change it describes was committed — and rolls back with a failed save. They are not visible to customers.
A table of its own, deployecommerce_order_status_audit. This is the copy that lasts:
- No foreign key on
order_id, so entries survive order deletion. Order comments cascade away with the order; these do not. - The actor is resolved to a name when the change happens, not looked up later, so entries survive the admin user being deleted too.
- Indexed on order, increment ID, actor, status and date, so "every change by this admin in March" is a query rather than a full scan of a text column.
Turning the table off leaves you with a trail that lasts exactly as long as the orders do. That is a reasonable choice for operational debugging and a poor one for compliance.
Configuration
Stores → Configuration → Advanced → Order Status Audit Log
| Setting | Default | |
|---|---|---|
| Enabled | Yes | |
| Write To Order Comments | Yes | |
| Write To Audit Table | Yes | The copy that survives order deletion |
| Record Order Placement | Yes | One extra entry per order. Without it the trail cannot show what an order started as |
| Statuses To Ignore | — | Changes into or out of these are skipped, for a status some integration flips constantly |
Default scope only. Audit policy is a property of the installation; scoping it per store view would leave you unable to say which policy applied when an entry was written.
What is and is not recorded
Detection is handled by
deployecommerce/module-order-status-transition,
which covers everything that goes through a normal order save: the admin, mass actions, the API,
shipments, invoices, refunds, payment webhooks, cron, the CLI, and the statuses Magento assigns
itself.
It cannot see writes that bypass the model layer — raw SQL, and bulk import tools that write
straight to the sales_order table. No event-based approach can. See that module's README.
Two related caveats worth knowing:
- Behind a CDN the recorded IP is only as trustworthy as Magento's trusted-proxy configuration.
With none configured, Magento takes the first
X-Forwarded-Forvalue, which the client controls. - Anything with database access can write rows that look like audit entries, including order import tools. The comment sink in particular shares a table with ordinary order comments.
Record both, and treat them as a strong account of what happened rather than as evidence.
Adding your own destination
A sink is a destination. It can filter, so it does not have to care about every change — here, one that alerts a channel when a parcel comes back:
use DeployEcommerce\OrderStatusAudit\Api\TransitionSinkInterface; use DeployEcommerce\OrderStatusTransition\Api\Data\ActorInterface; use DeployEcommerce\OrderStatusTransition\Api\Data\TransitionInterface; use Magento\Sales\Model\Order; class ReturnAlertSink implements TransitionSinkInterface { public function record(Order $order, TransitionInterface $transition, ActorInterface $actor): void { if ($transition->getTo() !== 'return_to_sender') { return; } $this->notifier->send(sprintf( 'Order %s came back — %s set it via %s', $transition->getIncrementId(), $actor->getLabel(), $actor->getTrigger() )); } }
<type name="DeployEcommerce\OrderStatusAudit\Model\Sink\CompositeSink"> <arguments> <argument name="sinks" xsi:type="array"> <item name="return_alert" sortOrder="30" xsi:type="object">My\Module\Model\ReturnAlertSink</item> </argument> </arguments> </type>
Sinks run inside the order's save transaction. They may attach data to the order, which Magento
persists as part of the same save; they must never call save() on it. A sink that throws is
logged and skipped — losing one destination must not cost you the others, and an audit log must
never break a checkout.
Development
composer install
composer test
Tests are written with Pest and need no Magento installation. Magento's own packages come from the public Mage-OS mirror, so no Adobe credentials are required.
Licence
MIT. See LICENSE.md.