How to Build a Custom "Product Availability Predictor" Module in Magento 2

Introduction
Hey — if you want to add a pragmatic, useful forecasting feature to a Magento 2 store, a Product Availability Predictor is a great place to start. In this post I’ll walk you step-by-step through building a small, well-structured Magento 2 module that:
- uses Magento stock and order data to estimate future availability,
- stores predictions in a lightweight table,
- updates predictions by cron (or on demand),
- exposes a frontend widget on product pages that shows estimated date or probability of being in stock, and
- integrates with the Force Product Stock Status extension so the store has consistent stock signals.
I’ll keep the tone relaxed and explain concepts so you can follow even if you’re new to module development. I’ll show code snippets you can copy and adapt. I’ll also point out variations for Magento with MSI (Multi-Source Inventory).
Why build a Product Availability Predictor?
Because merchants care about two things: conversions and expectations. Displaying an estimated available date or a probability that a product will be in stock soon reduces cart abandonment and ticket load. Instead of a vague "Out of stock" you can offer something actionable: "Likely restock in 7 days" or "10% chance in next 3 days".
High-level architecture
We’ll keep the architecture simple and modular so it’s maintainable:
- Data collection: read historical sales (order items) and current stock levels from Magento.
- Prediction engine: small service that turns historical sales into a forecast (moving average or exponential smoothing).
- Persistence: a table to cache predictions and metadata (last updated, confidence, predicted date, predicted days of stock left).
- Scheduler: cron job (or manual controller) that runs prediction on a schedule and writes results.
- UI: a block + template that reads the prediction and displays it on product pages as a widget.
- Integration: update Product Stock Status logic via the Force Product Stock Status extension when predictions suggest status change (optional, controlled by config).
Design decisions and Magento considerations
- Compatibility: show examples using StockRegistryInterface for basic stock access and mention MSI adaptations.
- Privacy & performance: aggregate order history in SQL rather than loading all orders in PHP.
- Batching: predict in batches to avoid timeouts—cron can process N products per run.
- Confidence & explainability: store a confidence score and simple explanation text so merchant support can understand suggestions.
Module skeleton
Let’s call the module Magefine_ProductAvailability. File structure (truncated):
app/code/Magefine/ProductAvailability/
├─ registration.php
├─ etc/module.xml
├─ etc/adminhtml/system.xml
├─ etc/crontab.xml
├─ etc/di.xml
├─ Setup/InstallSchema.php (or db_schema.xml for declarative schema)
├─ Model/AvailabilityPredictor.php
├─ Model/ResourceModel/Prediction.php
├─ Model/ResourceModel/Prediction/Collection.php
├─ Cron/RunPredictions.php
├─ Ui/Component/... (if you expose admin UI)
├─ view/frontend/layout/catalog_product_view.xml
├─ view/frontend/templates/widget.phtml
├─ Block/ProductAvailability.php
Step 1 — registration and module declaration
Create registration.php
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'Magefine_ProductAvailability',
__DIR__
);
Create 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">
<module name="Magefine_ProductAvailability" setup_version="1.0.0"/>
</config>
Step 2 — database schema
We need a small table to cache predictions. If you use Magento 2.3+ prefer declarative db_schema.xml. Here’s a simple declarative example:
<?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">
<table name="magefine_product_availability_prediction" resource="default" engine="innodb" charset="utf8">
<column xsi:type="int" name="prediction_id" padding="10" unsigned="true" nullable="false" identity="true"/>
<column xsi:type="int" name="product_id" nullable="false" unsigned="true"/>
<column xsi:type="decimal" name="predicted_days_in_stock" scale="2" precision="10" nullable="true"/>
<column xsi:type="datetime" name="predicted_date" nullable="true"/>
<column xsi:type="decimal" name="confidence" scale="2" precision="5" nullable="true" comment="0..1 probability"/>
<column xsi:type="text" name="explanation" nullable="true"/>
<column xsi:type="timestamp" name="updated_at" nullable="false" default="CURRENT_TIMESTAMP" on_update="true"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="prediction_id"/>
</constraint>
<index referenceId="PRODUCT_ID_IDX">
<column name="product_id"/>
</index>
</table>
</schema>
This gives us a compact store for predictions. If you use separate stock sources (MSI), you could add source_code column to keep per-source predictions.
Step 3 — resource models and model
Create a simple Model and ResourceModel to read/write the table. Skipping full boilerplate, but key parts:
// Model/Prediction.php
namespace Magefine\ProductAvailability\Model;
use Magento\Framework\Model\AbstractModel;
class Prediction extends AbstractModel
{
protected function _construct()
{
$this->_init(\Magefine\ProductAvailability\Model\ResourceModel\Prediction::class);
}
}
// Model/ResourceModel/Prediction.php
namespace Magefine\ProductAvailability\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
class Prediction extends AbstractDb
{
protected function _construct()
{
$this->_init('magefine_product_availability_prediction', 'prediction_id');
}
}
Step 4 — collecting input data (sales & stock)
The predictor needs two main inputs per product:
- Recent sales velocity (units sold per day/week) computed from sales_order_item / sales_order tables.
- Current stock quantity (Magento stock or MSI source quantity).
For stock quantity you can use StockRegistryInterface (for single-source Magento) or Magento MSI APIs for source-level stock. Example of basic stock reading:
use Magento\CatalogInventory\Api\StockRegistryInterface;
public function __construct(StockRegistryInterface $stockRegistry) {
$this->stockRegistry = $stockRegistry;
}
public function getStockQty($productId) {
$stockItem = $this->stockRegistry->getStockItem($productId);
return (float) $stockItem->getQty();
}
For MSI (Magento 2.3+), you should use Magento\InventoryApi modules, like InventorySourceItemRepositoryInterface or StockResolverInterface. Example outline:
// use \Magento\InventorySalesApi\Api\StockResolverInterface
// use \Magento\InventoryApi\Api\GetSourceItemsBySkuInterface
// You can resolve the stock and source quantities per SKU then include per-source logic.
For sales velocity we’ll compute aggregated units sold in the last N days. Use a resource model query to keep performance reasonable. Example SQL logic (simplified):
SELECT
product_id,
SUM(qty_ordered) as sold,
MIN(created_at) as first_sale,
MAX(created_at) as last_sale
FROM sales_order_item
WHERE created_at >= DATE_SUB(NOW(), INTERVAL :window DAY)
AND product_type = 'simple'
GROUP BY product_id;
Implement this with Magento resource connection and bind the :window param. Avoid loading whole order objects in PHP.
Step 5 — simple prediction algorithms
I'll show two small algorithms you can implement quickly. Keep in mind these are examples — depending on business you may need more advanced forecasting.
1) Simple moving average (SMA)
Compute daily sales over a window (e.g., last 30 days), average them to get units/day. If current stock is Q, predicted_days_in_stock = Q / avg_units_per_day. Predicted_date = now + predicted_days_in_stock.
// pseudo-code
$avgUnitsPerDay = $soldInWindow / $windowDays;
if ($avgUnitsPerDay > 0) {
$predictedDays = $currentQty / $avgUnitsPerDay;
$predictedDate = (new \DateTime())->modify("+" . ceil($predictedDays) . " days");
}
Confidence: if there are very few sales, confidence is low. Confidence could be a function of number of orders and variance in daily sales.
2) Exponential Weighted Moving Average (EWMA)
EWMA gives recent sales more weight. It’s easy to implement and reacts faster to recent trends. Formula:
EWMA_t = alpha * sale_t + (1 - alpha) * EWMA_{t-1}
Aggregate EWMA per day then compute predicted days similar to SMA. Choose alpha between 0.1 and 0.3 for typical retail cadence.
Choosing the right algorithm
If you need something quick, start with SMA. If you have promotions and spikes, EWMA or Holt’s linear trend will be better. I recommend building the system so you can swap algorithm implementations via DI (implement an PredictionInterface with multiple strategies).
Step 6 — implement AvailabilityPredictor service
Key responsibilities:
- fetch sales aggregates for a set of product IDs and a given window
- read current stock quantity for each product
- apply chosen algorithm
- return a structure with predicted_days, predicted_date, confidence, explanation
namespace Magefine\ProductAvailability\Model;
use Magento\Framework\DB\Adapter\AdapterInterface;
use Magento\CatalogInventory\Api\StockRegistryInterface;
class AvailabilityPredictor
{
private $connection;
private $stockRegistry;
public function __construct(AdapterInterface $connection, StockRegistryInterface $stockRegistry)
{
$this->connection = $connection;
$this->stockRegistry = $stockRegistry;
}
public function predictForProducts(array $productIds, $windowDays = 30)
{
$results = [];
$sales = $this->fetchSalesAggregates($productIds, $windowDays);
foreach ($productIds as $productId) {
$qty = $this->getStockQty($productId);
$sold = isset($sales[$productId]) ? $sales[$productId]['sold'] : 0;
$avgPerDay = $sold / max(1, $windowDays);
if ($avgPerDay > 0) {
$predDays = $qty / $avgPerDay;
$predDate = (new \DateTime())->modify('+' . ceil($predDays) . ' days');
$confidence = min(1, log(1 + $sold) / 5); // naive confidence
$explanation = sprintf('SMA over %d days: sold=%d avg/day=%.2f', $windowDays, $sold, $avgPerDay);
} else {
$predDays = null;
$predDate = null;
$confidence = 0;
$explanation = 'No sales in window';
}
$results[$productId] = [
'product_id' => $productId,
'predicted_days_in_stock' => $predDays,
'predicted_date' => $predDate ? $predDate->format('Y-m-d H:i:s') : null,
'confidence' => $confidence,
'explanation' => $explanation,
];
}
return $results;
}
private function fetchSalesAggregates(array $productIds, $windowDays)
{
// param binding example
$sql = "SELECT item.product_id AS product_id, SUM(item.qty_ordered) AS sold
FROM sales_order_item AS item
JOIN sales_order AS s ON item.order_id = s.entity_id
WHERE item.product_id IN (?)
AND s.created_at >= DATE_SUB(NOW(), INTERVAL :window DAY)
GROUP BY item.product_id";
// Use connection to fetch; make sure to bind window param and use proper quoting for IN
// return array indexed by product_id
}
private function getStockQty($productId)
{
$stockItem = $this->stockRegistry->getStockItem($productId);
return (float) $stockItem->getQty();
}
}
Note: the SQL above is illustrative. Use Magento resource connection methods properly (quoteInto, fetchAll, etc.) and consider store visibility and canceled/refunded orders depending on your needs.
Step 7 — Cron runner to batch predictions
Create Cron/RunPredictions that:
- loads a batch of product IDs (e.g., active, visible products),
- calls AvailabilityPredictor,
- writes/updates the prediction table via ResourceModel,
- optionally triggers Force Product Stock Status updates when threshold rules match.
namespace Magefine\ProductAvailability\Cron;
class RunPredictions
{
private $productCollectionFactory;
private $predictor;
private $predictionResource;
public function __construct(
\Magento\Catalog\Model\ResourceModel\Product\CollectionFactory $productCollectionFactory,
\Magefine\ProductAvailability\Model\AvailabilityPredictor $predictor,
\Magefine\ProductAvailability\Model\ResourceModel\Prediction $predictionResource
) {
$this->productCollectionFactory = $productCollectionFactory;
$this->predictor = $predictor;
$this->predictionResource = $predictionResource;
}
public function execute()
{
$collection = $this->productCollectionFactory->create()
->addAttributeToFilter('type_id', ['in' => ['simple','virtual']])
->setPageSize(200) // batch size
->setCurPage(1);
$productIds = $collection->getAllIds();
$predictions = $this->predictor->predictForProducts($productIds, 30);
foreach ($predictions as $p) {
// upsert into prediction table using resource model
$model = \Mage::getModel('Magefine\ProductAvailability\Model\Prediction');
$model->setData($p);
$this->predictionResource->save($model);
}
return $this;
}
}
Wire the Cron in etc/crontab.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_productavailability_run" instance="Magefine\ProductAvailability\Cron\RunPredictions" method="execute">
<schedule>0 */6 * * *</schedule> <!-- every 6 hours -->
</job>
</group>
</config>
Step 8 — frontend widget (block + template + layout)
We’ll add a small block that loads the prediction for the current product and displays it in a friendly way. Keep it non-blocking: display a simple message if no prediction exists.
// view/frontend/layout/catalog_product_view.xml
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="product.info.main">
<block class="Magefine\ProductAvailability\Block\ProductAvailability" name="magefine.product.availability" template="Magefine_ProductAvailability::widget.phtml" after="product.info.price" />
</referenceContainer>
</body>
</page>
// Block/ProductAvailability.php
namespace Magefine\ProductAvailability\Block;
use Magento\Framework\View\Element\Template;
class ProductAvailability extends Template
{
private $registry;
private $predictionFactory;
public function __construct(Template\Context $context, \Magento\Framework\Registry $registry, \Magefine\ProductAvailability\Model\PredictionFactory $predictionFactory, array $data = [])
{
parent::__construct($context, $data);
$this->registry = $registry;
$this->predictionFactory = $predictionFactory;
}
public function getPredictionForCurrentProduct()
{
$product = $this->registry->registry('current_product');
if (!$product) return null;
$prediction = $this->predictionFactory->create()->getCollection()->addFieldToFilter('product_id', $product->getId())->getFirstItem();
return $prediction && $prediction->getId() ? $prediction : null;
}
}
// view/frontend/templates/widget.phtml (simplified)
$prediction = $block->getPredictionForCurrentProduct();
if (!$prediction) {
echo '<div class="magefine-predictor">Estimated availability: <em>No data</em></div>';
} else {
$predDate = $prediction->getPredictedDate();
$confidence = $prediction->getConfidence();
$days = $prediction->getPredictedDaysInStock();
echo '<div class="magefine-predictor">';
if ($predDate) {
echo '<strong>Estimated available on:</strong> ' . $this->escapeHtml($predDate);
} else {
echo '<strong>Estimated availability:</strong> ' . ($days ? round($days,1) . ' days' : 'Unavailable');
}
echo '<div class="mf-confidence">Confidence: ' . round($confidence*100) . '%</div>';
echo '</div>';
}
Style the widget with CSS in view/frontend/web/css/source/_module.less or include inline styles for quick testing.
Step 9 — Integration with Force Product Stock Status
If you use an extension like Force Product Stock Status to override stock statuses (for marketing or manual control), you’ll want predictions to be consistent with that behavior. There are two ways to integrate:
- Read the extension’s API / repository to understand how it sets the frontend stock status and replicate decisions when displaying predictions. For example, if Force Product Stock Status marks a product "Available" regardless of qty, don’t show an "Unavailable" prediction.
- Trigger the extension’s update routines when your prediction crosses a merchant-defined threshold. For instance, if predicted_days_in_stock > 30 you might programmatically set status to "On backorder" or similar via the extension API.
Example integration sketch (pseudocode, assuming the extension exposes a manager class):
// in your cron after you computed $prediction
if ($prediction['confidence'] > 0.6 && $prediction['predicted_days_in_stock'] < 1) {
// tell ForcedStockManager to mark product as "Will be back soon" or adjust status
$this->forcedStockManager->setForceStatus($productId, 'soon_available');
}
Important: do not modify stock_count directly to trick sales — use the extension's intended API. If the extension does not provide an API, prefer creating a small plugin observer to adapt the displayed message rather than changing stock records.
Step 10 — admin configuration and controls
Add admin config to control windows, algorithm, batch size, and whether to update Force Product Stock Status. Put configuration under Stores > Configuration > Magefine > Product Availability.
// etc/adminhtml/system.xml simplified structure
<config>
<system>
<section id="magefine_productavailability" translate="label" type="text" sortOrder="900" showInDefault="1" showInWebsite="1" showInStore="1">
<group id="general" translate="label" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
<field id="window_days" translate="label" type="text" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1"><label>Window Days</label></field>
<field id="algorithm" translate="label" type="select" sortOrder="20"><label>Algorithm</label></field>
<field id="enable_force_integration" translate="label" type="select" sortOrder="30"><label>Integrate with Force Stock</label></field>
</group>
</section>
</system>
</config>
Use these values in your predictor and cron. Add a manual admin action or controller to "Recalculate predictions" for selected products.
Testing & validation
Quick tips to validate predictions:
- Seed sales data in a local environment so the predictor has examples.
- Test edge cases: no sales, very large spikes, products with zero stock.
- Compare predicted_days_in_stock to actual stockout events over a test run (measure error).
- Log predictions with explanation so merchants can evaluate and tune windowDays / algorithm.
Performance and scaling
For big catalogs (10k+ SKUs) consider:
- processing in small batches with cron and remembering last processed ID,
- pre-aggregating sales in a nightly batch into a stats table (daily_sales_by_product) to avoid heavy queries,
- caching predictions in Redis if generating on demand,
- offloading heavy forecasting to a separate microservice if you need advanced ML models.
Advanced algorithms and machine learning
If simple statistical approaches aren’t enough, you can move to more advanced models:
- Holt-Winters or ARIMA for seasonality,
- prophet (Facebook) for complicated seasonality and holiday effects (would require an external Python service),
- simple regression models that include price, promotions, and category-level trends,
- classification model to predict "in stock vs out of stock" in the next N days (binary) rather than exact day.
When integrating ML, typical architecture is: Magento exports aggregated features to a data store (CSV, BigQuery), a separate service trains and produces predictions, those predictions are imported back into Magento and stored in the prediction table. This keeps Magento responsive and avoids heavy libraries inside PHP.
Security and permissions
Make sure your cron and controllers validate permissions for admin actions. Don’t allow arbitrary SQL or file writes from UI. Sanitize all inputs and escape outputs in templates to avoid XSS.
UX considerations
- Don’t show overly confident messages when confidence is low — use language like "Likely available around..." or "Estimate: low confidence".
- Offer CTA alternatives: allow customers to request email on restock or show similar products in stock.
- Allow merchant override per product through product attributes if they want to force a message.
Example: putting it all together — simple end-to-end flow
- Cron runs every 6 hours and picks 200 products.
- AvailabilityPredictor fetches sales aggregates for the last 30 days and current stock via StockRegistryInterface.
- Using SMA it calculates predicted_days_in_stock and a predicted_date.
- Prediction saved to magefine_product_availability_prediction table.
- On the product page, ProductAvailability block loads the latest prediction and renders a friendly message. If confidence < 0.2 it shows "Estimate: low confidence".
- If integration is enabled and predicted_days_in_stock < 1 and confidence > 0.6, the cron calls Force Product Stock Status manager to set a "Coming soon" label or similar (via the extension API).
Implementation tips and gotchas
- Returned sales qty should ignore refunded and canceled orders unless you want to count them — decide on business logic carefully.
- For configurable/simple product relationships: you often want to forecast at the simple SKU level but display at the configurable parent. Consider aggregating child sales or mapping predictions to parent products for display.
- Consider timezone differences when you compute days and show dates to customers.
- When using MSI, pick whether predictions are per-source or aggregated across all sources and warehouses.
Example code snippets you can copy
Below is a compact PHP snippet for the core prediction calculation (SMA) you can drop into your predictor class.
/**
* Calculate simple moving average prediction
* @param float $currentQty
* @param int $soldInWindow
* @param int $windowDays
* @return array ['predicted_days' => ?float, 'predicted_date' => ?string, 'confidence' => float]
*/
public function calculateSmaPrediction($currentQty, $soldInWindow, $windowDays = 30)
{
$avgPerDay = $soldInWindow / max(1, $windowDays);
if ($avgPerDay > 0) {
$predictedDays = $currentQty / $avgPerDay;
$predictedDate = (new \DateTime())->modify('+' . ceil($predictedDays) . ' days')->format('Y-m-d');
// confidence: more sales -> more confident. Here we cap at 1.
$confidence = min(1, log(1 + $soldInWindow) / 4);
} else {
$predictedDays = null;
$predictedDate = null;
$confidence = 0;
}
return ['predicted_days' => $predictedDays, 'predicted_date' => $predictedDate, 'confidence' => $confidence];
}
Monitoring and improvement
After you deploy:
- Monitor predictions vs actual stockout events — measure mean absolute error (MAE) for predicted_days.
- Log changes made by cron related to Force Product Stock Status so merchant can review automated changes.
- Add UI in admin to view recent prediction history for a product with a chart (small sparkline based on historical predictions).
Wrapping up
Building a Product Availability Predictor in Magento 2 is mostly about combining reliable data access (sales and stock), a clear prediction algorithm, and safe UI/merchant controls. Start small with an SMA approach, keep your code modular, and add more advanced forecasting or ML later if you need it.
If you’re using Magefine hosting or extensions (like Force Product Stock Status), keep integration points minimal and use the extension’s public APIs to update statuses — that keeps behavior predictable and maintainable.
Want me to generate the full module code (module skeleton, db_schema.xml, all PHP classes, DI wiring) as a downloadable zip you can drop into app/code? Tell me which Magento version you run (2.3, 2.4 with MSI) and whether you want SMA, EWMA, or an external ML pipeline — I’ll adapt the code exactly to your environment.
Happy coding — and let me know which algorithm you prefer; we can run a quick comparison on sample data right here.