How to Implement a Custom "Gift with Purchase" Promotion in Magento 2

Why build a custom "Gift with Purchase" in Magento 2?

Let me be frank — the default Magento 2 promotion tools are powerful, but when you need a precise, behavior-driven "gift with purchase" (GWP) flow that ties into your stock management and admin workflow, a custom module is often the cleanest choice. This post walks you through a practical, maintainable way to implement a GWP module: clear architecture, code examples you can drop into a module skeleton, admin configuration, cart/order integration, stock synchronization with Force Stock Status, and best practices to avoid conflicts with other promo extensions.

What we’ll build (high level)

  • A lightweight custom Magento 2 module that adds a free gift product to the cart when configured conditions are met.
  • Admin configuration to declare the gift SKU, minimum subtotal (or other conditions), maximum gifts per order, and enable/disable the rule.
  • Integration with the Magento cart & order flow so the gift appears as a free line item, is converted to an order item on checkout, and respects stock logic.
  • Stock handling advice and an example of synchronizing with the “Force Stock Status” extension if you use it to force items in stock.
  • Best practices to ensure compatibility with other promotion extensions and maintainability.

Architecture & design

Keep the design modular and non-invasive. Key components:

  • Admin configuration (system.xml) — an intuitive interface to define the rule(s).
  • Config model / helper — reads and validates settings.
  • Observer (or plugin) — monitors the quote and adds/removes the gift product when conditions change.
  • Service class — encapsulates the logic for checking conditions and manipulating the quote (single responsibility).
  • Stock integration layer — uses Magento inventory APIs and optionally interacts with Force Stock Status (via its public interfaces) to ensure gift product availability.
  • Data/persistence — no DB tables needed for a single-rule system; if you plan multiple rules, add custom entities.

Why use an observer and not a sales rule?

Magento cart price rules can give discounts but aren’t designed to add actual free product SKUs in a flexible, audited way. Using an observer that programmatically adds a gift product lets you:

  • Add the gift as a distinct quote item (with price 0 and a custom option or buyRequest flag).
  • Track why a gift was added (private option or item metadata).
  • Manage stock independently and ensure the gift is converted to an order item on checkout.

Module structure and files

We’ll create Vendor/Gwp (Vendor = your namespace). Basic file tree:

/app/code/Vendor/Gwp/
├─ registration.php
├─ etc/module.xml
├─ etc/adminhtml/system.xml
├─ etc/di.xml
├─ etc/events.xml
├─ Observer/CartUpdateObserver.php
├─ Model/GiftManager.php
├─ Helper/Data.php
└─ view/adminhtml/ui_component (optional UI components if you expand)

1) registration.php

<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
    ComponentRegistrar::MODULE,
    'Vendor_Gwp',
    __DIR__
);

2) 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="Vendor_Gwp" setup_version="1.0.0"/>
</config>

3) etc/events.xml (global events)

We’ll observe quote save so the gift is re-evaluated whenever the cart changes. Place this in etc/frontend/events.xml to only run on storefront operations.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework/Event/etc/events.xsd">
    <event name="checkout_cart_save_after">
        <observer name="vendor_gwp_cart_update" instance="Vendor\Gwp\Observer\CartUpdateObserver"/>
    </event>
    <event name="sales_order_place_after">
        <observer name="vendor_gwp_order_place" instance="Vendor\Gwp\Observer\OrderPlaceObserver"/>>
    </event>
</config>

Note: we also added an order observer placeholder; we’ll mention it later for order-time cleanup or analytics if needed.

4) etc/adminhtml/system.xml (simple admin config)

This gives store admins an easy panel in Stores > Configuration > Sales > Gift with Purchase.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
    <system>
        <section id="vendor_gwp" translate="label" type="text" sortOrder="900" showInDefault="1" showInWebsite="1" showInStore="0">
            <label>Gift with Purchase</label>
            <group id="general" translate="label" type="text" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="0">
                <label>General Settings</label>
                <field id="enabled" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Enabled</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                </field>
                <field id="gift_sku" translate="label" type="text" sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Gift Product SKU</label>
                </field>
                <field id="min_subtotal" translate="label" type="text" sortOrder="30" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Minimum Subtotal (base currency)</label>
                </field>
                <field id="max_qty" translate="label" type="text" sortOrder="40" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Max Gift Quantity</label>
                </field>
            </group>
        </section>
    </system>
</config>

5) Helper/Data.php

A small helper to read config values cleanly.

<?php
namespace Vendor\Gwp\Helper;

use Magento\Framework\App\Helper\AbstractHelper;
use Magento\Store\Model\ScopeInterface;

class Data extends AbstractHelper
{
    const XML_PATH_GWP = 'vendor_gwp/general/';

    public function isEnabled($store = null)
    {
        return $this->scopeConfig->isSetFlag(self::XML_PATH_GWP . 'enabled', ScopeInterface::SCOPE_STORE, $store);
    }

    public function getGiftSku($store = null)
    {
        return $this->scopeConfig->getValue(self::XML_PATH_GWP . 'gift_sku', ScopeInterface::SCOPE_STORE, $store);
    }

    public function getMinSubtotal($store = null)
    {
        return (float) $this->scopeConfig->getValue(self::XML_PATH_GWP . 'min_subtotal', ScopeInterface::SCOPE_STORE, $store) ?: 0;
    }

    public function getMaxQty($store = null)
    {
        return (int) $this->scopeConfig->getValue(self::XML_PATH_GWP . 'max_qty', ScopeInterface::SCOPE_STORE, $store) ?: 1;
    }
}

6) Model/GiftManager.php

This class encapsulates the logic to check if conditions are met and to add/remove the gift item. It keeps observers thin.

<?php
namespace Vendor\Gwp\Model;

use Magento\Quote\Model\Quote;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Quote\Model\Quote\ItemFactory;
use Magento\Quote\Model\Quote\Item\CartItemPersister;
use Magento\CatalogInventory\Api\StockRegistryInterface;
use Vendor\Gwp\Helper\Data as GwpHelper;

class GiftManager
{
    protected $productRepository;
    protected $itemFactory;
    protected $stockRegistry;
    protected $gwpHelper;

    public function __construct(
        ProductRepositoryInterface $productRepository,
        ItemFactory $itemFactory,
        StockRegistryInterface $stockRegistry,
        GwpHelper $gwpHelper
    ) {
        $this->productRepository = $productRepository;
        $this->itemFactory = $itemFactory;
        $this->stockRegistry = $stockRegistry;
        $this->gwpHelper = $gwpHelper;
    }

    public function processQuote(Quote $quote)
    {
        if (!$this->gwpHelper->isEnabled($quote->getStoreId())) {
            $this->removeGift($quote);
            return;
        }

        $min = $this->gwpHelper->getMinSubtotal($quote->getStoreId());
        $giftSku = $this->gwpHelper->getGiftSku($quote->getStoreId());
        $maxQty = $this->gwpHelper->getMaxQty($quote->getStoreId());

        $subtotal = (float) $quote->getSubtotal();

        // Basic condition: minimal subtotal
        if ($subtotal >= $min && $giftSku) {
            try {
                $product = $this->productRepository->get($giftSku);
            } catch (\Exception $e) {
                // product not found -> remove any gift and return
                $this->removeGift($quote);
                return;
            }

            // Check stock
            $stockItem = $this->stockRegistry->getStockItem($product->getId(), $quote->getStore()->getWebsiteId());

            if (!$this->isAvailable($stockItem)) {
                // Optionally try to accept backorders or use forced in-stock from other extensions
                $this->removeGift($quote);
                return;
            }

            $this->addOrUpdateGiftItem($quote, $product, $maxQty);
        } else {
            $this->removeGift($quote);
        }
    }

    protected function isAvailable($stockItem)
    {
        if (!$stockItem) {
            return false;
        }
        // available if qty > 0 or backorders allowed
        if ($stockItem->getQty() > 0) {
            return true;
        }
        return (bool) $stockItem->getBackorders();
    }

    protected function findGiftItem(Quote $quote, $giftSku)
    {
        foreach ($quote->getAllItems() as $item) {
            if ($item->getSku() === $giftSku && $item->getOptionByCode('gwp_flag')) {
                return $item;
            }
        }
        return null;
    }

    protected function addOrUpdateGiftItem(Quote $quote, $product, $maxQty)
    {
        $giftSku = $product->getSku();
        $existing = $this->findGiftItem($quote, $giftSku);

        if ($existing) {
            // update qty to configured max (or keep same if lower)
            $existing->setQty($maxQty);
            $existing->setCustomPrice(0);
            $existing->setOriginalCustomPrice(0);
            $existing->save();
            return;
        }

        // create a new quote item
        $item = $this->itemFactory->create();
        $item->setProduct($product);
        $item->setQty($maxQty);
        $item->setPrice(0);
        $item->setCustomPrice(0);
        $item->setOriginalCustomPrice(0);
        // store a flag so we can detect it later
        $item->addOption(new \Magento\Quote\Model\Quote\Item\Option([ 'code' => 'gwp_flag', 'value' => '1' ]));

        $quote->addItem($item);
    }

    protected function removeGift(Quote $quote)
    {
        foreach ($quote->getAllItems() as $item) {
            if ($item->getOptionByCode('gwp_flag')) {
                $quote->removeItem($item->getItemId());
            }
        }
    }
}

Notes:

  • We mark gift items using a custom quote option code 'gwp_flag' to identify and manage them safely.
  • We set custom price and original custom price to 0 so the gift line is free.
  • If you have Magento MSI (Multi Source Inventory), you'll want to adapt stock checks to MSI APIs. The example uses StockRegistryInterface as a single-source example for simplicity.

7) Observer/CartUpdateObserver.php

<?php
namespace Vendor\Gwp\Observer;

use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Event\Observer;
use Vendor\Gwp\Model\GiftManager;

class CartUpdateObserver implements ObserverInterface
{
    protected $giftManager;

    public function __construct(GiftManager $giftManager)
    {
        $this->giftManager = $giftManager;
    }

    public function execute(Observer $observer)
    {
        $quote = $observer->getEvent()->getCart()->getQuote();
        $this->giftManager->processQuote($quote);
        // quote will be saved by the cart flow
    }
}

Important: Working with Magento MSI

If your store uses MSI (sources and reservations), the single-source StockRegistryInterface won't be sufficient. Use Magento\Inventory\Api and the reservation APIs to:

  • Check source quantities via GetSourceItemsBySkuInterface or GetSourceItemsBySku
  • Respect reservations and expected shipments
  • Create reservations on order placement if you intend to reserve gift stock

Example pseudo-code to check overall salable quantity (MSI):

// Inject \Magento\InventorySalesApi\Api\GetProductSalabilityStatusInterface or
// \Magento\InventorySalesApi\Api\GetProductSalableQtyInterface (by SKU and stockId)
$salableQty = $this->getProductSalableQty->execute($sku, $stockId);
if ($salableQty > 0) { /* available */ }

Integration with Force Stock Status

If you already use the Force Stock Status extension (commonly used to mark products as "In Stock" regardless of qty), you should integrate carefully. Best approach:

  • Use the extension public API if it exposes a method to check whether the product is forced in stock; many extensions expose a helper class or a config path.
  • If the extension stores forced status in product attribute or a separate table, check that source before rejecting a gift due to zero inventory.
  • Do not hard-depend on third-party classes. Use is_callable or try-catch to detect if the extension’s helper exists; if not, fall back to default stock logic.

Example snippet detecting a Force Stock Status helper:

// inside GiftManager::isAvailable or a dedicated integration class
try {
    // replace \Vendor\ForceStock\Helper\Data with actual class name of the extension if present
    if (class_exists('\Vendor\ForceStock\Helper\Data')) {
        $fsHelper = \Magento\Framework\App\ObjectManager::getInstance()->get('\Vendor\ForceStock\Helper\Data');
        if ($fsHelper->isForcedInStock($product->getId())) {
            return true;
        }
    }
} catch (\Exception $e) {
    // fallback to default stock check
}

Tip: If Force Stock Status exposes an event or plugin point, prefer to subscribe to it rather than use ObjectManager. If you need to keep compatibility, check for the helper with DI by adding it as an optional constructor argument using ? type hints and a null default.

Making the admin interface friendlier

system.xml is a quick start but consider these additions for a better admin experience:

  • Use a product chooser UI component for the gift SKU instead of a free text input. That prevents typos and ensures the SKU exists.
  • Allow multiple rules in a grid (name, active, gift product, condition type, value, priority). That requires a custom DB table and UI components (ui_component grid and form).
  • Show statistics (how many times gift granted, current reserved qty) so merchants can monitor promo budget.

Example of product chooser field (UI component reference)

High level: create a ui_component form that uses Magento\Catalog\Ui\Component\Product\Listing for selection. There are several examples in the Magento core for product linkers used by related products and categories.

What happens on checkout & order creation?

Because we add the gift as a quote item (with a special option), Magento will convert that into an order item during checkout by default. A few things to keep in mind:

  • Ensure the gift item has price 0 so it doesn’t affect order totals.
  • Keep the gwp_flag option so you can identify the gift on the order (useful for fulfillment & reporting).
  • If you want to reserve stock for the gift, create reservation entries at order place event (MSI) or decrement stock in the legacy system.

Order-level observer example (optional)

<?php
namespace Vendor\Gwp\Observer;

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

class OrderPlaceObserver implements ObserverInterface
{
    public function execute(Observer $observer)
    {
        $order = $observer->getEvent()->getOrder();
        foreach ($order->getAllItems() as $item) {
            $options = $item->getProductOptions();
            if (isset($options['gwp_flag'])) {
                // log, create reservation, notify fulfillment, etc.
            }
        }
    }
}

Stock management best practices for gifts

  • Don’t give away more gifts than you have — set sensible max_qty and optionally daily limits.
  • Consider reserving inventory on add-to-cart for high-value gifts. With MSI, create a reservation when the gift is added and cancel it when the gift is removed.
  • Use the Force Stock Status integration cautiously: it’s great for marketing, but if you physically don’t have stock, you’ll face fulfillment issues. Prefer showing a limited quantity or queueing customers instead of silently selling more than available.
  • Report on gift consumption: track gift order items for reconciliation with warehouse counts.

Avoiding conflicts with other promotion extensions

Promotion systems are often the place where modules collide. Here are practical tips to reduce risk:

  • Identify the gift item uniquely. We used a quote option code 'gwp_flag'. This avoids accidental interaction with other rules looking at SKU only.
  • Do not override core classes. Use observers and small services. If you must plugin a method, prefer non-invasive before/after plugins and document the plugin priority.
  • Make your module toggleable by store configuration and check this flag early in the observer so your module is effectively disabled without code changes.
  • Use module sequence in module.xml to load after popular promotion extensions if necessary (so your observer executes after theirs), but prefer explicit checks over fragile sequencing.
  • If you expect other modules to alter quote totals, evaluate your conditions after the cart save event, not before; checkout_cart_save_after is appropriate.
  • Expose events from your module (e.g., vendor_gwp_gift_added) so other modules can react instead of overriding it.

Testing checklist

Before shipping to production, test these cases:

  • Gift is added when subtotal >= min and removed when subtotal drops below min.
  • Multiple adds/removes don't create duplicate gift lines.
  • Gift respects configured max_qty and gets converted to an order item.
  • Stock shortage removes gift and triggers a graceful message to the admin (log).
  • MSI-enabled stores: salable qty checks are correct and reservations are created if required.
  • Force Stock Status enabled: forced-in-stock SKUs are accepted as available for gifting if the extension says so.
  • Compatibility tests with other promotion or cart customization extensions used on your site.

Deployment & performance considerations

  • Keep the observer light — delegate heavy operations (like MSIs or external API calls) to asynchronous jobs when possible. For example, if you need to update remote stock systems, do it via a cron or queue, not the cart save lifecycle.
  • Cache config values in your helper if you read them frequently within a single request; use storeScope caching patterns.
  • Avoid excessive DB queries while iterating quote items. Use getAllItems() once, then inspect it.
  • Log only the necessary details to avoid spamming logs for normal operations.

Advanced: multiple rules & conditions engine

If you need multiple GWP rules with complex conditions (SKU-based, customer group, cart attribute, coupon codes), consider these approaches:

  • Extend Magento's rule condition model (Magento\Rule module) to create a UI and condition tree. This is the same approach Magento uses for CatalogPriceRule and SalesRule.
  • Build a lightweight rule table that stores JSON conditions and evaluate them using a service that supports common operators. This is simpler to implement but less visual in admin.
  • Use a priority system and allow multiple gifts with stacking rules if desired.

Full example: core files recap

Quick recap of the key files we used (copying earlier code snippets into your module):

  • registration.php
  • etc/module.xml
  • etc/frontend/events.xml (observe checkout_cart_save_after)
  • etc/adminhtml/system.xml (simple config)
  • Helper/Data.php (config reader)
  • Model/GiftManager.php (core logic)
  • Observer/CartUpdateObserver.php (glues cart events to logic)

Common pitfalls and how to fix them

  • Duplicate gift lines: ensure your findGiftItem logic checks for the option (gwp_flag) and doesn’t rely on SKU only.
  • Gift disappears on checkout: make sure custom price is set on the quote item (setCustomPrice), and that you’re not resetting prices later in the pipeline (other observers/plugins might do so). Consider protecting your item with a plugin on the price calculation if needed.
  • Stock mismatch on MSI: check salable qty not physical qty.
  • Compatibility issues with other promotion modules: add feature toggle, and, if necessary, add priority handling by listening to events and waiting for other modules to complete (not always possible—documentation and clear configuration are better).

Extending for analytics and reporting

Want to measure how your GWP affects conversions?

  • Record a simple log or custom DB entry each time a gift is added and when the order is placed for that gift.
  • Collect metrics: gift-add rate, gift-redemption rate, and revenue uplift. Use these to A/B test gift rules.

Wrap up

Building a custom "Gift with Purchase" module in Magento 2 is straightforward if you keep the design small, don’t touch core logic, and encapsulate behavior. The solution above gives you a robust starting point:

  1. Admin-configurable rule via system.xml (starter) or a proper rule engine for multiple rules.
  2. Observer & service-based architecture to evaluate and add gifts to quotes.
  3. Stock-aware checks and MSI compatibility notes.
  4. Optional integration with Force Stock Status — detect at runtime and act accordingly.
  5. Best practices to avoid conflicts with other promotion modules.

If you want, I can:

  • Convert the gift product SKU field into a product chooser UI component example.
  • Show an MSI-ready version of GiftManager using Inventory API classes.
  • Provide a sample upgrade to support multiple rules with a full admin grid and form.

Drop me a note on which of the three you want next and I’ll produce the full code for that slice (including UI XML and DB schema if needed). Happy hacking — and don’t forget to test your gift flows with the fulfillment team especially when stock is tight.

Author note: This post focuses on pragmatic, maintainable code patterns used by Magento 2 dev teams. Keep your module small and documented, and avoid relying on private APIs of third-party modules. If you do integrate with an extension like Force Stock Status, prefer public helpers or events and provide fallbacks if it’s not installed.