← Back to blog

Magento 2 Custom Order Status: States, Statuses and Workflow

Rodrigo Biazzi

Merchants think in their own words: "awaiting stock", "in production", "ready for pickup". Magento has its own fixed lifecycle underneath. Custom order statuses are how you connect the two, and they work well as long as you respect the difference between a state and a status. When you do not, Magento quietly overwrites your status on the next save and nobody understands why.

This article explains states and statuses in Magento Open Source and Adobe Commerce 2.4.8, how to create statuses in code so every environment matches, how to change them safely, and how to notify customers.

States and statuses

A state is part of Magento's code. It decides what can happen to the order: can it be invoiced, shipped, refunded, canceled. The states are constants on Magento\Sales\Model\Order:

ConstantValue
STATE_NEWnew
STATE_PENDING_PAYMENTpending_payment
STATE_PROCESSINGprocessing
STATE_COMPLETEcomplete
STATE_CLOSEDclosed
STATE_CANCELEDcanceled
STATE_HOLDEDholded
STATE_PAYMENT_REVIEWpayment_review

You cannot add a state without rewriting large parts of the sales module, and you should not try.

A status is a label stored in the database. Every status is assigned to one or more states in sales_order_status_state, and each state has one default status. The order's status column is what admins and customers see. The state column is what the code checks.

So "in production" is not a new stage in Magento. It is a status under the processing state. The order behaves exactly like any processing order, it just shows a different label.

Designing the workflow

Start by mapping each business stage to a state, before touching code. Ask for every stage: can the order still be canceled? Is it paid? Has it shipped? The answers point to the state:

  • "Awaiting bank transfer" fits pending_payment or new, depending on how the payment method works.
  • "Awaiting stock" and "in production" usually fit processing, when the order is paid but not shipped.
  • "Ready for pickup" is tricky. If the shipment is already created in Magento, the order may already be complete.

Keep the list short. Every status needs someone or something that moves orders in and out of it. Statuses that nobody updates become noise in the order grid.

Creating statuses with a data patch

You can create statuses in the admin under Stores, Settings, Order Status. That is fine for a one off, but then staging, local and production drift apart. A data patch keeps them in code. Declarative schema and data patches covers patches in general.

<?php
declare(strict_types=1);

namespace Vendor\OrderFlow\Setup\Patch\Data;

use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\Sales\Model\Order;
use Magento\Sales\Model\Order\StatusFactory;
use Magento\Sales\Model\ResourceModel\Order\Status as StatusResource;

class AddProductionStatuses implements DataPatchInterface
{
    private const STATUSES = [
        'awaiting_stock' => ['label' => 'Awaiting Stock', 'state' => Order::STATE_PROCESSING],
        'in_production' => ['label' => 'In Production', 'state' => Order::STATE_PROCESSING],
    ];

    public function __construct(
        private readonly StatusFactory $statusFactory,
        private readonly StatusResource $statusResource
    ) {
    }

    public function apply(): self
    {
        foreach (self::STATUSES as $code => $config) {
            $status = $this->statusFactory->create();
            $this->statusResource->load($status, $code);

            $status->setData('status', $code);
            $status->setData('label', $config['label']);
            $this->statusResource->save($status);

            $status->assignState($config['state'], false, true);
        }

        return $this;
    }

    public static function getDependencies(): array
    {
        return [];
    }

    public function getAliases(): array
    {
        return [];
    }
}

assignState() takes the state, whether the status is the default for that state, and whether it is visible on the storefront. Keep is_default false unless you really want every new processing order to get your status. Changing the default status of processing affects payment methods, invoices and third party extensions that expect the core label.

Changing the status from code

When code moves an order to a custom status, set both state and status explicitly, add a history comment, and save through the repository:

<?php
declare(strict_types=1);

namespace Vendor\OrderFlow\Model;

use Magento\Framework\Exception\LocalizedException;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Model\Order;

class MoveToProduction
{
    public function __construct(
        private readonly OrderRepositoryInterface $orderRepository
    ) {
    }

    public function execute(int $orderId, string $comment = ''): void
    {
        /** @var Order $order */
        $order = $this->orderRepository->get($orderId);

        if ($order->getState() !== Order::STATE_PROCESSING) {
            throw new LocalizedException(__('Only processing orders can move to production.'));
        }

        $order->setStatus('in_production');
        $order->addCommentToStatusHistory($comment ?: (string) __('Order sent to production.'), 'in_production');

        $this->orderRepository->save($order);
    }
}

Checking the state first matters. Moving a canceled or complete order to "in production" creates a label that contradicts what the order can actually do. Put this logic in a service class like the one above and call it from wherever the trigger comes from: an admin button, a CLI command, an ERP webhook or a queue consumer. One place to change the rule, one place to test it.

Why Magento overwrites your status

This is the part that surprises people. When an order is saved after an invoice or a shipment, Magento\Sales\Model\ResourceModel\Order\Handler\State checks whether the order should move to another state. For example, a processing order that has been fully invoiced and fully shipped moves to complete, and the status changes to the default status of complete.

That is correct behavior, but it means:

  • A custom status under processing is replaced when the order is shipped and invoiced. Plan for that. Usually it is what you want.
  • If you need a custom status after shipment, like "delivered", assign it to complete and set it after Magento has moved the order to that state.
  • When an invoice is created for a pending order, Magento moves it to processing with the default status, replacing a custom pending status.

Do not fight this with plugins that block state changes. It breaks reports, refunds and integrations that rely on the standard flow.

Payment methods and the initial status

Most payment methods have a "New Order Status" setting, the order_status config value under payment/<code>. The admin dropdown only lists statuses assigned to the state the method uses, which is why a new status sometimes "does not appear" there. Assign it to the right state and it shows up.

Notifying the customer

Changing a status does not send any email by itself. To notify the customer with the history comment, use Magento\Sales\Model\Order\Email\Sender\OrderCommentSender:

$this->orderCommentSender->send($order, true, $comment);

It uses the "Order Update" email template, which you can customize per store. Mark the history entry as customer notified so the admin shows that an email went out. For richer messages per status, create a dedicated template. Custom email templates covers how. The "Visible on Storefront" flag decides whether the status label and the comment appear in the customer's order history page.

Automatic transitions

Some transitions should happen without anyone clicking. "Awaiting stock" can turn into "ready to ship" when a stock update arrives, or an order that sat in a status for too long can be flagged. Two patterns work well:

  • Event driven. React to the thing that changed, like a stock import or a webhook, and call the service class for the affected orders. This is precise and fast.
  • Scheduled. A cron job that searches orders by status and age, using OrderRepositoryInterface::getList() with a search criteria on status and updated_at. Keep the page size small and the job idempotent, so a second run does nothing harmful.

Whatever moves the order, always leave a history comment explaining why. Six months later, the comment is the only record of who or what changed the status. The cron jobs guide covers scheduling and locking.

Integrations

External systems can add a comment and change the status through REST with POST /V1/orders/{id}/comments, passing a statusHistory object with comment, status and is_customer_notified. For anything with rules, I prefer exposing your own endpoint that calls the service class above, so the state check runs no matter who sends the request. If an ERP is driving the workflow, see ERP integration patterns.

FAQ

What is the difference between order state and order status in Magento 2?

The state is a fixed value in code that decides what can be done with the order. The status is a configurable label assigned to a state. Many statuses can share one state.

Can I create a new order state?

Not in a practical way. States are hardcoded in the sales module. Create a status and assign it to the closest existing state.

Why did my custom status change back to Processing or Complete?

Magento recalculates the state when invoices and shipments are saved and then applies the default status for the new state. Set your status after that transition, or assign it to the state the order ends up in.

Does changing the status email the customer?

No. Send the order comment email with OrderCommentSender, or your own template, when the status changes.

I am available for freelance Magento and Adobe Commerce work, including order workflows and integrations. If you need help with this, get in touch.

Related articles