Modeling Custom Order Statuses and Status Transitions
AI generated
M2
di.xml
Magento 2 · Sales · Order Workflow
Modeling Custom Order Statuses and Status Transitions
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.

11 min read Order Status Order State Sales Workflow Admin Grid

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.

11. FAQ: Custom Order Statuses: The Essentials at a Glance

1What is the difference between order state and order status?
State is a fixed, core relevant constant, status is freely definable and primarily meant for display and communication.
2Can several statuses be assigned to the same state?
Yes, a state such as processing can carry both the default status and several custom statuses.
3In which table is a new status registered?
In sales_order_status, with the assignment to the state additionally living in sales_order_status_state.
4Why does status registration belong in a data patch rather than db_schema.xml?
Because it is data, not schema, and db_schema.xml is meant for structural changes.
5How is a status assigned to an existing state?
Through StatusResource::assignState() inside the data patch, without creating a new state in the core.
6Why use setStatus() instead of setState()?
Because setState() implicitly resets the status to the target state's default status, overwriting the custom status.
7Does a new status appear automatically in the admin grid filter?
Yes, as long as it was correctly registered through sales_order_status, with no additional code for the grid integration.
8Does a new status automatically trigger a customer email?
No, standard notifications hang off the state transition, a dedicated observer is needed for additional emails.
9How can a status be hidden from the customer?
Through the visible_on_front flag in sales_order_status_state, while it remains normally visible in the admin grid.
10What prevents disallowed status transitions?
A dedicated state machine class that explicitly lists allowed transitions and refuses disallowed ones with an exception.