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
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.
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
- 2. Building an observer
- 3. events.xml: registering observers
- 4. Passing and reading event data
- 5. The most important Magento core events
- 6. Asynchronous events with a message queue
- 7. Plugin vs. Observer: the decision guide
- 8. Real-world examples
- 9. Summary
- 10. FAQ
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.
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?
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?
$proceed.3 In what order do observers run?
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?
try/catch with logging. Never throw exceptions outward.5 Can I register the same observer for multiple events?
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?
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?
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?
10 How do I debug an observer that isn't running?
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().