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 third-party subscription? Good — this post walks you through a clear, pragmatic approach: design, database, module skeleton, points collection, tier assignment, price integration, admin UI and automated tier-based email 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 feature. You can map customers to groups and use cart price rules, but that’s clunky if you want dynamic tier movement, visible member dashboards, or tier-based automated emails. A custom solution gives you:

  • Precise control of how tiers are assigned (points, spend, orders).
  • Custom attributes for customers and a dedicated tiers table for flexible settings.
  • Direct integration with quote totals so discounts apply predictably.
  • An admin dashboard to view/manage customers per tier.
  • Automated email 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) + customer attribute loyalty_tier and loyalty_points.
  • Service: TierManager to calculate and assign tiers.
  • Points collector: update points on order events.
  • Discount integration: Quote total collector applies a tier discount percent.
  • Admin: CRUD for tiers and a customers-by-tier grid/dashboard.
  • Notifications: cron job to email promotions based on tiers.

Architecture technical — schema and attributes

Let’s start with the database and customer attributes. I prefer Magento’s declarative schema (db_schema.xml) for a table and a Data Patch to add customer 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">
    <column xsi:type="int" name="tier_id" padding="10" unsigned="true" nullable="false" identity="true" />
    <column xsi:type="varchar" name="code" nullable="false" length="64" />
    <column xsi:type="varchar" name="name" nullable="false" length="128" />
    <column xsi:type="decimal" name="min_points" nullable="false" scale="0" precision="10" default="0" />
    <column xsi:type="decimal" name="discount_percent" nullable="false" scale="2" precision="5" default="0.00" />
    <column xsi:type="int" name="priority" nullable="false" default="0" comment="Higher = applied first" />
    <column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP" />
    <constraint xsi:type="primary" referenceId="PRIMARY">
        <column 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 customer attributes: loyalty_points (int) and loyalty_tier (varchar or int referencing tier code). Using a data patch:

// 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 $customerSetupFactory;

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

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

        $customerSetup = $this->customerSetupFactory->create(['setup' => $this->moduleDataSetup]);

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

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

        // make attributes available programmatically (for admin grid / forms if wanted)
        $loyaltyPoints = $customerSetup->getEavConfig()->getAttribute('customer', 'loyalty_points');
        $loyaltyPoints->setData('used_in_forms', ['adminhtml_customer']);
        $loyaltyPoints->save();

        $loyaltyTier = $customerSetup->getEavConfig()->getAttribute('customer', 'loyalty_tier');
        $loyaltyTier->setData('used_in_forms', ['adminhtml_customer']);
        $loyaltyTier->save();

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

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

That creates attributes available in admin customer edit (optional) and accessible in code via customer 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 function _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 function _construct()
    {
        $this->_init('vendor_loyalty_tier', 'tier_id');
    }
}

Collection class follows Magento conventions.

Assigning points and tiers — events vs cron

You can assign points in two ways:

  • Event-driven: on sales_order_place_after (or checkout_submit_all_after), read order, calculate points, update customer attribute and possibly re-evaluate tier immediately.
  • Cron: batch job nightly recalculation based on 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 observer example

// events.xml (global or frontend)
<event name="sales_order_place_after">
    <observer name="vendor_loyalty_on_order" 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 $customerRepository;
    private $tierManager;
    private $logger;

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

    public function execute(Observer $observer)
    {
        $order = $observer->getEvent()->getOrder();
        $customerId = $order->getCustomerId();
        if (!$customerId) {
            return; // guest
        }

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

            // Increment customer loyalty points and recalc tier
            $this->tierManager->addPointsToCustomer($customerId, $points);
        } catch (\Exception $e) {
            $this->logger->error('Loyalty order observer error: ' . $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 $customerRepository;

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

    public function addPointsToCustomer($customerId, $points)
    {
        $customer = $this->customerRepository->getById($customerId);
        $existing = (int)$customer->getCustomAttribute('loyalty_points')?->getValue() ?: 0;
        $new = $existing + $points;

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

        $this->customerRepository->save($customer);
    }

    public function determineTierByPoints($points)
    {
        $collection = $this->tierCollectionFactory->create();
        // order 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 orders in a date window.

Integration with Magento pricing: 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 customer attribute. This can work if you extend conditions to allow custom customer attributes. It’s more work to integrate into rule conditions.
  2. Write a quote totals collector that applies a discount based on the customer's assigned tier. This is more deterministic and avoids editing core sales rules behavior.

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

Quote totals collector example

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

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

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

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

        try {
            $customer = $this->customerRepository->getById($customerId);
            $tierCode = $customer->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 value 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 function fetch(\Magento\Quote\Model\Quote\Address\Total $total)
    {
        $amount = $total->getData('loyalty_discount') ?: 0;
        return [
            'code' => $this->getCode(),
            'title' => __('Loyalty discount'),
            'value' => -$amount
        ];
    }
}

Notes:

  • Ensure you register the total in quote totals sequence properly and add the total to layout blocks so it shows in checkout summary and order totals.
  • To save the discount on order, listen to the quote-to-order conversion and transfer loyalty_discount_amount to the order entity (use extension attributes or custom columns on sales_order table).

Admin dashboard: visual management of customers by level

Admins need an easy way to view and manage customers by tier. The simplest acceptable approach is an admin grid (UI component) with filters for loyalty_tier and loyalty_points. You can also add actions like 'promote to Gold' or 'set points'.

Admin grid basics

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

// app/code/Vendor/Loyalty/etc/adminhtml/ui_component/vendor_loyalty_customer_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_customer_ds" />
    <columns name="vendor_loyalty_columns">
        <column name="entity_id" />
        <column name="email" />
        <column name="firstname" />
        <column name="lastname" />
        <column name="loyalty_tier"/ >
        <column name="loyalty_points" />
    </columns>
</listing>

Backend: create a data provider which joins customer entity with the EAV tables to expose loyalty_tier and loyalty_points. Alternatively, read from customer repository for each row (slower) or from customer_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 component. Implement controllers to handle bulk updates through the customer repository — remember to reindex if you write to flat tables.

Notifications automatic emails per level

Tier-specific promotions are major value: Gold members get exclusive discount codes or early access. You can send tier-targeted emails with a cron job (daily/weekly) or when a customer moves to a tier (event-based immediate email).

Email templates and sending example

// etc/email_templates.xml
<template id="vendor_loyalty_tier_promo" label="Loyalty Tier Promo" file="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 $customerCollectionFactory;
    private $transportBuilder;
    private $storeManager;

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

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

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

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

        return $this;
    }
}

You can also send a welcome email immediately when a customer moves to a new tier by dispatching a custom event like vendor_loyalty_tier_changed and listening to it to send an email.

Extra tips and edge cases

  • Guest checkouts: you can invite guests to create an account and claim points later with an order reference; better to require accounts for loyalty.
  • Refunds/cancellations: subtract points when an order is refunded; listen to creditmemo events and update loyalty_points.
  • Multiple stores: store-specific tiers? Keep tier definitions global, but promotions and emails per store.
  • Reporting: add admin reports with conversions by tier (avg order value, 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 customers in tight loops. For mass updates use resource model updates or batched SQL queries. Use cron jobs 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 implementation checklist

  1. Create module skeleton and register module.
  2. Declarative db_schema.xml for vendor_loyalty_tier and Data Patch for customer attributes.
  3. Models, resource models and collection for tiers.
  4. TierManager service to calculate and change tiers.
  5. Observer for order place to add points and call TierManager.
  6. Quote totals collector to apply tier discounts.
  7. Admin UI: menu entry, grid for tiers and customers by tier, forms to create/update tiers.
  8. Cron job + email templates to send tier promos and event to send email on tier change.
  9. Tests – unit tests around TierManager and integration tests 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 function __construct(ModuleDataSetupInterface $moduleDataSetup)
    {
        $this->moduleDataSetup = $moduleDataSetup;
    }

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

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

        $rows = [
            [
                '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 ($rows as $row) {
            $this->moduleDataSetup->getConnection()->insertOnDuplicate($table, $row);
        }

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

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

How to test quickly

1) Install the module and run setup:upgrade. 2) Place an order with a logged-in customer and check customer_loyalty_points attribute in admin. 3) Add tiers and confirm the TierManager assigns the correct tier. 4) Add items to cart and check checkout totals show a "Loyalty discount" line. 5) Trigger cron or use manual invocation to send tier promos to customers in Gold tier.

Why magefine.com readers should care

If you run a Magento 2 store or sell Magento extensions/hosting like magefine.com, a flexible loyalty program is a high-ROI feature. It increases repeat purchase rates, average order values, and customer lifetime value. Building it as a modular Magento 2 extension means you can sell it or offer it as a hosted feature to clients, integrate with your hosting plans and support contracts, and keep the logic future-proof.

Final thoughts and next steps

This guide gives you 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 customer area showing their points, next tier threshold and personalized offers
  • Add referral bonuses and non-purchase point accrual
  • Consider GDPR & consent when emailing customers — integrate subscription flag

If you 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 step (cart, checkout, emails, orders). Want that sample module scaffold next?