How to Create a Custom "Product Locator" Module for Multi-Location Inventory in Magento 2

Working with multi-location inventory in Magento 2 can quickly become messy if you try to treat each location like another attribute on the product. In this post I’ll walk you through how to build a clean, performant custom "Product Locator" module that exposes availability per location to the storefront, checkout, admin, and mobile apps via a REST API — while keeping your Magento store fast and maintainable. I’ll be relaxed and practical, like I’m explaining it at the desk next to you. Code samples are included step by step.
What this module solves
Short version: you want customers and staff to know which store/warehouse has a product, show this info on product pages and the checkout, provide an admin interface to manage stocks per location, expose a REST API for mobile apps, and make sure the implementation scales without killing performance.
High-level architecture
Here’s how I recommend structuring the solution:
- Keep a dedicated relational table (or use Magento MSI if you prefer) to map product SKU (or product_id) to locations and quantities. Avoid storing large arrays in product attributes.
- Read performance: use indexed tables and cache responses for storefront API calls (Full Page Cache, varnish-friendly JSON endpoints cached via Redis if needed).
- Write consistency: admin writes should update the table and trigger background indexers to keep read models fast.
- Expose a REST API (webapi.xml) for mobile apps and AJAX calls from the frontend.
- Provide a UI component grid and edit form in the admin to manage multi-location stocks with auto-sync hooks.
- Integrate into checkout via a small JS extension that calls the REST endpoint and displays availability per shipping address or pickup location.
Why not store inventory inside product EAV?
Magento’s product EAV is great for attributes but not for per-location time-series or large lists. Storing per-location quantities in EAV bloats indexes and slows the catalog. Use a normalized table for multi-source inventory data; this keeps queries faster and indexing simpler.
Module structure (brief)
We’ll create a module named MageFine_ProductLocator with the following structure:
app/code/MageFine/ProductLocator/
├── registration.php
├── etc/module.xml
├── etc/db_schema.xml
├── etc/webapi.xml
├── etc/adminhtml/menu.xml
├── etc/acl.xml
├── etc/di.xml
├── Controller/Api/Availability.php
├── Model/Location.php
├── Model/ResourceModel/Location.php
├── Model/ResourceModel/Location/Collection.php
├── Api/LocationRepositoryInterface.php
├── Api/Data/LocationInterface.php
├── view/frontend/web/js/view/product-locator.js
├── view/adminhtml/ui_component/magefine_location_listing.xml
└── ...
Step 1 — Declarative schema for the location table
Use a simple table that links product_id to source_code, quantity, updated_at. We’ll add an index on product_id and source_code for fast lookups.
<?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_locator" resource="default" engine="innodb" comment="MageFine Product Locator">
<column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" />
<column xsi:type="int" name="product_id" unsigned="true" nullable="false" />
<column xsi:type="varchar" name="source_code" nullable="false" length="64" />
<column xsi:type="decimal" name="quantity" nullable="false" scale="4" precision="12" default="0.0000" />
<column xsi:type="timestamp" name="updated_at" nullable="false" on_update="true" default="CURRENT_TIMESTAMP" />
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="entity_id"/>
</constraint>
<index referenceId="MAGEFINE_PRODUCT_LOCATOR_PRODUCT_IDX" indexType="btree">
<column name="product_id"/>
</index>
<index referenceId="MAGEFINE_PRODUCT_LOCATOR_SOURCE_IDX" indexType="btree">
<column name="source_code"/>
</index>
</table>
</schema>
Step 2 — Simple Model, ResourceModel and Repository
Create a normal Magento model and repository so others can inject LocationRepositoryInterface. Keep the repository lean: save, getById, getByProductId, delete.
<?php
namespace MageFine\ProductLocator\Api\Data;
interface LocationInterface
{
public function getId();
public function getProductId();
public function getSourceCode();
public function getQuantity();
public function getUpdatedAt();
public function setProductId($productId);
public function setSourceCode($code);
public function setQuantity($qty);
}
<?php
namespace MageFine\ProductLocator\Api;
use MageFine\ProductLocator\Api\Data\LocationInterface;
interface LocationRepositoryInterface
{
public function save(LocationInterface $location);
public function getById($id);
public function getByProductId($productId);
public function delete(LocationInterface $location);
}
ResourceModel and Model follow standard Magento patterns — I’ll skip repetitive boilerplate here but keep them in the repository implementation.
Step 3 — Admin UI: grid and edit form
Use a UI component grid for listing locations per product and a form for editing quantities. This keeps the admin familiar with Magento patterns and allows mass actions.
Important admin features:
- Inline editing for quick updates
- Bulk import for syncing a CSV from warehouse ERP
- Auto-sync hook: after saving a product or when a source changes, update the locator table via an observer
<!-- view/adminhtml/ui_component/magefine_location_listing.xml -->
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd" >
<columns name="magefine_location_columns" >
<column name="product_id" class="Magento\Ui\Component\Listing\Columns\Column">
<settings><label>Product ID</label></settings>
</column>
<column name="source_code">
<settings><label>Location</label></settings>
</column>
<column name="quantity">
<settings><label>Quantity</label></settings>
</column>
</columns>
</listing>
Step 4 — Integrate with checkout and product page
We want customers to see availability for each location on the product page and during checkout (for pickup or to display if the shipped product will come from multiple locations). The pattern I suggest:
- Expose a lightweight REST endpoint that returns availability by product_id and optionally by shipping_address or zip code.
- On product page load, call the endpoint asynchronously and render a small UX block: "Available in 3 stores near you" with a list and quantities (or "low stock" badges).
- During checkout, when shipping address is set, query the endpoint to decide from which source shipping cost or fulfillment choice is available; if pickup option is active, show which pickups are available.
Example REST route (webapi.xml):
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<route url="V1/magefine/product-locator/:productId/availability" method="GET">
<service class="MageFine\ProductLocator\Api\LocationRepositoryInterface" method="getByProductId" />
<resources><resource ref="anonymous" /></resources>
</route>
</routes>
Controller or service returns JSON like:
{
"product_id": 123,
"locations": [
{"source_code":"store_ny","quantity":12,"name":"NY Store"},
{"source_code":"wh_los_angeles","quantity":0,"name":"LA Warehouse"}
]
}
Frontend: small JS widget
Add a KnockoutJS or plain JS UI component that calls the REST endpoint on product page render. Keep it async and cache per page in sessionStorage to reduce API calls.
// view/frontend/web/js/view/product-locator.js
define(['jquery','mage/url'], function($, urlBuilder){
'use strict';
return function(productId, elemSelector){
var cacheKey = 'mf_locator_' + productId;
if(window.sessionStorage && sessionStorage.getItem(cacheKey)){
render(JSON.parse(sessionStorage.getItem(cacheKey)));
return;
}
var url = urlBuilder.build('rest/V1/magefine/product-locator/' + productId + '/availability');
$.get(url).done(function(response){
if(window.sessionStorage) sessionStorage.setItem(cacheKey, JSON.stringify(response));
render(response);
});
function render(data){
var html = '';
if(!data.locations || !data.locations.length){
html += 'No inventory info available';
} else {
html += '';
data.locations.forEach(function(l){
html += '- ' + l.name + ' — ' + (l.quantity>0? l.quantity +' available' : 'Out of stock') + '
';
});
html += '
';
}
html += '';
$(elemSelector).html(html);
}
}
});
Step 5 — Checkout integration
In the checkout, we need to show availability based on shipping address. Two main flows:
- Ship from nearest warehouse: after shipping address is entered, call the endpoint with zip/postcode or country to return prioritized sources. Use this to estimate shipping times and show a message: "This item will ship from LA warehouse and arrive in 2–3 days"
- Pickup in store: when user chooses store pickup, show only stores with quantity > 0
Important: the checkout must not block the user while waiting for API calls — show a skeleton and update asynchronously. Cache results per address and product.
// Example: in checkout JS mixin
var actions = {
onShippingAddressChange: function(address){
// call endpoint with product ids and address
// update line item summaries with source and estimated ETA
}
}
Step 6 — API for mobile apps
Your webapi.xml route can be public or protected. For mobile apps, create a token-authenticated route returning structured results with optional geolocation/zip filtering and pagination. Example response supports querying multiple product IDs to save calls.
// example request
GET /rest/V1/magefine/products/availability?product_ids=123,124&zip=10001
// example response
{
"results":{
"123": [{"source_code":"store_ny","quantity":12,"distance_km":2.3}],
"124": [{"source_code":"wh_lax","quantity":50,"distance_km":400}]
}
}
For performance, the API should accept multiple product IDs in one call and return a compact result. Use Redis/Full Page Cache to store frequently-requested results (for example, popular product bundles or category pages).
Architecture details: structuring multi-location data without performance impact
Here’s a more technical breakdown of choices to avoid performance pitfalls:
- Normalized table vs EAV: use a table with product_id and source_code. Add composite index on (product_id, source_code) and an index on source_code for source-centric queries.
- Read-heavy optimization: build a denormalized read table or use an indexer to precompute "availability_summary" per product (e.g., total_quantity, in_stock_sources_count). Update this via an indexer so frontend reads one small row instead of joining many rows.
- Cache at the edge: cache results by product_id and by geohash/zip. For mobile APIs accept a location bucket to reduce unique cache keys.
- Limit joins in catalog_product_collection: never join locator table into product collection unless necessary; instead load availability via separate AJAX calls to avoid bloating catalog pages and FPC invalidation scope.
- Bulk writes: if you sync with ERP, perform bulk upserts using transactional SQL or use Magento’s bulk APIs to avoid firing many small writes.
- Async background sync: long-running syncs should update a staging table then swap to production table to avoid locking during reads.
Synchronization & admin UX
Admin users often need to sync with ERP or update stocks manually. Consider these features:
- CSV import with batch processing (via cron or message queue)
- API endpoint for ERP push with HMAC or token-based auth
- UI that shows last synced time per source and per product
- Automatic reconciliation: when a manual adjust is made in admin product stock, trigger a sync job to update locator table
Example: observer to update locator row when admin updates product stock
<?php
namespace MageFine\ProductLocator\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Event\Observer;
class ProductSaveAfter implements ObserverInterface
{
protected $locationRepository;
protected $stockRegistry;
public function __construct(
\MageFine\ProductLocator\Api\LocationRepositoryInterface $locationRepository,
\Magento\CatalogInventory\Api\StockRegistryInterface $stockRegistry
){
$this->locationRepository = $locationRepository;
$this->stockRegistry = $stockRegistry;
}
public function execute(Observer $observer)
{
$product = $observer->getEvent()->getProduct();
// read default stock and push to default source if needed
$stockItem = $this->stockRegistry->getStockItemBySku($product->getSku());
// ... logic to map to default source and update locator table ...
}
}
Security and authorization
Ensure admin routes require proper ACL. API endpoints used by mobile apps should require a token. If any endpoint returns sensitive internal source names or costs, restrict them to authenticated requests. Log API usage and throttle heavy endpoints to protect DB from spikes.
Performance tips
- Precompute minimal availability summary per product (indexed) to show the main availability badge without joining large tables.
- For product pages, load detailed per-location info asynchronously.
- Use Redis to cache REST results for a short TTL (30s–5 min depending on stock volatility).
- Use bulk queries when checking availability for many products at once (e.g., cart with 20 items).
- Prefer SQL upserts for large imports instead of many ORM saves.
Concrete code: a compact AvailabilityService
Here’s a simple PHP service you can use to get availability and a prioritized source based on zip code proximity (you’ll plug in your own geo-distance logic):
<?php
namespace MageFine\ProductLocator\Model;
class AvailabilityService
{
protected $locationCollectionFactory;
protected $cache;
public function __construct(
\MageFine\ProductLocator\Model\ResourceModel\Location\CollectionFactory $collectionFactory,
\Magento\Framework\Cache\FrontendInterface $cache
){
$this->locationCollectionFactory = $collectionFactory;
$this->cache = $cache;
}
public function getAvailabilityForProduct($productId, $zip = null)
{
$cacheKey = 'mf_locator_avail_' . $productId . ($zip? '_' . $zip: '');
if($this->cache->load($cacheKey)){
return unserialize($this->cache->load($cacheKey));
}
$collection = $this->locationCollectionFactory->create();
$collection->addFieldToFilter('product_id', $productId);
$data = [];
foreach($collection as $item){
$data[] = [
'source_code' => $item->getSourceCode(),
'quantity' => (float)$item->getQuantity(),
'name' => $this->getSourceName($item->getSourceCode())
];
}
// optionally prioritize by zip
if($zip){
// inject your geolocation logic to compute distance and sort
usort($data, function($a,$b){ return $b['quantity'] - $a['quantity']; });
}
$this->cache->save(serialize($data), $cacheKey, ['magefine_product_locator'], 60);
return $data;
}
protected function getSourceName($code){
// map source codes to human names — ideally read from a config table
return ucwords(str_replace(['_','-'], ' ', $code));
}
}
Use case & success story
Client: a mid-sized omnichannel retailer with 12 physical stores and 2 warehouses. Problem: high stockout rate online because stock was only aggregated; customers often bought items that were shipped from a single warehouse even though local stores had stock. Result: longer ship times and returns.
Solution implemented:
- We installed the Product Locator module and synced location data from their ERP via bulk CSV/API.
- Added product page widget to show pickup availability by store and a checkout integration to select local pickup or nearest warehouse shipping.
- Built a REST API for their mobile app to show in-app shop availability and enable click-and-collect flows.
- Configured a background indexer to precompute availability summary for the top 2,000 SKUs to keep product listing fast.
Outcome in 3 months:
- Stockouts for web orders dropped by 48% because the storefront started offering pickup from nearby stores instead of marking items out of stock.
- Click-and-collect accounted for 18% of orders, improving in-store footfall and upsell opportunities.
- Customer refund rate due to inventory errors dropped by 60% after we added automated reconciliation jobs.
- API latency for availability queries was kept under 80ms with Redis caching in front of DB.
Testing and monitoring
Key metrics to monitor:
- API latency and error rate for availability endpoints
- Cache hit ratio for availability keys
- Stockout rate and pickup order counts
- Time taken for bulk imports and indexers
Recommended tools: New Relic/APM for slow queries, Blackfire for PHP profiling, and a dashboard in Grafana showing Redis metrics and queue depth.
Migration & backward compatibility
If you already use Magento MSI, you can either integrate with MSI tables (source_item, source) or keep your module as a lightweight wrapper that reads MSI data and exposes it in a simplified format. The benefit of a wrapper is that your frontend and mobile apps have a consistent API even if the underlying source of truth changes.
Roadmap & extra features you can add later
- Realtime stock reservation across sales channels to avoid overselling (use locks or atomic DB operations).
- Support for per-location lead times and cut-off times to compute ETA more precisely.
- Availability prediction based on sales velocity and incoming purchase orders.
- Machine learning to recommend fulfillment source per order based on cost and SLA.
Final checklist before shipping
- Make sure all admin ACLs are in place.
- Set sensible cache TTLs and clear instructions for when to invalidate caches after manual syncs.
- Test checkout flows with and without pickup enabled.
- Load test the REST endpoint with expected concurrency.
- Plan rollback steps for bulk import mistakes (versioned staging table).
Wrap-up
Building a custom Product Locator for Magento 2 is very doable and can bring real business value: fewer stockouts, happier customers, and higher conversions. The key is to store per-location inventory in a normalized, indexed table; keep reads extremely fast via precomputed summaries and caching; expose a compact REST API for frontend and mobile; and offer an admin UI with automatic sync and reconciliation.
If you want a starter kit I can share a minimal module scaffold you can drop into app/code and extend. And if you’re running on MageFine hosting, we can also help with optimized Redis/varnish caching rules that fit this architecture.
Need the scaffold or sample repository? Tell me whether your store uses MSI or not and which Magento version you’re on (2.3 vs 2.4+), and I’ll generate the starter module adapted to your platform.