How to Build a Custom Product Sampling Program Module in Magento 2

Let’s build a clean, responsible, and reusable “product sampling” program in Magento 2 — the kind of module you can drop into stores to let shoppers request free or discounted samples, with fine-grained eligibility rules, cart and order integration, and admin reporting. I’ll walk you through the architecture, the core code pieces, and practical examples for beauty, food, and high-tech brands. No fluff — just explanations you can follow along with, copy-paste, and adapt.
Why build a custom sampling module?
Many merchants want to offer samples as a marketing lever. But built-in product types don’t capture the business rules: how many samples per customer, whether samples are free or discounted, per-category eligibility, or reporting on sample-to-purchase conversion. A custom module gives you a specialized product type and workflows, so sampling behaves predictably and is easy to manage.
High-level architecture
Here’s the mental model before we code. The module has these main parts:
- New product type: "sample" — a product type derived from simple/virtual where price can be zero or discounted and attributes include sample-specific fields (limit, expiry, availability).
- Eligibility and rules engine — an admin-configurable service to decide if a customer can request a sample (per-cart limits, per-email limits, SKU or category rules, customer group rules).
- Cart and order integration — adding sample items to cart, ensuring quote and order data reflect sample metadata (is_sample flag, sample_price, origin_sku).
- Admin dashboard / grid — a UI for listing sample requests, their status, conversion metrics (sample -> purchase), and CSV export.
- Events and reports — track sample impressions, sample requests, and purchases to compute ROI.
Module structure
Create a standard Magento 2 module skeleton. Example vendor/module name: Magefine_Sampling.
app/code/Magefine/Sampling/
├─ registration.php
├─ etc/module.xml
├─ etc/frontend/di.xml
├─ etc/adminhtml/routes.xml
├─ etc/frontend/routes.xml
├─ etc/product_types.xml
├─ Setup/InstallSchema.php (if you prefer declarative schema, use db_schema.xml)
├─ Model/
│ ├─ Product/Type/Sample.php
│ ├─ SampleEligibility.php
│ ├─ Cart/SampleManager.php
│ └─ Repository/SampleRequestRepository.php
├─ Observer/
│ └─ BeforeCartAddObserver.php
├─ Controller/
│ └─ Sample/Request.php
├─ view/adminhtml/ui_component/sample_request_grid.xml
└─ view/frontend/templates/sample-button.phtml
Step 1 — registration and module declaration
Simple files first.
// app/code/Magefine/Sampling/registration.php
// app/code/Magefine/Sampling/etc/module.xml
Step 2 — new product type definition
We want a product type identifier like "sample" and a simple class extending Magento\Catalog\Model\Product\Type\AbstractType (or the simple type to reuse behavior). Register the type in etc/product_types.xml and add a product type model and price model if needed.
// app/code/Magefine/Sampling/etc/product_types.xml
\Magefine\Sampling\Model\Product\Type\Sample
\Magefine\Sampling\Model\Product\Type\Price\SamplePrice
0
Then implement a simple product type class. We will reuse much of simple product behavior but add an isSample flag and sample-specific methods.
// app/code/Magefine/Sampling/Model/Product/Type/Sample.php
getTypeId() === self::TYPE_CODE;
}
}
Step 3 — sample product attributes
Add attributes that drive sampling behavior: sample_limit_per_customer, sample_free (boolean), sample_price, sample_start_date, sample_end_date, sample_eligibility_rules (serialized or JSON, or better: use advanced rule engine).
// Example db_schema.xml snippet (declarative schema)
<!-- add EAV attributes via InstallData or UpgradeData. For brevity, use InstallData -->
Install attributes using Setup/InstallData.php or UpgradeData. Example snippet:
// app/code/Magefine/Sampling/Setup/InstallData.php
eavSetupFactory = $eavSetupFactory;
}
public function install(ModuleDataSetupInterface $setup, ModuleContextInterface $context)
{
$eavSetup = $this->eavSetupFactory->create(['setup' => $setup]);
$eavSetup->addAttribute(
\Magento\Catalog\Model\Product::ENTITY,
'sample_limit_per_customer',
[
'type' => 'int',
'label' => 'Sample limit per customer',
'input' => 'text',
'required' => false,
'user_defined' => true,
'visible_on_front' => false,
]
);
$eavSetup->addAttribute(
\Magento\Catalog\Model\Product::ENTITY,
'sample_free',
[
'type' => 'int',
'label' => 'Is sample free',
'input' => 'select',
'source' => 'Magento\\Eav\\Model\\Entity\\Attribute\\Source\\Boolean',
'required' => false,
'user_defined' => true,
]
);
$eavSetup->addAttribute(
\Magento\Catalog\Model\Product::ENTITY,
'sample_price',
[
'type' => 'decimal',
'label' => 'Sample price',
'input' => 'price',
'required' => false,
'user_defined' => true,
]
);
// add other attributes as needed
}
}
Step 4 — eligibility and rules engine
The brain of the module: decide who is allowed to request a sample. Keep rules modular and testable. At minimum, implement the following checks:
- Product-level rules (start/end date, sample_free flag, inventory check)
- Customer-level rules (per-customer limit, new-customer only, allowed customer groups)
- Cart-level rules (max samples per order, cannot mix with coupons, or only allow one unique sample SKU per order)
Create a SampleEligibility model with methods returning a boolean and a message why an attempt failed. Always use explicit error messages for the UI.
// app/code/Magefine/Sampling/Model/SampleEligibility.php
customerSession = $customerSession;
$this->productRepository = $productRepository;
$this->quoteRepository = $quoteRepository;
}
/**
* Return [bool, message]
*/
public function isEligible($productId)
{
try {
$product = $this->productRepository->getById($productId);
} catch (\Exception $e) {
return [false, 'Product not found'];
}
// product-level checks
if ($product->getData('sample_free') === null && $product->getTypeId() !== 'sample') {
return [false, 'Not a sample product'];
}
$now = new \DateTime();
$start = $product->getData('sample_start_date');
$end = $product->getData('sample_end_date');
if ($start && new \DateTime($start) > $now) {
return [false, 'Sample not available yet'];
}
if ($end && new \DateTime($end) < $now) {
return [false, 'Sample period ended'];
}
// customer-level check
$customer = $this->customerSession->getCustomer();
$customerId = $customer ? $customer->getId() : null;
if ($customerId) {
// check previous sample requests from DB / orders (simplified example)
$quote = $this->quoteRepository->getActiveForCustomer($customerId);
// inspect quote items or historical sample requests (not shown)
}
// other checks: stock, config limits, etc.
return [true, 'Eligible'];
}
}
Step 5 — cart and order integration
Samples should behave well in the cart and in the order lifecycle. Key considerations:
- Add a flag on quote_item / order_item: is_sample = 1, and store original SKU and sample price.
- Ensure taxes and shipping behave as desired (free samples might be taxable or not, depending on jurisdiction).
- Prevent abuse by enforcing per-quote or per-customer limits before adding to cart.
Implement an observer that intercepts product add-to-cart events and redirects through the eligibility service. Example using event checkout_cart_product_add_before or plugin on \Magento\Checkout\Model\Cart::addProduct.
// app/code/Magefine/Sampling/Observer/BeforeCartAddObserver.php
eligibility = $eligibility;
$this->messageManager = $messageManager;
}
public function execute(Observer $observer)
{
$product = $observer->getEvent()->getProduct();
if ($product->getTypeId() !== 'sample') {
return;
}
list($ok, $msg) = $this->eligibility->isEligible($product->getId());
if (!$ok) {
$this->messageManager->addErrorMessage(__('Sample cannot be added: %1', $msg));
// Optionally throw an exception or set response
throw new \Magento\Framework\Exception\LocalizedException(__($msg));
}
// if eligible, set sample metadata on quote item via around plugin or after add
}
}
When a sample is actually added to cart, mark the quote item:
// In code where quote item is created (plugin or afterAddAction)
$quoteItem->setData('is_sample', 1);
$quoteItem->setData('sample_origin_sku', $product->getSku());
$quoteItem->setData('original_price', $product->getPrice());
$quoteItem->setCustomPrice($samplePrice);
$quoteItem->setOriginalCustomPrice($samplePrice);
$quoteItem->getProduct()->setIsSuperMode(true);
Make sure to persist is_sample to sales_order_item when the order is created. You can use the sales_convert_quote_item_to_order_item observer or plugin to copy the custom fields.
// Example: app/code/Magefine/Sampling/Observer/QuoteToOrderObserver.php
public function execute(\Magento\Framework\Event\Observer $observer)
{
$quoteItem = $observer->getEvent()->getItem();
$orderItem = $observer->getEvent()->getOrderItem();
$orderItem->setData('is_sample', $quoteItem->getData('is_sample'));
$orderItem->setData('sample_origin_sku', $quoteItem->getData('sample_origin_sku'));
}
Step 6 — admin UI: request flow and tracking
You should expose two types of flows:
- Customer-facing: a simple "Request sample" button on product pages for the "sample" product type. It calls a controller that validates eligibility and either adds the sample to cart or creates a sample request (for fulfillment outside Magento).
- Admin-facing: a grid to list sample requests and statuses, with actions (Fulfill, Cancel) and analytics. Hook reporting to observe whether customers who received samples later purchased full-sized products.
Front-end button template (very basic):
// view/frontend/templates/sample-button.phtml
<?php
/** @var $block \Magento\Catalog\Block\Product\View */
$product = $block->getProduct();
if ($product->getTypeId() === 'sample') :
?>
<?php endif; ?>
Controller to handle the request (frontend):
// app/code/Magefine/Sampling/Controller/Sample/Request.php
eligibility = $eligibility;
$this->cart = $cart;
$this->productRepository = $productRepository;
}
public function execute()
{
$productId = (int)$this->getRequest()->getParam('product_id');
list($ok, $msg) = $this->eligibility->isEligible($productId);
if (!$ok) {
$this->messageManager->addErrorMessage($msg);
return $this->_redirect($this->_redirect->getRefererUrl());
}
$product = $this->productRepository->getById($productId);
try {
$params = ['product' => $productId, 'qty' => 1];
$this->cart->addProduct($product, $params);
$this->cart->save();
$this->messageManager->addSuccessMessage(__('Sample added to cart'));
} catch (\Exception $e) {
$this->messageManager->addErrorMessage(__('Could not add sample to cart'));
}
return $this->_redirect('checkout/cart');
}
}
Step 7 — admin grid and dashboard
Create a simple table that stores sample requests (customer_id, email, product_id, status, requested_at, fulfilled_at, order_id_if_any). Use a UI Component grid for adminhtml. The grid will let merchants filter by product or date and export CSV for analysis.
// Example db table columns (simplified)
sample_request
- id int PK
- customer_id int nullable
- email varchar(255)
- product_id int
- product_sku varchar(64)
- status enum('requested','fulfilled','cancelled')
- requested_at datetime
- fulfilled_at datetime nullable
- order_id int nullable
- notes text
Populate this table when a sample request is created (either when added to cart or when the customer submits a dedicated form). For fulfillment that happens outside Magento, admins can change a request to "fulfilled".
For analytics, capture events:
- sample.request.created
- sample.request.fulfilled
- sample.purchased (customer who received a sample later buys a full product)
Step 8 — detection of conversion (sample -> purchase)
To measure effectiveness, link fulfilled sample requests with subsequent orders where the customer buys the same SKU (or related SKUs). You can set a window (30 days by default). Implement a background job (cron) that runs daily and computes conversion rates per SKU and product category.
// Pseudocode for conversion check
for each fulfilled request where fulfilled_at between (today - 30 days) and today:
find orders for the same customer after fulfilled_at
if order contains full-size SKU or category match:
mark request as converted
increment counters
Step 9 — handling taxes, shipping, and payments
Decide whether samples are:
- completely free: price 0 — ensure shipping rules allow adding a sample without adding shipping cost if you intend that (or charge shipping).
- discounted: set sample_price to a small number and apply normal checkout rules.
Remember to:
- Respect tax configuration — samples may be taxable.
- Exclude samples from promotions if needed (or include them).
- Respect inventory and reservations when samples are limited.
Step 10 — security and anti-abuse
Sampling programs are subject to abuse. Implement these safeguards:
- Rate limit requests by IP and email.
- Per-customer limits stored on sample_request table and enforced at request time.
- ReCAPTCHA on sample request forms if public (guest sampling).
- Block list for specific emails or regions (GDPR and shipping rules apply — don’t ship prohibited goods).
Use cases: how brands can use this system
Beauty brands
Beauty brands want to offer small testers to encourage trial. Common rules:
- One free sample per order for new customers only.
- Limit per customer to 2 samples per quarter to prevent hoarding.
- Require signup or newsletter opt-in to collect consent and track conversions.
Implementation tips:
- Add a product attribute sample_age_limit (for cosmetics with age restrictions).
- Use the eligibility engine to require a newsletter subscription flag when requesting free samples.
- Include a small tracking token in the sample packaging to tie offline returns back to the sample request when customers later purchase via code redemption.
Food / FMCG
Food products may require VAT and shipping considerations and may be limited by geography.
- Allow free samples only within allowed shipping zones.
- Limit 1 sample per household — implement via email + address checks.
- Track conversion in a stricter time window (7-14 days) because purchase cycle is faster.
High-tech
High-tech samples (like trial dongles or limited firmware-enabled units) need stricter inventory control and return flow.
- Mark samples with serialized SKUs and track shipping & returns.
- For demo hardware, require a deposit (sample_price > 0) and create a return workflow to refund deposit upon return.
- Use a different fulfillment process that creates RMA records tied to the sample_request row.
Example: full flow for a beauty brand (step-by-step)
Here’s how a small beauty brand might configure and run this module:
- Create product SKUs of type "sample" for perfume testers, each with sample_free = 1, sample_limit_per_customer = 1, sample_start_date = now.
- Configure global sampling rules in admin: guests allowed? no. Customer groups allowed: Retail. Max samples per order: 1.
- On product pages, add a "Request sample" button. When clicked, controller runs eligibility check: customer logged-in? yes. Has customer already requested 1 sample this quarter? no. Product within date range? yes. -> Add to cart.
- Cart shows the sample with price $0. Taxes calculated accordingly. Checkout collects shipping address and charges shipping if configured. Quote item is stored with is_sample flag.
- Order placed. The sales_order_item has is_sample = 1 and sample_origin_sku.
- Cron job marks request as fulfilled when shipment is processed (observer on shipment_save_after copies tracking number into sample_request table and sets status = 'fulfilled').
- Admin reviews sample_request grid to export CSV and see conversion over 30 days. If conversions are high, brand increases sample inventory.
Testing and QA
Write unit tests for your SampleEligibility service and integration tests for cart flow. Key test cases:
- Guest requesting sample when guests not allowed — fail.
- Customer who already hit personal limit — fail.
- Sample outside start/end period — fail.
- Cart-level max samples per order enforced — fail if exceeded.
- Quote -> Order copy of is_sample fields works.
Performance considerations
Keep the eligibility logic fast. Avoid heavy DB queries per request; cache aggregated counters (e.g. per-customer sample counts) in Redis for short lived TTLs. Use batch jobs for analytics instead of computing on-the-fly in the admin grid.
Extensibility and integration points
The module should be easy to extend:
- Expose events: sample.request.created, sample.request.fulfilled, sample.request.cancelled.
- Make eligibility checks pluggable via virtual types or plugins so merchants can add custom rules.
- Provide a web API (REST) to create and query sample requests for third-party fulfillment systems or mobile apps.
- Provide data export hooks so an analytics platform (Google BigQuery or DataLake) can pull sample activity.
Admin UX details
Simple but useful admin features:
- Grid columns: request id, requested_at, customer email, product SKU, status, fulfilled_at, order id, tracking number.
- Filters: date range, product SKU, status, customer email.
- Bulk actions: mark as fulfilled (with optional tracking number), export selected CSV.
- Dashboard widget: top 10 sample SKUs by requests, conversion rate by SKU, total cost of samples shipped this month (sample_price * fulfilled_count).
Real-world constraints and shipping
Depending on region and product category, samples might be regulated (cosmetics, food, electronics batteries). Make sure the module has shipping zone configuration and product attributes to block requests outside allowed zones.
Analytics and KPI suggestions
Track these key metrics:
- Request rate: # requests / # product page views (or per marketing campaign impressions)
- Fulfillment rate: % of requests that were shipped within SLA
- Conversion rate: % of fulfilled samples that lead to a purchase of the same or related SKU within X days
- Cost per conversion: (sample cost + shipping + handling) / conversions
Example: sample-to-order link (cron pseudocode)
// cron job run daily
$requests = $sampleRequestRepository->getFulfilledRequestsBetween($from, $to);
foreach ($requests as $request) {
$orders = $orderRepository->getCustomerOrdersAfter($request->getCustomerId(), $request->getFulfilledAt());
foreach ($orders as $order) {
foreach ($order->getAllItems() as $item) {
if ($item->getSku() === $request->getProductSku() || isSameCategory($request->getProductId(), $item->getProductId())) {
$request->setConverted(1);
$request->setConversionOrderId($order->getId());
$sampleRequestRepository->save($request);
}
}
}
}
Deployment and upgrade notes
Follow standard Magento module deployment: put code in app/code (or package as composer), run setup:upgrade, compile, deploy static content, reindex if needed, and clear caches. If you add attributes in InstallData, handle upgrades carefully for existing stores.
SEO and marketing integration
Make sure sample product pages are well-indexed when you want organic traffic to find sample offers — but use canonical tags if samples are duplicate or near duplicates of full products. If samples are campaign-limited, consider marking them noindex until they're live.
Summary and next steps
This guide covered the core pieces to build a robust product sampling module in Magento 2: a new product type, attribute schema, eligibility rules, cart and order integration, admin UI, analytics, and production considerations. The example code gives a skeleton you can expand to add features like multi-item sample packs, deposit/return flows, or REST APIs for external fulfillment partners.
If you want, I can:
- Provide a downloadable skeleton module with the files above.
- Help you adapt the eligibility engine to specific business rules (e.g. loyalty tiers, campaign coupons).
- Walk through building the admin UI component XML and data provider in a follow-up.
Want me to generate the scaffold code for the full module with db schema, UI components, and a sample installer? Tell me what exact features you need (guest sampling? deposits? analytics window) and I’ll produce the full module skeleton you can install on a dev instance.