Magento_Sales Anti-Patterns
Magento_Sales Anti-Patterns
Magento_Sales Anti-Patterns
Overview
This document catalogs common anti-patterns, mistakes, and problematic approaches when working with the Magento_Sales module. Each anti-pattern includes the problematic code, explanation of why it's wrong, the correct approach, and real-world consequences of using the anti-pattern.
Direct Model Manipulation Anti-Patterns
Anti-Pattern 1: Direct Order Save Without Service Contracts
Problematic Code:
<?php
// BAD: Direct model manipulation bypasses business logic
$order = $this->orderFactory->create()->load($orderId);
$order->setState(\Magento\Sales\Model\Order::STATE_COMPLETE);
$order->setStatus('complete');
$order->save();
Why This Is Wrong: - Bypasses all plugins and observers - Skips state validation (canComplete(), state machine rules) - No event dispatching (sales_order_save_before/after) - No transaction handling - partial saves possible - Breaks extension attribute handling - No API compatibility - direct saves don't work via REST/GraphQL
Correct Approach:
<?php
declare(strict_types=1);
namespace Vendor\Module\Service;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Api\OrderManagementInterface;
use Magento\Framework\Exception\LocalizedException;
/**
* Proper order state management
*/
class OrderStateManagement
{
public function __construct(
private OrderRepositoryInterface $orderRepository,
private OrderManagementInterface $orderManagement
) {}
/**
* Complete order using service contracts
*
* @param int $orderId
* @return void
* @throws LocalizedException
*/
public function completeOrder(int $orderId): void
{
$order = $this->orderRepository->get($orderId);
// Validate state transition is allowed
if ($order->getState() === \Magento\Sales\Model\Order::STATE_COMPLETE) {
throw new LocalizedException(__('Order is already complete.'));
}
if (!$order->canShip() && !$order->canInvoice()) {
// Order can be completed - all items shipped/invoiced
$order->setState(\Magento\Sales\Model\Order::STATE_COMPLETE);
$order->setStatus(
$order->getConfig()->getStateDefaultStatus(
\Magento\Sales\Model\Order::STATE_COMPLETE
)
);
// Use repository save - triggers all plugins and events
$this->orderRepository->save($order);
} else {
throw new LocalizedException(
__('Order cannot be completed - items pending shipment/invoice.')
);
}
}
}
Real-World Consequences: - Integration failures: Third-party modules miss order state changes - Inventory issues: Stock not updated because observers didn't fire - Payment problems: Payment gateway not notified of completion - Reporting errors: Analytics/BI systems miss state changes - Audit trail gaps: No history comments generated
Anti-Pattern 2: Direct Order Status Change Without State
Problematic Code:
<?php
// BAD: Changing status without updating state
$order = $this->orderFactory->create()->load($orderId);
$order->setStatus('custom_status');
$order->save();
// State remains old value - inconsistent!
Why This Is Wrong: - State and status become inconsistent - Business logic checks state, not status - canInvoice(), canShip() checks fail - Order workflow breaks - Admin UI shows wrong available actions
Correct Approach:
<?php
declare(strict_types=1);
namespace Vendor\Module\Service;
use Magento\Sales\Model\Order;
/**
* Proper status/state management
*/
class OrderStatusManager
{
/**
* Set custom status while maintaining state consistency
*
* @param Order $order
* @param string $status
* @param string $comment
* @return void
*/
public function setOrderStatus(
Order $order,
string $status,
string $comment = ''
): void {
// Validate status exists and maps to current state
$statusState = $order->getConfig()->getStateDefaultStatus($order->getState());
$availableStatuses = $order->getConfig()->getStateStatuses($order->getState());
if (!in_array($status, $availableStatuses, true)) {
throw new \InvalidArgumentException(
sprintf(
'Status "%s" is not valid for state "%s"',
$status,
$order->getState()
)
);
}
// Set status (state remains unchanged)
$order->setStatus($status);
// Add status history comment
if ($comment) {
$order->addCommentToStatusHistory(
$comment,
$status,
false // Not visible to customer
);
}
$this->orderRepository->save($order);
}
/**
* Create custom status mapped to processing state
*
* This should be done via data patch, not runtime code
*/
public function createCustomStatus(): void
{
// Example: Add "awaiting_approval" status to "processing" state
$status = $this->statusFactory->create();
$status->setData([
'status' => 'awaiting_approval',
'label' => 'Awaiting Approval'
]);
$this->statusResource->save($status);
// Assign to processing state
$status->assignState(
Order::STATE_PROCESSING,
false, // Not default
true // Visible on storefront
);
}
}
Anti-Pattern 3: Direct Collection Load Without Search Criteria
Problematic Code:
<?php
// BAD: Direct collection usage bypasses API contracts
$orderCollection = $this->orderCollectionFactory->create();
$orderCollection->addFieldToFilter('customer_id', $customerId);
$orderCollection->addFieldToFilter('status', 'processing');
$orderCollection->load();
foreach ($orderCollection as $order) {
// Process orders
}
Why This Is Wrong: - Not compatible with Web API - Bypasses collection processors (ACL, store scope) - No extension attribute loading - Breaks when collection implementation changes - Doesn't support GraphQL resolvers - Can't add custom filters via plugins
Correct Approach:
<?php
declare(strict_types=1);
namespace Vendor\Module\Model;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\Api\SortOrderBuilder;
/**
* Order search service using search criteria
*/
class OrderSearchService
{
public function __construct(
private OrderRepositoryInterface $orderRepository,
private SearchCriteriaBuilder $searchCriteriaBuilder,
private SortOrderBuilder $sortOrderBuilder
) {}
/**
* Get customer orders with filters
*
* @param int $customerId
* @param array $statuses
* @param int $pageSize
* @param int $currentPage
* @return \Magento\Sales\Api\Data\OrderSearchResultInterface
*/
public function getCustomerOrders(
int $customerId,
array $statuses = [],
int $pageSize = 20,
int $currentPage = 1
): \Magento\Sales\Api\Data\OrderSearchResultInterface {
$this->searchCriteriaBuilder
->addFilter('customer_id', $customerId, 'eq');
if (!empty($statuses)) {
$this->searchCriteriaBuilder
->addFilter('status', $statuses, 'in');
}
// Add sort order
$sortOrder = $this->sortOrderBuilder
->setField('created_at')
->setDirection('DESC')
->create();
$searchCriteria = $this->searchCriteriaBuilder
->addSortOrder($sortOrder)
->setPageSize($pageSize)
->setCurrentPage($currentPage)
->create();
return $this->orderRepository->getList($searchCriteria);
}
/**
* Get orders by date range
*
* @param string $fromDate
* @param string $toDate
* @return \Magento\Sales\Api\Data\OrderSearchResultInterface
*/
public function getOrdersByDateRange(
string $fromDate,
string $toDate
): \Magento\Sales\Api\Data\OrderSearchResultInterface {
$searchCriteria = $this->searchCriteriaBuilder
->addFilter('created_at', $fromDate, 'gteq')
->addFilter('created_at', $toDate, 'lteq')
->create();
return $this->orderRepository->getList($searchCriteria);
}
}
Invoice/Shipment/Credit Memo Anti-Patterns
Anti-Pattern 4: Manual Invoice Creation Without Validation
Problematic Code:
<?php
// BAD: Creating invoice without proper validation
$invoice = $this->invoiceFactory->create();
$invoice->setOrderId($orderId);
$invoice->setState(\Magento\Sales\Model\Order\Invoice::STATE_PAID);
$invoice->setGrandTotal($amount);
$invoice->save();
Why This Is Wrong: - No order validation (canInvoice check skipped) - Invoice items not created - Order totals not updated - No payment capture - No customer notification - Inventory not updated
Correct Approach:
<?php
declare(strict_types=1);
namespace Vendor\Module\Service;
use Magento\Sales\Api\Data\InvoiceInterface;
use Magento\Sales\Model\Order;
use Magento\Framework\Exception\LocalizedException;
/**
* Proper invoice creation service
*/
class InvoiceCreationService
{
public function __construct(
private \Magento\Sales\Api\OrderRepositoryInterface $orderRepository,
private \Magento\Sales\Model\Service\InvoiceService $invoiceService,
private \Magento\Framework\DB\TransactionFactory $transactionFactory,
private \Magento\Sales\Model\Order\Email\Sender\InvoiceSender $invoiceSender,
private \Psr\Log\LoggerInterface $logger
) {}
/**
* Create invoice properly
*
* @param int $orderId
* @param array $items Item ID => Qty to invoice
* @param bool $captureOnline
* @param bool $notify
* @return InvoiceInterface
* @throws LocalizedException
*/
public function createInvoice(
int $orderId,
array $items = [],
bool $captureOnline = true,
bool $notify = true
): InvoiceInterface {
$order = $this->orderRepository->get($orderId);
// Validate order can be invoiced
if (!$order->canInvoice()) {
throw new LocalizedException(
__('The order does not allow an invoice to be created.')
);
}
// Use InvoiceService for proper invoice creation
$invoice = $this->invoiceService->prepareInvoice($order, $items);
if (!$invoice->getTotalQty()) {
throw new LocalizedException(
__('You can\'t create an invoice without products.')
);
}
// Set capture case
$invoice->setRequestedCaptureCase(
$captureOnline
? \Magento\Sales\Model\Order\Invoice::CAPTURE_ONLINE
: \Magento\Sales\Model\Order\Invoice::NOT_CAPTURE
);
// Register invoice (updates order, creates invoice items)
$invoice->register();
// Save invoice and order in transaction
$transaction = $this->transactionFactory->create();
$transaction->addObject($invoice)
->addObject($order)
->save();
// Send customer notification
if ($notify) {
try {
$this->invoiceSender->send($invoice);
} catch (\Exception $e) {
$this->logger->error('Failed to send invoice email', [
'invoice_id' => $invoice->getEntityId(),
'error' => $e->getMessage()
]);
}
}
return $invoice;
}
}
Anti-Pattern 5: Shipment Without Order Item Validation
Problematic Code:
<?php
// BAD: Creating shipment without checking available quantities
$shipment = $this->shipmentFactory->create();
$shipment->setOrderId($orderId);
foreach ($orderItems as $itemId => $qty) {
$shipmentItem = $this->shipmentItemFactory->create();
$shipmentItem->setOrderItemId($itemId);
$shipmentItem->setQty($qty); // No validation!
$shipment->addItem($shipmentItem);
}
$shipment->save();
Why This Is Wrong: - Can ship more than ordered - Can ship already shipped items - Can ship canceled items - Can ship virtual items - Order state not updated - Customer not notified
Correct Approach:
<?php
declare(strict_types=1);
namespace Vendor\Module\Service;
use Magento\Sales\Api\Data\ShipmentInterface;
use Magento\Framework\Exception\LocalizedException;
/**
* Proper shipment creation with validation
*/
class ShipmentCreationService
{
public function __construct(
private \Magento\Sales\Api\OrderRepositoryInterface $orderRepository,
private \Magento\Sales\Model\Order\ShipmentFactory $shipmentFactory,
private \Magento\Sales\Model\Order\Shipment\TrackFactory $trackFactory,
private \Magento\Framework\DB\TransactionFactory $transactionFactory,
private \Magento\Sales\Model\Order\Email\Sender\ShipmentSender $shipmentSender,
private \Psr\Log\LoggerInterface $logger
) {}
/**
* Create shipment with proper validation
*
* @param int $orderId
* @param array $items Item ID => Qty
* @param array $tracking Tracking info
* @param bool $notify
* @return ShipmentInterface
* @throws LocalizedException
*/
public function createShipment(
int $orderId,
array $items = [],
array $tracking = [],
bool $notify = true
): ShipmentInterface {
$order = $this->orderRepository->get($orderId);
// Validate order can ship
if (!$order->canShip()) {
throw new LocalizedException(
__('Cannot create shipment for this order.')
);
}
// Validate and prepare items
$validatedItems = $this->validateShipmentItems($order, $items);
// Create shipment with validated items
$shipment = $this->shipmentFactory->create(
$order,
$validatedItems
);
if (!$shipment->getTotalQty()) {
throw new LocalizedException(
__('Cannot create shipment without products.')
);
}
// Add tracking information
foreach ($tracking as $trackData) {
$track = $this->trackFactory->create();
$track->setNumber($trackData['number'] ?? '')
->setCarrierCode($trackData['carrier_code'] ?? '')
->setTitle($trackData['title'] ?? '');
$shipment->addTrack($track);
}
// Register shipment (updates order quantities)
$shipment->register();
// Save in transaction
$transaction = $this->transactionFactory->create();
$transaction->addObject($shipment)
->addObject($order)
->save();
// Send notification
if ($notify) {
try {
$this->shipmentSender->send($shipment);
} catch (\Exception $e) {
$this->logger->error('Failed to send shipment email', [
'shipment_id' => $shipment->getEntityId(),
'error' => $e->getMessage()
]);
}
}
return $shipment;
}
/**
* Validate shipment items
*
* @param \Magento\Sales\Model\Order $order
* @param array $items
* @return array
* @throws LocalizedException
*/
private function validateShipmentItems(
\Magento\Sales\Model\Order $order,
array $items
): array {
$validatedItems = [];
foreach ($items as $itemId => $qty) {
$orderItem = $order->getItemById($itemId);
if (!$orderItem) {
throw new LocalizedException(
__('Order item %1 not found.', $itemId)
);
}
// Check if item is virtual
if ($orderItem->getIsVirtual()) {
throw new LocalizedException(
__('Cannot ship virtual item %1.', $orderItem->getSku())
);
}
// Check available quantity
$qtyAvailable = $orderItem->getQtyToShip();
if ($qty > $qtyAvailable) {
throw new LocalizedException(
__(
'Cannot ship %1 of item %2. Only %3 available.',
$qty,
$orderItem->getSku(),
$qtyAvailable
)
);
}
// Check item is not canceled
if ($orderItem->getQtyCanceled() > 0 &&
$orderItem->getQtyToShip() <= 0) {
throw new LocalizedException(
__('Cannot ship canceled item %1.', $orderItem->getSku())
);
}
$validatedItems[$itemId] = $qty;
}
return $validatedItems;
}
}
Payment Processing Anti-Patterns
Anti-Pattern 6: Direct Payment Capture Without Gateway Communication
Problematic Code:
<?php
// BAD: Marking payment as captured without gateway interaction
$order = $this->orderFactory->create()->load($orderId);
$payment = $order->getPayment();
$payment->setAmountPaid($order->getGrandTotal());
$payment->save();
$order->save();
Why This Is Wrong: - No actual payment capture from gateway - Payment gateway and Magento out of sync - Funds never actually captured - No transaction record created - Fraud detection bypassed - Reconciliation impossible
Correct Approach:
<?php
declare(strict_types=1);
namespace Vendor\Module\Service;
use Magento\Sales\Model\Order;
use Magento\Framework\Exception\LocalizedException;
/**
* Proper payment capture service
*/
class PaymentCaptureService
{
public function __construct(
private \Magento\Sales\Api\OrderRepositoryInterface $orderRepository,
private \Magento\Sales\Api\InvoiceManagementInterface $invoiceManagement,
private \Psr\Log\LoggerInterface $logger
) {}
/**
* Capture payment properly through gateway
*
* @param int $orderId
* @return bool
* @throws LocalizedException
*/
public function capturePayment(int $orderId): bool
{
$order = $this->orderRepository->get($orderId);
// Validate order can be invoiced
if (!$order->canInvoice()) {
throw new LocalizedException(
__('Order cannot be invoiced.')
);
}
$payment = $order->getPayment();
$methodInstance = $payment->getMethodInstance();
// Validate payment method supports capture
if (!$methodInstance->canCapture()) {
throw new LocalizedException(
__('Payment method does not support capture.')
);
}
try {
// Create invoice with online capture
// This properly calls payment gateway
$invoice = $this->invoiceService->prepareInvoice($order);
$invoice->setRequestedCaptureCase(
\Magento\Sales\Model\Order\Invoice::CAPTURE_ONLINE
);
$invoice->register();
// Payment capture happens during invoice pay()
$invoice->pay();
// Save invoice and order
$transaction = $this->transactionFactory->create();
$transaction->addObject($invoice)
->addObject($order)
->save();
$this->logger->info('Payment captured successfully', [
'order_id' => $order->getEntityId(),
'transaction_id' => $payment->getLastTransId(),
'amount' => $invoice->getGrandTotal()
]);
return true;
} catch (\Exception $e) {
$this->logger->error('Payment capture failed', [
'order_id' => $order->getEntityId(),
'error' => $e->getMessage()
]);
throw new LocalizedException(
__('Payment capture failed: %1', $e->getMessage())
);
}
}
}
Order State Machine Anti-Patterns
Anti-Pattern 7: Bypassing State Validation
Problematic Code:
<?php
// BAD: Force state change without validation
$order->setState(\Magento\Sales\Model\Order::STATE_COMPLETE);
$order->save();
// Order may have pending shipments or invoices!
Why This Is Wrong: - State inconsistent with order operations - Business logic broken (canShip/canInvoice return wrong values) - Workflow automation breaks - Reports show incorrect data - Customer sees wrong order status
Correct Approach:
<?php
declare(strict_types=1);
namespace Vendor\Module\Service;
use Magento\Sales\Model\Order;
use Magento\Framework\Exception\LocalizedException;
/**
* Order state machine with proper validation
*/
class OrderStateMachine
{
public function __construct(
private \Magento\Sales\Api\OrderRepositoryInterface $orderRepository,
private \Psr\Log\LoggerInterface $logger
) {}
/**
* Transition order to complete state with validation
*
* @param int $orderId
* @return void
* @throws LocalizedException
*/
public function completeOrder(int $orderId): void
{
$order = $this->orderRepository->get($orderId);
// Validate current state allows completion
$validFromStates = [
Order::STATE_PROCESSING,
Order::STATE_PAYMENT_REVIEW
];
if (!in_array($order->getState(), $validFromStates, true)) {
throw new LocalizedException(
__(
'Cannot complete order from state "%1".',
$order->getState()
)
);
}
// Validate all items are fully processed
if (!$this->isOrderFullyProcessed($order)) {
throw new LocalizedException(
__('Order has pending shipments or invoices.')
);
}
// Transition state
$order->setState(Order::STATE_COMPLETE);
$order->setStatus(
$order->getConfig()->getStateDefaultStatus(Order::STATE_COMPLETE)
);
$order->addCommentToStatusHistory(
'Order completed - all items shipped and invoiced.',
false,
false
);
$this->orderRepository->save($order);
$this->logger->info('Order completed', [
'order_id' => $order->getEntityId(),
'from_state' => $order->getOrigData('state'),
'to_state' => $order->getState()
]);
}
/**
* Check if order is fully processed
*
* @param Order $order
* @return bool
*/
private function isOrderFullyProcessed(Order $order): bool
{
// Check all items are invoiced
foreach ($order->getAllItems() as $item) {
if ($item->getQtyToInvoice() > 0) {
return false;
}
}
// Check all items are shipped (for physical products)
foreach ($order->getAllItems() as $item) {
if (!$item->getIsVirtual() && $item->getQtyToShip() > 0) {
return false;
}
}
return true;
}
/**
* Valid state transition map
*
* @return array
*/
private function getValidTransitions(): array
{
return [
Order::STATE_NEW => [
Order::STATE_PENDING_PAYMENT,
Order::STATE_PROCESSING,
Order::STATE_HOLDED,
Order::STATE_CANCELED
],
Order::STATE_PENDING_PAYMENT => [
Order::STATE_PROCESSING,
Order::STATE_HOLDED,
Order::STATE_CANCELED
],
Order::STATE_PROCESSING => [
Order::STATE_COMPLETE,
Order::STATE_HOLDED,
Order::STATE_CANCELED,
Order::STATE_CLOSED
],
Order::STATE_HOLDED => [
Order::STATE_PROCESSING,
Order::STATE_CANCELED
],
Order::STATE_PAYMENT_REVIEW => [
Order::STATE_PROCESSING,
Order::STATE_CANCELED
],
Order::STATE_COMPLETE => [
Order::STATE_CLOSED
],
Order::STATE_CLOSED => [],
Order::STATE_CANCELED => []
];
}
/**
* Validate state transition
*
* @param string $fromState
* @param string $toState
* @return bool
*/
public function isTransitionValid(string $fromState, string $toState): bool
{
$validTransitions = $this->getValidTransitions();
return in_array(
$toState,
$validTransitions[$fromState] ?? [],
true
);
}
}
Performance Anti-Patterns
Anti-Pattern 8: Loading Full Order in Loop
Problematic Code:
<?php
// BAD: N+1 query problem
$orderIds = [1, 2, 3, 4, 5, /* ... 1000 IDs */];
foreach ($orderIds as $orderId) {
$order = $this->orderRepository->get($orderId); // 1000 queries!
$total = $order->getGrandTotal();
$items = $order->getAllItems(); // 1000 more queries!
// Process order
}
Why This Is Wrong: - 2000+ database queries for 1000 orders - Extremely slow (seconds to minutes) - High database load - Memory inefficient - Doesn't scale
Correct Approach:
<?php
declare(strict_types=1);
namespace Vendor\Module\Service;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Sales\Api\OrderRepositoryInterface;
/**
* Efficient bulk order loading
*/
class BulkOrderLoader
{
public function __construct(
private OrderRepositoryInterface $orderRepository,
private SearchCriteriaBuilder $searchCriteriaBuilder,
private \Magento\Framework\App\ResourceConnection $resourceConnection
) {}
/**
* Load multiple orders efficiently
*
* @param array $orderIds
* @return array
*/
public function loadOrders(array $orderIds): array
{
// Use search criteria with IN filter
$searchCriteria = $this->searchCriteriaBuilder
->addFilter('entity_id', $orderIds, 'in')
->create();
$searchResult = $this->orderRepository->getList($searchCriteria);
return $searchResult->getItems();
}
/**
* Get order totals efficiently (single query)
*
* @param array $orderIds
* @return array [order_id => grand_total]
*/
public function getOrderTotals(array $orderIds): array
{
$connection = $this->resourceConnection->getConnection();
$select = $connection->select()
->from(
$this->resourceConnection->getTableName('sales_order'),
['entity_id', 'grand_total']
)
->where('entity_id IN (?)', $orderIds);
return $connection->fetchPairs($select);
}
/**
* Process orders in batches
*
* @param array $orderIds
* @param callable $processor
* @param int $batchSize
* @return void
*/
public function processBatch(
array $orderIds,
callable $processor,
int $batchSize = 100
): void {
$batches = array_chunk($orderIds, $batchSize);
foreach ($batches as $batch) {
$orders = $this->loadOrders($batch);
foreach ($orders as $order) {
$processor($order);
}
// Clear memory after each batch
unset($orders);
gc_collect_cycles();
}
}
}
Anti-Pattern 9: Loading Entire Order Collection Without Limit
Problematic Code:
<?php
// BAD: Loading all orders into memory
$orderCollection = $this->orderCollectionFactory->create();
$orderCollection->addFieldToFilter('status', 'processing');
// No page size set - loads ALL orders!
foreach ($orderCollection as $order) {
// Process order
}
// Out of memory error with 10,000+ orders
Why This Is Wrong: - Memory exhaustion with large datasets - Long execution time - Database overload - PHP timeout - Potential server crash
Correct Approach:
<?php
declare(strict_types=1);
namespace Vendor\Module\Service;
/**
* Efficient order batch processing
*/
class OrderBatchProcessor
{
private const BATCH_SIZE = 100;
public function __construct(
private \Magento\Sales\Model\ResourceModel\Order\CollectionFactory $orderCollectionFactory,
private \Psr\Log\LoggerInterface $logger
) {}
/**
* Process orders in batches
*
* @param array $filters
* @param callable $processor
* @return int Total processed
*/
public function processOrders(array $filters, callable $processor): int
{
$currentPage = 1;
$totalProcessed = 0;
do {
// Load one page of orders
$collection = $this->orderCollectionFactory->create();
foreach ($filters as $field => $value) {
$collection->addFieldToFilter($field, $value);
}
$collection->setPageSize(self::BATCH_SIZE);
$collection->setCurPage($currentPage);
$collection->load();
// Process batch
foreach ($collection as $order) {
try {
$processor($order);
$totalProcessed++;
} catch (\Exception $e) {
$this->logger->error('Order processing failed', [
'order_id' => $order->getId(),
'error' => $e->getMessage()
]);
}
}
// Clear collection from memory
$collection->clear();
unset($collection);
$currentPage++;
} while ($currentPage <= $collection->getLastPageNumber());
return $totalProcessed;
}
/**
* Process orders with iterator (memory efficient)
*
* @param array $filters
* @param callable $processor
* @return int
*/
public function processWithIterator(array $filters, callable $processor): int
{
$connection = $this->resourceConnection->getConnection();
$select = $connection->select()
->from($this->resourceConnection->getTableName('sales_order'), ['entity_id']);
foreach ($filters as $field => $value) {
$select->where("$field = ?", $value);
}
// Fetch IDs only (minimal memory)
$orderIds = $connection->fetchCol($select);
$totalProcessed = 0;
// Process in batches
foreach (array_chunk($orderIds, self::BATCH_SIZE) as $batch) {
$orders = $this->loadOrders($batch);
foreach ($orders as $order) {
try {
$processor($order);
$totalProcessed++;
} catch (\Exception $e) {
$this->logger->error('Order processing failed', [
'order_id' => $order->getId(),
'error' => $e->getMessage()
]);
}
}
unset($orders);
gc_collect_cycles();
}
return $totalProcessed;
}
}
Transaction Handling Anti-Patterns
Anti-Pattern 10: No Transaction for Multi-Entity Operations
Problematic Code:
<?php
// BAD: Saving related entities without transaction
$order->setState(\Magento\Sales\Model\Order::STATE_COMPLETE);
$order->save(); // Saved
$invoice->setState(\Magento\Sales\Model\Order\Invoice::STATE_PAID);
$invoice->save(); // If this fails, order already changed!
$shipment->register();
$shipment->save(); // Inconsistent state if this fails
Why This Is Wrong: - Partial saves leave inconsistent state - No rollback on errors - Data integrity compromised - Orphaned records - Impossible to recover
Correct Approach:
<?php
declare(strict_types=1);
namespace Vendor\Module\Service;
use Magento\Framework\DB\TransactionFactory;
use Magento\Framework\Exception\LocalizedException;
/**
* Proper transaction handling for multi-entity operations
*/
class TransactionalOrderService
{
public function __construct(
private TransactionFactory $transactionFactory,
private \Psr\Log\LoggerInterface $logger
) {}
/**
* Complete order with invoice and shipment atomically
*
* @param \Magento\Sales\Model\Order $order
* @param \Magento\Sales\Model\Order\Invoice $invoice
* @param \Magento\Sales\Model\Order\Shipment $shipment
* @return void
* @throws LocalizedException
*/
public function completeOrderWithInvoiceAndShipment(
\Magento\Sales\Model\Order $order,
\Magento\Sales\Model\Order\Invoice $invoice,
\Magento\Sales\Model\Order\Shipment $shipment
): void {
// Begin transaction
$transaction = $this->transactionFactory->create();
try {
// Add all entities to transaction
$transaction->addObject($order);
$transaction->addObject($invoice);
$transaction->addObject($shipment);
// Add invoice items
foreach ($invoice->getAllItems() as $item) {
$transaction->addObject($item);
}
// Add shipment items
foreach ($shipment->getAllItems() as $item) {
$transaction->addObject($item);
}
// Validate all operations
if (!$order->canInvoice()) {
throw new LocalizedException(__('Order cannot be invoiced.'));
}
if (!$order->canShip()) {
throw new LocalizedException(__('Order cannot be shipped.'));
}
// Update order state
$order->setState(\Magento\Sales\Model\Order::STATE_COMPLETE);
$order->setStatus($order->getConfig()->getStateDefaultStatus(
\Magento\Sales\Model\Order::STATE_COMPLETE
));
// Register invoice
$invoice->register();
$invoice->pay();
// Register shipment
$shipment->register();
// Save all atomically
$transaction->save();
$this->logger->info('Order completed with invoice and shipment', [
'order_id' => $order->getId(),
'invoice_id' => $invoice->getId(),
'shipment_id' => $shipment->getId()
]);
} catch (\Exception $e) {
$this->logger->error('Transaction failed', [
'order_id' => $order->getId(),
'error' => $e->getMessage()
]);
// Transaction automatically rolled back
throw new LocalizedException(
__('Failed to complete order: %1', $e->getMessage())
);
}
}
}
Data Integrity Anti-Patterns
Anti-Pattern 11: Modifying Order Totals Without Recalculation
Problematic Code:
<?php
// BAD: Changing item price without recalculating order totals
$orderItem = $order->getItemById($itemId);
$orderItem->setPrice(99.99);
$orderItem->save();
// Order grand_total now incorrect!
Why This Is Wrong: - Order totals incorrect - Tax amount wrong - Discounts not recalculated - Subtotal inconsistent - Invoices/refunds fail
Correct Approach:
<?php
declare(strict_types=1);
namespace Vendor\Module\Service;
use Magento\Sales\Model\Order;
use Magento\Framework\Exception\LocalizedException;
/**
* Proper order total recalculation
*/
class OrderTotalRecalculation
{
/**
* Update item price and recalculate order
*
* @param Order $order
* @param int $itemId
* @param float $newPrice
* @return void
* @throws LocalizedException
*/
public function updateItemPrice(
Order $order,
int $itemId,
float $newPrice
): void {
// Validate order state allows modification
if ($order->getState() !== Order::STATE_PENDING_PAYMENT) {
throw new LocalizedException(
__('Cannot modify order items after payment.')
);
}
$orderItem = $order->getItemById($itemId);
if (!$orderItem) {
throw new LocalizedException(__('Order item not found.'));
}
// Update item prices
$orderItem->setPrice($newPrice);
$orderItem->setBasePrice($newPrice);
$orderItem->setRowTotal($newPrice * $orderItem->getQtyOrdered());
$orderItem->setBaseRowTotal($newPrice * $orderItem->getQtyOrdered());
// Recalculate order totals
$this->recalculateOrderTotals($order);
// Save order
$this->orderRepository->save($order);
}
/**
* Recalculate all order totals
*
* @param Order $order
* @return void
*/
private function recalculateOrderTotals(Order $order): void
{
$subtotal = 0;
$baseSubtotal = 0;
$taxAmount = 0;
$baseTaxAmount = 0;
$discountAmount = 0;
$baseDiscountAmount = 0;
// Sum item totals
foreach ($order->getAllVisibleItems() as $item) {
$subtotal += $item->getRowTotal();
$baseSubtotal += $item->getBaseRowTotal();
$taxAmount += $item->getTaxAmount();
$baseTaxAmount += $item->getBaseTaxAmount();
$discountAmount += abs($item->getDiscountAmount());
$baseDiscountAmount += abs($item->getBaseDiscountAmount());
}
// Update order totals
$order->setSubtotal($subtotal);
$order->setBaseSubtotal($baseSubtotal);
$order->setTaxAmount($taxAmount);
$order->setBaseTaxAmount($baseTaxAmount);
$order->setDiscountAmount(-$discountAmount);
$order->setBaseDiscountAmount(-$baseDiscountAmount);
// Calculate grand total
$grandTotal = $subtotal
+ $taxAmount
+ $order->getShippingAmount()
- $discountAmount;
$baseGrandTotal = $baseSubtotal
+ $baseTaxAmount
+ $order->getBaseShippingAmount()
- $baseDiscountAmount;
$order->setGrandTotal($grandTotal);
$order->setBaseGrandTotal($baseGrandTotal);
// Update total due
$totalPaid = $order->getTotalPaid() ?? 0;
$order->setTotalDue($grandTotal - $totalPaid);
$order->setBaseTotalDue($baseGrandTotal - ($order->getBaseTotalPaid() ?? 0));
}
}
Summary: Anti-Pattern Checklist
Before Making Changes, Verify:
- [ ] Using service contracts (repositories, management interfaces)?
- [ ] Using search criteria instead of direct collections?
- [ ] State/status validation before changes?
- [ ] Transaction wrapping for multi-entity operations?
- [ ] Proper event dispatching (not bypassed)?
- [ ] Using InvoiceService/ShipmentFactory instead of direct creation?
- [ ] Payment operations going through gateway?
- [ ] Batch processing with pagination for large datasets?
- [ ] Extension attributes for custom data (not direct table changes)?
- [ ] Error handling and logging present?
Assumptions: - Adobe Commerce 2.4.7+ with PHP 8.2+ - All examples show Commerce-specific patterns - Service contracts are the correct approach for all operations - Transaction handling essential for data integrity
Why This Approach: - Service contracts maintain upgrade safety - Proper validation prevents data corruption - Transactions ensure atomicity - Batch processing prevents memory issues - Event dispatching enables extensibility
Security Impact: - Proper validation prevents unauthorized order modifications - Transaction boundaries prevent race conditions - Service contracts enforce ACL - Logging provides audit trail
Performance Impact: - Batch processing scales to large datasets - Proper indexing with search criteria - Transaction overhead minimal vs data integrity - Memory management prevents crashes
Backward Compatibility: - Service contracts stable across versions - Anti-patterns may "work" but break on upgrades - Direct model manipulation bypasses future improvements - Proper patterns future-proof code
Tests to Add: - Unit tests: Validation logic, state machine - Integration tests: Transaction rollback, multi-entity operations - Performance tests: Batch processing benchmarks - Negative tests: Verify errors thrown for invalid operations
Docs to Update: - README.md: Link to anti-patterns - Code review checklist: Include these patterns - Developer onboarding: Required reading