How to Implement a Custom Product Stock Reservation Module for Time-Limited Offers

Why a custom product stock reservation module?

Running time-limited offers (flash sales, countdown promotions, limited-quantity drops) on Magento 2 is always exciting — and risky. When traffic spikes and many customers try to reserve the same limited units, overselling can happen if stock handling is naive. A custom reservation module gives you a predictable, audit-friendly way to lock inventory for a short period while a customer completes checkout.

High-level architecture

At a glance, the module has these parts:

  • Database table(s) to store reservations and their state.
  • Service layer to create, query, extend, confirm, and expire reservations.
  • Safe concurrency controls (DB transactions / locks) to avoid overselling.
  • Cron job that expires old/abandoned reservations and cleans up.
  • Integration with the Force Product Stock Status module (from Magefine) to automatically mark products as out of stock when reservation quota is reached.
  • Frontend components (blocks, templates, small JS) to show remaining stock, reservation timers and handle UX flows.
  • Optional real-time notification channel (Pusher/WebSockets) to update other shoppers live.

Design considerations and constraints

Before coding, decide a few behavioral rules that will drive the implementation:

  • When is a reserve created? On Add to Cart, or at a special “Reserve now” button? Common choice: reserve when user clicks a dedicated CTA during the offer.
  • How long is the reservation valid? Typical windows: 10–30 minutes. Keep it configurable.
  • Do you reserve per quote item or per product SKU? Reserve per SKU + sales channel to keep logic simple.
  • How do reservations affect available stock? Use: available = salable - active_reserved_qty.
  • How do orders consume reservations? If a user checks out within the reservation time, the reservation must be confirmed (consumed) and removed from active reservations.

Database schema (declarative schema / db_schema.xml)

Use Magento 2 declarative schema. Create a simple reservation table. Keep it minimal but extensible.

<table name="magefine_stock_reservation" resource="default" engine="innodb" comment="Magefine Stock Reservations">
    <column xsi:type="int" name="reservation_id" unsigned="true" nullable="false" identity="true" comment="Reservation ID"/>
    <column xsi:type="varchar" name="sku" nullable="false" length="64" comment="Product SKU"/>
    <column xsi:type="int" name="qty" nullable="false" default="0" comment="Reserved quantity"/>
    <column xsi:type="smallint" name="status" nullable="false" default="0" comment="0=active,1=confirmed,2=expired"/>
    <column xsi:type="int" name="customer_id" nullable="true" comment="Customer ID (if logged in)"/>
    <column xsi:type="varchar" name="quote_id" nullable="true" length="128" comment="Quote identifier"/>
    <column xsi:type="timestamp" name="created_at" nullable="false" default="current_timestamp"/>
    <column xsi:type="timestamp" name="expires_at" nullable="false"/>
    <constraint xsi:type="primary" referenceId="PRIMARY">
        <column name="reservation_id"/>
    </constraint>
    <index referenceId="IDX_SKU" indexType="btree" >
        <column name="sku"/>
    </index>
</table>

Notes:

  • Store quote_id as a string to support both numeric and masked quote IDs.
  • expires_at allows efficient queries to find expired rows.

Module bootstrap files

Standard minimal files: registration.php and etc/module.xml.

// registration.php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
    ComponentRegistrar::MODULE,
    'Magefine_StockReservation',
    __DIR__
);

// etc/module.xml
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Magefine_StockReservation" setup_version="1.0.0"/>
</config>

Model / ResourceModel / Collection

Create a Reservation model that maps to the DB table and a ResourceModel to handle persistence. Example skeleton for resource model (PHP omitted for brevity).

// Model/Reservation.php
namespace Magefine\StockReservation\Model;

use Magento\Framework\Model\AbstractModel;

class Reservation extends AbstractModel
{
    protected function _construct()
    {
        $this->_init(ResourceModel\Reservation::class);
    }
}

// Model/ResourceModel/Reservation.php
namespace Magefine\StockReservation\Model\ResourceModel;

use Magento\Framework\Model\ResourceModel\Db\AbstractDb;

class Reservation extends AbstractDb
{
    protected function _construct()
    {
        $this->_init('magefine_stock_reservation', 'reservation_id');
    }
}

Service layer: Reservation Manager

Keep business logic out of the model. Create a manager (service class) that exposes clear methods:

  • createReservation(sku, qty, quoteId, customerId, ttlSeconds)
  • confirmReservation(reservationId, orderId)
  • expireReservation(reservationId)
  • getActiveReservedQty(sku)

Important: the createReservation method must be concurrency-safe. Use DB transactions and FOR UPDATE locks on the product stock rows or on an aggregate row. If you cannot lock the stock table directly (because MSI adds complexity), use an optimistic check + transactional insert and a stored procedure style check: check current salable - reserved >= requested qty; if so, insert reservation; else fail.

Example: createReservation (simplified)

public function createReservation($sku, $qty, $quoteId = null, $customerId = null, $ttl = 900)
{
    $connection = $this->resource->getConnection();
    try {
        $connection->beginTransaction();

        // 1) check current salable qty using Magento stock APIs (MSI or legacy)
        $salable = $this->stockProvider->getSalableQuantity($sku);

        // 2) sum active reservations for this sku (SELECT SUM(qty) FROM magefine_stock_reservation WHERE sku = ? AND status = 0 FOR UPDATE)
        $sql = $connection->select()
            ->from('magefine_stock_reservation', ['sum_qty' => new \Zend_Db_Expr('SUM(qty)')])
            ->where('sku = ?', $sku)
            ->where('status = 0');
        // Lock the reservation rows to avoid race
        $row = $connection->fetchRow($sql);
        $reserved = (int)$row['sum_qty'];

        if (($salable - $reserved) < $qty) {
            $connection->rollBack();
            throw new \Exception('Insufficient stock to reserve');
        }

        // 3) insert reservation
        $expiresAt = date('Y-m-d H:i:s', time() + $ttl);
        $connection->insert('magefine_stock_reservation', [
            'sku' => $sku,
            'qty' => $qty,
            'status' => 0,
            'customer_id' => $customerId,
            'quote_id' => $quoteId,
            'created_at' => date('Y-m-d H:i:s'),
            'expires_at' => $expiresAt
        ]);

        $connection->commit();
        return $connection->lastInsertId();
    } catch (\Exception $e) {
        if ($connection->inTransaction()) {
            $connection->rollBack();
        }
        throw $e;
    }
}

Notes:

  • We explicitly read the active reservation sum with a table lock. Using "FOR UPDATE" in Magento's adapter can be achieved by selecting via the connection after disabling autocommit and prior to insert. This prevents two concurrent processes from inserting reservations that, combined, would exceed salable stock.
  • If you use MSI (Multi Source Inventory), use the salable quantity API: Magento\InventorySalesApi\Api\GetProductSalableQtyInterface and ensure you read the same source that will be consumed at checkout.

Preventing overselling (conflict management)

Even with a reservation table, you must ensure reservations and actual orders don't conflict. Typical safe workflow:

  1. User reserves N items (reservation row created).
  2. When the user starts checkout, keep the reservation linked to the quote so you can confirm it later.
  3. On order placement (plugin around \Magento\Quote\Model\QuoteManagement::submit or observer on sales_order_place_before), do the final stock check inside a transaction: check salable - (active_reserved_except_current) >= items-being-ordered. Then mark reservation as confirmed and let Magento decrement physical stock via normal stock flows.
  4. If the checkout fails or the reservation expired, the cron will remove reservation and release reserved qty back to available pool.

Implementation tips:

  • When confirming reservation you should use a DB transaction and, if possible, lock the reservation row with SELECT FOR UPDATE so another process doesn't expire it mid-check.
  • Reserve minimal necessary data: SKU, qty, quote_id. This keeps the locking logic straightforward.
  • Make reservation creation idempotent where appropriate: if user tries to reserve the same item twice, either extend the existing reservation or return the existing reservation id with the new TTL.

Cron job: expire old reservations

Use etc/crontab.xml to schedule a cron every minute (or every 5 minutes depending on precision you need). The job should:

  • Find reservations where status = 0 and expires_at < now
  • Mark them expired (status = 2) or delete them; marking is preferred for audit logs
  • If the reservation caused the product to be flagged out-of-stock via Force Product Stock Status, check if you need to unset that flag
  • Emit events or push notifications so the storefront updates live
// etc/crontab.xml
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/crontab.xsd">
    <group id="default">
        <job name="magefine_reservation_expire" instance="Magefine\StockReservation\Cron\ExpireReservations" method="execute">
            <schedule>* * * * *</schedule>
        </job>
    </group>
</config>

// Cron/ExpireReservations.php (simplified)
public function execute()
{
    $connection = $this->resource->getConnection();
    $now = date('Y-m-d H:i:s');
    $select = $connection->select()
        ->from('magefine_stock_reservation', ['reservation_id', 'sku', 'qty'])
        ->where('status = ?', 0)
        ->where('expires_at < ?', $now);
    $rows = $connection->fetchAll($select);
    foreach ($rows as $row) {
        $connection->update('magefine_stock_reservation', ['status' => 2], ['reservation_id = ?' => $row['reservation_id']]);
        // dispatch event for stock change if needed
        $this->eventManager->dispatch('magefine_reservation_expired', ['reservation' => $row]);
    }
}

Integration with Force Product Stock Status (Magefine)

Magefine's Force Product Stock Status extension provides an attribute-driven way to set a product to "Out of Stock" programmatically. Integrate by listening to reservation creation and expiration events and toggling the forced stock status when thresholds are crossed.

Design flow:

  1. Define a configuration per promo product: reserve_quota (max units to allow reservations) or when total active reservations reach a threshold you want to mark product out of stock.
  2. When you create a reservation, compute total active reserved qty. If reserved >= product quota or salable - reserved <= 0, call Force Product Stock Status API / helper to set product as out of stock.
  3. When reservations expire or are confirmed and freed, re-check and clear the forced flag if stock is again available.

Example code calling the Force Product Stock Status helper:

// pseudo-code showing how to toggle a forced status
$forceHelper = $this->forceStockHelper; // injected from Magefine\ForceStock\Helper\Data

if ($totalReserved >= $productQuota || ($salable - $totalReserved) <= 0) {
    // this method would exist in the Force Stock extension
    $forceHelper->setProductOutOfStockBySku($sku, true);
} else {
    $forceHelper->setProductOutOfStockBySku($sku, false);
}

Notes:

  • Don't change core stock fields directly. Use the extension's API or dispatch well-known events so the extension can manage the product attribute.
  • Be tolerant: the Force Product Stock Status extension may keep its own cache; update observers or flush caches sensibly (e.g., cache invalidation only for the product).

Order placement and confirming reservations

When an order is placed, your process must bind the order to the reservation and mark reservation status = 1 (confirmed). A safe place to do this is a plugin around \Magento\Quote\Model\QuoteManagement::submit or an observer on sales_order_place_after.

Plugin approach (outline):

public function aroundSubmit($subject, $proceed, \Magento\Quote\Model\Quote $quote)
{
    $connection = $this->resource->getConnection();
    try {
        $connection->beginTransaction();

        // 1) find reservations linked to this quote and lock them
        $reservations = $this->reservationRepository->getByQuoteIdForUpdate($quote->getId());

        // 2) validate available stock vs reserved
        foreach ($reservations as $reservation) {
            $salable = $this->stockProvider->getSalableQuantity($reservation->getSku());
            $otherReserved = $this->getActiveReservedExcept($reservation->getSku(), $reservation->getId());
            if (($salable - $otherReserved) < $reservation->getQty()) {
                throw new \Exception('Cannot confirm reservation: stock changed');
            }
        }

        $order = $proceed($quote); // place Magento order

        // 3) mark reservations confirmed and store order id
        foreach ($reservations as $reservation) {
            $reservation->setStatus(1);
            $reservation->setOrderId($order->getId());
            $this->reservationRepository->save($reservation);
        }

        $connection->commit();
        return $order;
    } catch (\Exception $e) {
        if ($connection->inTransaction()) $connection->rollBack();
        throw $e;
    }
}

This ensures the reservation is consumed atomically with order creation, preventing double-fulfillment.

Frontend: showing remaining stock and a reservation timer

UX is critical: users should see how many units are left, how many are reserved, and how long their reservation lasts.

Block and template

Create a block method that returns:

  • remaining_salable = salable - activeReserved
  • user_reservation_ttl if user has an active reservation for this SKU
  • total_reserved
// template (PHTML) snippet
<div class="mf-reservation-widget" data-sku="<?= $block->escapeHtml($sku) ?>">
    <div class="mf-stock-left">Remaining: <span class="mf-stock-count"><?= (int)$block->getRemaining() ?></span></div>
    <div class="mf-reserved-count">Reserved: <span><?= (int)$block->getTotalReserved() ?></span></div>
    <?php if ($block->getUserReservationTtl()): ?>
        <div class="mf-timer" data-ttl="<?= (int)$block->getUserReservationTtl() ?>">
            Reservation expires in: <span class="mf-timer-value"></span>
        </div>
    <?php endif; ?>
</div>

Simple JS countdown and polling

(function(){
    function startTimer(el, ttl) {
        var end = Date.now() + ttl * 1000;
        var tv = el.querySelector('.mf-timer-value');
        function tick(){
            var diff = Math.max(0, end - Date.now());
            tv.innerText = new Date(diff).toISOString().substr(11, 8);
            if (diff > 0) setTimeout(tick, 500);
        }
        tick();
    }

    document.querySelectorAll('.mf-reservation-widget').forEach(function(w){
        var ttl = parseInt(w.getAttribute('data-ttl') || w.querySelector('.mf-timer').getAttribute('data-ttl') , 10);
        if (ttl) startTimer(w, ttl);

        // Poll every 10s for stock changes (or use websockets)
        setInterval(function(){
            var sku = w.getAttribute('data-sku');
            fetch('/rest/V1/magefine-reservation/stock?sku=' + encodeURIComponent(sku))
                .then(function(r){return r.json();})
                .then(function(data){
                    w.querySelector('.mf-stock-count').innerText = data.remaining;
                    w.querySelector('.mf-reserved-count').innerText = data.reserved;
                });
        }, 10000);
    });
})();

For better UX use websockets (Pusher or a server-side push) to broadcast reservation/stock updates in real time. Magento Cloud or your hosting may already support message brokers (RabbitMQ) — you can bridge events to a real-time service to push UI updates.

Handling reservation abandonment

Abandonment happens when a user reserves and never completes checkout. Your cron job cleans them up after TTL. Additionally:

  • Send an email or in-browser notification before TTL expiry to nudge checkout.
  • Allow customers to extend the TTL once per session if you want (e.g., “Hold for 5 more minutes” limited to one extension).
  • Consider a short grace period after TTL during which you attempt to re-hold stock if the customer quickly returns.

Edge cases and testing matrix

Test thoroughly with scenarios including:

  • Concurrent reservations from multiple users for the last units (simulate with parallel requests).
  • MSI enabled stores with multiple sources and salable calculations.
  • Cart merges (logged-in user loads cart from different device and sessions merging reserved quantities).
  • Quote expiration and guest users who have masked quote IDs.
  • Orders that fail after confirmation (payment denial) — ensure reservation is either kept (if you want) or re-released depending on your policy.

Performance and scaling

Reservations add writes to your database during busy drops. Keep these tips in mind:

  • Index the reservation table by sku and status for fast queries and cleanup.
  • If you expect extreme write concurrency, consider storing summary counters in Redis for fast reads and keep reservation details in MySQL for audit. Use Redis as a cache only if you still validate against MySQL when creating reservations.
  • Batch expire reservations in the cron to reduce overhead (expire by chunk size).
  • Use read replicas for product queries where appropriate; ensure writes always check master to avoid stale reads causing oversells.

Monitoring and alerts

Monitor the following:

  • Reservations created per minute (sudden spikes may indicate bots).
  • Failed reservation attempts due to insufficient stock (indicate demand).
  • Length of the reservation queue for a product (for troubleshooting).
  • Cron job success/failure and long-running cron jobs.

Putting it all together: a small end-to-end flow

Here is a step-by-step user flow with the main integration points:

  1. User clicks "Reserve Now" on a product page (AJAX to ReservationController::createAction with sku & qty).
  2. Controller calls Reservation Manager -> createReservation()
  3. Manager checks salable qty via MSI API, calculates current active reserved qty, uses a DB transaction, and inserts the reservation row if enough stock remains.
  4. Manager dispatches event magefine_reservation_created and returns reservation id + TTL to controller.
  5. Frontend receives reservation id and TTL, shows the countdown timer and updates the remaining stock. It subscribes to real-time events for changes.
  6. When checkout happens, plugin around QuoteManagement::submit locks reservation rows, validates stock, lets Magento place the order, marks reservations confirmed, and keeps everything atomic.
  7. Periodic cron expires timed-out reservations and triggers the Force Product Stock Status helper to unmark forced out-of-stock flags if thresholds passed.

Security and data privacy

Reservations tie to quotes and possibly customer IDs. Keep data minimal and avoid storing sensitive information (no payment data). Respect GDPR for user identifiers — the reservation table should not contain unnecessary PII.

Example: REST API endpoints

Provide small restful endpoints for the frontend. Examples:

  • POST /V1/magefine-reservation/create { sku, qty } -> { reservation_id, expires_at }
  • GET /V1/magefine-reservation/stock?sku=xxx -> { sku, reserved, remaining, salable }
  • POST /V1/magefine-reservation/extend { reservation_id } -> { expires_at }
// etc/webapi.xml snippet
<route url="/V1/magefine-reservation/create" method="POST">
    <service class="Magefine\StockReservation\Api\ReservationManagementInterface" method="create" />
    <resources>
        <resource ref="anonymous" />
    </resources>
</route>

// ReservationManagementInterface has signatures for create, extend, cancel

Common pitfalls

  • Relying only on frontend timers — always enforce TTL on the server.
  • Not accounting for MSI salable vs. stock — results in inconsistent counts between admin and frontend.
  • Using aggressive DB locks that slow down the site — prefer locking only the small set of rows you need and keep transactions short.
  • Forgetting to re-evaluate forced out-of-stock when reservations expire — which will keep products hidden wrongly.

Testing checklist

Before deploying to production test:

  • Unit tests for Reservation Manager logic (use in-memory DB or integration tests).
  • Integration tests with MSI salable API.
  • Load tests simulating high concurrency and many small reservations.
  • Manual QA of the UI countdown, reservation extension and checkout flows.

Final recommendations

Keep the reservation module lightweight and predictable. Prefer MySQL transactional checks with minimal locking to achieve correctness. Use the Force Product Stock Status extension to expose clear "Out of Stock" behavior for customers when quotas are hit — that makes the UX consistent and avoids confusion during peak events.

If your store runs a high frequency of flash sales, you may want to move hot-path counters to a fast store like Redis (for reads) while keeping MySQL as source-of-truth for writes and reconciliation. Also, consider rate-limiting reservation attempts to mitigate bots.

Summary

Implementing a custom product stock reservation module involves a mix of schema design, careful transactional logic to avoid overselling, cron-based expiry, and UX components that make the experience trustworthy. Integrating with Magefine's Force Product Stock Status ties the reservation logic to a product-level visibility control, so users see correct availability during and after promos. Done right, a reservation module increases conversion on time-limited offers while protecting fulfillment operations from oversell headaches.

If you want, I can:

  • Provide a complete sample module repo skeleton you can drop into Magento 2 for testing.
  • Show a full MSI-aware implementation that uses GetProductSalableQtyInterface in code samples.
  • Create example unit/integration tests for the critical paths (reservation creation, expiry, and confirmation).

Want me to scaffold the module files for you now?