How to Build a Custom Loyalty Tiers Program (Gold, Silver, Bronze) in Magento 2

Want a straightforward way to add Gold, Silver and Bronze loyalty tiers to your Magento 2 store without relying on a tiers subscription? Good — this post walks you through a clear, pragmatic approche: design, database, module skeleton, points collection, tier assignment, prix integration, admin UI and automated tier-based e-mail promotions. I’ll be casual and practical, like explaining to a teammate while we pair-program.

Why build a custom loyalty tiers program?

Out-of-the-box Magento doesn’t ship a loyalty-tiers fonctionnalité. Vous pouvez map clients to groups and use règle de prix du paniers, but that’s clunky if you want dynamic tier movement, visible member tableau de bords, or tier-based automated e-mails. A custom solution vous donne:

  • Precise control of how tiers are assigned (points, spend, commandes).
  • Custom attributes for clients and a dedicated tiers table for flexible settings.
  • Direct integration with quote totals so discounts apply predictably.
  • An admin tableau de bord to view/manage clients per tier.
  • Automated e-mail campaigns for Gold, Silver and Bronze members.

High-level design (short)

We’ll create a module Vendor/Loyalty with these responsibilities:

  • DB: loyalty_tier table (tier settings) + attribut client loyalty_tier and loyalty_points.
  • Service: TierManager to calculate and assign tiers.
  • Points collector: update points on commande events.
  • Discount integration: Quote total collector applies a tier discount percent.
  • Admin: CRUD for tiers and a clients-by-tier grid/tableau de bord.
  • Notifications: tâche cron to e-mail promotions basé sur tiers.

Architecture technical — schema and attributes

Let’s start with the database and attribut clients. I prefer Magento’s declarative schema (db_schema.xml) for a table and a Data Patch to add client EAV attributes. The table stores tier definition (name, code, min_spend/min_points, discount percent, priority).

Example loyalty_tier table

<table name="vendor_loyalty_tier" resource="default" engine="innodb" comment="Loyalty tiers">
    <colonne xsi:type="int" name="tier_id" padding="10" unsigned="true" nullable="false" identity="true" />
    <colonne xsi:type="varchar" name="code" nullable="false" length="64" />
    <colonne xsi:type="varchar" name="name" nullable="false" length="128" />
    <colonne xsi:type="decimal" name="min_points" nullable="false" scale="0" precision="10" default="0" />
    <colonne xsi:type="decimal" name="discount_percent" nullable="false" scale="2" precision="5" default="0.00" />
    <colonne xsi:type="int" name="priority" nullable="false" default="0" comment="Higher = applied first" />
    <colonne xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP" />
    <constraint xsi:type="primary" referenceId="PRIMARY">
        <colonne name="tier_id"/>
    </constraint>
</table>

Save the declarative schema as app/code/Vendor/Loyalty/etc/db_schema.xml. The table name vendor_loyalty_tier keeps it clear and non-conflicting.

Customer attributes: loyalty_points and loyalty_tier

We add two attribut clients: loyalty_points (int) and loyalty_tier (varchar or int referencing tier code). Using a data correctif:

// app/code/Vendor/Loyalty/Setup/Patch/Data/AddCustomerAttributes.php
namespace Vendor\Loyalty\Setup\Patch\Data;

use Magento\Customer\Setup\CustomerSetupFactory;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\PatchInterface;

class AddCustomerAttributes implements PatchInterface
{
    private $moduleDataSetup;
    private $clientSetupFactory;

    public fonction __construct(
        ModuleDataSetupInterface $moduleDataSetup,
        CustomerSetupFactory $clientSetupFactory
    ) {
        $this->moduleDataSetup = $moduleDataSetup;
        $this->clientSetupFactory = $clientSetupFactory;
    }

    public fonction apply()
    {
        $this->moduleDataSetup->getConnection()->startSetup();

        $clientSetup = $this->clientSetupFactory->create(['setup' => $this->moduleDataSetup]);

        $clientSetup->addAttribute(
            \Magento\Customer\Model\Customer::ENTITY,
            'loyalty_points',
            [
                'type' => 'int',
                'label' => 'Loyalty Points',
                'input' => 'text',
                'required' => false,
                'visible' => false,
                'utilisateur_defined' => true,
                'position' => 999,
            ]
        );

        $clientSetup->addAttribute(
            \Magento\Customer\Model\Customer::ENTITY,
            'loyalty_tier',
            [
                'type' => 'varchar',
                'label' => 'Loyalty Tier',
                'input' => 'text',
                'required' => false,
                'visible' => false,
                'utilisateur_defined' => true,
                'position' => 1000,
            ]
        );

        // make attributes available programmatically (for grille d'administration / forms if wanted)
        $loyaltyPoints = $clientSetup->getEavConfig()->getAttribute('client', 'loyalty_points');
        $loyaltyPoints->setData('used_in_forms', ['adminhtml_client']);
        $loyaltyPoints->save();

        $loyaltyTier = $clientSetup->getEavConfig()->getAttribute('client', 'loyalty_tier');
        $loyaltyTier->setData('used_in_forms', ['adminhtml_client']);
        $loyaltyTier->save();

        $this->moduleDataSetup->getConnection()->endSetup();
    }

    public static fonction getDependencies() { return []; }
    public fonction getAliases() { return []; }
}

That creates attributes available in admin client edit (optional) and accessible in code via client repository.

Module skeleton and models

Module registration and module.xml are straightforward.

// app/code/Vendor/Loyalty/registration.php
\Magento\Framework\Component\ComponentRegistrar::register(
    \Magento\Framework\Component\ComponentRegistrar::MODULE,
    'Vendor_Loyalty',
    __DIR__
);

// app/code/Vendor/Loyalty/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_Loyalty" setup_version="1.0.0" />
</config>

Model and resource model for our tiers table:

// Vendor/Loyalty/Model/Tier.php
namespace Vendor\Loyalty\Model;

use Magento\Framework\Model\AbstractModel;

class Tier extends AbstractModel
{
    protected fonction _construct()
    {
        $this->_init(\Vendor\Loyalty\Model\ResourceModel\Tier::class);
    }
}

// Vendor/Loyalty/Model/ResourceModel/Tier.php
namespace Vendor\Loyalty\Model\ResourceModel;

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

class Tier extends AbstractDb
{
    protected fonction _construct()
    {
        $this->_init('vendor_loyalty_tier', 'tier_id');
    }
}

Collection class follows Magento conventions.

Assigning points and tiers — events vs cron

Vous pouvez assign points in two ways:

  • Event-driven: on sales_commande_place_after (or paiement_submit_all_after), read commande, calculate points, update attribut client and possibly re-evaluate tier immediately.
  • Cron: batch job nightly recalculation basé sur purchases in the previous 12 months or lifetime spend.

I usually do both: event-driven updates for immediate feedback and a daily cron to ensure consistency.

Order observateur exemple

// events.xml (global or frontend)
<event name="sales_commande_place_after">
    <observateur name="vendor_loyalty_on_commande" instance="Vendor\Loyalty\Observer\OrderPlace" />
</event>

// Vendor/Loyalty/Observer/OrderPlace.php
namespace Vendor\Loyalty\Observer;

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

class OrderPlace implements ObserverInterface
{
    private $clientRepository;
    private $tierManager;
    private $logger;

    public fonction __construct(
        \Magento\Customer\Api\CustomerRepositoryInterface $clientRepository,
        \Vendor\Loyalty\Model\TierManager $tierManager,
        \Psr\Log\LoggerInterface $logger
    ) {
        $this->clientRepository = $clientRepository;
        $this->tierManager = $tierManager;
        $this->logger = $logger;
    }

    public fonction execute(Observer $observateur)
    {
        $commande = $observateur->getEvent()->getOrder();
        $clientId = $commande->getCustomerId();
        if (!$clientId) {
            return; // guest
        }

        try {
            // Example: 1 point per $1 spent (after discounts) — make this configurable
            $points = (int)floor($commande->getGrandTotal());

            // Increment client loyalty points and recalc tier
            $this->tierManager->addPointsToCustomer($clientId, $points);
        } catch (\Exception $e) {
            $this->logger->erreur('Loyalty commande observateur erreur: ' . $e->getMessage());
        }
    }
}

The TierManager is the heart of the logic.

TierManager highlights

// Vendor/Loyalty/Model/TierManager.php
namespace Vendor\Loyalty\Model;

use Vendor\Loyalty\Model\ResourceModel\Tier as TierResource;
use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Customer\Model\Data\Customer;

class TierManager
{
    private $tierCollectionFactory;
    private $clientRepository;

    public fonction __construct(
        \Vendor\Loyalty\Model\ResourceModel\Tier\CollectionFactory $tierCollectionFactory,
        CustomerRepositoryInterface $clientRepository
    ) {
        $this->tierCollectionFactory = $tierCollectionFactory;
        $this->clientRepository = $clientRepository;
    }

    public fonction addPointsToCustomer($clientId, $points)
    {
        $client = $this->clientRepository->getById($clientId);
        $existing = (int)$client->getCustomAttribute('loyalty_points')?->getValue() ?: 0;
        $new = $existing + $points;

        $client->setCustomAttribute('loyalty_points', $new);
        // decide tier
        $tierCode = $this->determineTierByPoints($new);
        $client->setCustomAttribute('loyalty_tier', $tierCode);

        $this->clientRepository->save($client);
    }

    public fonction determineTierByPoints($points)
    {
        $collection = $this->tierCollectionFactory->create();
        // commande by priority desc or min_points desc so the highest tier that matches is picked
        $collection->setOrder('min_points', 'DESC');

        foreach ($collection as $tier) {
            if ($points >= (int)$tier->getData('min_points')) {
                return $tier->getData('code');
            }
        }

        return 'bronze'; // default fallback
    }
}

Design note: min_points logic is simple and works for lifetime points; if you want rolling 12-month points, add a purchases table or compute from commandes in a date window.

Integration with Magento tarification: applying tier discounts

Two paths to apply discounts for tiers:

  1. Programmatically create Sales Rules (Cart Price Rules) per tier and try to use conditions to match attribut client. This can work if you extend conditions to allow custom attribut clients. It’s more work to integrate into rule conditions.
  2. Write a quote totals collector that applies a discount basé sur the client's assigned tier. C'est more deterministic and avoids editing core sales rules behavior.

I’ll show the totals collector approche because it’s reliable and clear.

Quote totals collector exemple

// di.xml — register the total
<type name="Magento\Quote\Model\Quote\Address\Total\Collector">
    <arguments>
        <argument name="collectors" xsi:type="tableau" />
    </arguments>
</type>

// Vendor/Loyalty/etc/di.xml (simplified)
<type name="Magento\Quote\Model\Quote\Address\Total\Collector">
    <arguments>
        <argument name="collectors" xsi:type="tableau">
            <item name="vendor_loyalty_discount" xsi:type="objet">Vendor\Loyalty\Model\Quote\Totals\LoyaltyDiscountclientRepository = $clientRepository;
        $this->tierFactory = $tierFactory;
        $this->setCode('loyalty_discount');
    }

    public fonction collect(\Magento\Quote\Model\Quote $quote, \Magento\Quote\Model\Quote\Address\Total $total)
    {
        parent::collect($quote, $total);

        $clientId = $quote->getCustomerId();
        if (!$clientId) {
            return $this;
        }

        try {
            $client = $this->clientRepository->getById($clientId);
            $tierCode = $client->getCustomAttribute('loyalty_tier')?->getValue();
            if (!$tierCode) {
                return $this;
            }

            // Load tier details
            $collection = $this->tierFactory->create();
            $collection->addFieldToFilter('code', $tierCode);
            $tier = $collection->getFirstItem();
            $discountPercent = (float)$tier->getData('discount_percent');

            if ($discountPercent <= 0) {
                return $this;
            }

            $subtotal = $total->getSubtotal();
            $discountAmount = round($subtotal * ($discountPercent / 100), 2);

            // Apply as negative valeur to total
            $total->addTotalAmount($this->getCode(), -$discountAmount);
            $total->addBaseTotalAmount($this->getCode(), -$discountAmount);

            // Set separate line in quote for visibility
            $total->setSubtotalWithDiscount($total->getSubtotalWithDiscount() - $discountAmount);
            $quote->setData('loyalty_discount_amount', $discountAmount);
        } catch (\Exception $e) {
            // log
        }

        return $this;
    }

    public fonction fetch(\Magento\Quote\Model\Quote\Address\Total $total)
    {
        $amount = $total->getData('loyalty_discount') ?: 0;
        return [
            'code' => $this->getCode(),
            'title' => __('Loyalty discount'),
            'valeur' => -$amount
        ];
    }
}

Notes:

  • Ensure you register the total in quote totals sequence properly and add the total to layout blocks so it shows in paiement résumé and commande totals.
  • To save the discount on commande, listen to the quote-to-commande conversion and transfer loyalty_discount_amount to the commande entity (use extension attributes or custom colonnes on sales_commande table).

Admin tableau de bord: visual management of clients by level

Admins need an easy way to view and manage clients by tier. The simplest acceptable approche is an grille d'administration (UI composant) with filtres for loyalty_tier and loyalty_points. Vous pouvez also add actions like 'promote to Gold' or 'set points'.

Admin grid basics

Create an admin route and a UI composant listing clients joined with their EAV loyalty attributes. Optionally create a materialised view in your module to select clients with attributes — but it’s often sufficient to use the Customer grid and add colonnes via a plugin. Here’s a straightforward UI composant xml snippet for a custom grid view:

// app/code/Vendor/Loyalty/etc/adminhtml/ui_composant/vendor_loyalty_client_grid.xml
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
    <dataSource name="vendor_loyalty_client_ds" />
    <colonnes name="vendor_loyalty_colonnes">
        <colonne name="entity_id" />
        <colonne name="e-mail" />
        <colonne name="firstname" />
        <colonne name="lastname" />
        <colonne name="loyalty_tier"/ >
        <colonne name="loyalty_points" />
    </colonnes>
</listing>

Backend: create a data provider which joins client entity with the EAV tables to expose loyalty_tier and loyalty_points. Alternatively, read from client repository for each ligne (slower) or from client_grid_flat if you copy those attributes into the grid table on save.

Admin actions

Provide quick actions in the grid to set a tier or add/subtract points using massAction in UI composant. Implement contrôleurs to handle bulk updates through the client repository — remember to réindexer if you write to flat tables.

Notifications automatic e-mails per level

Tier-specific promotions are major valeur: Gold members get exclusive discount codes or early access. Vous pouvez send tier-targeted e-mails with a tâche cron (daily/weekly) or when a client moves to a tier (event-based immediate e-mail).

Email templates and sending exemple

// etc/e-mail_templates.xml
<template id="vendor_loyalty_tier_promo" label="Loyalty Tier Promo" fichier="loyalty_tier_promo.html" type="html" module="Vendor_Loyalty" />

// Vendor/Loyalty/Cron/SendTierPromos.php
namespace Vendor\Loyalty\Cron;

use Magento\Framework\Mail\Template\TransportBuilder;
use Magento\Store\Model\StoreManagerInterface;

class SendTierPromos
{
    private $tierCollectionFactory;
    private $clientCollectionFactory;
    private $transportBuilder;
    private $storeManager;

    public fonction __construct(
        \Vendor\Loyalty\Model\ResourceModel\Tier\CollectionFactory $tierCollectionFactory,
        \Magento\Customer\Model\ResourceModel\Customer\CollectionFactory $clientCollectionFactory,
        TransportBuilder $transportBuilder,
        StoreManagerInterface $storeManager
    ) {
        $this->tierCollectionFactory = $tierCollectionFactory;
        $this->clientCollectionFactory = $clientCollectionFactory;
        $this->transportBuilder = $transportBuilder;
        $this->storeManager = $storeManager;
    }

    public fonction execute()
    {
        $tiers = $this->tierCollectionFactory->create();
        foreach ($tiers as $tier) {
            // exemple: only send to Gold weekly
            if ($tier->getCode() === 'gold') {
                $clients = $this->clientCollectionFactory->create();
                $clients->addAttributeToSelect('*');
                $clients->addFieldToFilter('loyalty_tier', $tier->getCode());

                foreach ($clients as $client) {
                    try {
                        $transport = $this->transportBuilder
                            ->setTemplateIdentifier('vendor_loyalty_tier_promo')
                            ->setTemplateOptions([
                                'area' => \Magento\Framework\App\Area::AREA_FRONTEND,
                                'store' => $this->storeManager->getDefaultStoreView()->getId()
                            ])
                            ->setTemplateVars(['client' => $client, 'tier' => $tier])
                            ->setFrom('general')
                            ->addTo($client->getEmail(), $client->getName())
                            ->getTransport();

                        $transport->sendMessage();
                    } catch (\Exception $e) {
                        // log erreur but continue
                    }
                }
            }
        }

        return $this;
    }
}

Vous pouvez also send a welcome e-mail immediately when a client moves to a new tier by discorrectifing a custom event like vendor_loyalty_tier_changed and listening to it to send an e-mail.

Extra conseils and edge cases

  • Guest paiements: you can invite guests to create an account and claim points later with an commande reference; better to require accounts for loyalty.
  • Refunds/cancellations: subtract points when an commande is refunded; listen to creditmemo events and update loyalty_points.
  • Mulconseille stores: store-specific tiers? Keep tier definitions global, but promotions and e-mails per store.
  • Reporting: add admin rapports with conversions by tier (avg commande valeur, repeat purchase rate).
  • Data cleanup: watch for stale data — a nightly cron to recompute tiers from points safeguards corruption.

Security and performance considerations

Customer repository saves hit EAV tables — avoid saving clients in tight loops. For mass updates use resource model updates or batched SQL queries. Use tâches cron for heavy recalculations and keep event-based writes minimal (just accumulate an event log and process in batches when necessary).

Putting it all together — quick implémentation checklist

  1. Create module skeleton and register module.
  2. Declarative db_schema.xml for vendor_loyalty_tier and Data Patch for attribut clients.
  3. Models, resource models and collection for tiers.
  4. TierManager service to calculate and change tiers.
  5. Observer for commande place to add points and call TierManager.
  6. Quote totals collector to apply tier discounts.
  7. Admin UI: menu entry, grid for tiers and clients by tier, forms to create/update tiers.
  8. Cron job + e-mail templates to send tier promos and event to send e-mail on tier change.
  9. Tests – test unitaires around TierManager and test d'intégrations for totals collector.

Example: create three default tiers via Data Patch

// app/code/Vendor/Loyalty/Setup/Patch/Data/CreateDefaultTiers.php
namespace Vendor\Loyalty\Setup\Patch\Data;

use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\PatchInterface;

class CreateDefaultTiers implements PatchInterface
{
    private $moduleDataSetup;

    public fonction __construct(ModuleDataSetupInterface $moduleDataSetup)
    {
        $this->moduleDataSetup = $moduleDataSetup;
    }

    public fonction apply()
    {
        $this->moduleDataSetup->getConnection()->startSetup();

        $table = $this->moduleDataSetup->getTable('vendor_loyalty_tier');

        $lignes = [
            [
                'code' => 'gold',
                'name' => 'Gold',
                'min_points' => 2000,
                'discount_percent' => '10.00',
                'priority' => 3
            ],
            [
                'code' => 'silver',
                'name' => 'Silver',
                'min_points' => 1000,
                'discount_percent' => '5.00',
                'priority' => 2
            ],
            [
                'code' => 'bronze',
                'name' => 'Bronze',
                'min_points' => 0,
                'discount_percent' => '0.00',
                'priority' => 1
            ]
        ];

        foreach ($lignes as $ligne) {
            $this->moduleDataSetup->getConnection()->insertOnDuplicate($table, $ligne);
        }

        $this->moduleDataSetup->getConnection()->endSetup();
    }

    public static fonction getDependencies() { return []; }
    public fonction getAliases() { return []; }
}

How to test quickly

1) Install the module and run setup:mise à jour. 2) Place an commande with a logged-in client and check client_loyalty_points attribute in admin. 3) Add tiers and confirm the TierManager assigns the correct tier. 4) Add items to cart and check paiement totals show a "Loyalty discount" line. 5) Trigger cron or use manual invocation to send tier promos to clients in Gold tier.

Why magefine.com readers should care

Si vous run a Magento 2 store or sell Magento extensions/hosting like magefine.com, a flexible loyalty program is a high-ROI fonctionnalité. It increases repeat purchase rates, average commande valeurs, and client lifetime valeur. Building it as a modular Magento 2 extension means you can sell it or offer it as a hosted fonctionnalité to clients, integrate with your hosting plans and support contracts, and keep the logic future-proof.

Final thoughts and next étapes

Ce guide vous donne a pragmatic architecture and code snippets to get a robust loyalty tiers system up and running. From here:

  • Add analytics: per-tier retention and revenue
  • Build a frontend client area showing their points, next tier threshold and personalized offers
  • Add referral bonuses and non-purchase point accrual
  • Consider GDPR & consent when e-mailing clients — integrate subscription flag

Si vous want, I can help you convert this into a downloadable module skeleton for magefine.com or walk through implementing the quote totals collector so the discount is fully visible in every étape (cart, paiement, e-mails, commandes). Want that sample module scaffold next?