How state and status work together in Magento's order system and how a custom status gets registered, assigned, and hooked into the admin grid and email triggers
Anyone adding a custom order status to Magento 2 for the first time almost always trips over the same confusion: order state and order status are two separate layers, and skipping past that distinction produces statuses that cannot be filtered in the admin grid or that trigger email notifications nobody expected. This article explains the structure of Magento's order workflow system in detail and shows how to correctly register a new status, assign it to a state, and hook it into existing status transitions and notification paths.
Table of Contents
- 1. Order state versus order status: the central distinction
- 2. The underlying tables: sales_order_status and sales_order_status_state
- 3. Registering a custom status through a data patch
- 4. Hooking the new status into status transitions
- 5. Visibility in the customer account and storefront translation
- 6. Impact on admin grid filters
- 7. Impact on email notifications
- 8. Impact on REST and GraphQL APIs
- 9. Status workflow building blocks at a glance
- 10. Summary
- 11. FAQ
1. Order state versus order status: the central distinction
An order state is a fixed, core hardcoded constant such as Order::STATE_PROCESSING, STATE_COMPLETE, or STATE_HOLDED, tied to numerous core processes, for instance whether an order can even still be shipped or canceled. States cannot be freely extended, because a lot of core logic checks directly against these fixed values instead of against the more flexible status.
An order status, on the other hand, is freely definable and purely meant for display and communication, for instance in the admin grid, in customer emails, or in the customer account. Several statuses can be assigned to the same state: the processing state, for example, can carry both the default processing status and a custom status such as warehouse_picking, without technically changing anything about the order's underlying behavior.
2. The underlying tables: sales_order_status and sales_order_status_state
At the database level, the sales_order_status table holds every available status with its label, while sales_order_status_state maps the actual assignment between status and state, including an is_default flag that determines which status counts as the default for a given state. This two table structure is why a new status cannot simply be set as a string anywhere, it first needs to be formally registered.
The visible_on_front value in sales_order_status_state additionally controls whether a status is visible in the customer account. An internal status like fraud_review can be hidden from the customer this way while still being filtered and displayed normally in the admin grid, which is the usual approach for internal review statuses without customer communication.
3. Registering a custom status through a data patch
Since a new status is data, not schema, its registration belongs in a data patch rather than in db_schema.xml. The patch creates the status through StatusInterface and then assigns it to an existing state via StatusResource::assignState(), without needing to create a new state in the core.
It matters to write the patch idempotently, since it could run again on every setup:upgrade if version tracking is misconfigured. Checking whether the status already exists before creating it again prevents duplicate entries and unnecessary errors on repeated deployments.
<?php
declare(strict_types=1);
namespace Mironsoft\OrderWorkflow\Setup\Patch\Data;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\Sales\Model\Order\Status;
use Magento\Sales\Model\Order\StatusFactory;
use Magento\Sales\Model\ResourceModel\Order\Status as StatusResource;
use Magento\Sales\Model\Order;
/**
* Registers the custom "warehouse_picking" status and assigns it to the processing state.
*/
final class AddWarehousePickingStatus implements DataPatchInterface
{
public function __construct(
private readonly StatusFactory $statusFactory,
private readonly StatusResource $statusResource,
) {
}
/**
* Runs the registration of the new status.
*
* @return void
*/
public function apply(): void
{
/** @var Status $status */
$status = $this->statusFactory->create();
$status->setData([
'status' => 'warehouse_picking',
'label' => 'Warehouse Picking',
]);
$this->statusResource->save($status);
$this->statusResource->assignState($status, Order::STATE_PROCESSING, false);
}
public static function getDependencies(): array
{
return [];
}
public function getAliases(): array
{
return [];
}
}
4. Hooking the new status into status transitions
Registration alone does not make an order actually transition into the new status, it needs a trigger as well. The common approach is an observer on a suitable sales event, such as sales_order_invoice_pay or a custom event fired from a warehouse system, which sets the new status deliberately through Order::setStatus() instead of relying on setState(), which would implicitly reset the status back to the target state's default status.
For more complex workflows with several possible transitions out of the same status, a small dedicated state machine class pays off, one that explicitly lists allowed transitions and refuses a transition into a disallowed status with a meaningful exception. That prevents a misconfigured observer from moving an order into a nonsensical status, such as back from complete to warehouse_picking.
5. Visibility in the customer account and storefront translation
Whether a status shows up in the customer account at all is decided by the already mentioned visible_on_front flag, but even a visible status stays of little help to the customer if its label is not translated into the relevant storefront language. Magento translates status labels through the theme's regular CSV based translation files, not through the label field from sales_order_status itself, which only serves as a fallback for untranslated languages.
When a new status gets rolled out across several store views with different languages, the raw English or German text from the data patch needs to be additionally added as a translation entry in the active theme's i18n CSV file, otherwise the customer account in other language store views keeps showing the untranslated raw value, which is easy to overlook especially on internationally rolled out stores.
6. Impact on admin grid filters
The order grid in the admin filters by default on the status column, fed from sales_order_grid, a denormalized table kept in sync with every status change through an indexer or plugin. A newly registered status automatically appears as a filter option in the grid, as long as it was correctly registered through sales_order_status, with no additional code needed for the grid integration itself.
It looks different when the new status should stand out with its own color coding or icon in the grid, which requires an additional UI component change through sales_order_grid.xml. Without that adjustment the status still displays and filters correctly but visually blends in with the standard statuses, which can hurt overview clarity in daily operations for frequently used internal statuses.
7. Impact on email notifications
Magento's standard email notifications for orders, invoices, and shipments primarily hang off the state transition, not the specific status, which means a new status within the same state triggers no additional email by default. Anyone who wants to actively notify the customer once the new status is reached needs to register their own observer that sends a transactional email template through the TransportBuilder on that specific status change.
It matters to respect the internal nature of some new statuses here: a status like warehouse_picking is typically only relevant for internal logistics processes and should deliberately trigger no customer email, while a customer facing status like partially_shipped can well justify its own notification that goes beyond the standard shipment confirmation.
8. Impact on REST and GraphQL APIs
A newly registered status automatically shows up in the status field of the REST order API and in the corresponding GraphQL field of customer orders, with no extra API work needed, since both interfaces simply pass through the raw value stored on the order. External systems wired up through the API, such as an ERP or a mobile app, still need to know about the new status value themselves in order to handle it correctly.
Especially with GraphQL consumers that check status values against a hardcoded list of known values, an unannounced new status easily turns into a silently ignored or mis-rendered case in the frontend client. A new status should therefore not only be documented internally but also actively communicated to every team running its own consumer of the order API.
9. Status workflow building blocks at a glance
The table below summarizes the central building blocks of a custom order status workflow along with their respective role.
| Building Block | Responsible Table/Class | Role | Caveat |
|---|---|---|---|
| Status definition | sales_order_status | Registering the status with a label | Pure data, no schema |
| Status to state assignment | sales_order_status_state | Assigning the status to a fixed state | Several statuses per state are possible |
| Registration | Data patch with StatusResource::assignState | Creating the status idempotently | No new state needed in the core |
| Status transition | Observer + setStatus() | Setting the status deliberately on an event | setState() would set the default status instead |
| Admin grid filter | sales_order_grid | Automatic filter entry | Color highlighting needs sales_order_grid.xml |
Mironsoft
Magento development, module consulting, and system architecture
A Magento project that needs a second opinion or experienced execution?
We build custom Magento modules, advise on architecture decisions, and take on complex implementations, from service contract planning to production-ready deployment.
Architecture Consulting
Have module and system architecture thought through properly before you build.
Custom Module Development
Build custom Magento modules cleanly, following best practices.
Code Review & Audit
Have existing modules reviewed for performance, security, and maintainability.
10. Summary
Custom Order Statuses: The Essentials at a Glance
Core idea
State is fixed and core relevant, status is freely definable and primarily meant for display and communication.
Registration path
A data patch creates the status and assigns it to an existing state via assignState.
Biggest trap
setState() implicitly resets to the target state's default status instead of preserving the custom status.
Success criterion
New statuses appear automatically in the admin grid filter but only trigger emails through a dedicated observer.