How to Build a Custom "Product Personalization" Module (Engraving, Custom Text) in Magento 2

Let’s build a clean, maintainable "Product Personalization" module for Magento 2 that supports engraving and custom text. I’ll walk you through architecture decisions, database layout, frontend integration (real-time preview), dynamic price adjustments, production workflow hooks, and performance best practices—step-by-step and with concrete code examples you can drop into a dev environment. I’ll keep the tone relaxed, like I’m explaining to a colleague who’s done some Magento work but hasn’t built this exact feature before.

Why you might want a custom module (and not just product options)

Magento’s built-in custom options can do simple text inputs and file uploads, but they quickly become limiting when you need:

  • Complex validation (character limits, allowed characters per product),
  • Server-side previews or combined images (laser layout, engraving mockups),
  • Dynamic pricing rules that depend on text length, font choice, or uploaded artwork complexity,
  • Tighter integration with production systems (automatic export/PDF generation),
  • Efficient storage and retrieval of personalization data across quote -> order -> archive.

So, building a small custom module gives you control and makes it easier to connect personalization to your production flow.

High-level architecture

Here’s the structure I recommend:

  • Store personalization details as both a quote_item option and in a dedicated table (magefine_personalization). The quote option keeps data attached to the cart and order item; the table provides easy queries and links to production artifacts (images/PDFs).
  • Expose a small API (controller) for price calculation and preview generation. The frontend widget calls it (AJAX) so we keep pages cacheable.
  • Use an observer / plugin to apply dynamic price adjustments on the quote item based on personalization data.
  • On order conversion, copy personalization into order item options and write a record into the customization table. Optionally trigger a job to generate a PDF/print-ready image or call a production webhook.
  • Store uploaded design files in pub/media/personalizations and generate thumbnails for previews.

Module basics: registration and module.xml

Create module Magefine_ProductPersonalization (vendor Magefine). Files:

app/code/Magefine/ProductPersonalization/registration.php
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
    ComponentRegistrar::MODULE,
    'Magefine_ProductPersonalization',
    __DIR__
);
app/code/Magefine/ProductPersonalization/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_ProductPersonalization" setup_version="1.0.0" />
</config>

DB schema using db_schema.xml (Magento 2.3+)

I prefer db_schema.xml so it works cleanly with declarative schema:

app/code/Magefine/ProductPersonalization/etc/db_schema.xml
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="magefine_personalization" resource="default" engine="innodb" comment="Product personalizations">
        <column xsi:type="int" name="personalization_id" nullable="false" unsigned="true" identity="true" comment="Entity ID" />
        <column xsi:type="int" name="quote_item_id" nullable="true" unsigned="true" comment="Quote Item ID" />
        <column xsi:type="int" name="order_item_id" nullable="true" unsigned="true" comment="Order Item ID" />
        <column xsi:type="int" name="product_id" nullable="false" unsigned="true" comment="Product ID" />
        <column xsi:type="text" name="type" nullable="false" comment="Type (engraving, print, file)" />
        <column xsi:type="text" name="data" nullable="true" comment="JSON-encoded personalization data" />
        <column xsi:type="decimal" name="price_adjustment" scale="4" precision="12" nullable="false" default="0.0000" comment="Surcharge" />
        <column xsi:type="varchar" name="file_path" nullable="true" length="255" />
        <column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP" />
        <constraint referenceId="PERSONALIZATION_PRODUCT_FK" xsi:type="foreign" column="product_id" referenceTable="catalog_product_entity" referenceColumn="entity_id" onDelete="CASCADE" />
        <index name="IDX_QUOTE_ITEM" indexType="btree" ><column name="quote_item_id" /></index>
        <index name="IDX_ORDER_ITEM" indexType="btree" ><column name="order_item_id" /></index>
    </table>
</schema>

This table stores the canonical personalization records which we attach to order items and use for production exports.

Frontend: adding the widget on the product page

Add a layout update for catalog_product_view to inject our block under price or next to add-to-cart form without breaking cacheability. The trick: deliver static markup and rely on AJAX for dynamic preview and price calculation.

app/code/Magefine/ProductPersonalization/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>
        <referenceContainer name="product.info.form.content">
            <block class="Magefine\ProductPersonalization\Block\Widget" name="magefine.personalization.widget" template="Magefine_ProductPersonalization::widget.phtml" />
        </referenceContainer>
    </body>
</page>

In widget.phtml keep markup minimal and mark it as cacheable. Use data attributes and an independent JS widget.

app/code/Magefine/ProductPersonalization/view/frontend/templates/widget.phtml
<?php /** @var $block \Magefine\ProductPersonalization\Block\Widget */ ?>
<div class="magefine-personalization" data-product-id="<?= $block->getProduct()->getId() ?>" data-mage-init='{"Magefine_Personalization/widget":{}}'>
    <label>Engraving text</label>
    <input type="text" name="personalization[text]" class="mf-personalization-text" maxlength="100" />

    <label>Font</label>
    <select name="personalization[font]" class="mf-personalization-font">
        <option value="default">Default</option>
        <option value="fancy">Fancy</option>
    </select>

    <label>Upload artwork (optional)</label>
    <input type="file" name="personalization[file]" class="mf-personalization-file" accept="image/*" />

    <div class="mf-personalization-preview">
        <img src="" alt="Preview" class="mf-preview-img" style="display:none;max-width:200px;"/>
    </div>

    <input type="hidden" name="personalization[price_adjustment]" class="mf-price-adjustment" value="0" />
</div>

JS widget: preview and price calculation

Create a small AMD module that does two things: shows a live preview using canvas/FileReader, and calls a backend controller to get price adjustments without reloading the page.

app/code/Magefine/ProductPersonalization/view/frontend/requirejs-config.js
var config = {
    map: {
        '*': {
            'Magefine_Personalization/widget': 'Magefine_ProductPersonalization/js/widget'
        }
    }
};
app/code/Magefine/ProductPersonalization/view/frontend/web/js/widget.js
define(['jquery','mage/url'], function($, urlBuilder){
    'use strict';
    return function(config, element) {
        var $root = $(element);
        var $text = $root.find('.mf-personalization-text');
        var $font = $root.find('.mf-personalization-font');
        var $file = $root.find('.mf-personalization-file');
        var $previewImg = $root.find('.mf-preview-img');
        var productId = $root.data('product-id');

        function updatePreview() {
            var textVal = $text.val();
            var fontVal = $font.val();
            // Simple client-side preview: draw on canvas and show as image
            var canvas = document.createElement('canvas');
            canvas.width = 400; canvas.height = 100;
            var ctx = canvas.getContext('2d');
            ctx.fillStyle = '#fff'; ctx.fillRect(0,0,canvas.width,canvas.height);
            ctx.fillStyle = '#000'; ctx.font = (fontVal==='fancy' ? '30px Georgia' : '24px Arial');
            ctx.fillText(textVal, 10, 50);
            $previewImg.attr('src', canvas.toDataURL('image/png')).show();
        }

        function calculatePrice() {
            var payload = {
                product_id: productId,
                text: $text.val() || '',
                font: $font.val() || ''
            };
            $.ajax({
                url: urlBuilder.build('productpersonalization/price/calculate'),
                method: 'POST',
                data: JSON.stringify(payload),
                contentType: 'application/json',
                success: function(response) {
                    if (response && response.price_adjustment!==undefined) {
                        $root.find('.mf-price-adjustment').val(response.price_adjustment);
                        // update price display on page (simple approach)
                        $(document).trigger('magefine:personalizationPriceUpdate', [response.price_adjustment]);
                    }
                }
            });
        }

        $text.on('input', function(){ updatePreview(); calculatePrice(); });
        $font.on('change', function(){ updatePreview(); calculatePrice(); });
        $file.on('change', function(e){
            var file = e.target.files[0];
            if (!file) return;
            var reader = new FileReader();
            reader.onload = function(ev){
                $previewImg.attr('src', ev.target.result).show();
            };
            reader.readAsDataURL(file);
            calculatePrice();
        });

        // initial render
        updatePreview();
    };
});

Note: we don’t update the product price directly in the HTML; instead, we keep the price adjustment in a hidden input and rely on server-side application of the surcharge when adding to cart. We also trigger a custom event so other JS (like price blocks) can listen and display an incremental price.

Controller: price calculation endpoint

We need a controller that accepts POSTed JSON and returns a price delta using rules you define (text length, font, file complexity). Keep this lightweight and fast.

app/code/Magefine/ProductPersonalization/Controller/Price/Calculate.php
<?php
namespace Magefine\ProductPersonalization\Controller\Price;

use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Catalog\Api\ProductRepositoryInterface;

class Calculate extends Action
{
    protected $resultJsonFactory;
    protected $productRepo;

    public function __construct(Context $context, JsonFactory $resultJsonFactory, ProductRepositoryInterface $productRepo)
    {
        parent::__construct($context);
        $this->resultJsonFactory = $resultJsonFactory;
        $this->productRepo = $productRepo;
    }

    public function execute()
    {
        $result = $this->resultJsonFactory->create();
        try {
            $payload = json_decode($this->getRequest()->getContent(), true);
            $productId = isset($payload['product_id']) ? (int)$payload['product_id'] : 0;
            $text = isset($payload['text']) ? trim($payload['text']) : '';
            $font = isset($payload['font']) ? $payload['font'] : 'default';

            // Example rule: base surcharge per char + font uplift
            $perChar = 0.5; // $0.5 per char
            $fontMultiplier = ($font==='fancy' ? 1.5 : 1);
            $priceAdjustment = strlen($text) * $perChar * $fontMultiplier;

            // Add file cost if file was included (client may omit file; real logic should consider server-side file size)
            // Keep it simple here
            $data = ['price_adjustment' => round($priceAdjustment, 2)];
            return $result->setData($data);
        } catch (\Exception $e) {
            return $result->setData(['error' => true, 'message' => $e->getMessage()]);
        }
    }
}

Make sure the route productpersonalization/price/calculate is declared in routes.xml and accessible via POST.

Adding personalization to cart (buyRequest and quote item option)

We need personalization data to survive the journey to the quote and order. The easiest way is to add an option to the quote item. When the user submits the product form, ensure the personalization fields are included as part of the product form (Magento reads POST into buyRequest).

In layout we added our inputs with names like personalization[text], personalization[font], personalization[file]—they will be part of the buyRequest. When the product is added to cart, Magento creates a quote item; we can listen to checkout_cart_product_add_after or use a plugin on \Magento\Checkout\Model\Cart::addProduct to attach options to the quote item and then set a custom price.

app/code/Magefine/ProductPersonalization/Observer/AttachToQuoteItem.php
<?php
namespace Magefine\ProductPersonalization\Observer;

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

class AttachToQuoteItem implements ObserverInterface
{
    public function execute(Observer $observer)
    {
        $quoteItem = $observer->getEvent()->getQuoteItem();
        $request = $observer->getEvent()->getRequest();
        if (!$request) {
            return;
        }

        $personalization = $request->getParam('personalization');
        if (!$personalization) {
            return;
        }

        // Save personalization as a JSON option on the quote item
        $quoteItem->addOption([
            'code' => 'personalization',
            'value' => json_encode($personalization)
        ]);

        // Calculate and apply custom price if price_adjustment is present
        $adjustment = isset($personalization['price_adjustment']) ? (float)$personalization['price_adjustment'] : 0;
        if ($adjustment > 0) {
            $productPrice = $quoteItem->getProduct()->getFinalPrice();
            $newPrice = $productPrice + $adjustment;
            $quoteItem->setCustomPrice($newPrice);
            $quoteItem->setOriginalCustomPrice($newPrice);
            $quoteItem->getProduct()->setIsSuperMode(true);
        }
    }
}

Register this observer for event checkout_cart_product_add_after in events.xml. If you prefer to use a plugin, you can intercept \Magento\Checkout\Model\Cart::addProduct and do similar logic.

Order conversion: saving personalization records

When the quote gets converted to an order, copy personalization options into the order item and create a row in magefine_personalization. Use the event sales_model_services_quote_submit_before or sales_convert_quote_item_to_order_item. I like sales_model_service_quote_submit_before because it gives access to both quote and order.

app/code/Magefine/ProductPersonalization/Observer/SavePersonalizationOnOrder.php
<?php
namespace Magefine\ProductPersonalization\Observer;

use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Event\Observer;
use Magefine\ProductPersonalization\Model\PersonalizationFactory;

class SavePersonalizationOnOrder implements ObserverInterface
{
    protected $personalizationFactory;

    public function __construct(PersonalizationFactory $personalizationFactory)
    {
        $this->personalizationFactory = $personalizationFactory;
    }

    public function execute(Observer $observer)
    {
        $order = $observer->getEvent()->getOrder();
        $quote = $observer->getEvent()->getQuote();

        foreach ($order->getAllItems() as $orderItem) {
            $quoteItem = $quote->getItemById($orderItem->getQuoteItemId());
            if (!$quoteItem) continue;

            $option = $quoteItem->getOptionByCode('personalization');
            if (!$option) continue;

            $data = json_decode($option->getValue(), true);
            // Save record in magefine_personalization
            $model = $this->personalizationFactory->create();
            $model->setData([
                'order_item_id' => $orderItem->getId(),
                'quote_item_id' => $quoteItem->getId(),
                'product_id' => $orderItem->getProductId(),
                'type' => isset($data['type']) ? $data['type'] : 'engraving',
                'data' => json_encode($data),
                'price_adjustment' => isset($data['price_adjustment']) ? $data['price_adjustment'] : 0
            ]);
            $model->save();

            // Append personalization to order item options so admin sees it
            $options = $orderItem->getProductOptions();
            $options['personalization'] = $data;
            $orderItem->setProductOptions($options);
        }
    }
}

Create a simple model and resource model for magefine_personalization. This gives you an entry point to list personalization data from admin, export jobs, or generate PDFs.

Production workflow: connecting orders to manufacturing

Now that personalization records exist in the DB and are attached to order items, you can:

  • Expose an admin grid that lists personalization-ready items for production staff (include preview thumbnails, fonts, and required instructions).
  • Provide a button to generate a PDF per personalization. Use a template that renders the engraved text and layout. You can generate an image server-side or draw the text onto a fixed-size canvas (via PHP GD or Imagick) and include it in the PDF.
  • Implement an automated webhook/queue: when an order item is marked "ready for production", push the personalization data + preview image to the manufacturing system (HTTP webhook or SFTP upload).
  • Include a unique production id in the magefine_personalization table to track lifecycle.

Example: generating a server-side image with Imagick (pseudo-code):

// $text = $data['text']; $font = '/path/to/font.ttf';
$canvas = new \Imagick();
$canvas->newImage(800, 200, new \ImagickPixel('white'));
$draw = new \ImagickDraw();
$draw->setFont($font);
$draw->setFontSize(48);
$draw->setFillColor('black');
$canvas->annotateImage($draw, 20, 60, 0, $text);
$canvas->setImageFormat('png');
$canvas->writeImage('/pub/media/personalizations/preview_123.png');

Store the generated preview path in the magefine_personalization.file_path column. Production can then retrieve and use it for laser layout.

Dynamic pricing details and pitfalls

Key points when implementing surcharges:

  • Always calculate final price server-side before applying to quote item. Client JS is only for UX and should not be trusted.
  • Set custom price on the quote item (setCustomPrice & setOriginalCustomPrice) so Magento totals include it. Remember to call $product->setIsSuperMode(true) to bypass validation.
  • Consider taxes: your surcharge is part of the item price and should be taxed according to product tax class and store config.
  • Be careful with promotions or special prices: if a product is on sale, decide whether the surcharge is added to the sale price or the original price. Typically it’s added to the final applied price.
  • Edge case: cart price rules could interfere. If you’re applying a custom price, cart rules that rely on original price or percent discounts might be affected—test thoroughly.

Admin UX: displaying personalization on Order View

Add personalization to order item options for the admin order view (we already did this when saving). In addition, an adminhtml grid and a printable production sheet are helpful. Create an admin grid with filters for production status and order date.

Performance: caching and image handling

Personalization adds dynamicity which may break caching if not implemented carefully. Here are strategies to keep your site fast while supporting personalization:

Keep main product pages cacheable

Don’t add server-side generated personalization markup into the page HTML. Render a small static block that the cache can store, and load dynamic content (price adjustment, preview) via AJAX after page load. This is why we used the price calc endpoint and client-side preview.

Use private content for per-customer info

If personalization data must be shown differently per logged-in user, use Magento’s customer-data (section) mechanism so the page stays full-page-cacheable while private fragments are injected per session.

Varnish & ESI: when to use

Magento’s Full Page Cache with Varnish is great. Avoid making large parts of the page uncachable. Use AJAX endpoints for dynamic info. Only hole-punch if you absolutely must render server-specific personalization content inside the page—AJAX is usually simpler.

Image handling and storage

  • Store raw uploads in pub/media/personalizations/original and generated previews in pub/media/personalizations/preview. Keep a clean folder structure per order ID for easier cleanup.
  • Generate thumbnails on upload and serve those for admin and frontend previews. Use Magento’s image adapter to ensure consistent sizes and caching.
  • Use a CDN for media to reduce load on your server. Ensure cache headers are set on preview images.
  • Lazy-load preview thumbnails in admin grids and production pages to reduce initial load.

Process heavy tasks asynchronously

Generating production PDFs or high-res mockups can be slow. Use a queue (Magento cron + message queue or a separate job worker) to prepare production assets. The order saves immediately; asset generation occurs shortly after and updates the magefine_personalization record with file_path and status.

Security considerations

  • Sanitize text inputs and validate against allowed character sets if engraving machines have limitations.
  • Validate uploaded files (mime type, size, dimensions) and process uploads through Magento uploader routines to avoid path traversal or malicious files.
  • Set correct file permissions and store files outside of direct web root where possible; or ensure file names are randomized and validated.
  • Use CSRF protection for endpoints (Magento uses form keys). For dedicated controllers used by AJAX, ensure they use proper ACL and form-key verification where needed.

Testing checklist

Before shipping, test these flows:

  • Add product with engraving text -> cart -> checkout: is the surcharge correctly applied and visible in cart, checkout, and order?
  • Order conversion: is personalization saved in DB and attached to order item options?
  • Admin: can staff generate PDFs and preview images for production? Are file paths correct?
  • Edge cases: empty text, very long text, invalid characters, malicious file upload—are all rejected or sanitized?
  • Promotion interactions: if a product is discounted by catalog or cart rules, is surcharge applied to the correct base price?
  • Performance: product page still cacheable (FPC), preview and price calls made by AJAX, and overall page weight reasonable.

Advanced ideas and expansions

  • Fonts & layout presets per product: allow the product admin to configure allowed fonts, character limits, and preview templates per SKU in the backend (custom product attribute or admin product tab).
  • Vector output for laser cutting: generate SVGs from text + font info for best production quality. Store the SVG per order item and include it in the production package.
  • Integrate with external production systems using a job queue and secure webhooks. Send metadata + SVG/PNG and get back a production id + ETA.
  • Add a personalized mockup generator that composites multiple layers (background image, text layer, uploaded logo) for a realistic product preview.

SEO and content notes (for magefine.com)

For SEO on a site like magefine.com, we should:

  • Use a clear URL key reflecting the subject (include “Magento 2”, “product personalization”, “engraving”),
  • Put concise meta title & description optimized for click-through (include brand name Magefine),
  • Use relevant keywords throughout (product personalization, engraving module, Magento 2 personalization, dynamic pricing engraving),
  • Offer clear code examples and headings so search engines can pick up structure, and readers can skim. We’ve included both.

Recap: minimal implementation checklist

  1. Create module + schema table magefine_personalization.
  2. Add product page widget that posts personalization fields as part of buyRequest.
  3. Create a price calculation controller called by JS for live feedback.
  4. On add to cart, attach personalization to quote item as an option and set custom price using setCustomPrice.
  5. On order submit, copy personalization to order item and save a row in magefine_personalization; generate production preview asynchronously.
  6. Implement admin UI for production and export (grid + PDF generator + webhook).
  7. Optimize: use AJAX endpoints, thumbnails, CDN, and asynchronous jobs for heavy tasks.

Useful code pointers and gotchas

  • Always set setIsSuperMode(true) on product when setting custom prices to avoid validation issues.
  • Store personalization as both a quote option and a DB row for robust record-keeping and easier reporting.
  • Keep product page HTML cacheable—move dynamic things to AJAX or customer-data sections.
  • Use Magento’s image adapter (\Magento\Framework\Image\Adapter\AdapterInterface) for server-side resizing to benefit from platform optimizations.
  • Test taxes and promotions thoroughly; custom pricing can change expected discount calculations.

Conclusion

Building a custom product personalization module for Magento 2 gives you the flexibility to offer engraving, custom texts, file uploads and robust production integration. Key takeaways:

  • Attach personalization data to the quote item via options and also persist in a dedicated table for production and reporting.
  • Calculate surcharges server-side and apply via quote item custom price so totals and taxes are correct.
  • Keep the product page cacheable by using AJAX for previews and price calculations.
  • Generate production-ready assets asynchronously and expose admin tools to manage production flow.

If you want, I can provide a downloadable skeleton module with the files above (module scaffolding, controller, observer, model and admin grid). Also happy to adapt the pricing rules to your specific engraving machine constraints or to help design the production webhook schema.

Happy building—and ping me if you want that skeleton module to jumpstart development on magefine.