Home Modules Sales Magento_Sales Plugins & Observers
Extension Points

Magento_Sales Plugins & Observers

Magento_Sales Plugins & Observers

Magento 2.4.7+ Magento_Sales

Magento_Sales Plugins and Observers

Overview

The Magento_Sales module provides extensive extension points through plugins (interceptors) and event observers. This document catalogs all critical extension points, provides complete implementation examples, and explains best practices for extending sales functionality without modifying core code.

Plugin Architecture in Sales

Plugin Types and When to Use Each

Before Plugin: Modify input parameters, add validation, prevent execution After Plugin: Modify return values, execute post-processing Around Plugin: Complete control over execution (use sparingly, prefer before/after)

Critical Plugin Targets

1. Order Repository Plugins

<?php
declare(strict_types=1);

namespace Vendor\Module\Plugin\Sales\Api;

use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Api\Data\OrderExtensionFactory;
use Magento\Sales\Api\Data\OrderSearchResultInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
use Vendor\Module\Api\CustomOrderDataRepositoryInterface;

/**
 * Plugin to load/save custom order data
 *
 * Demonstrates extension attributes pattern for orders
 */
class OrderRepositoryExtend
{
    public function __construct(
        private OrderExtensionFactory $orderExtensionFactory,
        private CustomOrderDataRepositoryInterface $customOrderDataRepository
    ) {}

    /**
     * Load custom data after order get
     *
     * @param OrderRepositoryInterface $subject
     * @param OrderInterface $order
     * @return OrderInterface
     */
    public function afterGet(
        OrderRepositoryInterface $subject,
        OrderInterface $order
    ): OrderInterface {
        $this->loadCustomData($order);
        return $order;
    }

    /**
     * Load custom data for order list
     *
     * @param OrderRepositoryInterface $subject
     * @param OrderSearchResultInterface $searchResult
     * @return OrderSearchResultInterface
     */
    public function afterGetList(
        OrderRepositoryInterface $subject,
        OrderSearchResultInterface $searchResult
    ): OrderSearchResultInterface {
        foreach ($searchResult->getItems() as $order) {
            $this->loadCustomData($order);
        }

        return $searchResult;
    }

    /**
     * Save custom data before order save
     *
     * @param OrderRepositoryInterface $subject
     * @param OrderInterface $order
     * @return array
     */
    public function beforeSave(
        OrderRepositoryInterface $subject,
        OrderInterface $order
    ): array {
        // Extract custom data before save
        $extensionAttributes = $order->getExtensionAttributes();

        if ($extensionAttributes && $extensionAttributes->getCustomOrderData()) {
            // Store for after save
            $order->setData('_custom_order_data', $extensionAttributes->getCustomOrderData());
        }

        return [$order];
    }

    /**
     * Save custom data after order save
     *
     * @param OrderRepositoryInterface $subject
     * @param OrderInterface $order
     * @return OrderInterface
     */
    public function afterSave(
        OrderRepositoryInterface $subject,
        OrderInterface $order
    ): OrderInterface {
        // Save custom data if present
        if ($customData = $order->getData('_custom_order_data')) {
            $customData->setOrderId($order->getEntityId());
            $this->customOrderDataRepository->save($customData);

            // Reload to ensure extension attributes are current
            $this->loadCustomData($order);
        }

        return $order;
    }

    /**
     * Load custom extension attributes
     *
     * @param OrderInterface $order
     * @return void
     */
    private function loadCustomData(OrderInterface $order): void
    {
        $extensionAttributes = $order->getExtensionAttributes()
            ?? $this->orderExtensionFactory->create();

        try {
            $customData = $this->customOrderDataRepository->getByOrderId(
                (int)$order->getEntityId()
            );
            $extensionAttributes->setCustomOrderData($customData);
        } catch (\Magento\Framework\Exception\NoSuchEntityException $e) {
            // No custom data exists yet
            $extensionAttributes->setCustomOrderData(null);
        }

        $order->setExtensionAttributes($extensionAttributes);
    }
}

Plugin Configuration (etc/di.xml):

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Sales\Api\OrderRepositoryInterface">
        <plugin name="vendor_module_order_repository_custom_data"
                type="Vendor\Module\Plugin\Sales\Api\OrderRepositoryExtend"
                sortOrder="10"/>
    </type>
</config>

2. Order Management Plugins

<?php
declare(strict_types=1);

namespace Vendor\Module\Plugin\Sales\Api;

use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Api\OrderManagementInterface;
use Psr\Log\LoggerInterface;

/**
 * Plugin for order management operations
 *
 * Adds custom validation and business logic
 */
class OrderManagementExtend
{
    public function __construct(
        private LoggerInterface $logger,
        private \Vendor\Module\Service\FraudDetectionService $fraudDetection,
        private \Vendor\Module\Service\InventoryValidation $inventoryValidation,
        private \Vendor\Module\Api\ExternalOrderSystemInterface $externalSystem
    ) {}

    /**
     * Validate order before placement
     *
     * @param OrderManagementInterface $subject
     * @param OrderInterface $order
     * @return array
     * @throws \Magento\Framework\Exception\LocalizedException
     */
    public function beforePlace(
        OrderManagementInterface $subject,
        OrderInterface $order
    ): array {
        // Custom fraud detection
        $fraudScore = $this->fraudDetection->calculateRiskScore($order);

        if ($fraudScore > 0.8) {
            $this->logger->warning('High fraud score detected', [
                'order_increment_id' => $order->getIncrementId(),
                'fraud_score' => $fraudScore,
                'customer_email' => $order->getCustomerEmail()
            ]);

            // Set order to payment review state
            $order->setState(\Magento\Sales\Model\Order::STATE_PAYMENT_REVIEW);
            $order->setStatus('suspected_fraud');

            $order->addCommentToStatusHistory(
                sprintf('Order flagged for review - Fraud score: %.2f', $fraudScore),
                false,
                false
            );
        }

        // Validate inventory availability (beyond default Magento checks)
        if (!$this->inventoryValidation->validateRealTimeStock($order)) {
            throw new \Magento\Framework\Exception\LocalizedException(
                __('One or more products are no longer available in the requested quantity.')
            );
        }

        return [$order];
    }

    /**
     * Execute post-order logic
     *
     * @param OrderManagementInterface $subject
     * @param OrderInterface $result
     * @return OrderInterface
     */
    public function afterPlace(
        OrderManagementInterface $subject,
        OrderInterface $result
    ): OrderInterface {
        try {
            // Send order to external system (ERP, WMS, etc.)
            $this->externalSystem->createOrder($result);

            $this->logger->info('Order synchronized to external system', [
                'order_id' => $result->getEntityId(),
                'increment_id' => $result->getIncrementId()
            ]);

        } catch (\Exception $e) {
            // Log error but don't fail order placement
            $this->logger->error('Failed to sync order to external system', [
                'order_id' => $result->getEntityId(),
                'error' => $e->getMessage()
            ]);

            // Add comment to order
            $result->addCommentToStatusHistory(
                'Warning: Failed to sync to external system. Will retry via cron.',
                false,
                false
            );
        }

        return $result;
    }

    /**
     * Before order cancellation
     *
     * @param OrderManagementInterface $subject
     * @param int $id
     * @return array
     * @throws \Magento\Framework\Exception\LocalizedException
     */
    public function beforeCancel(
        OrderManagementInterface $subject,
        $id
    ): array {
        // Custom cancellation validation
        $order = $this->orderRepository->get($id);

        // Check if already shipped to warehouse
        if ($this->externalSystem->isOrderShipped($order->getIncrementId())) {
            throw new \Magento\Framework\Exception\LocalizedException(
                __('Cannot cancel order: Already shipped from warehouse.')
            );
        }

        return [$id];
    }

    /**
     * After order cancellation
     *
     * @param OrderManagementInterface $subject
     * @param bool $result
     * @return bool
     */
    public function afterCancel(
        OrderManagementInterface $subject,
        bool $result
    ): bool {
        // Notify external systems of cancellation
        // Implementation details...

        return $result;
    }
}

3. Invoice Management Plugins

<?php
declare(strict_types=1);

namespace Vendor\Module\Plugin\Sales\Model\Service;

use Magento\Sales\Api\Data\InvoiceInterface;
use Magento\Sales\Api\InvoiceRepositoryInterface;

/**
 * Plugin for invoice operations
 *
 * Adds custom invoice processing logic
 */
class InvoiceServiceExtend
{
    public function __construct(
        private \Vendor\Module\Service\AccountingIntegration $accounting,
        private \Psr\Log\LoggerInterface $logger
    ) {}

    /**
     * After invoice save - sync to accounting system
     *
     * @param InvoiceRepositoryInterface $subject
     * @param InvoiceInterface $invoice
     * @return InvoiceInterface
     */
    public function afterSave(
        InvoiceRepositoryInterface $subject,
        InvoiceInterface $invoice
    ): InvoiceInterface {
        try {
            // Send invoice to accounting system
            $this->accounting->createInvoice($invoice);

            $this->logger->info('Invoice synced to accounting system', [
                'invoice_id' => $invoice->getEntityId(),
                'increment_id' => $invoice->getIncrementId(),
                'order_id' => $invoice->getOrderId()
            ]);

        } catch (\Exception $e) {
            $this->logger->error('Failed to sync invoice to accounting', [
                'invoice_id' => $invoice->getEntityId(),
                'error' => $e->getMessage()
            ]);
        }

        return $invoice;
    }
}

4. Payment Capture Plugins

<?php
declare(strict_types=1);

namespace Vendor\Module\Plugin\Sales\Model\Order;

use Magento\Sales\Model\Order\Payment;

/**
 * Plugin for payment capture operations
 *
 * Adds fraud detection and custom payment logic
 */
class PaymentExtend
{
    public function __construct(
        private \Vendor\Module\Service\PaymentFraudDetection $fraudDetection,
        private \Vendor\Module\Service\PaymentReconciliation $reconciliation,
        private \Psr\Log\LoggerInterface $logger
    ) {}

    /**
     * Before payment capture - additional fraud checks
     *
     * @param Payment $subject
     * @param \Magento\Sales\Model\Order\Invoice|null $invoice
     * @return array
     * @throws \Magento\Framework\Exception\LocalizedException
     */
    public function beforeCapture(
        Payment $subject,
        $invoice = null
    ): array {
        $order = $subject->getOrder();

        // Real-time fraud detection at capture
        $fraudResult = $this->fraudDetection->validateCapture($subject, $order);

        if (!$fraudResult->isValid()) {
            $this->logger->critical('Payment capture blocked by fraud detection', [
                'order_id' => $order->getEntityId(),
                'payment_method' => $subject->getMethod(),
                'fraud_reasons' => $fraudResult->getReasons()
            ]);

            throw new \Magento\Framework\Exception\LocalizedException(
                __('Payment capture blocked for security reasons.')
            );
        }

        return [$invoice];
    }

    /**
     * After payment capture - reconciliation
     *
     * @param Payment $subject
     * @param Payment $result
     * @return Payment
     */
    public function afterCapture(
        Payment $subject,
        Payment $result
    ): Payment {
        try {
            // Record payment for reconciliation
            $this->reconciliation->recordPayment($result);

            $this->logger->info('Payment captured and reconciled', [
                'order_id' => $result->getOrder()->getEntityId(),
                'transaction_id' => $result->getLastTransId(),
                'amount' => $result->getAmountOrdered()
            ]);

        } catch (\Exception $e) {
            $this->logger->error('Payment reconciliation failed', [
                'order_id' => $result->getOrder()->getEntityId(),
                'error' => $e->getMessage()
            ]);
        }

        return $result;
    }
}

5. Shipment Tracking Plugins

<?php
declare(strict_types=1);

namespace Vendor\Module\Plugin\Sales\Model\Order\Shipment;

use Magento\Sales\Model\Order\Shipment\Track;

/**
 * Plugin for shipment tracking
 *
 * Integrates with carrier APIs for real-time tracking
 */
class TrackExtend
{
    public function __construct(
        private \Vendor\Module\Service\CarrierIntegration $carrierIntegration,
        private \Magento\Sales\Api\ShipmentRepositoryInterface $shipmentRepository,
        private \Psr\Log\LoggerInterface $logger
    ) {}

    /**
     * After tracking save - notify carrier
     *
     * @param Track $subject
     * @param Track $result
     * @return Track
     */
    public function afterSave(
        Track $subject,
        Track $result
    ): Track {
        try {
            // Register tracking with carrier API
            $this->carrierIntegration->registerTracking(
                $result->getCarrierCode(),
                $result->getTrackNumber(),
                $result->getShipment()
            );

            $this->logger->info('Tracking registered with carrier', [
                'shipment_id' => $result->getParentId(),
                'carrier' => $result->getCarrierCode(),
                'tracking_number' => $result->getTrackNumber()
            ]);

        } catch (\Exception $e) {
            $this->logger->error('Failed to register tracking with carrier', [
                'tracking_number' => $result->getTrackNumber(),
                'error' => $e->getMessage()
            ]);
        }

        return $result;
    }
}

Event Observer Architecture

Core Sales Events Catalog

Order Events

Event Name When Fired Event Data Use Cases
sales_order_place_before Before order is saved during placement order Final validation, fraud checks
sales_order_place_after After order is saved order External system sync, notifications
sales_order_save_before Before any order save order, data_object Audit logging, data validation
sales_order_save_after After any order save order, data_object Cache invalidation, index updates
sales_order_load_after After order loaded from DB order, data_object Inject runtime data
sales_order_delete_before Before order deletion order, data_object Archive data, cleanup
sales_order_delete_after After order deletion order, data_object Cleanup related data

Order State Change Events

Event Name When Fired Event Data Use Cases
sales_order_state_change_before Before state changes order, state Validate state transition
order_cancel_after After order canceled order Inventory restoration, notifications

Invoice Events

Event Name When Fired Event Data Use Cases
sales_order_invoice_register After invoice registered, before save invoice, order Modify invoice data
sales_order_invoice_pay After payment captured invoice, order Payment logging
sales_order_invoice_save_before Before invoice save invoice, data_object Validation
sales_order_invoice_save_after After invoice save invoice, data_object Accounting sync
sales_order_invoice_cancel After invoice canceled invoice Payment reversal

Shipment Events

Event Name When Fired Event Data Use Cases
sales_order_shipment_save_before Before shipment save shipment, data_object Validation
sales_order_shipment_save_after After shipment save shipment, data_object Carrier notification
sales_order_shipment_track_save_after After tracking saved track, data_object Customer notification

Credit Memo Events

Event Name When Fired Event Data Use Cases
adminhtml_sales_order_creditmemo_register_before Before credit memo registered (admin only) creditmemo, order Modify refund data
sales_order_creditmemo_refund After refund processed creditmemo, order Payment logging
sales_order_creditmemo_save_before Before credit memo save creditmemo, data_object Validation
sales_order_creditmemo_save_after After credit memo save creditmemo, data_object Accounting sync

Observer Implementation Examples

1. Order Placement Observer

<?php
declare(strict_types=1);

namespace Vendor\Module\Observer\Sales;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Model\Order;

/**
 * Observer for order placement
 *
 * Executes custom logic after order is placed
 */
class OrderPlaceAfterObserver implements ObserverInterface
{
    public function __construct(
        private \Vendor\Module\Service\ErpIntegration $erpIntegration,
        private \Vendor\Module\Service\WarehouseNotification $warehouseNotification,
        private \Vendor\Module\Model\OrderQueueFactory $orderQueueFactory,
        private \Vendor\Module\Model\ResourceModel\OrderQueue $orderQueueResource,
        private \Psr\Log\LoggerInterface $logger
    ) {}

    /**
     * Execute after order placement
     *
     * @param Observer $observer
     * @return void
     */
    public function execute(Observer $observer): void
    {
        /** @var Order $order */
        $order = $observer->getEvent()->getOrder();

        try {
            // 1. Queue order for ERP sync (async processing)
            $this->queueOrderForErpSync($order);

            // 2. Notify warehouse (real-time)
            if ($this->shouldNotifyWarehouse($order)) {
                $this->warehouseNotification->notifyNewOrder($order);
            }

            // 3. Custom business logic
            $this->processCustomOrderLogic($order);

            $this->logger->info('Post-order processing completed', [
                'order_id' => $order->getEntityId(),
                'increment_id' => $order->getIncrementId()
            ]);

        } catch (\Exception $e) {
            // Log error but don't fail order placement
            $this->logger->error('Post-order processing failed', [
                'order_id' => $order->getEntityId(),
                'error' => $e->getMessage(),
                'trace' => $e->getTraceAsString()
            ]);
        }
    }

    /**
     * Queue order for ERP synchronization
     *
     * @param Order $order
     * @return void
     */
    private function queueOrderForErpSync(Order $order): void
    {
        $queueEntry = $this->orderQueueFactory->create();
        $queueEntry->setData([
            'order_id' => $order->getEntityId(),
            'increment_id' => $order->getIncrementId(),
            'status' => 'pending',
            'retry_count' => 0,
            'created_at' => date('Y-m-d H:i:s')
        ]);

        $this->orderQueueResource->save($queueEntry);
    }

    /**
     * Determine if warehouse notification is needed
     *
     * @param Order $order
     * @return bool
     */
    private function shouldNotifyWarehouse(Order $order): bool
    {
        // Don't notify for virtual/downloadable orders
        if ($order->getIsVirtual()) {
            return false;
        }

        // Only notify during business hours
        $currentHour = (int)date('H');
        if ($currentHour < 8 || $currentHour > 18) {
            return false;
        }

        return true;
    }

    /**
     * Custom order processing logic
     *
     * @param Order $order
     * @return void
     */
    private function processCustomOrderLogic(Order $order): void
    {
        // Check for special handling requirements
        foreach ($order->getAllItems() as $item) {
            if ($item->getProduct()->getData('requires_special_handling')) {
                $order->addCommentToStatusHistory(
                    'Order contains items requiring special handling',
                    false,
                    false
                );
                break;
            }
        }

        // Apply custom order tags
        if ($order->getGrandTotal() > 1000) {
            $order->setData('is_high_value', true);
        }
    }
}

Observer Configuration (etc/events.xml):

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
    <event name="sales_order_place_after">
        <observer name="vendor_module_order_place_after"
                  instance="Vendor\Module\Observer\Sales\OrderPlaceAfterObserver"/>
    </event>
</config>

2. Invoice Save Observer

<?php
declare(strict_types=1);

namespace Vendor\Module\Observer\Sales;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Model\Order\Invoice;

/**
 * Observer for invoice save operations
 *
 * Syncs invoices to external accounting system
 */
class InvoiceSaveAfterObserver implements ObserverInterface
{
    public function __construct(
        private \Vendor\Module\Service\AccountingSync $accountingSync,
        private \Vendor\Module\Model\InvoiceSyncQueueFactory $queueFactory,
        private \Vendor\Module\Model\ResourceModel\InvoiceSyncQueue $queueResource,
        private \Psr\Log\LoggerInterface $logger
    ) {}

    /**
     * Execute after invoice save
     *
     * @param Observer $observer
     * @return void
     */
    public function execute(Observer $observer): void
    {
        /** @var Invoice $invoice */
        $invoice = $observer->getEvent()->getInvoice();

        // Only sync paid invoices
        if ($invoice->getState() !== Invoice::STATE_PAID) {
            return;
        }

        try {
            // Immediate sync attempt
            try {
                $this->accountingSync->syncInvoice($invoice);

                $this->logger->info('Invoice synced to accounting system', [
                    'invoice_id' => $invoice->getEntityId(),
                    'increment_id' => $invoice->getIncrementId(),
                    'order_id' => $invoice->getOrderId()
                ]);

            } catch (\Exception $e) {
                // Queue for retry if immediate sync fails
                $this->queueInvoiceForRetry($invoice);

                $this->logger->warning('Invoice queued for accounting sync retry', [
                    'invoice_id' => $invoice->getEntityId(),
                    'error' => $e->getMessage()
                ]);
            }

        } catch (\Exception $e) {
            $this->logger->error('Invoice sync processing failed', [
                'invoice_id' => $invoice->getEntityId(),
                'error' => $e->getMessage()
            ]);
        }
    }

    /**
     * Queue invoice for retry
     *
     * @param Invoice $invoice
     * @return void
     */
    private function queueInvoiceForRetry(Invoice $invoice): void
    {
        $queueEntry = $this->queueFactory->create();
        $queueEntry->setData([
            'invoice_id' => $invoice->getEntityId(),
            'order_id' => $invoice->getOrderId(),
            'status' => 'pending',
            'retry_count' => 0,
            'next_retry_at' => date('Y-m-d H:i:s', strtotime('+5 minutes')),
            'created_at' => date('Y-m-d H:i:s')
        ]);

        $this->queueResource->save($queueEntry);
    }
}

3. Shipment Track Observer

<?php
declare(strict_types=1);

namespace Vendor\Module\Observer\Sales;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Model\Order\Shipment\Track;

/**
 * Observer for shipment tracking
 *
 * Sends customer notifications with tracking information
 */
class ShipmentTrackSaveAfterObserver implements ObserverInterface
{
    public function __construct(
        private \Magento\Sales\Api\ShipmentRepositoryInterface $shipmentRepository,
        private \Vendor\Module\Service\CustomerNotification $customerNotification,
        private \Vendor\Module\Service\CarrierTracking $carrierTracking,
        private \Psr\Log\LoggerInterface $logger
    ) {}

    /**
     * Execute after tracking save
     *
     * @param Observer $observer
     * @return void
     */
    public function execute(Observer $observer): void
    {
        /** @var Track $track */
        $track = $observer->getEvent()->getTrack();

        try {
            $shipment = $this->shipmentRepository->get($track->getParentId());
            $order = $shipment->getOrder();

            // Get real-time tracking info from carrier
            $trackingInfo = $this->carrierTracking->getTrackingInfo(
                $track->getCarrierCode(),
                $track->getTrackNumber()
            );

            // Send enhanced notification to customer
            $this->customerNotification->sendShipmentNotification(
                $order,
                $shipment,
                $track,
                $trackingInfo
            );

            $this->logger->info('Shipment notification sent', [
                'order_id' => $order->getEntityId(),
                'shipment_id' => $shipment->getEntityId(),
                'tracking_number' => $track->getTrackNumber(),
                'carrier' => $track->getCarrierCode()
            ]);

        } catch (\Exception $e) {
            $this->logger->error('Shipment notification failed', [
                'track_id' => $track->getEntityId(),
                'error' => $e->getMessage()
            ]);
        }
    }
}

4. Credit Memo Observer

<?php
declare(strict_types=1);

namespace Vendor\Module\Observer\Sales;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Model\Order\Creditmemo;

/**
 * Observer for credit memo operations
 *
 * Handles refund processing and accounting
 */
class CreditmemoSaveAfterObserver implements ObserverInterface
{
    public function __construct(
        private \Vendor\Module\Service\RefundProcessing $refundProcessing,
        private \Vendor\Module\Service\CustomerService $customerService,
        private \Psr\Log\LoggerInterface $logger
    ) {}

    /**
     * Execute after credit memo save
     *
     * @param Observer $observer
     * @return void
     */
    public function execute(Observer $observer): void
    {
        /** @var Creditmemo $creditmemo */
        $creditmemo = $observer->getEvent()->getCreditmemo();

        try {
            // Process refund in external system
            $this->refundProcessing->processRefund($creditmemo);

            // Update customer account (store credit, etc.)
            if ($creditmemo->getBaseCustomerBalanceReturnMax() > 0) {
                $this->customerService->addStoreCredit(
                    $creditmemo->getOrder()->getCustomerId(),
                    $creditmemo->getBaseCustomerBalanceReturnMax()
                );
            }

            // Log refund for auditing
            $this->logger->info('Credit memo processed', [
                'creditmemo_id' => $creditmemo->getEntityId(),
                'order_id' => $creditmemo->getOrderId(),
                'grand_total' => $creditmemo->getGrandTotal(),
                'adjustment_positive' => $creditmemo->getAdjustmentPositive(),
                'adjustment_negative' => $creditmemo->getAdjustmentNegative()
            ]);

        } catch (\Exception $e) {
            $this->logger->error('Credit memo post-processing failed', [
                'creditmemo_id' => $creditmemo->getEntityId(),
                'error' => $e->getMessage()
            ]);
        }
    }
}

5. Order Cancellation Observer

<?php
declare(strict_types=1);

namespace Vendor\Module\Observer\Sales;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Model\Order;

/**
 * Observer for order cancellation
 *
 * Handles cleanup and notifications
 */
class OrderCancelAfterObserver implements ObserverInterface
{
    public function __construct(
        private \Vendor\Module\Service\ErpIntegration $erpIntegration,
        private \Vendor\Module\Service\InventoryManagement $inventoryManagement,
        private \Vendor\Module\Service\PaymentProcessing $paymentProcessing,
        private \Psr\Log\LoggerInterface $logger
    ) {}

    /**
     * Execute after order cancellation
     *
     * @param Observer $observer
     * @return void
     */
    public function execute(Observer $observer): void
    {
        /** @var Order $order */
        $order = $observer->getEvent()->getOrder();

        try {
            // Notify ERP of cancellation
            $this->erpIntegration->cancelOrder($order->getIncrementId());

            // Release inventory reservations
            $this->inventoryManagement->releaseReservation($order);

            // Void/cancel payment authorization
            if ($order->getPayment()->getAuthorizationTransaction()) {
                $this->paymentProcessing->voidAuthorization($order->getPayment());
            }

            $this->logger->info('Order cancellation processed', [
                'order_id' => $order->getEntityId(),
                'increment_id' => $order->getIncrementId(),
                'state' => $order->getState(),
                'status' => $order->getStatus()
            ]);

        } catch (\Exception $e) {
            $this->logger->error('Order cancellation post-processing failed', [
                'order_id' => $order->getEntityId(),
                'error' => $e->getMessage()
            ]);
        }
    }
}

Best Practices for Plugins and Observers

1. Plugin vs Observer Decision Tree

Does the operation need to:
├─ Modify input parameters? → Use BEFORE plugin
├─ Modify return values? → Use AFTER plugin
├─ Completely replace logic? → Use AROUND plugin (rare)
└─ React to state change without modifying data? → Use OBSERVER

2. Performance Considerations

<?php
// BAD: Heavy processing in observer
public function execute(Observer $observer): void
{
    $order = $observer->getEvent()->getOrder();

    // This blocks order save!
    $this->externalApi->syncOrder($order); // May take 5-10 seconds
}

// GOOD: Queue for async processing
public function execute(Observer $observer): void
{
    $order = $observer->getEvent()->getOrder();

    // Fast queue operation
    $this->messageQueue->publish('sales.order.sync', [
        'order_id' => $order->getEntityId()
    ]);
}

3. Error Handling

<?php
// CRITICAL: Never let observer exceptions fail core operations
public function execute(Observer $observer): void
{
    try {
        // Your custom logic
        $this->doSomething($observer->getEvent()->getOrder());
    } catch (\Exception $e) {
        // Log error but DON'T re-throw
        $this->logger->error('Observer failed', [
            'observer' => self::class,
            'error' => $e->getMessage()
        ]);

        // Optionally: Add order comment for visibility
        $order = $observer->getEvent()->getOrder();
        $order->addCommentToStatusHistory(
            'Warning: ' . $e->getMessage(),
            false,
            false
        );
    }
}

4. Plugin Sort Order

<!-- etc/di.xml -->
<config>
    <!-- Validation plugins: sortOrder < 100 -->
    <type name="Magento\Sales\Api\OrderManagementInterface">
        <plugin name="vendor_module_validation"
                type="Vendor\Module\Plugin\ValidationExtend"
                sortOrder="50"/>
    </type>

    <!-- Business logic plugins: sortOrder 100-500 -->
    <type name="Magento\Sales\Api\OrderManagementInterface">
        <plugin name="vendor_module_business_logic"
                type="Vendor\Module\Plugin\BusinessLogicExtend"
                sortOrder="200"/>
    </type>

    <!-- Logging/monitoring plugins: sortOrder > 500 -->
    <type name="Magento\Sales\Api\OrderManagementInterface">
        <plugin name="vendor_module_logging"
                type="Vendor\Module\Plugin\LoggingExtend"
                sortOrder="600"/>
    </type>
</config>

5. Avoiding Plugin Conflicts

<?php
// BAD: Modifying same property in multiple plugins causes conflicts
public function afterGet(OrderRepositoryInterface $subject, OrderInterface $order): OrderInterface
{
    $order->setCustomAttribute('processed', true);
    return $order;
}

// GOOD: Use extension attributes (designed for this purpose)
public function afterGet(OrderRepositoryInterface $subject, OrderInterface $order): OrderInterface
{
    $extensionAttributes = $order->getExtensionAttributes();
    $extensionAttributes->setProcessed(true);
    $order->setExtensionAttributes($extensionAttributes);
    return $order;
}

Testing Plugins and Observers

Unit Test for Plugin

<?php
declare(strict_types=1);

namespace Vendor\Module\Test\Unit\Plugin\Sales\Api;

use PHPUnit\Framework\TestCase;
use Vendor\Module\Plugin\Sales\Api\OrderRepositoryExtend;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Api\OrderRepositoryInterface;

class OrderRepositoryExtendTest extends TestCase
{
    private OrderRepositoryExtend $plugin;
    private $customOrderDataRepository;
    private $orderExtensionFactory;

    protected function setUp(): void
    {
        $this->customOrderDataRepository = $this->createMock(
            \Vendor\Module\Api\CustomOrderDataRepositoryInterface::class
        );
        $this->orderExtensionFactory = $this->createMock(
            \Magento\Sales\Api\Data\OrderExtensionFactory::class
        );

        $this->plugin = new OrderRepositoryExtend(
            $this->orderExtensionFactory,
            $this->customOrderDataRepository
        );
    }

    public function testAfterGetLoadsCustomData(): void
    {
        $orderId = 123;
        $order = $this->createMock(OrderInterface::class);
        $order->method('getEntityId')->willReturn($orderId);

        $subject = $this->createMock(OrderRepositoryInterface::class);

        $customData = new \stdClass();
        $customData->value = 'test';

        $this->customOrderDataRepository
            ->expects($this->once())
            ->method('getByOrderId')
            ->with($orderId)
            ->willReturn($customData);

        $result = $this->plugin->afterGet($subject, $order);

        $this->assertSame($order, $result);
    }
}

Integration Test for Observer

<?php
declare(strict_types=1);

namespace Vendor\Module\Test\Integration\Observer\Sales;

use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
use Magento\Sales\Model\Order;

class OrderPlaceAfterObserverTest extends TestCase
{
    private $eventManager;
    private $orderQueueResource;

    protected function setUp(): void
    {
        $objectManager = Bootstrap::getObjectManager();
        $this->eventManager = $objectManager->get(\Magento\Framework\Event\ManagerInterface::class);
        $this->orderQueueResource = $objectManager->get(
            \Vendor\Module\Model\ResourceModel\OrderQueue::class
        );
    }

    /**
     * @magentoDataFixture Magento/Sales/_files/order.php
     */
    public function testOrderQueuedForErpSync(): void
    {
        $objectManager = Bootstrap::getObjectManager();
        $order = $objectManager->create(Order::class);
        $order->loadByIncrementId('100000001');

        // Dispatch event
        $this->eventManager->dispatch('sales_order_place_after', ['order' => $order]);

        // Verify order was queued
        $connection = $this->orderQueueResource->getConnection();
        $select = $connection->select()
            ->from($this->orderQueueResource->getMainTable())
            ->where('order_id = ?', $order->getId());

        $queueEntry = $connection->fetchRow($select);

        $this->assertNotEmpty($queueEntry);
        $this->assertEquals('pending', $queueEntry['status']);
    }
}

Assumptions: - Adobe Commerce 2.4.7+ with PHP 8.2+ - Plugins configured via di.xml with proper sort orders - Observers configured via events.xml - Async processing via message queues where appropriate

Why This Approach: - Plugins for data modification maintain upgrade safety - Observers for reactive logic don't block critical operations - Extension attributes for custom data avoid core table modifications - Error handling prevents custom code from failing core operations

Security Impact: - Plugin validation can enforce additional authorization checks - Observers should not expose sensitive data in logs - External API calls should use secure authentication - PII in custom data must follow same protection as core data

Performance Impact: - Observers execute synchronously; use queues for heavy operations - Plugin chains add overhead; keep count reasonable (< 10 per method) - Extension attribute loading is lazy when implemented properly - Database queries in observers/plugins should use connection pool

Backward Compatibility: - Plugin method signatures must match intercepted methods - Event data structure is stable across minor versions - Extension attributes via extension_attributes.xml are BC-safe - Observer event names maintained across versions

Tests to Add: - Unit tests: Plugin logic, observer logic - Integration tests: Event dispatching, data persistence - Functional tests: End-to-end flows with plugins/observers - Performance tests: Plugin chain overhead measurement

Docs to Update: - README.md: Link to this document - ARCHITECTURE.md: Reference extension points - Custom module documentation: Document your plugins/observers