Working with Orders in Code
Load an order through OrderRepositoryInterface, save it safely, add a status history comment, and stop writing statuses by hand.
OrderRepositoryInterface::save(\Magento\Sales\Api\Data\OrderInterface $entity): \Magento\Sales\Api\Data\OrderInterfaceOrders are read and written through Magento\Sales\Api\OrderRepositoryInterface. A repository is the injectable service that fetches and persists one entity type. This one declares four methods: get($id), getList(SearchCriteriaInterface $searchCriteria), save(OrderInterface $entity) and delete(OrderInterface $entity). Inject the interface in your constructor. Never call the object manager, and never instantiate the order model with new.
get() hands back a shared object
The order repository keeps a per-request registry keyed by id, so two calls to get(15) return the same instance. That is convenient and dangerous. If a plugin or observer earlier in the request already changed that object and did not save it, your save() writes their change too.
Adding a comment
The order model's addCommentToStatusHistory($comment, $status = false, $isVisibleOnFront = false) only stages a history row. Nothing reaches sales_order_status_history until you call save(). The alternative is OrderManagementInterface::addComment($id, OrderStatusHistoryInterface $statusHistory), which validates and saves for you.
Why setting a status directly is a trap
Calling setStatus('awaiting_pickup') writes any string at all, with no check that the status exists or belongs to the order's current state. The next invoice, shipment or credit memo changes the state, and Magento then overwrites your status with that state's default. The honest route is addCommentToStatusHistory($text, 'awaiting_pickup'): passing a status there sets it and records why it changed.
These are plain classes, so bin/magento cache:flush suffices in developer mode. Production mode needs bin/magento setup:di:compile before the new constructor arguments resolve.
Example
<?php
// app/code/Vendor/OrderOps/Model/OrderNotes.php
declare(strict_types=1);
namespace Vendor\OrderOps\Model;
use Magento\Sales\Api\Data\OrderStatusHistoryInterfaceFactory;
use Magento\Sales\Api\OrderManagementInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
class OrderNotes
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly OrderManagementInterface $orderManagement,
private readonly OrderStatusHistoryInterfaceFactory $historyFactory
) {
}
/**
* Route one: the management service validates and saves for you.
*/
public function addInternalNote(int $orderId, string $text): void
{
$history = $this->historyFactory->create();
$history->setParentId($orderId);
$history->setEntityName('order');
$history->setComment($text);
$history->setIsVisibleOnFront(0);
$history->setIsCustomerNotified(0);
// Leaving the status unset keeps the order's current status.
$this->orderManagement->addComment($orderId, $history);
}
/**
* Route two: stage the row on the order, then save once.
*/
public function addNoteAndSave(int $orderId, string $text): void
{
/** @var \Magento\Sales\Model\Order $order */
$order = $this->orderRepository->get($orderId);
// Stages a history row only. Nothing is written until save().
$order->addCommentToStatusHistory($text, false, false);
$this->orderRepository->save($order);
}
}
// Both write one row to sales_order_status_history and leave
// sales_order.status unchanged.When to use it
- Write a cron job that cancels orders left in state new for more than 48 hours.
- Record a note on the order every time a warehouse integration rejects a line.
- Set a custom status from an admin controller without it being wiped by the next shipment.
- Diagnose why one module's change to an order disappeared after another module saved it.
More examples
Shorthand and longhand on the order model
Every getter on Magento\Sales\Model\Order is a one-line wrapper over getData() with the snake_case column name, and the interface constants such as OrderInterface::INCREMENT_ID hold those names. You will meet both forms in other people's modules, often in the same file. Write the typed getter yourself, because an IDE and static analysis can check it, but recognise getData('increment_id') as the same value when you read it.
<?php
// app/code/Vendor/OrderOps/Model/OrderLabel.php
declare(strict_types=1);
namespace Vendor\OrderOps\Model;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Model\Order;
class OrderLabel
{
public function shortLabel(Order $order): string
{
// Shorthand: the typed getter from OrderInterface.
$increment = $order->getIncrementId();
// Longhand: exactly what that getter runs internally.
$same = $order->getData(OrderInterface::INCREMENT_ID);
// Shorthand: null coalescing on a nullable column.
$email = $order->getCustomerEmail() ?? 'no-email';
// Longhand for the line above.
$raw = $order->getCustomerEmail();
$email = $raw !== null ? $raw : 'no-email';
return $increment . ' / ' . $same . ' / ' . $email;
}
}
// Output:
// 000000015 / 000000015 / [email protected]Fetching orders with search criteria instead of a collection
Use this in a cron job or a console command that sweeps old orders. Two things make it safe. First, SearchCriteriaBuilder binds every value, so a date or a state coming from configuration cannot become SQL; writing the same filter as a string into addFieldToFilter on a raw collection does not give you that. Second, setPageSize keeps a store with a million orders from loading them all into memory. The FilterGroupBuilder import is unused here and shown only because multi-condition OR groups need it.
<?php
// app/code/Vendor/OrderOps/Model/StaleOrderFinder.php
declare(strict_types=1);
namespace Vendor\OrderOps\Model;
use Magento\Framework\Api\Search\FilterGroupBuilder;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Model\Order;
class StaleOrderFinder
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder
) {
}
/**
* @return OrderInterface[]
*/
public function olderThan(string $createdBefore, int $pageSize = 50): array
{
// Values are bound by the criteria layer, never concatenated.
$criteria = $this->searchCriteriaBuilder
->addFilter(OrderInterface::STATE, Order::STATE_NEW)
->addFilter(OrderInterface::CREATED_AT, $createdBefore, 'lt')
->setPageSize($pageSize)
->create();
return $this->orderRepository->getList($criteria)->getItems();
}
}
// Returns up to 50 OrderInterface objects. getTotalCount() on the search
// result gives the unpaginated total.Setting a custom status the supported way
This is the pattern to copy whenever a custom status is involved. getStateStatuses() returns the statuses assigned to a state, keyed by status code, so checking membership first turns a silent mismatch into a clear exception. Passing the status as the second argument to addCommentToStatusHistory is the only route that both sets the status and leaves an audit trail; calling setStatus() on its own leaves no record of who changed it or why, and the next invoice or shipment will overwrite it.
<?php
// app/code/Vendor/OrderOps/Model/PickupStatusSetter.php
declare(strict_types=1);
namespace Vendor\OrderOps\Model;
use Magento\Framework\Exception\LocalizedException;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Model\Order\Config as OrderStatusConfig;
class PickupStatusSetter
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly OrderStatusConfig $statusConfig
) {
}
public function markAwaitingPickup(int $orderId): void
{
/** @var \Magento\Sales\Model\Order $order */
$order = $this->orderRepository->get($orderId);
$allowed = $this->statusConfig->getStateStatuses($order->getState());
if (!array_key_exists('awaiting_pickup', $allowed)) {
throw new LocalizedException(
__('awaiting_pickup is not assigned to state %1.', $order->getState())
);
}
// Passing a status sets it AND records a history row explaining it.
$order->addCommentToStatusHistory(
'Parcel is on the collection shelf.',
'awaiting_pickup',
true
);
$this->orderRepository->save($order);
}
}
// Writes sales_order.status = awaiting_pickup plus one history row that the
// customer can see in My Account.
Discussion