Keeping Observers Safe
Observers run inline inside the caller's transaction, so keep them cheap, catch your own exceptions, and never rely on their order.
<observer name="..." instance="..." disabled="false" shared="true"/>Observers run inline. The event manager calls them one after another before dispatch() returns, in the same PHP process and often inside the caller's database transaction. Everything an observer does is paid for by whoever triggered the event.
Keep it cheap
No HTTP calls, no image processing, no reindexing, no loops over large result sets. One second of work in an observer on catalog_product_save_after adds an hour to an import of 3,600 products. Do the smallest possible thing in the request and leave the rest to something asynchronous.
Do not break the caller
An uncaught exception in your observer becomes the failure of whatever the user was doing. On an _after event dispatched inside the transaction it also rolls the save back, so an admin saving a product sees your integration's error and loses their edit. Catch what you can handle, log it, and return. Throw only when stopping the action is exactly what you intend, and then throw Magento\Framework\Exception\LocalizedException so the message reaches the user.
Order is not yours to choose
events.xsd has no sortOrder attribute. Observers run in merged configuration order, which follows module load order, which changes when modules are installed. Never write two observers where one depends on the other having run.
Bind narrowly
A binding in etc/events.xml fires in every area, including cron, CLI, REST and GraphQL, where there is no session and no storefront context. Use etc/frontend/events.xml when the code only makes sense on the storefront. After any change run bin/magento cache:flush.
Example
<?php
// app/code/Vendor/Feed/Observer/RecordProductChange.php
declare(strict_types=1);
namespace Vendor\Feed\Observer;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Psr\Log\LoggerInterface;
class RecordProductChange implements ObserverInterface
{
private LoggerInterface $logger;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}
public function execute(Observer $observer): void
{
$product = $observer->getEvent()->getProduct();
if (!$product instanceof ProductInterface || $product->getId() === null) {
return;
}
try {
// One cheap write. Anything slower belongs outside the request.
$this->logger->info('Product changed', ['sku' => $product->getSku()]);
} catch (\Throwable $e) {
// Bound to catalog_product_save_commit_after, so the save is already
// committed. Letting this escape would still show the admin a failed
// save for work that has nothing to do with the product.
$this->logger->error('Feed record failed: ' . $e->getMessage());
}
}
}When to use it
- Stop a slow observer from doubling the run time of a 50,000-product import.
- Make sure a failing integration does not prevent admins from saving orders.
- Work out why two observers on one event run in a different order on staging.
- Move a binding out of the global area so it stops firing during cron and CLI.
More examples
Bind after the commit, and narrowly
Magento\Framework\Model\AbstractModel dispatches three save events per entity: _save_before and _save_after inside the transaction, and _save_commit_after from the commit callback once the data is really stored. Side effects that must not undo the save belong on the commit event. Avoid the generic model_save_after, which fires for every model in the application, thousands of times during an import. Run bin/magento cache:flush, and bin/magento setup:di:compile in production mode for the new class.
<!-- app/code/Vendor/Feed/etc/events.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="catalog_product_save_commit_after">
<observer name="vendor_feed_record_product_change"
instance="Vendor\Feed\Observer\RecordProductChange"/>
</event>
</config>Hand the work off instead of doing it
The shape to aim for when the real work is slow: the observer only records that something needs doing. PublisherInterface::publish($topicName, $data) hands a message to a queue, and a separate consumer process does the export with no admin waiting on it. Message queues, topics and consumers are covered later in the course; the point here is only that an observer's job ends at the handover.
<?php
// app/code/Vendor/Feed/Observer/QueueProductExport.php (excerpt)
use Magento\Framework\MessageQueue\PublisherInterface;
private const TOPIC = 'vendor.feed.product.changed';
public function execute(Observer $observer): void
{
$product = $observer->getEvent()->getProduct();
if (!$product instanceof ProductInterface) {
return;
}
try {
// Returns immediately. The export itself runs in a consumer process.
$this->publisher->publish(self::TOPIC, (string) $product->getSku());
} catch (\Throwable $e) {
$this->logger->error('Could not queue export: ' . $e->getMessage());
}
}sortOrder is not an observer attribute
events.xsd allows only name, instance, disabled and shared on an observer. sortOrder belongs to plugins in di.xml, and in developer mode this file fails schema validation with an attribute-not-allowed error; the config cache can keep serving the previous version until you flush it. Observers run in merged configuration order, which follows module load order from <sequence> in module.xml, so it shifts as modules are installed. If First must run before Second, make them one observer that performs both steps.
<!-- app/code/Vendor/Feed/etc/events.xml - this file does NOT validate -->
<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_feed_first"
instance="Vendor\Feed\Observer\First"
sortOrder="10"/>
<observer name="vendor_feed_second"
instance="Vendor\Feed\Observer\Second"
sortOrder="20"/>
</event>
</config>
Discussion