Magento 2 Custom Order Status: States, Statuses and Workflow
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:
| Constant | Value |
|---|---|
| STATE_NEW | new |
| STATE_PENDING_PAYMENT | pending_payment |
| STATE_PROCESSING | processing |
| STATE_COMPLETE | complete |
| STATE_CLOSED | closed |
| STATE_CANCELED | canceled |
| STATE_HOLDED | holded |
| STATE_PAYMENT_REVIEW | payment_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_paymentornew, 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
processingis 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
completeand set it after Magento has moved the order to that state. - When an invoice is created for a pending order, Magento moves it to
processingwith 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 onstatusandupdated_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
Magento 2 Declarative Schema and Data Patches, Safely
Change the Magento 2 database without breaking production: db_schema.xml, the whitelist, data and schema patches, revertable patches and the dry-run mode.
Export Magento 2 Orders to an ERP Without Losing Any
A reliable way to export Magento 2 orders to an ERP: export status columns, the right event, message queues, retries, idempotency and a cron safety net.
Magento 2 Admin Security Hardening Checklist
Harden the Magento 2 admin: custom admin path, IP restriction, 2FA, lockout and session settings, user and role cleanup, API integrations and admin logs.
Magento 2 Admin Two Factor Authentication: Setup and Fixes
Set up Magento 2 admin two factor authentication: providers, forcing a provider, CLI commands to reset users, local development, API tokens and common problems.