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 flux de travail, 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 exemples you can drop into a module skeleton, configuration d'administration, cart/commande integration, stock synchronization with Force Stock Status, and bonnes pratiques 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 commande, and enable/disable the rule.
  • Integration with the Magento cart & commande flow so the gift appears as a free line item, is converted to an commande item on paiement, and respects stock logic.
  • Stock handling advice and an exemple 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 composants:

  • 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 inventaire 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 mulconseille rules, add custom entities.

Why use an observateur and not a sales rule?

Magento règle de prix du paniers can give discounts but aren’t designed to add actual free product SKUs in a flexible, audited way. Using an observateur that programmatically adds a gift product vous permet de:

  • Add the gift as a distinct quote item (with prix 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 commande item on paiement.

Module structure and fichiers

We’ll create Vendor/Gwp (Vendor = your namespace). Basic fichier 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_composant (optional UI composants 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 vitrine 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="paiement_cart_save_after">
        <observateur name="vendor_gwp_cart_update" instance="Vendor\Gwp\Observer\CartUpdateObserver"/>
    </event>
    <event name="sales_commande_place_after">
        <observateur name="vendor_gwp_commande_place" instance="Vendor\Gwp\Observer\OrderPlaceObserver"/>>
    </event>
</config>

Note: we also added an commande observateur placeholder; we’ll mention it later for commande-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_fichier.xsd">
    <system>
        <section id="vendor_gwp" translate="label" type="text" triOrder="900" showInDefault="1" showInWebsite="1" showInStore="0">
            <label>Gift with Purchase</label>
            <group id="general" translate="label" type="text" triOrder="10" showInDefault="1" showInWebsite="1" showInStore="0">
                <label>General Settings</label>
                <champ id="enabled" translate="label" type="select" triOrder="10" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Enabled</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                </champ>
                <champ id="gift_sku" translate="label" type="text" triOrder="20" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Gift Product SKU</label>
                </champ>
                <champ id="min_subtotal" translate="label" type="text" triOrder="30" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Minimum Subtotal (base currency)</label>
                </champ>
                <champ id="max_qty" translate="label" type="text" triOrder="40" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Max Gift Quantity</label>
                </champ>
            </group>
        </section>
    </system>
</config>

5) Helper/Data.php

A small helper to read config valeurs 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 fonction isEnabled($store = null)
    {
        return $this->scopeConfig->isSetFlag(self::XML_PATH_GWP . 'enabled', ScopeInterface::SCOPE_STORE, $store);
    }

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

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

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

6) Model/GiftManager.php

Cette classe encapsulates the logic to check if conditions are met and to add/remove the gift item. It keeps observateurs 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 fonction __construct(
        ProductRepositoryInterface $productRepository,
        ItemFactory $itemFactory,
        StockRegistryInterface $stockRegistry,
        GwpHelper $gwpHelper
    ) {
        $this->productRepository = $productRepository;
        $this->itemFactory = $itemFactory;
        $this->stockRegistry = $stockRegistry;
        $this->gwpHelper = $gwpHelper;
    }

    public fonction 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 backcommandes or use forced in-stock from other extensions
                $this->removeGift($quote);
                return;
            }

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

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

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

    protected fonction 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', 'valeur' => '1' ]));

        $quote->addItem($item);
    }

    protected fonction 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 prix and original custom prix to 0 so the gift line is free.
  • Si vous have Magento MSI (Multi Source Inventory), you'll want to adapt stock checks to MSI APIs. The exemple uses StockRegistryInterface as a single-source exemple 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 fonction __construct(GiftManager $giftManager)
    {
        $this->giftManager = $giftManager;
    }

    public fonction execute(Observer $observateur)
    {
        $quote = $observateur->getEvent()->getCart()->getQuote();
        $this->giftManager->processQuote($quote);
        // quote sera 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 expéditions
  • Create reservations on commande 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

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

  • Use the extension public API if it exposes a méthode to check whether the product is forced in stock; many extensions expose a classe helper or a config path.
  • If the extension stores forced status in attribut produit or a separate table, check that source before rejecting a gift due to zero inventaire.
  • Do not hard-depend on tiers 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. Si vous 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 composant for the gift SKU au lieu de a free text input. That prevents typos and ensures the SKU exists.
  • Allow mulconseille rules in a grid (name, active, gift product, condition type, valeur, priority). That requires a custom DB table and UI composants (ui_composant grid and form).
  • Show statistics (how many times gift granted, current reserved qty) so commerçants can monitor promo budget.

Example of product chooser champ (UI composant reference)

High level: create a ui_composant form that uses Magento\Catalog\Ui\Component\Product\Listing for selection. Il y a several exemples in the Magento core for product linkers used by related products and categories.

What happens on paiement & commande creation?

Parce que we add the gift as a quote item (with a special option), Magento will convert that into an commande item during paiement par défaut. A few things to keep in mind:

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

Order-level observateur exemple (optional)

<?php
namespace Vendor\Gwp\Observer;

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

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

Stock management bonnes pratiques for gifts

  • Don’t give away more gifts than you have — set sensible max_qty and optionally daily limits.
  • Consider reserving inventaire on add-to-cart for high-valeur 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 problèmes. Prefer showing a limited quantity or queueing clients au lieu de silently selling more than available.
  • Report on gift consumption: track gift commande items for reconciliation with entrepôt counts.

Avoiding conflicts with other promotion extensions

Promotion systems are often the place where modules collide. Voici practical conseils 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 observateurs and small services. Si vous must plugin a méthode, 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 observateur 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 observateur executes after theirs), but prefer explicit checks over fragile sequencing.
  • Si vous expect other modules to alter quote totals, evaluate your conditions after the cart save event, not before; paiement_cart_save_after is appropriate.
  • Expose events from your module (e.g., vendor_gwp_gift_added) so other modules can react au lieu de overriding it.

Testing checklist

Avant shipping to production, test these cases:

  • Gift is added when subtotal >= min and removed when subtotal drops below min.
  • Mulconseille adds/removes don't create duplicate gift lines.
  • Gift respects configured max_qty and gets converted to an commande 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 personnalisation extensions used on your site.

Deployment & performance considerations

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

Advanced: mulconseille rules & conditions engine

Si vous need mulconseille GWP rules with complex conditions (SKU-based, groupe de clients, cart attribute, code promos), consider these approchees:

  • Extend Magento's rule condition model (Magento\Rule module) to create a UI and condition tree. C'est the same approche 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. C'est simpler to implement but less visual in admin.
  • Use a priority system and allow mulconseille gifts with stacking rules if desired.

Full exemple: core fichiers recap

Quick recap of the clé fichiers we used (copying earlier code snippets into your module):

  • registration.php
  • etc/module.xml
  • etc/frontend/events.xml (observe paiement_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 comment correctif them

  • Duplicate gift lines: ensure your findGiftItem logic checks for the option (gwp_flag) and doesn’t rely on SKU only.
  • Gift disappears on paiement: make sure custom prix is set on the quote item (setCustomPrice), and that you’re not resetting prixs later in the pipeline (other observateurs/plugins might do so). Consider protecting your item with a plugin on the prix calculation if needed.
  • Stock mismatch on MSI: check salable qty not physical qty.
  • Compatibility problèmes with other promotion modules: add fonctionnalité 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 rapporting

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 commande 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 vous donne a robust starting point:

  1. Admin-configurable rule via system.xml (starter) or a proper rule engine for mulconseille 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.

Si vous want, I can:

  • Convert the gift product SKU champ into a product chooser UI composant exemple.
  • Show an MSI-ready version of GiftManager using Inventory API classes.
  • Provide a sample mise à jour to support mulconseille rules with a full grille d'administration 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 tiers modules. Si vous do integrate with an extension like Force Stock Status, prefer public helpers or events and provide fallbacks if it’s not installed.