Magento's catalog_product_save_after runs before the COMMIT. So does Laravel's saved(), as soon as the save sits inside DB::transaction(). Publish to a queue from either, and a fast consumer can read the old row.
What one save does in Magento
In Magento 2, $productRepository->save() isn't one UPDATE. It opens a transaction, 2 levels deep: a CatalogSearch plugin opens one, the EntityManager another. Then it fires catalog_product_save_before, writes the product and its attribute values, fires catalog_product_save_after, and only then commits. Stock data, URL rewrites and the quote refresh hang off catalog_product_save_after. To add behavior, you declare an observer in events.xml.
Laravel has the same shape without the XML: an Eloquent observer with saving() and saved(), attached to the model with #[ObservedBy].
Before the commit
Publish to RabbitMQ from catalog_product_save_after and 2 things can go wrong. A fast consumer reads the product before the transaction ends and gets the old row. Or something fails later in the save, the transaction rolls back, and the ERP has already heard about a product that was never saved.
Magento's answer is a separate event. After the commit, the resource model runs its commit callbacks. One of them is Magento\Framework\Model\AbstractModel::afterCommitCallback(), and it fires catalog_product_save_commit_after. AbstractResource::commit() runs those callbacks only when the transaction level is back to 0. If your save is part of a bigger transaction, the event waits for the outermost commit.
The core uses that event sparingly. Across all areas, the core has 5 observers on catalog_product_save_before, 5 on catalog_product_save_after, and 1 on catalog_product_save_commit_after: ImageResizeAfterProductSave, which resizes the new product images.
The heavier jobs register commit callbacks directly. Search reindex does it in Update on Save mode. Since 2.4.9 catalog price rules do the same: before that, ApplyRules reindexed the saved product in an afterSave plugin, inside the transaction. The code comment says why it moved: to avoid DDL transaction issues.
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd"> <!-- not catalog_product_save_after: that one runs before the COMMIT --> <event name="catalog_product_save_commit_after"> <observer name="acme_notify_erp" instance="Acme\Catalog\Observer\NotifyErp"/> </event> </config> class NotifyErp implements ObserverInterface { public function __construct(private PublisherInterface $publisher) {} public function execute(Observer $observer): void { $sku = $observer->getEvent()->getProduct()->getSku(); $this->publisher->publish('acme.product.saved', $sku); } } The topic itself is declared in communication.xml, queue_publisher.xml and queue_topology.xml. All 3 are in this gist.
Laravel: the same problem, one level up
In Laravel, Eloquent's save() doesn't open a transaction. Only saveOrFail() does. saved() fires right after the query, and without a transaction that row is already committed. The problem shows up when you wrap the work in DB::transaction(), the normal way to save a product together with its prices.
The fix is an interface. An observer that implements ShouldHandleEventsAfterCommit gets its saved() after the outermost commit. Illuminate\Database\DatabaseTransactionsManager holds the call until then. No open transaction? It runs right away. Rollback? It never runs.
Before Laravel 12.54 the interface defers every method of the observer, saving() included, so a change you make to the model in saving() inside a transaction isn't written. Since 12.54 only the "after" methods wait. Keep an after-commit observer to saved() and the difference doesn't touch you.
For a job on its own there's ->afterCommit(), or 'after_commit' => true on the connection in config/queue.php. Laravel ships it as false on all 4 connections that have the option: Database, Beanstalkd, Redis and SQS.
class ProductObserver implements ShouldHandleEventsAfterCommit { public function saved(Product $product): void { NotifyErp::dispatch($product->sku); } } #[ObservedBy(ProductObserver::class)] class Product extends Model { public function prices(): HasMany { return $this->hasMany(Price::class); } } DB::transaction(function () use ($product, $prices) { $product->save(); $product->prices()->createMany($prices); }); // saved() is called here, after the COMMIT What waiting costs
After the commit there's nothing left to roll back. If the publish fails there, the product is saved and the message is lost.
Magento and Laravel handle that failure differently. Magento catches an exception thrown in a commit callback and logs it as CRITICAL in exception.log, and save() returns as if nothing happened. Laravel throws it to the caller of DB::transaction(), after the row is committed.
When the queue is in the same database
Then there's nothing to wait for: the publish is part of the transaction. Magento's MySQL queue inserts into queue_message through the default connection. Laravel's database queue driver does the same when it uses the app's default connection. A rollback removes the message, and no consumer sees it before the commit. If a message must not be lost, that's the setup that gives you both or neither.
Switch to RabbitMQ, Redis or SQS and that guarantee is gone. Your observer doesn't change by a line.
The rule
If a listener talks to the outside world, a queue, an API or an email, wait for the commit, and decide what happens when the call fails after it. Magento gives you a separate event. Laravel gives you an interface.
Next: Magento plugins and Laravel decorators, and why around plugins are the ones to watch.
Have you ever had a consumer read a row that wasn't committed yet?
Part 2 of 15 of the Magento 2 vs Laravel series. Originally published on LinkedIn.