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

Working with multi-location inventaire 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 comment build a clean, performant custom "Product Locator" module that exposes availability per location to the vitrine, paiement, admin, and mobile apps via a API REST — 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 étape par étape.

What this module solves

Short version: you want clients and staff to know which store/entrepôt has a product, show this info on page produits and the paiement, provide an admin interface to manage stocks per location, expose a API REST for mobile apps, and make sure the implémentation 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 tableaus in attribut produits.
  • Read performance: use indexed tables and cache responses for vitrine 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 indexeurs to keep read models fast.
  • Expose a API REST (webapi.xml) for mobile apps and AJAX calls from the frontend.
  • Provide a UI composant grid and edit form in the admin to manage multi-location stocks with auto-sync hooks.
  • Integrate into paiement via a small JS extension that calls the REST endpoint and displays availability per shipping address or pickup location.

Why not store inventaire 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 inventaire data; this keeps queries faster and indexation simpler.

Module structure (brief)

We’ll create a module named MageFine_ProductLocator with les éléments suivants 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_composant/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">
    <colonne xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" />
    <colonne xsi:type="int" name="product_id" unsigned="true" nullable="false" />
    <colonne xsi:type="varchar" name="source_code" nullable="false" length="64" />
    <colonne xsi:type="decimal" name="quantity" nullable="false" scale="4" precision="12" default="0.0000" />
    <colonne xsi:type="timestamp" name="updated_at" nullable="false" on_update="true" default="CURRENT_TIMESTAMP" />
    <constraint xsi:type="primary" referenceId="PRIMARY">
      <colonne name="entity_id"/>
    </constraint>
    <index referenceId="MAGEFINE_PRODUCT_LOCATOR_PRODUCT_IDX" indexType="btree">
      <colonne name="product_id"/>
    </index>
    <index referenceId="MAGEFINE_PRODUCT_LOCATOR_SOURCE_IDX" indexType="btree">
      <colonne 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 fonction getId();
    public fonction getProductId();
    public fonction getSourceCode();
    public fonction getQuantity();
    public fonction getUpdatedAt();

    public fonction setProductId($productId);
    public fonction setSourceCode($code);
    public fonction setQuantity($qty);
}
<?php
namespace MageFine\ProductLocator\Api;

use MageFine\ProductLocator\Api\Data\LocationInterface;

interface LocationRepositoryInterface
{
    public fonction save(LocationInterface $location);
    public fonction getById($id);
    public fonction getByProductId($productId);
    public fonction delete(LocationInterface $location);
}

ResourceModel and Model follow standard Magento patterns — I’ll skip repetitive boilerplate here but keep them in the repository implémentation.

Step 3 — Admin UI: grid and edit form

Use a UI composant 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 fonctionnalités:

  • Inline editing for quick updates
  • Bulk import for syncing a CSV from entrepôt ERP
  • Auto-sync hook: after saving a product or when a source changes, update the locator table via an observateur
<!-- view/adminhtml/ui_composant/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" >
  <colonnes name="magefine_location_colonnes" >
    <colonne name="product_id" class="Magento\Ui\Component\Listing\Columns\Column">
      <settings><label>Product ID</label></settings>
    </colonne>
    <colonne name="source_code">
      <settings><label>Location</label></settings>
    </colonne>
    <colonne name="quantity">
      <settings><label>Quantity</label></settings>
    </colonne>
  </colonnes>
</listing>

Step 4 — Integrate with paiement and page produit

We want clients to see availability for each location on the page produit and during paiement (for pickup or to display if the shipped product will come from mulconseille locations). The pattern I suggest:

  1. Expose a lightweight REST endpoint that returns availability by product_id and optionally by shipping_address or zip code.
  2. On page produit 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).
  3. During paiement, when shipping address is set, query the endpoint to decide from which source shipping cost or fulfillment choice est disponible; if pickup option is active, show which pickups sont disponibles.

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" méthode="GET">
    <service class="MageFine\ProductLocator\Api\LocationRepositoryInterface" méthode="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 composant that calls the REST endpoint on page produit 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'], fonction($, urlBuilder){
  'use strict';
  return fonction(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(fonction(response){
      if(window.sessionStorage) sessionStorage.setItem(cacheKey, JSON.chaîneify(response));
      render(response);
    });

    fonction render(data){
      var html = '
'; if(!data.locations || !data.locations.length){ html += '
No inventaire info available
'; } else { html += '
    '; data.locations.forEach(fonction(l){ html += '
  • ' + l.name + ' — ' + (l.quantity>0? l.quantity +' available' : 'Out of stock') + '
  • '; }); html += '
'; } html += '
'; $(elemSelector).html(html); } } });

Step 5 — Checkout integration

In the paiement, we need to show availability basé sur shipping address. Two main flows:

  • Ship from nearest entrepôt: 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 entrepôt and arrive in 2–3 days"
  • Pickup in store: when utilisateur chooses store pickup, show only stores with quantity > 0

Important: the paiement must not block the utilisateur while waiting for API calls — show a skeleton and update asynchronously. Cache results per address and product.

// Example: in paiement JS mixin
var actions = {
  onShippingAddressChange: fonction(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 peut être public or protected. For mobile apps, create a token-authenticated route returning structured results with optional geolocation/zip filtreing and pagination. Example response supports querying mulconseille product IDs to save calls.

// exemple request
GET /rest/V1/magefine/products/availability?product_ids=123,124&zip=10001

// exemple 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 mulconseille product IDs in one call and return a compact result. Use Redis/Full Page Cache to store frequently-requested results (for exemple, popular product bundles or page de catégories).

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 indexeur to precompute "availability_résumé" per product (e.g., total_quantity, in_stock_sources_count). Update this via an indexeur so frontend reads one small ligne au lieu de joining many lignes.
  • Cache at the edge: cache results by product_id and by geohash/zip. For mobile APIs accept a location bucket to reduce unique cache clés.
  • 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 utilisateurs often need to sync with ERP or update stocks manually. Consider these fonctionnalités:

  • CSV import with batch processing (via cron or fichier de messages)
  • point d'accès API 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: observateur to update locator ligne when admin updates product stock

<?php
namespace MageFine\ProductLocator\Observer;

use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Event\Observer;

class ProductSaveAprès implements ObserverInterface
{
    protected $locationRepository;
    protected $stockRegistry;

    public fonction __construct(
        \MageFine\ProductLocator\Api\LocationRepositoryInterface $locationRepository,
        \Magento\CatalogInventory\Api\StockRegistryInterface $stockRegistry
    ){
        $this->locationRepository = $locationRepository;
        $this->stockRegistry = $stockRegistry;
    }

    public fonction execute(Observer $observateur)
    {
        $product = $observateur->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. points d'accès API 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 conseils

  • Precompute minimal availability résumé per product (indexed) to show the main availability badge without joining large tables.
  • For page produits, 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 au lieu de many ORM saves.

Concrete code: a compact AvailabilityService

Here’s a simple PHP service you can use to get availability and a prioritized source basé sur zip code proximity (you’ll plug in your own geo-distance logic):

<?php
namespace MageFine\ProductLocator\Model;

class AvailabilityService
{
    protected $locationCollectionFactory;
    protected $cache;

    public fonction __construct(
        \MageFine\ProductLocator\Model\ResourceModel\Location\CollectionFactory $collectionFactory,
        \Magento\Framework\Cache\FrontendInterface $cache
    ){
        $this->locationCollectionFactory = $collectionFactory;
        $this->cache = $cache;
    }

    public fonction 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 tri
            utri($data, fonction($a,$b){ return $b['quantity'] - $a['quantity']; });
        }

        $this->cache->save(serialize($data), $cacheKey, ['magefine_product_locator'], 60);

        return $data;
    }

    protected fonction 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 entrepôts. Problem: high stockout rate online because stock was only aggregated; clients often bought items that were shipped from a single entrepôt even though local stores had stock. Result: longer ship times and returns.

Solution implemented:

  1. We installed the Product Locator module and synced location data from their ERP via bulk CSV/API.
  2. Added page produit widget to show pickup availability by store and a paiement integration to select local pickup or nearest entrepôt shipping.
  3. Built a API REST for their mobile app to show in-app shop availability and enable click-and-collect flows.
  4. Configured a background indexeur to precompute availability résumé for the top 2,000 SKUs to keep liste de produits fast.

Outcome in 3 months:

  • Stockouts for web commandes dropped by 48% because the vitrine started offering pickup from nearby stores au lieu de marking items out of stock.
  • Click-and-collect accounted for 18% of commandes, improving in-store footfall and upsell opportunities.
  • Customer refund rate due to inventaire erreurs 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 erreur rate for availability endpoints
  • Cache hit ratio for availability clés
  • Stockout rate and pickup commande counts
  • Time taken for bulk imports and indexeurs

Recommended tools: New Relic/APM for slow queries, Blackfire for PHP profiling, and a tableau de bord in Grafana showing Redis metrics and queue depth.

Migration & backward compatibility

Si vous 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 avantage of a wrapper is that your frontend and mobile apps have a consistent API even if the underlying source of truth changes.

Roadmap & extra fonctionnalités 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 basé sur sales velocity and incoming purchase commandes.
  • Machine learning to recommend fulfillment source per commande basé sur cost and SLA.

Final checklist before shipping

  1. Assurez-vous all admin ACLs are in place.
  2. Set sensible cache TTLs and clear instructions for when to invalidate caches after manual syncs.
  3. Test paiement flows with and without pickup enabled.
  4. Load test the REST endpoint with expected concurrency.
  5. Plan rollback étapes for bulk import mistakes (versioned staging table).

Wrap-up

Building a custom Product Locator for Magento 2 is very doable and can bring real entreprise valeur: fewer stockouts, happier clients, and higher conversions. The clé is to store per-location inventaire in a normalized, indexed table; keep reads extremely fast via precomputed summaries and caching; expose a compact API REST for frontend and mobile; and offer an admin UI with automatic sync and reconciliation.

Si vous 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.