How to Implement a Custom 'Price Match Guarantee' Module in Magento 2
In this post I’ll walk you through implementing a custom Price Match Guarantee module in Magento 2. I’ll keep the language relaxed — think of me explaining to a colleague who’s comfortable with PHP and Magento basics but hasn’t built a full custom module yet. We’ll cover architecture, database design, models/resource models, admin workflows, validation rules, front-end customer UX, and conversion strategies like conditional coupons and alternative product suggestions.
Why build a Price Match Guarantee module?
Price match can be a powerful trust and conversion lever. Customers often shop around; offering a clear, easy way to submit a price-match request reduces friction. With a custom module you control: validation rules, admin approval workflow, notification emails, reporting, and post-decision marketing (coupons / upsell suggestions).
High-level architecture
Keep a clear separation of concerns:
- Database: a dedicated table to store requests and their states.
- Model / Resource Model: standard Magento models to interact with that table.
- Admin UI: grid & edit forms for staff to validate and process requests (approve/reject).
- Frontend: a product-page form to submit requests and a “My Price Matches” list in the customer account.
- Service layer: validation and business logic (time windows, competitor checks, difference threshold).
- Marketing hooks: generate coupons or suggest alternative products based on decision.
Database design (declarative schema)
We’ll use Magento 2’s declarative schema (db_schema.xml) to create a table price_match_request. Main columns:
- request_id (primary)
- customer_id
- product_id
- sku
- our_price (decimal)
- competitor_url
- competitor_price (decimal)
- status (enum: pending, approved, rejected, expired)
- requested_at (timestamp)
- decision_at (timestamp, nullable)
- admin_note (text)
- coupon_code (varchar, nullable)
- store_id
- expires_at (timestamp for validity window)
Example db_schema.xml (simplified):
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="magefine_price_match_request" resource="default" engine="innodb" comment="Magefine Price Match Requests">
<column xsi:type="int" name="request_id" nullable="false" unsigned="true" identity="true" />
<column xsi:type="int" name="customer_id" nullable="true" unsigned="true" />
<column xsi:type="varchar" name="email" nullable="false" length="255" />
<column xsi:type="int" name="product_id" nullable="false" unsigned="true" />
<column xsi:type="varchar" name="sku" nullable="false" length="64" />
<column xsi:type="decimal" name="our_price" nullable="false" scale="4" precision="12" />
<column xsi:type="varchar" name="competitor_url" nullable="false" length="1024" />
<column xsi:type="decimal" name="competitor_price" nullable="false" scale="4" precision="12" />
<column xsi:type="varchar" name="status" nullable="false" length="32" default="pending" />
<column xsi:type="timestamp" name="requested_at" nullable="false" default="CURRENT_TIMESTAMP" />
<column xsi:type="timestamp" name="decision_at" nullable="true" />
<column xsi:type="text" name="admin_note" nullable="true" />
<column xsi:type="varchar" name="coupon_code" nullable="true" length="64" />
<column xsi:type="smallint" name="store_id" unsigned="true" nullable="false" default="0" />
<column xsi:type="timestamp" name="expires_at" nullable="true" />
<constraint referenceId="PRIMARY" xsi:type="primary" >
<column name="request_id" />
</constraint>
</table>
</schema>
Notes:
- Use a long competitor_url length to handle long URLs.
- Status can be an enum-like varchar; you can also add a lookup table for statuses.
- expires_at helps implement a validity window.
Module basics: registration and module.xml
Create the usual module files:
// registration.php
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Magefine_PriceMatch', __DIR__);
// 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="Magefine_PriceMatch" setup_version="1.0.0" />
</config>
Model & Resource Model
Use a typical pattern: Model PriceMatch, ResourceModel PriceMatch, Collection. Keep business logic in a service class rather than the model where appropriate.
// Model/PriceMatch.php
namespace Magefine\PriceMatch\Model;
use Magento\Framework\Model\AbstractModel;
class PriceMatch extends AbstractModel
{
protected function _construct()
{
$this->_init(\Magefine\PriceMatch\Model\ResourceModel\PriceMatch::class);
}
}
// Model/ResourceModel/PriceMatch.php
namespace Magefine\PriceMatch\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
class PriceMatch extends AbstractDb
{
protected function _construct()
{
$this->_init('magefine_price_match_request', 'request_id');
}
}
// Model/ResourceModel/Collection.php
namespace Magefine\PriceMatch\Model\ResourceModel\PriceMatch;
use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
class Collection extends AbstractCollection
{
protected function _construct()
{
$this->_init(\Magefine\PriceMatch\Model\PriceMatch::class, \Magefine\PriceMatch\Model\ResourceModel\PriceMatch::class);
}
}
Repository and service layer
Create a repository and a service class to validate requests. This is where you implement eligibility checks and thresholds. Example interface snippet:
// Api/PriceMatchRepositoryInterface.php
namespace Magefine\PriceMatch\Api;
interface PriceMatchRepositoryInterface
{
public function save(\Magefine\PriceMatch\Model\PriceMatch $request);
public function getById($id);
public function deleteById($id);
}
Service validation example (pseudo):
// Model/Validator.php
namespace Magefine\PriceMatch\Model;
class Validator
{
protected $storeManager;
protected $allowedDomains = ['example-competitor.com', 'another.com']; // config-driven
public function __construct(\Magento\Store\Model\StoreManagerInterface $sm)
{
$this->storeManager = $sm;
}
public function validate(array $data)
{
// 1) Check validity window: requested_at <= now <= requested_at + X days
// 2) Check competitor URL domain is allowed / or not same domain as store
// 3) Check competitor_price less than our_price by threshold percentage
// 4) Optionally verify competitor page structure (simple remote check)
// Return array with success boolean and messages
return [
'is_valid' => true,
'messages' => []
];
}
}
Advanced validation rules
Let’s detail the business rules you might implement. Keep them modular and config-driven so marketing can tweak thresholds without code deploys.
- Validity window: requests must be submitted within N days of purchase or within M days from now. Implement expires_at on the DB side when persisting.
- Competitor eligibility: disallow competitor URLs from marketplaces you don’t match (e.g., marketplaces, private resellers), or ensure the domain is in a configured whitelist/blacklist.
- Price difference threshold: only accept matches where competitor_price <= our_price * (1 - threshold). Threshold could be a percent like 2% to prevent tiny price differences triggering matches.
- Duplicate/abuse checks: rate-limit per customer or per IP, disallow repeated submissions on same SKU from same customer within X days.
- Automated basic verification: fetch competitor page metadata (title, price microdata) to ensure the URL points to the same product (optional and fragile).
Example function to compute eligibility:
public function isEligible($ourPrice, $competitorPrice, $thresholdPercent)
{
if ($competitorPrice >= $ourPrice) {
return false; // not cheaper
}
$diff = ($ourPrice - $competitorPrice) / $ourPrice * 100; // percent
return $diff >= $thresholdPercent;
}
Admin workflow
Admin users need a grid to view requests and an edit page to accept/reject, add admin notes, and trigger notifications or coupon creation. Use UI components (uiComponent grids, forms) so your admin experience matches the rest of Magento.
Key admin actions:
- Manual approve: sets status=approved, sets decision_at, optionally create coupon and set coupon_code on request, send approved email.
- Reject: sets status=rejected, decision_at, store admin_note, optionally generate automatic coupon to retain customer, send rejected email.
- Auto-expire: a cron job to mark expired requests (status=expired) when expires_at < now, and send expiry email if you want.
Example controller action for Approve (simplified):
// Controller/Adminhtml/Request/Approve.php
namespace Magefine\PriceMatch\Controller\Adminhtml\Request;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
class Approve extends Action
{
protected $repository;
protected $couponService;
protected $transportBuilder;
public function __construct(Context $context, $repository, $couponService, $transportBuilder)
{
parent::__construct($context);
$this->repository = $repository;
$this->couponService = $couponService;
$this->transportBuilder = $transportBuilder;
}
public function execute()
{
$id = $this->getRequest()->getParam('id');
$request = $this->repository->getById($id);
$request->setData('status', 'approved');
$request->setData('decision_at', date('Y-m-d H:i:s'));
// Optionally create coupon
$couponCode = $this->couponService->createPriceMatchCoupon($request);
$request->setData('coupon_code', $couponCode);
$this->repository->save($request);
// Send notification email
// ... use transportBuilder with a template
$this->_redirect('*/*/index');
}
}
Sending emails on decisions
Use Magento's TransportBuilder for transactional emails. Prepare templates for pending confirmation, approved, rejected, and expired. Example sending snippet:
$this->transportBuilder->setTemplateIdentifier('magefine_price_match_approved')
->setTemplateOptions([
'area' => \Magento\Framework\App\Area::AREA_FRONTEND,
'store' => $request->getStoreId()
])
->setTemplateVars([
'request' => $request,
'coupon' => $couponCode
])
->setFrom('general')
->addTo($request->getEmail())
->getTransport()
->sendMessage();
Frontend: product page form
Make it frictionless. A simple form on the product detail page should prefill product SKU and current price and allow pasting the competitor URL and price. If the customer is logged in, prefill name/email. Use AJAX to submit so we can provide immediate client-side validation feedback.
Layout block inclusion example:
// layout/catalog_product_view.xml
<block class="Magefine\PriceMatch\Block\Form" name="magefine.price.match.form" template="Magefine_PriceMatch::form.phtml" after="product.info.additional" />
Simple form (form.phtml):
<form id="price-match-form" action="<?= $block->getUrl('pricematch/index/submit') ?>" method="post" data-mage-init='{"validation":{}}'>
<input type="hidden" name="sku" value="<?= $block->getProduct()->getSku() ?>" />
<input type="hidden" name="product_id" value="<?= $block->getProduct()->getId() ?>" />
<label>Competitor URL</label>
<input name="competitor_url" type="url" required />
<label>Competitor price</label>
<input name="competitor_price" type="number" step="0.01" required />
<button type="submit">Request price match</button>
</form>
<script>
require(['jquery'], function($){
$('#price-match-form').on('submit', function(e){
e.preventDefault();
var form = $(this);
$.ajax({
url: form.attr('action'),
method: 'POST',
data: form.serialize(),
success: function(res){
// show messages to user
}
});
});
});
</script>
Controller for processing frontend submissions should call Validator, persist the request with status pending, set expires_at according to config, send an acknowledgement email.
Customer dashboard: "My Price Match Requests"
Add a new section under the customer account for transparency. Customers should be able to see status, submitted URL, competitor price, admin note, coupon if approved, and actions like "view" or "cancel" while pending.
Quick layout snippet for account.xml:
// view/frontend/layout/customer_account.xml
<referenceBlock name="customer_account_navigation">
<block class="Magento\Customer\Block\Account\SortLink" name="customer-account-navigation-pricematch" after="-
">
<arguments>
<argument name="path" xsi:type="string">pricematch/customer/index</argument>
<argument name="label" xsi:type="string">My Price Match Requests</argument>
</arguments>
</block>
</referenceBlock>
Automations and cron jobs
Automate expiration and light verification tasks via cron:
- Expire old requests (set status=expired) and notify customer.
- Optional remote check job that visits competitor URLs for a sanity check to help the admin when they open the request. Be careful with scraping; respect robots and timeouts to avoid being blocked.
- Generate periodic reports for marketing/sales.
Creating coupons programmatically
If you decide to issue coupons (for approval or to retain customers after rejection), you can create a one-time coupon via SalesRule. Keep coupon properties conservative: single use per customer or per email, limited validity, and limited usage per coupon.
// Model/CouponService.php (simplified)
public function createPriceMatchCoupon($request)
{
// Use \Magento\SalesRule\Model\RuleFactory and \Magento\SalesRule\Model\CouponFactory
$rule = $this->ruleFactory->create();
$rule->setName('Price match - ' . $request->getSku())
->setIsActive(1)
->setWebsiteIds([$request->getStoreId()])
->setCustomerGroupIds([0,1,2])
->setFromDate(date('Y-m-d'))
->setToDate(date('Y-m-d', strtotime('+14 days')))
->setCouponType(2) // specific coupon
->setSimpleAction('by_percent')
->setDiscountAmount(10)
->setUsesPerCustomer(1)
->save();
// Create actual coupon code
$coupon = $this->couponFactory->create();
$coupon->setRuleId($rule->getId())
->setCode('PM-' . strtoupper(uniqid()))
->setUsageLimit(1)
->save();
return $coupon->getCode();
}
Important: carefully manage rule/coupon creation to avoid bloat. Consider generating coupons only when needed and reusing templates or storing coupon metadata in your table.
Upsell and conversion strategies
When a price match is rejected, or even after approval, there are multiple strategies to improve conversion and lifetime value:
- Conditional coupon on rejection: give a small coupon (5-10%) to keep the customer engaged. Generate it automatically and send it in the rejection email with a friendly message.
- Alternative product suggestions: when rejecting due to eligibility or tiny difference, provide similar products at comparable prices. Use catalog search to find products within +/- X% price and surface them in the rejection email and on the success page.
- Smart expiration offers: if a request expires, trigger a retention coupon with a limited validity to encourage a purchase.
- Cross-sell on approval: include accessories or warranties in the approval email with a small discount to increase average order value.
- Track LTV: tag the customer or create a note in CRM when a price match is approved so marketing can retarget high-intent shoppers.
Example logic to find alternatives:
// in a service class
public function findAlternatives($product, $percentRange = 10, $limit = 5)
{
$price = $product->getPrice();
$min = $price * (1 - $percentRange/100);
$max = $price * (1 + $percentRange/100);
// Use product collection filter by price, exclude current product, order by relevance
$collection = $this->productCollectionFactory->create()
->addAttributeToSelect(['name','price','small_image'])
->addFieldToFilter('price', ['from' => $min, 'to' => $max])
->addFieldToFilter('entity_id', ['neq' => $product->getId()])
->setPageSize($limit);
return $collection;
}
Security considerations
Price match involves user-submitted URLs and numeric prices — sanitize everything. Prevent open-redirects and SSRF by validating domains (do not allow internal IPs or private network addresses). When making remote calls, set timeouts and limit retries. Rate-limit frontend submissions to prevent abuse.
Testing and metrics
Before shipping:
- Unit tests for validation rules and coupon generation.
- Integration tests for persistence and admin actions.
- Manual testing for UX flows on frontend and customer dashboard.
Track metrics:
- Number of requests submitted.
- Approval rate.
- Conversion lift for customers who received coupons.
- Time to decision (admin SLA) — aim for fast turnaround.
Configuration and admin settings
Expose these settings in system.xml so store owners can configure behavior without code deploys:
- Validity window in days
- Price difference threshold percent
- Competitor domain whitelist/blacklist
- Auto-generate coupon on approval (yes/no) and default discount
- Email templates to use
Example system.xml entries are straightforward and follow other Magento modules patterns — place controls under Stores > Configuration > Magefine > Price Match.
Putting everything together: a simple request lifecycle
One concise walkthrough of the lifecycle:
- Customer on product page submits competitor URL and competitor price. The SKU and product ID are automatically attached.
- Your frontend controller validates basic fields and persists a new record with status = pending and sets expires_at according to config (e.g., +7 days).
- An acknowledgement email is sent to the customer saying the request was received and will be reviewed.
- In admin, staff open the request. The Validator service can provide hints: competitor domain, threshold pass/fail, last verification attempt.
- Admin approves or rejects. If approve: create coupon (optional), set status = approved, send approved email with coupon and next steps. If reject: set status = rejected, optionally create a retention coupon and send rejection email with alternatives and the coupon.
- If a request sits beyond expires_at it’s marked expired by cron and customer is notified; optionally trigger a small retention coupon.
Performance and scaling
The module is mostly CRUD and some occasional remote checks. Keep attention on:
- Don’t run synchronous remote competitor checks on submit; make them async with a queue/cron to avoid blocking frontend requests.
- Index any admin grid queries that filter by common columns (status, product_id, customer_id).
- Limit the number of coupon rules created — prefer templated rules with unique coupon codes to avoid too many rules.
Examples of UX copy (friendly tone)
These microcopies help keep the tone trusting and conversion-friendly:
- Form submit CTA: "Request a price match — we’ll respond within 48 hours"
- Pending email subject: "We’ve received your price match request for {{product_name}}"
- Approved subject: "Good news — your price match for {{product_name}} is approved"
- Rejected subject: "Your price match request for {{product_name}}" with friendly offer "We still have something for you"
Developer tips and gotchas
- Make validation and thresholds config-driven from the start so marketing can tweak them.
- If you do competitor page scraping, cache results and respect robots.txt — scraping is fragile and can get blocked quickly.
- When generating coupon codes, ensure uniqueness and a naming pattern so you can identify them later (e.g., PM-STORE-YYYYNNN).
- Use repository/service patterns to keep controllers thin and testable.
- Use proper ACL resources for admin controllers so only authorized roles can approve or reject.
Wrapping up — roadmap ideas
Once you have a working MVP of the Price Match module, you can iterate with these features:
- Automated competitor verification with machine learning to compare product images or titles (advanced).
- Integration with a 3rd-party price intelligence API to verify competitor prices reliably.
- Analytics dashboards showing top competitors, products with many requests, and approval rates.
- Batch actions in admin for mass approvals with templated coupons.
Building a solid Price Match module in Magento 2 is a compact project that touches many parts of the platform: DB schema, models, admin UI, frontend UX, email, and marketing flows. The key to success is keeping validation rules configurable, preserving a great customer experience on the front-end, and giving admins good tools to verify and act on requests quickly.
If you’re running Magento 2 and want to ship this safely, keep the code modular, add unit tests for the validator and coupon logic, and instrument metrics so you can measure the business impact. If you’d like, I can provide the full skeleton module file-by-file (db_schema.xml, registration.php, module.xml, model/resource, repository, admin ui_component XML, controllers, email templates) as a downloadable zip tuned for Magento 2.4 — tell me if you want that and I’ll draft it next.
Also note: if you host with Magefine, you can pair this custom module with specialized hosting optimizations and monitoring to make sure your site stays fast while you increase service gestures like price matching — it’s a good match for stores focusing on conversion and trust.
Got questions about a particular piece of code above? Want the admin grid UI component XML or the exact controller plumbing for the customer dashboard? Say which file you want and I’ll show it step-by-step.



