How to Build a Custom "Product Alert" System for Price Changes in Magento 2

Want to send customers an automatic heads-up when a product price changes in your Magento 2 store? In this post I’ll walk you through a pragmatic architecture and a step-by-step implementation to build a custom "Product Alert" system that detects price changes, stores them, triggers scheduled notifications, and tracks the conversions generated by those alerts. Think of this as pairing observers (to detect changes) with cron jobs (to batch and send alerts) and Magento transactional emails (to deliver them).
Why build a custom system?
There are plugins and hosted services that do price alerts, but a custom solution gives you:
- Full control over detection logic (exactly which price changes matter)
- Flexible rules for thresholds, frequency and product exclusions
- Native integration with your transactional email templates and UTM tracking
- Lower ongoing cost and tighter privacy control when hosted with your store (useful if you self-host or use Magefine hosting)
High-level architecture
Here’s the architecture I recommend:
- Observer(s) on product save to detect price changes — write events to a custom DB table (price_alert_events)
- An admin-managed subscription table mapping customers (or guest emails) to products and preferences
- Cron job that runs on a schedule (hourly/daily) and collects events by product and subscriber preferences
- Cron assembles personalized transactional emails using Magento's TransportBuilder and a custom email template
- Emails contain UTM-tagged links so conversions can be tracked in Google Analytics and attributed to the alert campaign
- Metrics: store delivered-alert -> click -> order mapping via campaign UTMs and order referral data
Module overview — what we’ll add
We’ll create a Magento 2 module named Magefine_PriceAlert (use vendor name consistent with your ecosystem). The module responsibilities:
- Create DB tables: price_alert_events, price_alert_subscriptions
- Observer catalog_product_save_after to write events where price changed
- Admin configuration (system.xml) to control default threshold, frequency, excluded SKUs/categories
- Cron job: Magefine\PriceAlert\Cron\SendAlerts
- Email template and TransportBuilder usage
Step-by-step implementation
I’ll show the essential code snippets and file layout. This is a practical blueprint — you’ll still want to adapt names and DI wiring to match your coding standards.
1) Module skeleton
Files:
app/code/Magefine/PriceAlert/registration.php
<?php
\u00a0\u00a0use Magento\Framework\Component\ComponentRegistrar;
\u00a0\u00a0ComponentRegistrar::register(
\u00a0\u00a0\u00a0\u00a0ComponentRegistrar::MODULE,
\u00a0\u00a0\u00a0\u00a0'Magefine_PriceAlert',
\u00a0\u00a0\u00a0\u00a0__DIR__
\u00a0\u00a0);
app/code/Magefine/PriceAlert/etc/module.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
\u00a0\u00a0<module name="Magefine_PriceAlert" setup_version="1.0.0"/>
</config>
2) DB schema
With declarative schema (db_schema.xml) we create two tables. This is a compact version — add indexes and FK constraints in production.
app/code/Magefine/PriceAlert/etc/db_schema.xml
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
\u00a0\u00a0<table name="magefine_price_alert_event" resource="default" engine="innodb" comment="Magefine Price Alert Events">
\u00a0\u00a0\u00a0\u00a0<column xsi:type="int" name="event_id" nullable="false" unsigned="true" identity="true" comment="Event ID"/>
\u00a0\u00a0\u00a0\u00a0<column xsi:type="int" name="product_id" nullable="false" unsigned="true" comment="Product ID"/>
\u00a0\u00a0\u00a0\u00a0<column xsi:type="decimal" name="old_price" nullable="false" scale="2" precision="12" comment="Old price"/>
\u00a0\u00a0\u00a0\u00a0<column xsi:type="decimal" name="new_price" nullable="false" scale="2" precision="12" comment="New price"/>
\u00a0\u00a0\u00a0\u00a0<column xsi:type="decimal" name="change_percent" nullable="false" scale="4" precision="8" comment="Change percent"/>
\u00a0\u00a0\u00a0\u00a0<column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/>
\u00a0\u00a0\u00a0\u000<constraint xsi:type="primary" referenceId="PRIMARY">
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0<column name="event_id"/>
\u00a0\u00a0\u00a0\u00a0</constraint>
\u00a0\u00a0</table>
\u00a0\u00a0<table name="magefine_price_alert_subscription" resource="default" engine="innodb" comment="Magefine Price Alert Subscriptions">
\u00a0\u00a0\u00a0\u00a0<column xsi:type="int" name="subscription_id" nullable="false" unsigned="true" identity="true" comment="Subscription ID"/>
\u00a0\u00a0\u00a0\u00a0<column xsi:type="int" name="customer_id" nullable="true" unsigned="true" comment="Customer ID (nullable for guests)"/>
\u00a0\u00a0\u00a0\u00a0<column xsi:type="varchar" name="email" nullable="false" length="255" comment="Subscriber email"/>
\u00a0\u00a0\u00a0\u00a0<column xsi:type="int" name="product_id" nullable="false" unsigned="true" comment="Product ID"/>
\u00a0\u00a0\u00a0\u00a0<column xsi:type="varchar" name="frequency" nullable="false" length="20" default="immediate" comment="immediate|daily|weekly"/>
\u00a0\u00a0\u00a0\u00a0<column xsi:type="decimal" name="threshold" nullable="true" scale="4" precision="8" comment="Minimum percent change to notify"/>
\u00a0\u00a0\u00a0\u00a0<column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP"/>
\u00a0\u00a0\u00a0\u00a0<constraint xsi:type="primary" referenceId="PRIMARY">
\u00a00\u00a0\u00a0\u00a0\u00a0<column name="subscription_id"/>
\u00a0\u00a0\u00a0\u00a0</constraint>
\u00a0\u00a0</table>
</schema>
Run bin/magento setup:upgrade to create these tables.
3) Observer to capture price changes
You can use the event catalog_product_save_after. Magento keeps original values on the product model under getOrigData(). That makes detection easy.
app/code/Magefine/PriceAlert/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">
\u00a0\u00a0<event name="catalog_product_save_after">
\u00a0\u00a0\u00a0\u00a0<observer name="magefine_price_alert_product_save" instance="Magefine\PriceAlert\Observer\ProductPriceChangeObserver" />
\u00a0\u00a0</event>
</config>
app/code/Magefine/PriceAlert/Observer/ProductPriceChangeObserver.php
<?php
namespace Magefine\PriceAlert\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Event\Observer;
use Magento\Catalog\Model\Product;
class ProductPriceChangeObserver implements ObserverInterface
{
\u00a0\u00a0protected $resourceConnection;
\u00a0\u00a0protected $dateTime;
\u00a0\u00a0public function __construct(
\u00a0\u00a0\u00a0\u00a0\Magento\Framework\App\ResourceConnection $resourceConnection,
\u00a0\u00a0\u00a0\u00a0\Magento\Framework\Stdlib\DateTime\DateTime $dateTime
\u00a0\u00a0) {
\u00a0\u00a0\u00a0\u00a0$this->resourceConnection = $resourceConnection;
\u00a0\u00a0\u00a0\u00a0$this->dateTime = $dateTime;
\u00a0\u00a0}
\u00a0\u00a0public function execute(Observer $observer)
\u00a0\u00a0{
\u00a0\u00a0\u00a0\u00a0/** @var Product $product */
\u00a0\u00a0\u00a0\u00a0$product = $observer->getEvent()->getProduct();
\u00a0\u00a0\u00a0\u00a0$origPrice = $product->getOrigData('price');
\u00a0\u00a0\u00a0\u00a0$newPrice = $product->getPrice();
\u00a0\u00a0\u00a0\u00a0if ($origPrice === null) {
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0// likely a new product, skip or treat separately
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0return;
\u00a0\u00a0\u00a0\u00a0}
\u00a0\u00a0\u00a0\u00a0if ((float)$origPrice != (float)$newPrice) {
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$changePercent = ($newPrice - $origPrice) / max($origPrice, 0.0001) * 100;
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0// insert into price alert event table
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$connection = $this->resourceConnection->getConnection();
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$table = $this->resourceConnection->getTableName('magefine_price_alert_event');
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$connection->insert($table, [
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0'product_id' => (int)$product->getId(),
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0'old_price' => (float)$origPrice,
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0'new_price' => (float)$newPrice,
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0'change_percent' => (float)$changePercent,
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0'created_at' => $this->dateTime->gmtDate(),
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0]);
\u00a0\u00a0\u00a0\u00a0}
\u00a0\u00a0}
}
Note: the observer inserts a single row per product save where price changed. We intentionally decouple detection from emailing so admins can decide frequency and avoid spamming customers when a product is updated several times.
4) Subscription mechanism
Basic idea: let customers subscribe on the product page (or from admin import). Store subscriptions in magefine_price_alert_subscription with email, product_id, frequency, threshold. Keep customers opt-in. Example minimal controller to subscribe:
app/code/Magefine/PriceAlert/Controller/Subscribe/Subscribe.php
<?php
namespace Magefine\PriceAlert\Controller\Subscribe;
use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Customer\Model\Session as CustomerSession;
class Subscribe extends Action
{
\u00a0\u00a0protected $resourceConnection;
\u00a0\u00a0protected $customerSession;
\u00a0\u00a0public function __construct(Context $context, \Magento\Framework\App\ResourceConnection $resourceConnection, CustomerSession $customerSession)
\u00a0\u00a0{
\u00a0\u00a0\u00a0\u00a0parent::__construct($context);
\u00a0\u00a0\u00a0\u00a0$this->resourceConnection = $resourceConnection;
\u00a0\u00a0\u00a0\u00a0$this->customerSession = $customerSession;
\u00a0\u00a0}
\u00a0\u00a0public function execute()
\u00a0\u00a0{
\u00a0\u00a0\u00a0\u00a0$req = $this->getRequest();
\u00a0\u00a0\u00a0\u00a0$email = $req->getParam('email');
\u00a0\u00a0\u00a0\u00a0$productId = (int)$req->getParam('product_id');
\u00a0\u00a0\u00a0\u00a0$frequency = $req->getParam('frequency', 'immediate');
\u00a0\u00a0\u00a0\u00a0$threshold = $req->getParam('threshold');
\u00a0\u00a0\u00a0\u00a0// sanitize and basic validation
\u00a0\u00a0\u00a0\u00a0if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$this->messageManager->addErrorMessage(__('Invalid email'));
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0return $this->_redirect($this->_redirect->getRefererUrl());
\u00a0\u00a0\u00a0\u00a0}
\u00a0\u00a0\u00a0\u00a0$connection = $this->resourceConnection->getConnection();
\u00a0\u00a0\u00a0\u00a0$table = $this->resourceConnection->getTableName('magefine_price_alert_subscription');
\u00a0\u00a0\u00a0\u00a0$connection->insertOnDuplicate($table, [
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0'email' => $email,
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0'product_id' => $productId,
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0'frequency' => $frequency,
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0'threshold' => $threshold
\u00a0\u00a0\u00a0\u00a0], ['subscription_id']);
\u00a0\u00a0\u00a0\u00a0$this->messageManager->addSuccessMessage(__('Subscribed successfully'));
\u00a0\u00a0\u00a0\u00a0return $this->_redirect($this->_redirect->getRefererUrl());
\u00a0\u00a0}
}
Keep subscriptions lightweight. If you have a lot of subscribers you may want a dedicated repository model and indexing.
5) Cron job to collect events and send emails
Key idea: Cron picks up events and matches subscriptions. It respects each subscription's threshold and frequency. This example shows the main flow — adapt to your queueing logic for large catalogs.
app/code/Magefine/PriceAlert/etc/crontab.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
\u00a0\u00a0<group id="default">
\u00a0\u00a0\u00a0\u00a0<job name="magefine_price_alert_send" instance="Magefine\PriceAlert\Cron\SendAlerts" method="execute">
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0<schedule>0 * * * *</schedule> <!-- run hourly, change to daily/whatever -->
\u00a0\u00a0\u00a0\u00a0</job>
\u00a0\u00a0</group>
</config>
app/code/Magefine/PriceAlert/Cron/SendAlerts.php
<?php
namespace Magefine\PriceAlert\Cron;
use Magento\Framework\App\ResourceConnection;
use Magento\Framework\Stdlib\DateTime\DateTime;
use Magento\Framework\Translate\Inline\StateInterface;
use Magento\Framework\UrlInterface;
use Magento\Store\Model\StoreManagerInterface;
use Magento\Framework\App\Area;
use Magento\Framework\App\State as AppState;
use Magento\Framework\Locale\ResolverInterface;
class SendAlerts
{
\u00a0\u00a0protected $resource;
\u00a0\u00a0protected $dateTime;
\u00a0\u00a0protected $transportBuilder;
\u00a0\u00a0protected $inlineTranslation;
\u00a0\u00a0protected $storeManager;
\u00a0\u00a0protected $urlBuilder;
\u00a0\u00a0protected $appState;
\u00a0\u00a0protected $localeResolver;
\u00a0\u00a0public function __construct(
\u00a0\u00a0\u00a0\u00a0ResourceConnection $resource,
\u00a0\u00a0\u00a0\u00a0DateTime $dateTime,
\u00a0\u00a0\u00a0\u00a0\Magento\Framework\Mail\Template\TransportBuilder $transportBuilder,
\u00a0\u00a0\u00a0\u00a0StateInterface $inlineTranslation,
\u00a0\u00a0\u00a0\u00a0StoreManagerInterface $storeManager,
\u00a0\u00a0\u00a0\u000UrlInterface $urlBuilder,
\u00a0\u00a0\u00a0\u000AppState $appState,
\u00a0\u00a0\u000\u000LocaleResolverInterface $localeResolver
\u00a0\u00a0) {
\u00a0\u00a0\u000\u000$this->resource = $resource;
\u00a0\u00a0\u000\u000$this->dateTime = $dateTime;
\u00a0\u00a0\u000\u000$this->transportBuilder = $transportBuilder;
\u00a0\u000\u000\u000$this->inlineTranslation = $inlineTranslation;
\u00a0\u00a0\u000\u000$this->storeManager = $storeManager;
\u00a0\u000\u000\u000$this->urlBuilder = $urlBuilder;
\u00a0\u000\u000\u000$this->appState = $appState;
\u00a0\u000\u000\u000$this->localeResolver = $localeResolver;
\u00a0\u00a0}
\u00a0\u00a0public function execute()
\u00a0\u00a0{
\u00a0\u00a0\u00a0\u000// ensure correct area
\u00a0\u00a0\u00a0\u000try {
\u00a0\u00a0\u00a0\u000\u00a0\u000$this->appState->setAreaCode(Area::AREA_FRONTEND);
\u00a0\u00a0\u00a0\u000} catch (\Exception $e) {
\u00a0\u00a0\u00a0\u000\u00a0\u00a0// area already set
\u00a0\u00a0\u00a0\u000}
\u00a0\u00a0\u00a0\u00a0$connection = $this->resource->getConnection();
\u00a0\u00a0\u00a0\u000$eventTable = $this->resource->getTableName('magefine_price_alert_event');
\u00a0\u00a0\u00a0\u000$subTable = $this->resource->getTableName('magefine_price_alert_subscription');
\u00a0\u00a0\u00a0\u000// Basic flow: join events and subscriptions per product and send per email if threshold & frequency match
\u00a0\u00a0\u00a0\u000$sql = "SELECT s.subscription_id, s.email, s.product_id, s.frequency, s.threshold, e.event_id, e.old_price, e.new_price, e.change_percent, e.created_at FROM " . $eventTable . " e JOIN " . $subTable . " s ON e.product_id = s.product_id WHERE e.created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)";
\u00a0\u00a0\u00a0\u000$rows = $connection->fetchAll($sql);
\u00a0\u00a0\u00a0\u000$grouped = [];
\u00a0\u00a0\u00a0\u000foreach ($rows as $r) {
\u00a0\u00a0\u00a0\u000\u00a0\u00a0$threshold = $r['threshold'] !== null ? (float)$r['threshold'] : 0;
\u00a0\u00a0\u00a0\u000\u00a0\u00a0if (abs((float)$r['change_percent']) < $threshold) continue; // skip if below threshold
\u00a0\u00a0\u00a0\u000\u00a0\u00a0$key = $r['email'] . '|' . $r['frequency'];
\u00a0\u00a0\u00a0\u000\u00a0\u00a0$grouped[$key][] = $r;
\u00a0\u00a0\u00a0\u000}
\u00a0\u00a0\u00a0\u000foreach ($grouped as $key => $items) {
\u00a0\u00a0\u00a0\u000\u00a0\u00a0list($email, $frequency) = explode('|', $key);
\u00a0\u00a0\u00a0\u000\u00a0\u00a0// build email variables
\u00a0\u00a0\u00a0\u000\u00a0\u00a0$templateVars = ['items' => $items, 'store' => $this->storeManager->getStore()];
\u00a0\u00a0\u00a0\u000\u00a0\u00a0$this->inlineTranslation->suspend();
\u00a0\u00a0\u00a0\u000\u00a0\u00a0$transport = $this->transportBuilder
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u000->setTemplateIdentifier('magefine_price_alert_email_template')
\u00a0\u00a0\u00a0\u000\u00a0\u00a0\u00a0->setTemplateOptions(['area' => Area::AREA_FRONTEND, 'store' => $this->storeManager->getStore()->getId()])
\u00a0\u00a0\u00a0\u000\u00a0\u00a0\u00a0->setTemplateVars($templateVars)
\u00a0\u00a0\u00a0\u000\u00a0\u00a0\u00a0->setFromByScope(['email' => 'support@yourstore.com', 'name' => 'Your store'])
\u00a0\u00a0\u00a0\u000\u00a0\u00a0\u00a0->addTo($email)
\u00a0\u00a0\u00a0\u000\u00a0\u00a0\u00a0->getTransport();
\u00a0\u00a0\u00a0\u000\u00a0\u00a0$transport->sendMessage();
\u00a0\u00a0\u00a0\u000\u00a0\u00a0$this->inlineTranslation->resume();
\u00a0\u00a0\u00a0\u000}
\u00a0\u00a0\u00a0\u000// optionally cleanup old events
\u00a0\u00a0\u00a0\u000$connection->delete($eventTable, "created_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY)");
\u00a0\u00a0\u00a0\u000return $this;
\u00a0\u00a0}
}
Important: send frequency logic is simplified. You’ll want to respect s.frequency (immediate/daily/weekly). For immediate alerts you might trigger an email right in the observer instead of waiting for cron, but that can cause performance issues on product save events. An approach is to let the observer insert with a flag and let cron process immediate items with highest priority.
6) Email template & transactional emails
Use a normal transactional email template (Content > Email Templates in admin) and reference it by identifier magefine_price_alert_email_template. The template can iterate over passed items. Example template body (pseudocode):
<strong>Price change alert</strong>
<#if items>
<ul>
<?php foreach($items as $item): ?>
\u00a0<li><a href="{{var store.getUrl('catalog/product/view', ['id'=> $item['product_id']])}}?utm_source=price_alert&utm_medium=email&utm_campaign=price_alert_{{var item.event_id}}">Product #{{var item.product_id}}</a>: from {{var item.old_price}} to {{var item.new_price}} ({{var item.change_percent}}%)</li>
<?php endforeach; ?>
</ul>
<#else>No changes</#if>
Important: compose friendly copy, add an unsubscribe link, and respect GDPR/opt-in requirements for your region.
Customization options to implement
Make this system flexible by adding the following:
- Thresholds per-subscription (percent change before notifying)
- Frequency options: immediate, hourly, daily, weekly (store can choose default in system config)
- Global exclusions: SKUs or categories to ignore (configure in system.xml and filter in observer)
- Event deduplication: if the price flips up and down quickly, keep the latest or compute net change over a period
- Admin UI: grid for events and subscriptions to allow manual review
Example: implementing threshold logic
Simple change: when you insert the event in the observer you can compute and store change_percent. During cron you compare the subscription threshold to change_percent and skip if below. That’s already in the sample cron code above.
Exclusion by category or SKU
Implement a small helper that checks product categories or attributes and uses system config values. Example helper pseudo-code:
public function isExcludedProduct($product) {
\u00a0\u00a0$excludedSkus = explode(',', $this->scopeConfig->getValue('magefine_price_alert/settings/excluded_skus'));
\u00a0\u00a0if (in_array($product->getSku(), $excludedSkus)) return true;
\u00a0\u00a0// check categories similarly
\u00a0\u00a0return false;
}
Measure impact — track conversions from alerts
This part is crucial: if you send alerts, you want to know how many alerts produce sales. Here’s a practical approach you can implement quickly.
1) Add UTM parameters to all alert links
When assembling email product links, append UTM params:
?utm_source=price_alerts&utm_medium=email&utm_campaign=price_alert_<EVENT_ID>&utm_term=product_<PRODUCT_ID>
Example in code (from the cron):
$productUrl = $this->storeManager->getStore()->getBaseUrl() . 'catalog/product/view/id/' . $productId;
$productUrl .= '?utm_source=price_alerts&utm_medium=email&utm_campaign=price_alert_' . $eventId . '&utm_term=product_' . $productId;
2) Track in Google Analytics
Set up a campaign in Google Analytics that filters on source=price_alerts. Create a goal for purchases or track eCommerce transactions to see orders that came from those campaigns. GA will handle UTM attribution automatically for sessions that result in orders.
3) Add server-side matching (optional but more reliable)
UTM attribution via GA is client-side; sometimes cookies or UTM params are lost. To pin alerts to orders reliably, you can:
- Inject a unique query param such as price_alert_id in the product link
- When a new quote is created (quote_save_before), check for price_alert_id in the request and save it into the quote table as a custom attribute
- When order is placed, copy that attribute to sales_order table and use it for reporting and measuring conversion rate
// When building the email link
$productUrl .= '&price_alert_id=' . $eventId;
// In your frontend controller that serves the product page
if ($this->getRequest()->getParam('price_alert_id')) {
\u00a0\u00a0$this->checkoutSession->setPriceAlertId($this->getRequest()->getParam('price_alert_id'));
}
// On quote save you can persist this onto the quote and then to the order in the observer sales_model_service_quote_submit_before
4) Report example
Create a simple admin report that counts the number of emails sent, clicks (if collected), orders with price_alert_id, and revenue. Calculate conversion rate = orders / emails_sent or revenue per email.
Compare with existing solutions
There are many marketplace extensions or SaaS tools for price monitoring and alerts. Here’s a balanced comparison so you can decide:
Commercial extensions or SaaS — Pros
- Faster to get started (pre-built UI, subscriptions, templates)
- Often include analytics dashboards out of the box
- May provide additional channels like SMS or push notifications
Commercial extensions or SaaS — Cons
- Monthly or license cost increases with usage
- May not match custom business logic or workflows
- Potential privacy and data flow concerns — customer emails and events may be processed by third-parties
- Tighter coupling with the vendor for feature changes
Custom solution — Pros
- Fully adaptable thresholds, frequency and exclusions
- Directly integrates with your Magento transactional emails and store templates
- No recurring SaaS cost; maintain control over data and UTM structure
- Run on your own schedule and scale with your hosting; works well on Magefine hosting
Custom solution — Cons
- Requires development and maintenance effort
- Needs monitoring (deliverability, cron reliability, queueing)
In short: pick a custom solution if you need tight integration and custom rules. Choose an off-the-shelf or SaaS tool for speed and less engineering maintenance if you don’t want to own the feature.
Operational tips & best practices
- Use an email delivery provider (SES, SendGrid, Mailgun) if you send many alerts — Magento's default mail is not optimized for bulk deliverability.
- Respect frequency: if a product price flips many times in a single day, consider deduplicating events and sending a single digest email per subscriber.
- Throttle immediate notifications to avoid sending too many emails at once; use a queue or a rate-limited cron.
- Provide unsubscribe links and honor them quickly — keep unsubscribe tokens in the subscription table.
- Store a history of emails sent and clicks if you want to measure open/click rates (use tracking pixels and click proxies).
Security, privacy and compliance
Because subscription emails include personal data, follow data protection best practices:
- Store only the data you need (email, product_id, preferences)
- Add explicit opt-in for marketing if you plan to reuse the data for other communication
- Offer data export and deletion for GDPR compliance
- Use secure access control for admin grids that display subscriber emails
Scaling considerations
If your store has thousands of subscribers and frequent price updates, consider:
- Using message queues (RabbitMQ, Redis) to process events and send emails asynchronously
- Batching emails and grouping by domain to avoid being throttled by email providers
- Using a dedicated heatmap/analytics pipeline to process conversions rather than real-time queries on the store DB
- Offloading heavy aggregation to a reporting DB or a scheduled ETL job
Testing checklist before going live
- Unit tests and integration tests that validate observer writes and cron reads
- Send test emails with UTM links and verify GA campaign attribution
- Test unsubscribe and privacy flows
- Load test cron job if you expect bursts of product updates
- Verify correct handling of price precision and currencies
Summary and next steps
We walked through a practical architecture for a custom Magento 2 Product Alert system: using observers to detect price changes, storing events, scheduling cron jobs to send digest or immediate alerts, integrating with Magento transactional emails and tracking conversions via UTM + server-side attributes.
Next steps I’d recommend if you implement this in production:
- Implement a robust queue and retry mechanism for email sending
- Use third-party email provider integration for deliverability
- Add an admin dashboard in the Magento backend to monitor alerts, delivery, and conversions
- Consider A/B testing different email copy and timing to maximize conversion
Further reading & useful APIs
- Magento product events: catalog_product_save_before / catalog_product_save_after
- TransportBuilder for transactional email sending
- Declarative schema with db_schema.xml
- Cron configuration with crontab.xml
If you want, I can:
- Provide a ready-to-install skeleton module with the code shown above
- Suggest a DB schema optimized for high volume
- Help integrate a specific email provider (SES/SendGrid/Mailgun) into the sending flow
Want me to generate the module skeleton files for you to drop into app/code so you can test locally on Magefine hosting? Tell me which email provider you plan to use and whether you prefer immediate or digest-first behaviour — I’ll tailor the code accordingly.