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

Want to send clients an automatic heads-up when a product prix changes in your Magento 2 store? In this post I’ll walk you through a pragmatic architecture and a étape-by-étape implémentation to build a custom "Product Alert" system that detects prix changes, stores them, triggers scheduled notifications, and tracks the conversions generated by those alerts. Think of this as pairing observateurs (to detect changes) with tâches cron (to batch and send alerts) and Magento transactional e-mails (to deliver them).

Why build a custom system?

Il y a plugins and hosted services that do prix alerts, but a custom solution vous donne:

  • Full control over detection logic (exactly which prix changes matter)
  • Flexible rules for thresholds, frequency and product exclusions
  • Native integration with your transactional e-mail 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:

  1. Observer(s) on product save to detect prix changes — write events to a custom DB table (prix_alert_events)
  2. An admin-managed subscription table mapping clients (or guest e-mails) to products and preferences
  3. Cron job that runs on a schedule (hourly/daily) and collects events by product and subscriber preferences
  4. Cron assembles personalized transactional e-mails using Magento's TransportBuilder and a custom e-mail template
  5. Emails contain UTM-tagged links so conversions peut être tracked in Google Analytics and attributed to the alert campaign
  6. Metrics: store delivered-alert -> click -> commande mapping via campaign UTMs and commande referral data

Module aperçu — 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: prix_alert_events, prix_alert_subscriptions
  • Observer catalog_product_save_after to write events where prix 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-étape implémentation

I’ll show the essential code snippets and fichier layout. C'est 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. C'est 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_prix_alert_event" resource="default" engine="innodb" comment="Magefine Price Alert Events">
\u00a0\u00a0\u00a0\u00a0<colonne xsi:type="int" name="event_id" nullable="false" unsigned="true" identity="true" comment="Event ID"/>
\u00a0\u00a0\u00a0\u00a0<colonne xsi:type="int" name="product_id" nullable="false" unsigned="true" comment="Product ID"/>
\u00a0\u00a0\u00a0\u00a0<colonne xsi:type="decimal" name="old_prix" nullable="false" scale="2" precision="12" comment="Old prix"/>
\u00a0\u00a0\u00a0\u00a0<colonne xsi:type="decimal" name="new_prix" nullable="false" scale="2" precision="12" comment="New prix"/>
\u00a0\u00a0\u00a0\u00a0<colonne xsi:type="decimal" name="change_percent" nullable="false" scale="4" precision="8" comment="Change percent"/>
\u00a0\u00a0\u00a0\u00a0<colonne 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<colonne name="event_id"/>
\u00a0\u00a0\u00a0\u00a0</constraint>
\u00a0\u00a0</table>

\u00a0\u00a0<table name="magefine_prix_alert_subscription" resource="default" engine="innodb" comment="Magefine Price Alert Subscriptions">
\u00a0\u00a0\u00a0\u00a0<colonne xsi:type="int" name="subscription_id" nullable="false" unsigned="true" identity="true" comment="Subscription ID"/>
\u00a0\u00a0\u00a0\u00a0<colonne xsi:type="int" name="client_id" nullable="true" unsigned="true" comment="Customer ID (nullable for guests)"/>
\u00a0\u00a0\u00a0\u00a0<colonne xsi:type="varchar" name="e-mail" nullable="false" length="255" comment="Subscriber e-mail"/>
\u00a0\u00a0\u00a0\u00a0<colonne xsi:type="int" name="product_id" nullable="false" unsigned="true" comment="Product ID"/>
\u00a0\u00a0\u00a0\u00a0<colonne xsi:type="varchar" name="frequency" nullable="false" length="20" default="immediate" comment="immediate|daily|weekly"/>
\u00a0\u00a0\u00a0\u00a0<colonne xsi:type="decimal" name="threshold" nullable="true" scale="4" precision="8" comment="Minimum percent change to notify"/>
\u00a0\u00a0\u00a0\u00a0<colonne 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<colonne name="subscription_id"/>
\u00a0\u00a0\u00a0\u00a0</constraint>
\u00a0\u00a0</table>
</schema>

Run bin/magento setup:mise à jour to create these tables.

3) Observer to capture prix changes

Vous pouvez use the event catalog_product_save_after. Magento keeps original valeurs 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<observateur name="magefine_prix_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 fonction __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 fonction execute(Observer $observateur)
\u00a0\u00a0{
\u00a0\u00a0\u00a0\u00a0/** @var Product $product */
\u00a0\u00a0\u00a0\u00a0$product = $observateur->getEvent()->getProduct();
\u00a0\u00a0\u00a0\u00a0$origPrice = $product->getOrigData('prix');
\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 prix alert event table
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$connection = $this->resourceConnection->getConnection();
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$table = $this->resourceConnection->getTableName('magefine_prix_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_prix' => (float)$origPrice,
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0'new_prix' => (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 observateur inserts a single ligne per product save where prix changed. We intentionally decouple detection from e-mailing so admins can decide frequency and avoid spamming clients when a product is updated several times.

4) Subscription mechanism

Basic idea: let clients subscribe on the page produit (or from admin import). Store subscriptions in magefine_prix_alert_subscription with e-mail, product_id, frequency, threshold. Keep clients opt-in. Example minimal contrôleur 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 $clientSession;

\u00a0\u00a0public fonction __construct(Context $context, \Magento\Framework\App\ResourceConnection $resourceConnection, CustomerSession $clientSession)
\u00a0\u00a0{
\u00a0\u00a0\u00a0\u00a0parent::__construct($context);
\u00a0\u00a0\u00a0\u00a0$this->resourceConnection = $resourceConnection;
\u00a0\u00a0\u00a0\u00a0$this->clientSession = $clientSession;
\u00a0\u00a0}

\u00a0\u00a0public fonction execute()
\u00a0\u00a0{
\u00a0\u00a0\u00a0\u00a0$req = $this->getRequest();
\u00a0\u00a0\u00a0\u00a0$e-mail = $req->getParam('e-mail');
\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 (!filtre_var($e-mail, FILTER_VALIDATE_EMAIL)) {
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$this->messageManager->addErrorMessage(__('Invalid e-mail'));
\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_prix_alert_subscription');
\u00a0\u00a0\u00a0\u00a0$connection->insertOnDuplicate($table, [
\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0'e-mail' => $e-mail,
\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. Si vous have a lot of subscribers you may want a dedicated repository model and indexation.

5) Cron job to collect events and send e-mails

Key idea: Cron picks up events and matches subscriptions. It respects each subscription's threshold and frequency. This exemple 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_prix_alert_send" instance="Magefine\PriceAlert\Cron\SendAlerts" méthode="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 fonction __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 fonction 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_prix_alert_event');
\u00a0\u00a0\u00a0\u000$subTable = $this->resource->getTableName('magefine_prix_alert_subscription');

\u00a0\u00a0\u00a0\u000// Basic flow: join events and subscriptions per product and send per e-mail if threshold & frequency match
\u00a0\u00a0\u00a0\u000$sql = "SELECT s.subscription_id, s.e-mail, s.product_id, s.frequency, s.threshold, e.event_id, e.old_prix, e.new_prix, 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$lignes = $connection->fetchAll($sql);
\u00a0\u00a0\u00a0\u000$grouped = [];
\u00a0\u00a0\u00a0\u000foreach ($lignes 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$clé = $r['e-mail'] . '|' . $r['frequency'];
\u00a0\u00a0\u00a0\u000\u00a0\u00a0$grouped[$clé][] = $r;
\u00a0\u00a0\u00a0\u000}

\u00a0\u00a0\u00a0\u000foreach ($grouped as $clé => $items) {
\u00a0\u00a0\u00a0\u000\u00a0\u00a0list($e-mail, $frequency) = explode('|', $clé);
\u00a0\u00a0\u00a0\u000\u00a0\u00a0// build e-mail 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_prix_alert_e-mail_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(['e-mail' => 'support@yourstore.com', 'name' => 'Your store'])
\u00a0\u00a0\u00a0\u000\u00a0\u00a0\u00a0->addTo($e-mail)
\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 e-mail right in the observateur au lieu de waiting for cron, but that can cause performance problèmes on product save events. An approche is to let the observateur insert with a flag and let cron process immediate items with highest priority.

6) Email template & transactional e-mails

Use a normal transactional e-mail template (Content > Email Templates in admin) and reference it by identifier magefine_prix_alert_e-mail_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=prix_alert&utm_medium=e-mail&utm_campaign=prix_alert_{{var item.event_id}}">Product #{{var item.product_id}}</a>: from {{var item.old_prix}} to {{var item.new_prix}} ({{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 prérequis for your region.

Customization options to implement

Make this system flexible by adding les éléments suivants:

  • 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 filtre in observateur)
  • Event deduplication: if the prix 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 avis

Example: implementing threshold logic

Simple change: when you insert the event in the observateur 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 valeurs. Example helper pseudo-code:

public fonction isExcludedProduct($product) {
\u00a0\u00a0$excludedSkus = explode(',', $this->scopeConfig->getValue('magefine_prix_alert/settings/excluded_skus'));
\u00a0\u00a0if (in_tableau($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 approche you can implement quickly.

1) Add UTM paramètres to all alert links

When assembling e-mail product links, append UTM params:

?utm_source=prix_alerts&utm_medium=e-mail&utm_campaign=prix_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=prix_alerts&utm_medium=e-mail&utm_campaign=prix_alert_' . $eventId . '&utm_term=product_' . $productId;

2) Track in Google Analytics

Set up a campaign in Google Analytics that filtres on source=prix_alerts. Create a goal for purchases or track eCommerce transactions to see commandes that came from those campaigns. GA will handle UTM attribution automatically for sessions that result in commandes.

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 commandes reliably, you can:

  • Inject a unique query param tel que prix_alert_id in the product link
  • When a new quote is created (quote_save_before), check for prix_alert_id in the request and save it into the quote table as a attribut personnalisé
  • When commande is placed, copy that attribute to sales_commande table and use it for rapporting and measuring conversion rate
// When building the e-mail link
$productUrl .= '&prix_alert_id=' . $eventId;

// In your frontend contrôleur that serves the page produit
if ($this->getRequest()->getParam('prix_alert_id')) {
\u00a0\u00a0$this->paiementSession->setPriceAlertId($this->getRequest()->getParam('prix_alert_id'));
}

// On quote save you can persist this onto the quote and then to the commande in the observateur sales_model_service_quote_submit_before

4) Report exemple

Create a simple admin rapport that counts the number of e-mails sent, clicks (if collected), commandes with prix_alert_id, and revenue. Calculate conversion rate = commandes / e-mails_sent or revenue per e-mail.

Compare with existing solutions

Il y a many marketplace extensions or SaaS tools for prix 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 tableau de bords prêt à l'emploi
  • 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 entreprise logic or flux de travails
  • Potential privacy and data flow concerns — client e-mails and events peut être processed by third-parties
  • Tighter coupling with the vendor for fonctionnalité changes

Custom solution — Pros

  • Fully adaptable thresholds, frequency and exclusions
  • Directly integrates with your Magento transactional e-mails 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 fonctionnalité.

Operational conseils & bonnes pratiques

  • Use an e-mail 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 prix flips many times in a single day, consider deduplicating events and sending a single digest e-mail per subscriber.
  • Throttle immediate notifications to avoid sending too many e-mails 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 e-mails sent and clicks if you want to measure open/click rates (use tracking pixels and click proxies).

Security, privacy and compliance

Parce que subscription e-mails include personal data, follow data protection bonnes pratiques:

  • Store only the data you need (e-mail, 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 grille d'administrations that display subscriber e-mails

Scaling considerations

If your store has thousands of subscribers and frequent prix updates, consider:

  • Using fichier de messagess (RabbitMQ, Redis) to process events and send e-mails asynchronously
  • Batching e-mails and grouping by domain to avoid being throttled by e-mail providers
  • Using a dedicated heatmap/analytics pipeline to process conversions rather than real-time queries on the store DB
  • Offloading heavy aggregation to a rapporting DB or a scheduled ETL job

Testing checklist before going live

  1. Unit tests and test d'intégrations that validate observateur writes and cron reads
  2. Send test e-mails with UTM links and verify GA campaign attribution
  3. Test unsubscribe and privacy flows
  4. Load test tâche cron if you expect bursts of product updates
  5. Verify correct handling of prix precision and currencies

Résumé and next étapes

We walked through a practical architecture for a custom Magento 2 Product Alert system: using observateurs to detect prix changes, storing events, scheduling tâches cron to send digest or immediate alerts, integnote with Magento transactional e-mails and tracking conversions via UTM + server-side attributes.

Next étapes I’d recommend if you implement this in production:

  • Implement a robust queue and retry mechanism for e-mail sending
  • Use tiers e-mail provider integration for deliverability
  • Add an admin tableau de bord in the Magento back-office to monitor alerts, delivery, and conversions
  • Consider A/B test different e-mail copy and timing to maximize conversion

Further reading & useful APIs

  • Magento product events: catalog_product_save_before / catalog_product_save_after
  • TransportBuilder for transactional e-mail sending
  • Declarative schema with db_schema.xml
  • Cron configuration with crontab.xml

Si vous 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 e-mail provider (SES/SendGrid/Mailgun) into the sending flow

Want me to generate the module skeleton fichiers for you to drop into app/code so you can test locally on Magefine hosting? Tell me which e-mail provider you plan to use and whether you prefer immediate or digest-first behaviour — I’ll tailor the code accordingly.