How to Implement a Custom "Low Stock" Alert for Customers in Magento 2

If you want to notify customers when an item is running low, Magento 2 doesn’t provide a turnkey, polished UX for per-customer thresholds out of the box. But it’s a solvable engineering task—and one that can increase conversions if you get the UX right. In this post I’ll walk you through a practical approach to building a custom “Low Stock” alert system in Magento 2. I’ll include architecture choices (plugins, queues, cron), code snippets you can adapt, UX placement ideas, rate limiting and how to integrate with an existing “Force Product Stock Status” module so your forced in-stock/out-of-stock rules and alerts play nicely together.

What we’re building (high level)

  • A backend watcher that detects stock changes (via a plugin on stock save).
  • A mechanism to enqueue alert checks (either via RabbitMQ message, or a small DB queue processed by cron) to avoid slow synchronous work on save.
  • A customer-level preference: each customer can set a threshold ("notify me when stock ≤ X") in their account.
  • Sync with Magento native product stock alerts so the customer can still use Magento’s subscription if desired.
  • UX components: product page button/field, cart/wishlist placements and responsive email templates.
  • Rate limiting and anti-spam: client-side throttling + server-side last-notified checks.
  • Integration considerations with Force Product Stock Status modules so forced status updates can still trigger notification flows.

Important architecture choices

There are two key performance/architecture decisions to make:

  1. Where to intercept stock changes? Plugin on StockItem save or hook into MSI events if you use Magento MSI.
  2. How to process notifications? Immediately (bad for perf) vs. asynchronous via message queue (RabbitMQ) or a cron/DB queue.

For most production stores I recommend: plugin on the stock save API + push a lightweight message to the message queue (RabbitMQ) or insert a lightweight row into a custom queue table and let a cron (or consumer) process that queue. That keeps the critical write path fast and shifts heavy work (looking up subscribers, composing emails) off the request thread.

Observe stock changes using plugins (aroundSave / afterSave)

We’ll target the classic single-source stock save path first: \Magento\CatalogInventory\Model\Stock\ItemRepository::save (the concrete class). If you use Magento MSI (Inventory), the hooks differ and I’ll explain what to do at the end of this section.

Plugin approach:

  • Use an aroundSave if you need to wrap the save operation (rare).
  • Use an afterSave to act on the saved stock item (typical).

Sample di.xml to register a plugin:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\CatalogInventory\Model\Stock\ItemRepository">
        <plugin name="magefine_lowstock_stockitem_plugin"
                type="Vendor\LowStock\Plugin\StockItemSavePlugin" />
    </type>
</config>

Plugin class (simplified). Here we use afterSave so the stock is already persisted and we can compare previous vs current states if needed.

<?php
namespace Vendor\LowStock\Plugin;

use Magento\CatalogInventory\Model\Stock\Item as StockItem;
use Magento\CatalogInventory\Model\Stock\ItemRepository;
use Magento\Framework\MessageQueue\PublisherInterface; // optional
use Psr\Log\LoggerInterface;

class StockItemSavePlugin
{
    private $publisher;
    private $logger;

    public function __construct(
        PublisherInterface $publisher,
        LoggerInterface $logger
    ) {
        $this->publisher = $publisher;
        $this->logger = $logger;
    }

    /**
     * After save plugin: enqueue a message with product id and qty.
     */
    public function afterSave(ItemRepository $subject, StockItem $result)
    {
        try {
            $data = [
                'product_id' => (int)$result->getProductId(),
                'qty' => (float)$result->getQty(),
                'is_in_stock' => (bool)$result->getIsInStock(),
            ];

            // publish to a queue for async processing
            $this->publisher->publish('magefine.low_stock.topic', json_encode($data));
        } catch (\Exception $e) {
            $this->logger->error('LowStock publisher error: ' . $e->getMessage());
        }

        return $result;
    }
}

Notes:

  • If you prefer a DB-backed lightweight queue (cron approach), instead of publishing to RabbitMQ you can insert a row into a custom table with product_id, qty, inserted_at. A cron job will poll that table and process pending rows.
  • Keep the payload tiny: product id, qty, timestamp, and optionally the website id. Do heavy reads/joins in the consumer process.

Message queue (RabbitMQ) setup—publisher and consumer

If you already use RabbitMQ for performance, this is the cleanest approach. You’ll:

  1. Declare a topic/exchange and a queue using queue_topology.xml.
  2. Publish to the topic from the plugin.
  3. Create a consumer that receives messages and runs the alert logic (lookup customers, check thresholds, send emails).

Example queue_topology.xml (lightweight):

<queue_topology xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_MessageQueue:etc/queue_topology.xsd">
    <exchange name="magefine.low_stock.exchange" type="topic" />
    <queue name="magefine.low_stock.queue" />
    <binding id="magefine.low_stock.binding" topic="magefine.low_stock.topic" />
</queue_topology>

Example consumer declaration (queue_consumer.xml):

<consumers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_MessageQueue:etc/consumers.xsd">
    <consumer name="magefine_low_stock_consumer" queue="magefine.low_stock.queue"
              handler="Vendor\LowStock\Model\Consumer\LowStockConsumer" />
</consumers>

Consumer skeleton (where the heavy lifting happens):

<?php
namespace Vendor\LowStock\Model\Consumer;

use Psr\Log\LoggerInterface;
use Magento\Customer\Model\CustomerFactory;
use Magento\Catalog\Model\ProductRepository;
use Magento\ProductAlert\Model\Stock as ProductAlertStock; // native product alert

class LowStockConsumer
{
    private $logger;
    private $customerFactory;
    private $productRepository;
    private $productAlertStock;

    public function __construct(
        LoggerInterface $logger,
        CustomerFactory $customerFactory,
        ProductRepository $productRepository,
        ProductAlertStock $productAlertStock
    ) {
        $this->logger = $logger;
        $this->customerFactory = $customerFactory;
        $this->productRepository = $productRepository;
        $this->productAlertStock = $productAlertStock;
    }

    public function process($message)
    {
        $payload = json_decode($message, true);
        if (!isset($payload['product_id'])) {
            return;
        }

        $productId = (int)$payload['product_id'];
        $newQty = (float)$payload['qty'];

        // fetch product and decide which customers to notify
        $product = $this->productRepository->getById($productId);

        // pseudo-code: find customers with threshold >= newQty
        // $customers = $this->findSubscribedCustomers($productId, $newQty);

        // For each customer: check rate-limiting, then send templated email
        // If customer also uses native product alert subscriptions, you can create or delete them using $this->productAlertStock

        $this->logger->info("Processed low-stock for product $productId, qty=$newQty");
    }
}

Key advantages:

  • Consumers can scale separately from web nodes.
  • If the consumer takes time to generate a personalized email (template processing, image rendering), it won’t slow down checkout or admin product saves.

Cron-based (DB queue) alternative

Not everyone uses RabbitMQ. A simpler method is:

  1. Plugin on stock save inserts a small record into a custom DB table (product_id, qty, processed flag, created_at).
  2. Create a cron job that runs every minute (or every 5 minutes depending on tolerance) and processes rows in batches.

Example cron.xml:

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
    <group id="default">
        <job name="magefine_lowstock_process_queue" instance="Vendor\LowStock\Cron\ProcessQueue" method="execute">
            <schedule>*/1 * * * *</schedule>
        </job>
    </group>
</config>

Example Cron class (simplified):

<?php
namespace Vendor\LowStock\Cron;

use Psr\Log\LoggerInterface;
use Vendor\LowStock\Model\QueueRepository; // your small repo for queue rows

class ProcessQueue
{
    private $queueRepo;
    private $logger;

    public function __construct(QueueRepository $queueRepo, LoggerInterface $logger)
    {
        $this->queueRepo = $queueRepo;
        $this->logger = $logger;
    }

    public function execute()
    {
        $items = $this->queueRepo->getPending(50);
        foreach ($items as $item) {
            try {
                // similar logic to consumer: determine subscribers & send emails
                $this->queueRepo->markProcessed($item->getId());
            } catch (\Exception $e) {
                $this->logger->error($e->getMessage());
            }
        }
    }
}

This cron-driven approach is fine for modest traffic sites and is easier to deploy than message queues.

Customer personalization: a per-customer threshold

One of the most valuable features is letting customers set their own alert threshold ("Notify me when less than X items remain"). We’ll implement this as a customer EAV attribute so it’s editable in the account area.

InstallData example to add the attribute (simplified):

<?php
namespace Vendor\LowStock\Setup;

use Magento\Customer\Setup\CustomerSetupFactory;
use Magento\Framework\Setup\InstallDataInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\ModuleDataSetupInterface;

class InstallData implements InstallDataInterface
{
    private $customerSetupFactory;

    public function __construct(CustomerSetupFactory $customerSetupFactory)
    {
        $this->customerSetupFactory = $customerSetupFactory;
    }

    public function install(ModuleDataSetupInterface $setup, ModuleContextInterface $context)
    {
        $customerSetup = $this->customerSetupFactory->create(['setup' => $setup]);

        $customerSetup->addAttribute(
            \Magento\Customer\Model\Customer::ENTITY,
            'low_stock_threshold',
            [
                'type' => 'int',
                'label' => 'Low stock notification threshold',
                'input' => 'text',
                'required' => false,
                'visible' => true,
                'user_defined' => true,
                'position' => 999,
                'system' => 0,
            ]
        );

        $attribute = $customerSetup->getEavConfig()->getAttribute('customer', 'low_stock_threshold');
        $attribute->setData('used_in_forms', ['customer_account_edit', 'customer_account_create', 'adminhtml_customer']);
        $attribute->save();
    }
}

Then add the field to the customer account edit form via layout/ui component or by injecting a block into customer_account_edit. That’s standard Magento form work—expose an input and save the attribute like other customer attributes.

On save, ensure the value is validated (integer >= 0). Also provide sane defaults (e.g. 3 units) and let the admin set a global cap to avoid crazy thresholds.

Sync with Magento native product stock alerts

Magento has a built-in product alert module (Magento_ProductAlert) which allows customers to subscribe to stock alerts. You can use this alongside your custom threshold system for compatibility.

  • If a customer opts in via your UI, create the native subscription programmatically: use \Magento\ProductAlert\Model\Stock and its subscribe method (or insert directly into product_alert_stock table with correct flags).
  • If a customer removes the subscription in your UI, remove the native subscription too.
  • When your consumer finds a low stock event it can both (a) send your custom templated email and (b) optionally trigger the native product alert flow if you wish to rely on Magento’s standard email templates.

Sample pseudo-code to subscribe a logged-in customer to native stock alert:

/** @var \Magento\ProductAlert\Model\Stock $stockModel */
$stockModel->setCustomerId($customerId)
    ->setProductId($productId)
    ->setWebsiteId($websiteId)
    ->subscribe();

Important: the native product alert’s stock notification is not per-customer threshold based. If you want both behaviors, keep your table of per-customer thresholds and optionally call the native subscribe for compatibility with other systems.

UX and conversion: where to show alerts

Placement is crucial for conversions. Three high-impact places:

  • Product page (primary): next to the Add to Cart area. Let customers set their personal threshold and subscribe there. Keep it small—an input + toggle or a compact slider works well on mobile.
  • Cart page: if a cart contains low-stock items, show a subtle CTA: “Set an alert for this product before it sells out”.
  • Wishlist: people expect to be notified when wishlist products restock; a native integration here increases signups.

Product page snippet (block + template). Example template code (simplified):

<div class="low-stock-alert"
     data-product-id="<?= $block->escapeHtml($product->getId()) ?>">
    <label for="low_stock_threshold">Notify me when stock <=</label>
    <input id="low_stock_threshold" type="number" min="1" value="<?= $currentCustomerThreshold ?: 3 ?>"/>
    <button class="subscribe-low-stock">Notify me</button>
</div>

<script>
require(['jquery'], function($){
    var debounce = function(fn, wait){
        var t;
        return function(){
            clearTimeout(t);
            t = setTimeout(fn, wait);
        }
    };

    $(document).on('click', '.subscribe-low-stock', function(e){
        e.preventDefault();
        var $container = $(this).closest('.low-stock-alert');
        var productId = $container.data('product-id');
        var threshold = parseInt($container.find('#low_stock_threshold').val(), 10) || 1;

        // client-side rate limit: disable button for 30s
        var $btn = $(this);
        $btn.prop('disabled', true);
        setTimeout(function(){ $btn.prop('disabled', false); }, 30000);

        $.ajax({
            url: '/rest/V1/magefine/lowstock/subscribe',
            type: 'POST',
            contentType: 'application/json',
            data: JSON.stringify({product_id: productId, threshold: threshold}),
            success: function(){
                alert('You will be notified when stock reaches ' + threshold);
            }
        });
    });
});
</script>

Notes on UX:

  • Make the field optional; offer a default threshold (e.g. 3) and allow quick toggles for common choices (1, 3, 5).
  • On mobile, collapse the UI into a compact row under the price section.
  • In the cart/wishlist, show the number of units left and a clear CTA to set an alert.

Email templates and responsive design

When sending emails, use responsive templates with inline CSS for best compatibility. Provide clear call-to-action: product link, available qty, and unsubscribe link. Include a note about frequency and allow customers to adjust their threshold or pause notifications to avoid churn.

<!-- app/code/Vendor/LowStock/view/frontend/email/low_stock_notification.html -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="font-family: Arial, sans-serif;">
  <tr><td align="center">
    <table width="600" cellpadding="0" cellspacing="0" role="presentation" style="max-width:600px;">
      <tr>
        <td style="padding:20px; background:#ffffff; border-radius:4px;">
          <h2 style="margin:0 0 10px 0; font-size:18px;">Product back to low stock</h2>
          <p style="margin:0 0 10px 0; font-size:14px;">The product <strong>{{var product_name}}</strong> now has {{var qty}} left.</p>
          <p style="margin:10px 0;">
            <a href="{{var product_url}}" style="display:inline-block; padding:10px 15px; background:#1979c3; color:#fff; text-decoration:none; border-radius:3px;">View product</a>
          </p>
          <small style="color:#666;">You can change your notification preferences in your account or unsubscribe from this product.</small>
        </td>
      </tr>
    </table>
  </td></tr>
</table>

Keep email payloads small and avoid heavy embedded images. Test the email across common clients (Gmail, Outlook, Apple Mail) and ensure CTA buttons are finger-friendly on mobile.

Spam prevention and rate limiting

Two places to prevent spam / over-notification:

  1. Client-side throttling: disable the subscribe button for a short cooldown after click (30s) to prevent accidental repeated clicks.
  2. Server-side limits: store the date/time of last notification per (customer_id, product_id) and block notifications if last_sent >= configured interval (e.g., do not send more than once per 24 hours). Also limit how many product notifications a customer can subscribe to (e.g., 50) to avoid abuse.

DB schema idea for tracking notifications:

<table name="magefine_lowstock_notifications">
  id INT AUTO_INCREMENT PRIMARY KEY,
  customer_id INT NOT NULL,
  product_id INT NOT NULL,
  last_notified_at DATETIME NULL,
  created_at DATETIME NOT NULL,
  UNIQUE(customer_id, product_id)
</table>

Processing logic in consumer/cron:

// when considering notifying a customer
$entry = $this->notificationsRepo->getByCustomerAndProduct($customerId, $productId);
if ($entry && $entry->getLastNotifiedAt() &gt; time() - (24*3600)) {
    // skip - recently notified
} else {
    // send mail and update last_notified_at
}

Also add protection for guest users: if you allow email-only subscriptions from guests, ensure you verify email addresses or require captcha on the subscribe endpoint to avoid bots generating many subscriptions.

Integrating with Force Product Stock Status modules

Many stores use an extension to force a product’s catalog status to in-stock or out-of-stock regardless of physical inventory. Typical use cases: a product is always shown as in-stock (pre-order) or hidden when forced out-of-stock.

Integration goals:

  • When an admin toggles force status, optionally trigger the same alert flow (e.g., when a force turns a product to in-stock after being forced out-of-stock, notify subscribers).
  • Respect forced out-of-stock when deciding whether to show the subscribe UI or allow native Magento product alert subscription (if product is hidden you may still want subscription).

How to combine them safely:

  1. Detect if the Force Product Stock Status module is present using \Magento\Framework\Module\Manager. If present, check the module’s API or DB column (commonly modules add a product attribute e.g. force_stock_status or force_in_stock).
  2. If the admin toggles the forced flag, publish a low-stock topic message (as we do on raw stock saves). Your consumer should check both physical qty and forced flag to decide email content ("The product was marked in stock" vs "Quantity is now X").

Example code snippet to check module and read forced flag:

/** @var \Magento\Framework\Module\Manager $moduleManager */
if ($moduleManager->isEnabled('Vendor_ForceStockStatus')) {
    // either read product attribute
    $forceFlag = $product->getData('force_in_stock');
    // or use the module's helper if it exposes one
}

Be careful: forced status might be stored in a custom table. If you don’t know the module internals, check its public API or read its source before relying on DB columns.

MSI (Multi Source Inventory) notes

If your store uses Magento MSI (Inventory Management), stock is handled differently (source items, reservations, aggregate salable quantity). You cannot reliably watch StockItem::save anymore. Instead:

  • Listen to MSI-specific events or use plugins on Inventory repository interfaces such as \Magento\InventoryApi\Api\SourceItemsSaveInterface::execute or on the StockResolver or Salable Quantity calculators.
  • Alternatively, consider running a periodic job that reads salable quantity for SKUs using the MSI APIs and enqueues alerts when salable quantity crosses thresholds.

In short: for MSI, prefer a scheduled job (cron or consumer) that periodically reads salable qty and compares to customer thresholds. That approach is simpler and consistent across replicas of inventory APIs.

Testing, metrics and operational considerations

  • Load test the path that enqueues messages. The plugin itself should do very little work: push to queue and return.
  • Monitor queue backlog. If consumers lag, consider autoscaling or increasing consumer workers.
  • Log notification events to a small table (or to your observability platform) so you can answer questions like "when was customer X notified for product Y?"
  • Design the email templates for conversion: include product hero image, price, urgency text ("only X left"), and a one-click CTA to buy. But don’t overdo urgency language—be honest about stock counts to avoid trust issues.

Deploying to production safely

  1. Ship the queue/cron consumer as disabled by default; test in a staging environment with a snapshot of catalog and customers.
  2. Start with conservative rate limits and a low sending cadence; verify unsubscribe flows and email deliverability.
  3. Gradually increase concurrency for consumers once you confirm emails are delivering and there’s no runaway behavior.
  4. Have an admin UI to view and manage subscriptions, pause notifications per product, and re-sync subscriptions if the Force Stock Status module is used.

Summary — concrete next steps you can implement this week

  1. Create a small module scaffold and add the plugin on StockItemRepository::save which publishes a simple payload to a message queue.
  2. If you don’t run RabbitMQ, create a lightweight DB queue + cron that runs every minute and processes small batches.
  3. Add a customer attribute low_stock_threshold and expose it in the account edit page.
  4. Create a consumer/cron process that, for each queue row, finds customers with threshold >= current qty and checks last_notified_at before sending an email. Implement server-side rate limiting and a cap on the number of subscribed products per customer.
  5. Integrate with Magento_ProductAlert if you want compatibility with native subscriptions and integrate with Force Product Stock Status by checking the forced flag when deciding to send alerts.

If you’d like, I can also generate a concrete module skeleton (files, di.xml, plugin, consumer, queue config, basic templates) you can drop into a dev environment. Say the word and I’ll output the file tree and minimal code for each file so you can paste and test quickly.

Good luck—this setup is a smart way to increase urgency and conversions while keeping the store responsive. If you host with MageFine, pairing this with a queue-backed consumer and optimized cron cadence is straightforward on our hosting plans; otherwise make sure RabbitMQ is enabled and your consumers are supervised by systemd or a process manager so they don’t die silently.

Want the module skeleton? Tell me whether you prefer RabbitMQ or DB-cron for processing and whether your store uses MSI; I’ll create the scaffolding accordingly.