How to Implement a Custom Product Badge System (New, Sale, etc.) in Magento 2

Hey — if you want to add clear, consistent product badges like "New", "Sale", "Low stock" or "Trending" in Magento 2 without installing a third-party extension, this post walks you through a pragmatic, step-by-step approach. I’ll show you how to build a small custom module (PHP/XML), how to integrate it with Magento marketing rules for automation, how to avoid performance pitfalls, and how to extend it for dynamic badges. Think of this as chatting with a colleague while pairing on the code.

Why build custom badges (and why not just install an extension)

Extensions are great, but a custom solution gives you:

  • Precise control over markup, styles and placement.
  • No extra license or unpredictable updates.
  • The ability to integrate tightly with your business rules and indexers.

That said, a custom approach needs proper planning to avoid hurting performance or maintainability. Let’s do it right.

Overview — what we’ll build

We are going to build a compact Magento 2 module that:

  • Defines a product EAV attribute to store computed badge(s).
  • Provides a frontend block + template to render badges on product list and product view.
  • Includes a cron/indexer job to compute badges in bulk (avoids heavy per-request logic).
  • Includes hooks to update badges when stock or sales data changes.
  • Shows how to plug into marketing rules for automation (example using Magento Rule model).
  • Shows UX best practices for design, placement and color choices.
  • Explains how to add dynamic badges (low stock, trending) and keeps performance in mind.

High-level architecture

Keep the runtime path light. Don’t calculate badges per product during page rendering. Instead:

  1. Create a product attribute (varchar) called product_badges that stores comma-separated badge codes.
  2. Implement an Indexer/Cron that computes the badge list for products and writes it to product_badges.
  3. On the frontend, fetch the attribute as part of the product collection and render HTML/CSS based on that attribute.

Step-by-step: Create the module skeleton

Create a module named Magefine_Badges (replace Magefine with your vendor if needed). Minimal files:

  • app/code/Magefine/Badges/registration.php
  • app/code/Magefine/Badges/etc/module.xml
  • app/code/Magefine/Badges/etc/di.xml
  • app/code/Magefine/Badges/etc/cron.xml (optional)
  • app/code/Magefine/Badges/Setup/InstallData.php (or UpgradeData/Declarative schema to add attribute)
  • app/code/Magefine/Badges/Model/Indexer or Model/Cron/BadgesProcessor.php
  • app/code/Magefine/Badges/view/frontend/templates/badges.phtml
  • app/code/Magefine/Badges/view/frontend/layout/catalog_product_view.xml and catalog_category_view.xml

registration.php

 <?php
use Magento\Framework\Component\ComponentRegistrar;

ComponentRegistrar::register(
    ComponentRegistrar::MODULE,
    'Magefine_Badges',
    __DIR__
);

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="Magefine_Badges" setup_version="1.0.0"/>
</config>

Add the product attribute

We’ll add a simple varchar attribute that stores a comma-separated list of badge codes (e.g. new,sale,low_stock). This will allow simple queries via product collection. Using an attribute also ensures it’s indexable and available in product collections.

Declarative way (recommended on newer Magento versions)

Use InstallData or declarative schema with data patch. Example data patch:

 <?php
namespace Magefine\Badges\Setup\Patch\Data;

use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\Catalog\Setup\CategorySetupFactory;

class AddProductBadgesAttribute implements DataPatchInterface
{
    private $moduleDataSetup;
    private $categorySetupFactory;

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

    public function apply()
    {
        $this->moduleDataSetup->getConnection()->startSetup();
        $categorySetup = $this->categorySetupFactory->create(['setup' => $this->moduleDataSetup]);

        $categorySetup->addAttribute(
            \Magento\Catalog\Model\Product::ENTITY,
            'product_badges',
            [
                'type' => 'varchar',
                'label' => 'Product Badges',
                'input' => 'text',
                'required' => false,
                'visible' => false,
                'user_defined' => true,
                'global' => \Magento\Catalog\Model\ResourceModel\Eav\Attribute::SCOPE_GLOBAL,
                'visible_on_front' => false,
            ]
        );

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

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

After the patch, run bin/magento setup:upgrade. The attribute will exist and you can populate it programmatically from your indexer/cron.

Compute badges: indexer vs cron

Both indexer and cron are valid. Use an Indexer if you want the badge computation to run automatically in response to product changes and integrate with Magento's indexing mechanism. Use a cron job if your logic is heavy (calls external APIs, analyzing sales history) and you prefer scheduled batches.

Simple cron processor (example)

Create a cron that runs every 5–15 minutes and recalculates badges for changed products. Put this in etc/cron.xml and wire a class to compute badges.

 <?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/cron.xsd">
    <group id="default">
        <job name="magefine_badges_cron" instance="Magefine\Badges\Model\Cron\BadgesProcessor" method="execute">
            <schedule>*/15 * * * *</schedule>
        </job>
    </group>
</config>

BadgesProcessor (skeleton)

 <?php
namespace Magefine\Badges\Model\Cron;

use Psr\Log\LoggerInterface;
use Magento\Catalog\Model\ProductFactory;
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory;

class BadgesProcessor
{
    private $logger;
    private $productCollectionFactory;
    private $productRepository;

    public function __construct(
        LoggerInterface $logger,
        CollectionFactory $productCollectionFactory,
        \Magento\Catalog\Api\ProductRepositoryInterface $productRepository
    ) {
        $this->logger = $logger;
        $this->productCollectionFactory = $productCollectionFactory;
        $this->productRepository = $productRepository;
    }

    public function execute()
    {
        try {
            $collection = $this->productCollectionFactory->create();
            $collection->addAttributeToSelect(['created_at', 'price', 'special_price', 'special_from_date', 'special_to_date', 'product_badges']);
            $collection->setPageSize(200);

            $pages = $collection->getLastPageNumber();
            for ($page = 1; $page <= $pages; $page++) {
                $collection->setCurPage($page);
                $collection->load();
                foreach ($collection as $product) {
                    $badges = $this->determineBadges($product);
                    $product->setData('product_badges', implode(',', $badges));
                    $this->productRepository->save($product);
                }
                $collection->clear();
            }
        } catch (\Exception $e) {
            $this->logger->error($e->getMessage());
        }
    }

    private function determineBadges($product)
    {
        $badges = [];

        // Example: New badge: products created in last N days
        $isNew = $this->isNewProduct($product);
        if ($isNew) $badges[] = 'new';

        // Sale badge: check special price
        if ($this->isOnSale($product)) $badges[] = 'sale';

        // Low stock and others are added via dedicated checks
        if ($this->isLowStock($product)) $badges[] = 'low_stock';

        // Additional checks like trending would be added here

        return $badges;
    }

    private function isNewProduct($product)
    {
        // Simple example: created in last 14 days
        $created = strtotime($product->getCreatedAt());
        return (time() - $created) <= 14 * 24 * 3600;
    }

    private function isOnSale($product)
    {
        return (float)$product->getFinalPrice() < (float)$product->getPrice();
    }

    private function isLowStock($product)
    {
        // If you use MSI, prefer the inventory API. This is a naive example.
        try {
            $stock = $product->getExtensionAttributes()->getStockItem();
            if ($stock) {
                return ($stock->getQty() <= 5 && !$stock->getIsInStock());
            }
        } catch (\Exception $e) {
            return false;
        }
        return false;
    }
}

Note: For stock, on Magento 2.3+ with MSI, use Magento inventory APIs to get sources and salable quantities. Don’t query stock per product inside the loop if you can batch it.

Render badges on the frontend

We will fetch the product_badges attribute on product collections and render a small template that outputs badges markup. Add layout updates to include the badge template in product list and product page.

Layout updates

 <!-- view/frontend/layout/catalog_category_view.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceBlock name="category.products.list">
            <block class="Magefine\Badges\Block\Badges" name="magefine.badges.list" template="Magefine_Badges::badges.phtml"/ >
        </referenceBlock>
    </body>
</page>
 <!-- view/frontend/layout/catalog_product_view.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceBlock name="product.info.media">
            <block class="Magefine\Badges\Block\Badges" name="magefine.badges.view" template="Magefine_Badges::badges.phtml"/ >
        </referenceBlock>
    </body>
</page>

Block class

 <?php
namespace Magefine\Badges\Block;

use Magento\Framework\View\Element\Template;
use Magento\Catalog\Api\ProductRepositoryInterface;

class Badges extends Template
{
    public function getBadgesForProduct($product)
    {
        $value = $product->getData('product_badges');
        if (!$value) return [];
        return array_filter(array_map('trim', explode(',', $value)));
    }
}

Template (badges.phtml)

 <?php
/** @var $block \Magefine\Badges\Block\Badges */
$product = $block->getProduct() ?: (isset($product) ? $product : null);
if (!$product) return;
$badges = $block->getBadgesForProduct($product);
?>
<div class="magefine-product-badges">
<?php foreach ($badges as $badge): ?>
    <span class="badge badge--<?php echo $badge; ?>"><?php echo ucfirst(str_replace('_',' ', $badge)); ?></span>
<?php endforeach; ?>
</div>

Styling and UX best practices

Badges are visual signposts — they should be clear, consistent and unobtrusive. Here are some best practices:

  • Placement: top-left or top-right corner of the product image is conventional. Keep consistent placement across list and product views.
  • Size: ensure badges are readable on small screens. Use rem/em units and media queries.
  • Contrast: use accessible contrast. For example, white text on a colored background; keep compliance with WCAG AA for large text.
  • Colors: limit the palette. Typical choices: red for sale/urgent actions, green for new/benefit, orange for trending/attention. Avoid more than 3-4 badge colors to maintain focus.
  • Count: avoid stacking many badges. If more than 2 badges exist, choose a primary badge and offer a small “+n” indicator or tooltip for the rest.
  • Animations: subtle fades or scale are fine; avoid heavy animations that hurt performance.

Example CSS (minimal)

 .magefine-product-badges { position: absolute; top: 8px; left: 8px; display:flex; gap:6px; z-index: 10; }
.badge { padding: .25rem .5rem; border-radius: 4px; color: #fff; font-size: .75rem; font-weight: 600; display: inline-block; }
.badge--sale { background: #e53935; }
.badge--new { background: #4caf50; }
.badge--low_stock { background: #ff9800; }
.badge--trending { background: #2196f3; }

@media (max-width: 480px) {
  .badge { font-size: .6rem; padding: .2rem .4rem; }
}

Integration with Magento marketing rules (automation)

You asked specifically about integrating badges with Magento marketing rules so badges can be automated based on your marketing logic. Here are two practical ways:

  1. Reuse Magento Rule condition models (Magento\Rule) to evaluate custom badge rules.
  2. Create a mapping layer so admin users can create badge rules in a simple UI and the processor evaluates them and marks products.

Approach: Create a Badge Rule entity that uses Magento's Rule model

The Rule framework in Magento is used by Catalog Price Rules and Cart Price Rules. Reusing the Rule condition parser is powerful because it already supports attribute conditions and complex boolean logic.

Steps:

  • Create a new table magefine_badge_rule and model that extends Magento\Rule\Model\Rule.
  • In the admin you provide a form that saves conditions_serialized and a badge code (like sale or special_offer).
  • During badge indexer/cron run, load active badge rules, and evaluate them against product models using rule->getActions()->validate($product) or similar.

Example pseudo-code: evaluating a rule

 // $rule is instance of Magefine\Badge\Model\Rule (extends \Magento\Rule\Model\Rule)
$rule->load($id);
foreach ($productCollection as $product) {
    // Make sure the product has all attributes needed by the condition
    if ($rule->getConditions()->validate($product)) {
        // add the rule's badge code to product badges
    }
}

This lets you author expressive rules in the admin using the same condition UI as Catalog Price Rules. It’s a good integration path that provides marketing teams a non-developer way to control badges.

Performance considerations — how to avoid slowing down product pages

Key idea: move heavy work out of page render and into background processes or indexers.

Do this:

  • Precompute badge values and store them on the product entity (attribute). Then include the attribute on product collections using addAttributeToSelect. This makes display a simple attribute read during rendering.
  • If a badge relies on stock or sales data, compute it in batch and store the result (don’t compute with many SELECTs for each product on render).
  • Use a proper indexer if the badge depends on product data changes — indexers can be incremental.
  • Use caching: the badge HTML can be part of product list block and benefit from FPC (Full Page Cache). Avoid adding Vary headers that break caching unless necessary.
  • Limit attributes selected in product collections to the ones you need (price, image, product_badges) to reduce DB overhead.
  • If you need to show dynamic badges per customer (e.g., "Back in stock for you"), use ESI or AJAX fragments to keep FPC intact for the rest of the page.

Avoid this:

  • Don’t call expensive services, heavy queries, or PHP loops per-product during page render.
  • Don’t load stock data for every product by calling the inventory API per product in the template.

Example: using an indexer

Indexer advantages:

  • Supports partial reindex on product save.
  • Better integrated with Magento admin and CLI reindex commands.

Indexer implementation outline:

  1. Create an indexer class implementing Magento\Framework\Indexer\ActionInterface and TagScopeInterface.
  2. When products change, the indexer receives product ids to reindex and you run badge computation only for those ids.

Extending for dynamic badges: low stock, trending, limited time offers

Let’s look at common dynamic badges and how to implement them while staying performant.

Low stock

Don’t query stock per product on rendering. Options:

  • Batch stock query in cron/indexer using Inventory Reservation/Source API (MSI) and write a badge if salable quantity < threshold.
  • Use events: observe stock change events (cataloginventory_stock_item_save_after or MSI events) and enqueue product ids for reindexing.

Trending

Trending usually requires analyzing recent sales. Implement as a scheduled batch job:

  • Query sales_order_item grouped by product_id for last N days.
  • Define a threshold or ranking: top 10% or threshold of units sold.
  • Write badge to top products only. Keep historical data to avoid jumping badges too often (smooth using moving average).

Limited-time or campaign badges (marketing-driven)

Use the Badge Rule entity approach; marketing can set conditions like "category is Shoes AND price > 100" and a date range during which the rule is active.

Back-in-stock / Personalized badges

These are customer-specific (e.g. "Back in stock: you saved this item"). Render via a small AJAX call to a controller that checks the customer context and returns a tiny fragment. Keep AJAX minimal and cache responses per customer session if possible.

Triggering updates efficiently

Don’t reindex every product every time. Instead:

  • Observe domain events and add product ids to a queue (a simple DB table or Redis list) to be processed by the scheduled job.
  • Use the indexer to handle product save/delete events — it provides incremental updates.
  • For structural marketing changes (a badge rule change), you can schedule a full reindex; for small changes, do targeted reindex.

Admin UI ideas for marketing teams

To let non-developers manage badge logic, create an admin screen similar to Catalog Price Rules:

  • Fields: rule name, badge_code, conditions (using Magento Rule condition UI), active from/to, priority, status.
  • When rule saves, mark it active and optionally kick off a reindex for affected products.

Testing and QA checklist

  • Check that badges appear in category pages and product view consistently.
  • Test mobile and responsive behavior.
  • Test performance: load category with many products and measure DB queries before and after adding the badge attribute to collection.
  • Test rule authoring: create a rule in admin and verify matching products get the badge after reindex/cron runs.
  • Edge cases: product with many badges — ensure UI handles it (stacking, +n indicator).

Advanced ideas & optimizations

Store view / multi-website badges

If badges must differ by store view, make the attribute scope to website or store and compute per-scope in your batch. Be careful with indexer complexity.

Use Redis / Message queue for heavy jobs

If computing trending involves analyzing millions of rows, push product ids to a queue and process in workers rather than in a single cron run.

SVGs & sprites

Use SVG icons or CSS sprites for badge visuals to avoid many small image requests. Inline critical CSS for FCP improvements.

Accessibility

Ensure badges have appropriate aria-labels or hidden text for screen readers if the visual meaning is important. Don’t rely only on color.

Example: Full flow for "Sale" badge with marketing rule

  1. Create rule in admin: name "Sale badge - summer" badge_code="sale" conditions: special_price < price & category is "Sale"; active dates set.
  2. Cron reads all active badge rules, loads product collections including attributes required by conditions, and evaluates each rule for the products.
  3. When the rule matches a product, its badge code is appended to the product_badges attribute value.
  4. Frontend fetches product_badges as part of product collection and renders the sale badge.

Common pitfalls & how to avoid them

  • Updating product via repository in large loops can be slow — prefer using resource model save or bulk queries when safe.
  • Don’t mix per-product heavy logic in templates — keep template rendering simple.
  • Be careful with attribute indexing: if you store product_badges as a non-indexable attribute and you query on it, you may see poor performance.

Conclusion — cheat-sheet

Quick checklist to implement a robust, performant badge system:

  1. Create a product attribute to hold badge codes.
  2. Compute badges in background: use indexer or cron with queueing for incremental updates.
  3. Allow marketing to define rules via the Magento Rule framework for automation.
  4. On frontend, add attribute to product collection and render minimal HTML/CSS.
  5. Optimize: batch stock/sales queries, use cache, avoid per-product DB calls during page render.
  6. Design: keep placement consistent, use limited color set and ensure accessibility.

Further reading and references

  • Magento DevDocs: creating custom indexers and cron jobs.
  • Magento Rule framework docs and examples in CatalogPriceRule.
  • Magefine blog and docs for Magento 2 performance tips — build integrations that keep FPC intact.

If you want, I can generate a ready-to-install minimal module archive with the exact files we discussed (module skeleton, data patch, cron processor and templates) customized for your Magento version (2.3 / 2.4 / 2.5). I can also add MSI-aware stock checks or an example Badge Rule admin UI to manage rules from the backend. Which Magento version are you on?