The Order Lifecycle

State is Magento's internal order machine; status is the editable label. Learn which is which and who moves them.

SyntaxMagento\Sales\Model\Order\Config::getStateDefaultStatus(string $state): ?string

An order is the frozen record of a finished checkout. It is never edited the way a cart is. It moves through positions instead, and Magento records that position twice, in two columns beginners assume are the same thing.

State against status

State is Magento's internal machine. There are eight values, fixed as constants on Magento\Sales\Model\Order: new, pending_payment, processing, complete, closed, canceled, holded and payment_review. Core code branches on state. You never invent one.

Status is the label a merchant sees and a customer reads. Statuses are rows in sales_order_status, assigned to one or more states in sales_order_status_state, whose primary key is the status and state pair, and editable under Stores > Order Status. Magento ships statuses whose codes happen to match state names, such as processing and complete, which is where the confusion starts.

Who moves the order

Magento performs the transitions itself as documents are created. A first invoice or shipment takes a new order to processing. Invoicing and shipping everything takes it to complete. A full credit memo closes it. The decision is made by Magento\Sales\Model\Order\OrderStateResolverInterface::getStateForOrder(), not by your code.

What trips people up

When the state changes, Magento replaces the status with that state's default status, read from Magento\Sales\Model\Order\Config::getStateDefaultStatus(). A custom status written straight onto the order survives until the next document is created, then vanishes.

A new console command stays invisible to bin/magento list until its di.xml entry exists and, in production mode, bin/magento setup:di:compile has run.

Example

Example · php
<?php
// app/code/Vendor/OrderOps/Console/Command/OrderStateCommand.php
declare(strict_types=1);

namespace Vendor\OrderOps\Console\Command;

use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Model\Order\Config as OrderStatusConfig;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class OrderStateCommand extends Command
{
    public function __construct(
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly OrderStatusConfig $statusConfig,
        ?string $name = null
    ) {
        parent::__construct($name);
    }

    protected function configure(): void
    {
        $this->setName('vendor:order:state')
            ->setDescription('Show the state and status of one order')
            ->addArgument('order_id', InputArgument::REQUIRED, 'sales_order.entity_id');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $order = $this->orderRepository->get((int)$input->getArgument('order_id'));
        $state = $order->getState();

        $output->writeln('state:    ' . $state);
        $output->writeln('status:   ' . $order->getStatus());
        $output->writeln('default:  ' . (string)$this->statusConfig->getStateDefaultStatus($state));

        return Command::SUCCESS;
    }
}

// bin/magento vendor:order:state 15
// state:    processing
// status:   awaiting_pickup
// default:  processing

When to use it

  • Explain to a merchant why the status they typed into the order disappeared after the shipment was created.
  • Add an "Awaiting Pickup" status for click-and-collect orders without breaking the grid filters.
  • Decide whether a report should group by state or by status when both columns exist on sales_order_grid.

More examples

Shorthand and longhand: the typed getter is a data key

Magento\Sales\Model\Order::getState() is a one-line wrapper that returns getData('state'), and the same holds for getStatus() and getIncrementId(). Both forms appear in real modules, so you need to recognise each. Prefer the typed getter in your own code, and reach for the constant Order::STATE_PROCESSING rather than the literal 'processing' so a typo becomes a fatal error instead of a silent mismatch.

Example · php
<?php
// app/code/Vendor/OrderOps/Model/StateReader.php
declare(strict_types=1);

namespace Vendor\OrderOps\Model;

use Magento\Sales\Model\Order;

class StateReader
{
    public function describe(Order $order): string
    {
        // Shorthand: the typed getter declared on the order model.
        $state = $order->getState();

        // Longhand: exactly what getState() does internally. The same value.
        $same = $order->getData(Order::STATE);

        // Compare against the constant, never the string literal.
        $isOpen = $state === Order::STATE_PROCESSING || $state === Order::STATE_NEW;

        return sprintf('%s/%s open=%s', $state, $same, $isOpen ? 'yes' : 'no');
    }
}

// Output:
// processing/processing open=yes

Creating a custom status with a data patch

Reach for this whenever a merchant asks for a label Magento does not ship, such as "Awaiting Pickup" or "Sent to Warehouse". The patch runs once per environment during setup:upgrade, which is what makes the status reproducible rather than something that exists only on the machine where someone clicked Create New Status. Note assignState($state, $isDefault, $visibleOnFront): leaving $isDefault false means the status is available in that state but is not what Magento falls back to on a transition.

Example · php
<?php
// app/code/Vendor/OrderOps/Setup/Patch/Data/AddAwaitingPickupStatus.php
declare(strict_types=1);

namespace Vendor\OrderOps\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 AddAwaitingPickupStatus implements DataPatchInterface
{
    public const STATUS_CODE = 'awaiting_pickup';

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

    public function apply(): void
    {
        $status = $this->statusFactory->create();
        $status->setData([
            'status' => self::STATUS_CODE,
            'label' => 'Awaiting Pickup',
        ]);

        $this->statusResource->save($status);

        // Assign it to one state. Not default, visible on the front end.
        $status->assignState(Order::STATE_PROCESSING, false, true);
    }

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

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

// bin/magento setup:upgrade
// The status appears under Stores > Order Status, mapped to Processing.

Observing the transition instead of forcing it

Use this when you need to react to a state change rather than cause one, for example pushing the new state to a warehouse system. The generic <entity>_save_after event comes from the _eventPrefix property on the model, so sales_invoice_save_after and sales_shipment_save_after exist on the same pattern. Reacting after the fact is safe; trying to set the state yourself inside the observer is not, because you are competing with the resolver that is already running.

Example · xml
<!-- app/code/Vendor/OrderOps/etc/events.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
    <event name="sales_order_save_after">
        <observer name="vendor_orderops_log_state"
                  instance="Vendor\OrderOps\Observer\LogStateChange"/>
    </event>
</config>

<!--
The observer receives the order as $observer->getEvent()->getOrder(),
because Magento\Sales\Model\Order sets _eventPrefix to 'sales_order'
and _eventObject to 'order'.

After adding this file: bin/magento cache:flush
In production mode: bin/magento setup:di:compile first.
-->

Discussion

  • Be the first to comment on this lesson.