Observer / Event Pattern in Magento 2: Dispatching Events and Building Observers | Mironsoft
AI generated

Observer / Event Pattern in Magento 2: Dispatching Events and Building Observers

· Reading time: approx. 13 minutes · Part of the series: Design Patterns in Magento 2

Event
dispatch
Design Pattern #4 · Behavioral / Publisher-Subscriber

Observer / Event
Pattern in Magento 2

Dispatch your own events, build observers, events.xml, the most important core events, and the question everyone asks: Plugin or Observer? Answered in full.

⏱ 13 min. Loose coupling PHP 8.4 Multi-module

Observer Pattern: Decoupling Through Events

The Observer pattern, in modern systems also known as Publisher-Subscriber or Event-Driven pattern, elegantly solves the problem of coupling between modules. One module dispatches an event and effectively announces: "Something just happened here." It doesn't know, and doesn't need to know, who reacts to it. Other modules register observers that listen for exactly that event.

The result: no direct dependency between the module that triggers the event and the modules that react to it. Modules can be added or removed without ever touching the event dispatcher.

1. Dispatching events: the EventManager

An event is triggered with Magento\Framework\Event\ManagerInterface. You inject the interface and call dispatch() with an event name and optional data. The event name is a snake_case string that uniquely identifies the event.


<?php
declare(strict_types=1);

namespace Mironsoft\Blog\Model;

use Magento\Framework\Event\ManagerInterface as EventManager;
use Mironsoft\Blog\Api\Data\PostInterface;
use Mironsoft\Blog\Api\PostRepositoryInterface;

class PostPublisher
{
    public function __construct(
        private readonly PostRepositoryInterface $postRepository,
        private readonly EventManager $eventManager
    ) {}

    /**
     * Publishes a blog post and dispatches events before and after.
     */
    public function publish(int $postId): PostInterface
    {
        $post = $this->postRepository->getById($postId);

        // Dispatch BEFORE event, observers can read/modify context
        $this->eventManager->dispatch(
            'mironsoft_blog_post_publish_before',
            ['post' => $post]
        );

        $post->setStatus('published');
        $post->setPublishedAt(date('Y-m-d H:i:s'));
        $savedPost = $this->postRepository->save($post);

        // Dispatch AFTER event, observers react to completed action
        $this->eventManager->dispatch(
            'mironsoft_blog_post_publish_after',
            [
                'post'       => $savedPost,
                'post_id'    => $savedPost->getId(),
                'author_id'  => $savedPost->getAuthorId(),
            ]
        );

        return $savedPost;
    }
}

Naming convention: Event names are snake_case. Magento follows the pattern modulename_entity_action or modulename_entity_action_before/after. Always prefix custom events with your module name to avoid name collisions.

2. Building an observer

An observer implements the ObserverInterface with a single method: execute(Observer $observer). All event data is retrieved through the $observer object. Observers are stateless, there is no state carried between calls.


<?php
declare(strict_types=1);

namespace Mironsoft\Notifications\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Mironsoft\Blog\Api\Data\PostInterface;
use Mironsoft\Notifications\Service\EmailNotifier;
use Psr\Log\LoggerInterface;

/**
 * Observer: Sends author notification email when a blog post is published.
 */
class SendPublishNotificationObserver implements ObserverInterface
{
    public function __construct(
        private readonly EmailNotifier $emailNotifier,
        private readonly LoggerInterface $logger
    ) {}

    /**
     * Executes when 'mironsoft_blog_post_publish_after' is dispatched.
     */
    public function execute(Observer $observer): void
    {
        /** @var PostInterface $post */
        $post = $observer->getData('post');

        if (!$post || !$post->getAuthorId()) {
            return; // Guard: do nothing if data is missing
        }

        try {
            $this->emailNotifier->sendPublishConfirmation(
                $post->getAuthorId(),
                $post->getTitle()
            );
        } catch (\Exception $e) {
            // Observers should NEVER throw exceptions that interrupt the dispatch chain
            // Log and continue, other observers must still run
            $this->logger->error('Failed to send publish notification', [
                'post_id' => $post->getId(),
                'error'   => $e->getMessage(),
            ]);
        }
    }
}

Important rule: Observers should never throw unhandled exceptions. An exception in an observer interrupts the entire event dispatch chain and can cause subsequent observers to be skipped. Always use try/catch with logging.

3. events.xml: registering observers

Observers are registered in a module's events.xml. The file can live in etc/ (global), etc/frontend/, or etc/adminhtml/, depending on which scope the observer should be active in.


<!-- app/code/Mironsoft/Notifications/etc/events.xml (global scope) -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">

    <!-- Observer for custom event -->
    <event name="mironsoft_blog_post_publish_after">
        <observer name="mironsoft_notifications_send_publish_email"
                  instance="Mironsoft\Notifications\Observer\SendPublishNotificationObserver"/>
    </event>

    <!-- Observer for a Magento Core event -->
    <event name="catalog_product_save_after">
        <observer name="mironsoft_notifications_product_saved"
                  instance="Mironsoft\Notifications\Observer\ProductSavedObserver"/>
    </event>

    <!-- Observer can be disabled -->
    <event name="sales_order_place_after">
        <observer name="mironsoft_notifications_order_placed"
                  instance="Mironsoft\Notifications\Observer\OrderPlacedObserver"
                  disabled="false"/>
    </event>

</config>

<!-- Frontend-only observer: etc/frontend/events.xml -->
<?xml version="1.0"?>
<config ...>
    <!-- Only fires in frontend scope, not in adminhtml or CLI -->
    <event name="cms_page_render">
        <observer name="mironsoft_track_cms_page_view"
                  instance="Mironsoft\Analytics\Observer\Frontend\TrackPageViewObserver"/>
    </event>
</config>

4. Passing and reading event data

Event data is passed as an associative array in the dispatch() call. Inside the observer, it can be retrieved with $observer->getData('key') or the magic getters. Objects are passed by reference, so changes to an object are visible globally.


<?php
// At the dispatch site:
$this->eventManager->dispatch('mironsoft_order_status_changed', [
    'order'      => $order,           // Object, changes become globally visible!
    'old_status' => $oldStatus,       // String
    'new_status' => $newStatus,       // String
    'changed_by' => $adminUserId,     // int
]);

// Inside the observer:
public function execute(Observer $observer): void
{
    // getData() with a key
    $order     = $observer->getData('order');
    $oldStatus = $observer->getData('old_status');
    $newStatus = $observer->getData('new_status');

    // Magic getters (camelCase)
    $changedBy = $observer->getChangedBy();

    // The event object itself
    $event = $observer->getEvent();
    $eventName = $event->getName(); // 'mironsoft_order_status_changed'

    // Objects can be modified, changes are global
    if ($order && $newStatus === 'complete') {
        $order->setData('completion_notified', true);
        // This change is visible directly on the object, without calling save()
    }
}

5. The most important Magento core events

Magento 2 dispatches hundreds of core events. Here are the ones used most often in day-to-day development:


# PRODUCTS
catalog_product_save_before          # before saving a product
catalog_product_save_after           # after saving
catalog_product_load_after           # after loading
catalog_product_delete_before        # before deleting
catalog_product_import_finish_before # after CSV import

# CATEGORIES
catalog_category_save_after
catalog_category_delete_after

# ORDERS
sales_order_place_before             # before placing an order
sales_order_place_after              # after placing
sales_order_payment_pay              # on payment received
sales_order_invoice_save_after       # after invoice creation
sales_order_shipment_save_after      # after shipment creation
sales_order_creditmemo_save_after    # after credit memo creation
sales_order_status_history_save_after

# CART
checkout_cart_add_product_complete   # after adding to cart
checkout_cart_remove_item_after      # after removing
checkout_cart_update_items_after     # after quantity change
checkout_submit_all_after            # after checkout completion

# CUSTOMERS
customer_register_success            # after registration
customer_login                       # after login
customer_logout                      # after logout
customer_save_after_data_object      # after customer save

# ADMIN
adminhtml_block_html_before          # before admin block render
controller_action_predispatch        # before every controller call
controller_action_postdispatch       # after every controller call

# LAYOUT
layout_load_before                   # before layout loading
layout_generate_blocks_before        # before block generation

6. Asynchronous events with a message queue

For time-consuming observer actions (sending emails, calling external APIs, generating reports), asynchronous processing via Magento's message queue is the way to go. Instead of running the action synchronously inside the observer, it gets written to a queue and processed asynchronously by a consumer process.


<?php
declare(strict_types=1);

namespace Mironsoft\Notifications\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\MessageQueue\PublisherInterface;

/**
 * Observer: Queues the email notification instead of sending it synchronously.
 * The actual email is sent by the MQ consumer process.
 */
class QueuePublishNotificationObserver implements ObserverInterface
{
    private const TOPIC_NAME = 'mironsoft.blog.publish.notification';

    public function __construct(
        private readonly PublisherInterface $publisher
    ) {}

    public function execute(Observer $observer): void
    {
        $post = $observer->getData('post');
        if (!$post) {
            return;
        }

        // Publish to queue, returns immediately, no blocking
        $this->publisher->publish(
            self::TOPIC_NAME,
            ['post_id' => $post->getId(), 'author_id' => $post->getAuthorId()]
        );
    }
}

7. Plugin vs. Observer: the decision guide

The most common question in Magento development: should I use a plugin or an observer? The answer depends on the requirement:

  • Use a plugin when: A method's return value needs to be modified. A method's arguments need to be changed. The behavior must be tied specifically to one method. No event exists.
  • Use an observer when: You want to react to an existing Magento core event. Modules should stay fully decoupled. Multiple independent actions need to react to the same event. Execution order and return value don't matter.

<?php
// Decision tree:

// 1. Is there a core event for this use case?
//    → Yes: use an observer
//    → No: continue

// 2. Should a method's return value be changed?
//    → Yes: After plugin
//    → No: continue

// 3. Should a method's arguments be changed?
//    → Yes: Before plugin
//    → No: continue

// 4. Should the method be skipped under certain conditions?
//    → Yes: Around plugin (use with care!)
//    → No: dispatch a custom event + observer

// RULE OF THUMB:
// Plugin: "I want to change method X in class Y"
// Observer: "I want to react when event Z happens"

8. Real-world examples

Example 1: Checking stock levels after an order


<?php
namespace Mironsoft\Inventory\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Api\Data\OrderInterface;

class CheckInventoryAfterOrderObserver implements ObserverInterface
{
    public function __construct(
        private readonly \Mironsoft\Inventory\Service\LowStockNotifier $notifier
    ) {}

    /**
     * Checks stock levels after an order is placed.
     * Listens on: checkout_submit_all_after
     */
    public function execute(Observer $observer): void
    {
        /** @var OrderInterface $order */
        $order = $observer->getData('order');
        if (!$order) {
            return;
        }

        foreach ($order->getAllItems() as $item) {
            $this->notifier->checkAndNotify($item->getSku(), $item->getQtyOrdered());
        }
    }
}

Example 2: Updating SEO attributes after a product save


<?php
namespace Mironsoft\SeoTools\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Catalog\Api\Data\ProductInterface;

class UpdateSeoMetaObserver implements ObserverInterface
{
    public function __construct(
        private readonly \Mironsoft\SeoTools\Service\MetaGenerator $metaGenerator
    ) {}

    /**
     * Auto-generates SEO meta description if empty.
     * Listens on: catalog_product_save_before
     */
    public function execute(Observer $observer): void
    {
        /** @var ProductInterface $product */
        $product = $observer->getData('product');

        if ($product && !$product->getMetaDescription()) {
            // Strip HTML, limit to 160 chars
            $description = strip_tags($product->getDescription() ?? '');
            $product->setMetaDescription(mb_substr($description, 0, 160));
        }
    }
}

Mironsoft

Event-driven Magento 2 development

Need a Magento 2 module with a clean event system?

We build Magento 2 modules with cleanly decoupled components: custom events, observers, asynchronous queue processing, and full PHPUnit test coverage.

Event audit
Analysis of existing observers for bugs, exception handling, and performance issues.
Queue integration
Offloading heavy observer actions into asynchronous message queue consumers.
Observer tests
PHPUnit tests for observers with event mocking and full coverage of error scenarios.

9. Summary

The Observer pattern decouples modules completely: the event dispatcher knows nothing about its observers, and observers know nothing about each other. Events are anchor points for extension, without ever needing to change the module that triggers them. Dispatch synchronously for real-time actions, and use a message queue for expensive operations.

Observer / Event Pattern in Magento 2, rules at a glance

Dispatching an event

EventManager::dispatch(name, data). Dispatch before and after important actions. Custom names with a module prefix: vendor_module_action_before/after.

Implementing an observer

Implement ObserverInterface. execute(Observer $observer). Never throw unhandled exceptions. Always use try/catch with logging.

events.xml

Global in etc/events.xml, scope-specific in etc/frontend/ or etc/adminhtml/. Event name → observer class → disabled="false".

Plugin vs. Observer

Plugin: change return value/arguments. Observer: react to an event without a return value. Observer for full decoupling between modules. Plugin when no matching event exists.

10. FAQ: Observer / Event Pattern in Magento 2

1 How do I find all Magento core events?
Grep for dispatch( in the core: grep -r "dispatch('" vendor/magento/module-catalog/ --include="*.php". Or: the Magento DevDocs Events Reference. Or: the Magento Events List extension in the admin. For all modules: grep -rn 'eventManager->dispatch' vendor/magento/.
2 Can an observer abort the triggering method?
No. Observers cannot stop the triggering method. They can set flags on data objects (which the original method then evaluates), but the method execution itself keeps running. For a real method abort: use an Around plugin and don't call $proceed.
3 In what order do observers run?
Observers have no sortOrder attribute. Their order follows the module load order from module.xml (the sequence tag). If ordering is critical, either bundle the logic into a single observer or switch to plugins with sortOrder.
4 What happens if an observer throws an exception?
An unhandled exception interrupts the entire dispatch chain, all remaining observers for the same event are skipped, and the exception propagates to the calling method. That's why observers must always use try/catch with logging. Never throw exceptions outward.
5 Can I register the same observer for multiple events?
Yes, the same instance class can be entered in events.xml under several <event> nodes with different names. Inside execute(), distinguish which event fired using $observer->getEvent()->getName().
6 How do I test an observer with PHPUnit?
Observers are directly testable: call execute() with a mocked Observer object configured so getData() returns your test data. Then verify that the injected services were called with the expected arguments. No event dispatching is needed in the unit test.
7 What is the difference between global and scope-specific events.xml?
etc/events.xml is globally active (frontend, adminhtml, CLI, cron). etc/frontend/events.xml only in the frontend, etc/adminhtml/events.xml only in the admin panel. Scope-specific observers are preferred, they aren't loaded unnecessarily in other contexts and improve performance.
8 How do I pass data that observers can modify?
Objects are passed by reference, so changes are visible directly on the original. For primitive values: use a DataObject wrapper (new DataObject(['value' => $x])). Observers change the value on the DataObject, and the dispatcher reads it after dispatch() via $wrapper->getValue().
9 When should I use an asynchronous queue instead of a synchronous observer?
Use a queue when: the action takes longer than around 100ms (email, external API, report). Errors shouldn't block the main flow. Retry on failure is desired. Use synchronous observers for fast, critical actions: logging, cache invalidation, setting flags. Rule of thumb: if the user doesn't wait for the result, use a queue.
10 How do I debug an observer that isn't running?
Checklist: 1. bin/magento cache:flush, events.xml is cached. 2. Right scope? (global/frontend/adminhtml). 3. Module enabled? bin/magento module:status. 4. Observer name unique? Conflicts happen with identical names. 5. Run bin/magento setup:di:compile. 6. Set an Xdebug breakpoint in execute().