Chat on WhatsApp
Hyvä Theme 13 min read

Step-by-Step Hyvä Compatibility — Porting Mageplaza Layered Navigation

Most Hyvä compatibility tutorials stop at "install hyva-themes/magento2-default-theme and pray". This one ports a real Luma-only filter extension — Mageplaza Layered Navigation — to Hyvä across five concrete phases with diffs at every step. Phase 1: composer require the compat-module shell. Phase 2: copy the phtml and strip Knockout data-bind. Phase 3: rebuild the live-filter UI in Alpine.js against the existing Magento AJAX endpoint. Phase 4: swap Luma selectors for Tailwind utility classes that follow the Hyvä theme tokens. Phase 5: a Magewire wire-up for the count badges that need server-side reactivity. Plus a decision matrix on when to ditch the port and buy the Hyvä Catalog Add-on instead.

Step-by-Step Hyvä Compatibility — Porting Mageplaza Layered Navigation

Hyvä extension compatibility is the engineering pattern that adapts a Luma-only Magento 2 extension — Knockout templates, RequireJS modules, jQuery selectors — to run inside a Hyvä storefront on Magento 2.4.4 — 2.4.9 by replacing the frontend layer with Alpine.js, Tailwind, and optionally Magewire while keeping the original PHP code untouched. The work breaks cleanly into five phases. Below is the complete commit history for porting Mageplaza Layered Navigation, the most-requested third-party filter extension on Hyvä projects in 2026.[1]

Hyvä compatibility is not a rewrite — it is a frontend transplant

The PHP layer of a well-built Luma extension — observers, plugins, blocks, models, repositories, GraphQL resolvers — runs unchanged on Hyvä. What does not run is the Knockout binding layer, the RequireJS module graph, and the jQuery widgets the Luma phtml templates depend on. The Hyvä compat module replaces those three things and nothing else.[2]

Every Luma-only extension is 80% PHP that works on Hyvä and 20% frontend that does not. The compat module replaces only the 20%.

The five phases below isolate the work into reviewable commits. Each phase has a single concern. If the merchant pulls the engagement after phase 3, they have a working extension — the next two phases are polish.

The extension we are porting

Mageplaza Layered Navigation is a paid Magento 2 extension that adds price sliders, multi-select swatches, AJAX-refreshing counts, and a sticky filter sidebar to the category page. It ships exclusively as a Luma extension — Knockout templates, jQuery $.ajax, RequireJS module IDs. On a Hyvä storefront the templates silently fall back to nothing, and the category page renders without any filters at all.[3]

Same pattern applies to Amasty Improved Layered Navigation, BSS Commerce Shop By Brand, and roughly two dozen other "filter sidebar" extensions sold for Luma. The phase breakdown below transfers directly — only the selector names and the AJAX endpoint differ.

What the merchant sees before the port

<!-- Hyvä category page, Mageplaza module enabled -->
<main class="category-view">
  <aside class="sidebar-additional">
    <!-- Empty. Mageplaza injects nothing because Hyvä strips Knockout. -->
  </aside>
  <div class="product-listing">
    <!-- Products render fine — the listing has no Knockout. -->
  </div>
</main>

The filter sidebar simply does not appear. The category PLP works but loses every filter beyond the default Magento price and category_ids.

Phase 1 — Compat-module shell via composer

The compat module is a thin layer with one job: register itself, declare a dependency on the original Mageplaza module, and own the view/frontend/templates/ directory that overrides the Luma templates. Nothing more.

composer.json

{
    "name": "your-agency/mageplaza-layerednavigation-hyva-compat",
    "description": "Hyvä compatibility for Mageplaza Layered Navigation on Magento 2.4.4 — 2.4.9",
    "type": "magento2-module",
    "version": "1.0.0",
    "require": {
        "php": "~8.2.0||~8.3.0||~8.4.0",
        "magento/framework": ">=103.0.0",
        "mageplaza/module-layered-navigation": "^4.0",
        "hyva-themes/magento2-default-theme": "^1.3"
    },
    "autoload": {
        "files": ["registration.php"],
        "psr-4": {"YourAgency\\MageplazaLayeredNavigationHyvaCompat\\": ""}
    }
}

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="YourAgency_MageplazaLayeredNavigationHyvaCompat">
        <sequence>
            <module name="Mageplaza_LayeredNavigation"/>
            <module name="Hyva_Theme"/>
        </sequence>
    </module>
</config>

registration.php

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

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

The Hyvä theme fallback registration

This is the part Hyvä newcomers miss. The compat module's view/frontend/templates/ path only gets picked up if the Hyvä theme's fallbacks list includes it. Add a single entry to app/design/frontend/YourBrand/default/Magento_Theme/templates/Hyva/fallback.xml:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <modules>
        <module name="Mageplaza_LayeredNavigation"
                fallback="YourAgency_MageplazaLayeredNavigationHyvaCompat"/>
    </modules>
</config>

That tells Hyvä: any template the Mageplaza module declares, look in the compat module first. The original Mageplaza module stays installed and untouched — its PHP classes still run.

The first commit

+ composer.json
+ etc/module.xml
+ registration.php
+ app/design/frontend/YourBrand/default/Magento_Theme/templates/Hyva/fallback.xml

$ composer require your-agency/mageplaza-layerednavigation-hyva-compat:^1.0
$ bin/magento module:enable YourAgency_MageplazaLayeredNavigationHyvaCompat
$ bin/magento setup:upgrade
$ bin/magento setup:di:compile
$ bin/magento cache:flush

After phase 1, the category page still renders without filters — the compat module exists but has no templates yet. That is correct. Phases 2 through 5 fill it in.

Phase 2 — Copy the Luma phtml and strip Knockout

The Mageplaza module ships its main filter template at vendor/mageplaza/module-layered-navigation/view/frontend/templates/layer/view.phtml. Copy it into the compat module at the same relative path:

cp vendor/mageplaza/module-layered-navigation/view/frontend/templates/layer/view.phtml \
   app/code/YourAgency/MageplazaLayeredNavigationHyvaCompat/view/frontend/templates/layer/view.phtml

The copied template contains markup that depends on Knockout. Every data-bind="...", every <!-- ko if: ... --> virtual element, every x-magento-init JSON script tag has to go. Here is the before-and-after on the filter-options block:

Luma original (the part that breaks)

<div class="filter-options-item" data-bind="foreach: { data: filters, as: 'filter' }">
    <div class="filter-options-title" data-bind="click: $parent.toggleFilter">
        <span data-bind="text: filter.label"></span>
    </div>
    <div class="filter-options-content" data-bind="visible: filter.isOpen">
        <ol class="items">
            <!-- ko foreach: filter.options -->
            <li class="item">
                <a data-bind="attr: { href: $data.url }, click: $parent.$parent.applyFilter">
                    <span data-bind="text: $data.label"></span>
                    <span class="count" data-bind="text: '(' + $data.count + ')'"></span>
                </a>
            </li>
            <!-- /ko -->
        </ol>
    </div>
</div>
<script type="text/x-magento-init">
{ "*": { "Mageplaza_LayeredNavigation/js/view/filter": { "ajaxUrl": "<?= $block->getAjaxUrl() ?>" } } }
</script>

After phase 2 strip (markup only, no behaviour yet)

<?php /** @var $block \Mageplaza\LayeredNavigation\Block\Navigation\FilterRenderer */ ?>
<?php $filters = $block->getFilters(); ?>
<?php foreach ($filters as $filter): ?>
    <div class="filter-options-item">
        <div class="filter-options-title">
            <span><?= $block->escapeHtml($filter->getName()) ?></span>
        </div>
        <div class="filter-options-content">
            <ol class="items">
                <?php foreach ($filter->getItems() as $option): ?>
                    <li class="item">
                        <a href="<?= $block->escapeUrl($option->getUrl()) ?>">
                            <span><?= $block->escapeHtml($option->getLabel()) ?></span>
                            <span class="count">(<?= (int)$option->getCount() ?>)</span>
                        </a>
                    </li>
                <?php endforeach; ?>
            </ol>
        </div>
    </div>
<?php endforeach; ?>

The diff for phase 2

--- a/view/frontend/templates/layer/view.phtml
+++ b/view/frontend/templates/layer/view.phtml
@@
-<div class="filter-options-item" data-bind="foreach: { data: filters, as: 'filter' }">
-    <div class="filter-options-title" data-bind="click: $parent.toggleFilter">
-        <span data-bind="text: filter.label"></span>
-    </div>
-    <div class="filter-options-content" data-bind="visible: filter.isOpen">
-        <!-- ko foreach: filter.options -->
-        ...
-        <!-- /ko -->
-    </div>
-</div>
-<script type="text/x-magento-init">...</script>
+<?php foreach ($block->getFilters() as $filter): ?>
+    <div class="filter-options-item">
+        <div class="filter-options-title">
+            <span><?= $block->escapeHtml($filter->getName()) ?></span>
+        </div>
+        ...
+    </div>
+<?php endforeach; ?>

After phase 2 the sidebar renders. It does not refresh on click, the dropdowns do not collapse, and there is no AJAX — but the markup is in the DOM and Tailwind classes can be applied. The merchant can already preview the structure on staging.

Phase 3 — Alpine.js port of the live-filter UI

Alpine.js is the reactive layer Hyvä ships by default. The same toggle and AJAX-refresh behaviour Knockout was driving in Luma maps almost one-for-one onto Alpine directives. There is no new server code — the original Mageplaza AJAX endpoint at catalogsearch/result/index accepts the same query parameters.

Wrap each filter group in x-data

<?php foreach ($block->getFilters() as $filter): ?>
    <div class="filter-options-item" x-data="{ open: true }">
        <button type="button"
                class="filter-options-title"
                x-on:click="open = !open"
                :aria-expanded="open">
            <span><?= $block->escapeHtml($filter->getName()) ?></span>
            <svg x-bind:class="open ? 'rotate-180' : ''">...</svg>
        </button>
        <div class="filter-options-content" x-show="open" x-collapse>
            <!-- options list -->
        </div>
    </div>
<?php endforeach; ?>

The live-filter fetch() wrapper

Live filtering — the "apply filter without full page reload" behaviour the merchant paid Mageplaza for — comes from a single root x-data component that intercepts filter-link clicks, posts to the Magento AJAX endpoint, and swaps the product grid HTML.

// view/frontend/web/js/alpine/layered-filter.js
window.layeredFilter = () => ({
    loading: false,
    activeFilters: [],

    init() {
        // Read filters already applied via URL on page load
        const url = new URL(window.location.href);
        url.searchParams.forEach((value, key) => {
            if (key !== 'p') {
                this.activeFilters.push({ key, value });
            }
        });
    },

    async applyFilter(event) {
        event.preventDefault();
        this.loading = true;
        const targetUrl = event.currentTarget.getAttribute('href');

        try {
            const response = await fetch(targetUrl, {
                headers: {
                    'X-Requested-With': 'XMLHttpRequest',
                    'Accept': 'text/html'
                },
                credentials: 'same-origin'
            });
            if (!response.ok) throw new Error(`HTTP ${response.status}`);

            const html = await response.text();
            const doc = new DOMParser().parseFromString(html, 'text/html');

            // Swap the product grid
            const newGrid = doc.querySelector('.products-grid');
            const currentGrid = document.querySelector('.products-grid');
            if (newGrid && currentGrid) currentGrid.innerHTML = newGrid.innerHTML;

            // Swap the filter sidebar (counts will have changed)
            const newSidebar = doc.querySelector('.filter-options');
            const currentSidebar = document.querySelector('.filter-options');
            if (newSidebar && currentSidebar) currentSidebar.innerHTML = newSidebar.innerHTML;

            // Update the browser URL without a reload
            window.history.pushState({}, '', targetUrl);
        } catch (e) {
            console.error('Layered filter failed, falling back to full reload:', e);
            window.location.href = targetUrl;
        } finally {
            this.loading = false;
        }
    }
});

The root container wiring

<div x-data="layeredFilter()" x-cloak>
    <div class="filter-options">
        <?php foreach ($block->getFilters() as $filter): ?>
            <!-- group with x-data="{ open: true }" -->
            <ol class="items">
                <?php foreach ($filter->getItems() as $option): ?>
                    <li class="item">
                        <a href="<?= $block->escapeUrl($option->getUrl()) ?>"
                           x-on:click="applyFilter($event)">
                            <span><?= $block->escapeHtml($option->getLabel()) ?></span>
                            <span class="count">(<?= (int)$option->getCount() ?>)</span>
                        </a>
                    </li>
                <?php endforeach; ?>
            </ol>
        <?php endforeach; ?>
    </div>
    <div x-show="loading" class="loading-mask"><span>Filtering...</span></div>
</div>

Register the Alpine component with Hyvä

<!-- view/frontend/layout/catalog_category_view.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <body>
        <referenceBlock name="hyva.script">
            <arguments>
                <argument name="jsLayout" xsi:type="array">
                    <item name="layeredFilter" xsi:type="string">
                        YourAgency_MageplazaLayeredNavigationHyvaCompat::js/alpine/layered-filter.js
                    </item>
                </argument>
            </arguments>
        </referenceBlock>
    </body>
</page>

After phase 3 the filter is live. Click a swatch, watch the grid and the sidebar swap without a full reload. The behaviour matches the Luma original — the implementation is one-fifth the size.

Phase 4 — Tailwind class swap matching Hyvä tokens

Phase 2 left the Luma class names in place — .filter-options-title, .filter-options-content, .swatch-attribute. None of them exist in Hyvä's CSS bundle. The sidebar renders unstyled. Phase 4 replaces every Luma selector with Tailwind utilities that resolve through the Hyvä theme tokens so dark mode and brand colour overrides keep working.

The full Luma → Hyvä → effort table

Luma selectorHyvä Tailwind portEffort
.filter-options-titleflex items-center justify-between w-full px-3 py-2 font-medium text-primary border-b border-container0.4 h
.filter-options-contentpx-3 py-2 space-y-1 max-h-64 overflow-y-auto0.3 h
.filter-options-itembg-container rounded-md mb-2 overflow-hidden0.2 h
.swatch-attributegrid grid-cols-5 gap-1.5 mt-20.4 h
.swatch-option.colorw-6 h-6 rounded-full border-2 border-container hover:border-primary cursor-pointer0.5 h
.swatch-option.textmin-w-[2.5rem] h-8 px-2 inline-flex items-center justify-center border border-container rounded text-sm0.5 h
.counttext-xs text-secondary ml-10.1 h
.loading-maskabsolute inset-0 bg-white/70 flex items-center justify-center text-primary0.3 h
.price-slider-handlew-4 h-4 rounded-full bg-primary border-2 border-white shadow cursor-grab0.6 h
.filter-current .iteminline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary text-sm rounded0.3 h

The diff that swaps the classes

--- a/view/frontend/templates/layer/view.phtml
+++ b/view/frontend/templates/layer/view.phtml
@@
-<div class="filter-options-item" x-data="{ open: true }">
-    <button type="button" class="filter-options-title" x-on:click="open = !open">
-        <span><?= $block->escapeHtml($filter->getName()) ?></span>
-    </button>
-    <div class="filter-options-content" x-show="open" x-collapse>
+<div class="bg-container rounded-md mb-2 overflow-hidden" x-data="{ open: true }">
+    <button type="button"
+            class="flex items-center justify-between w-full px-3 py-2 font-medium text-primary border-b border-container"
+            x-on:click="open = !open">
+        <span><?= $block->escapeHtml($filter->getName()) ?></span>
+        <svg class="w-4 h-4 transition-transform" x-bind:class="open ? 'rotate-180' : ''"
+             fill="none" stroke="currentColor" viewBox="0 0 24 24">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
+        </svg>
+    </button>
+    <div class="px-3 py-2 space-y-1 max-h-64 overflow-y-auto" x-show="open" x-collapse>

Why text-primary instead of text-black

The Hyvä theme exposes a palette through CSS variables (--color-primary, --color-container, --color-secondary) compiled out of tailwind.config.js. Using text-primary instead of text-black means the merchant can rebrand by editing one config file — every Tailwind class in the compat module follows the theme token through to the brand colour. Hard-coding text-black looks the same on launch day and breaks every rebrand after.[4]

Run Tailwind JIT after the swap

cd app/design/frontend/YourBrand/default/web/tailwind
npm run build-prod
# tailwind.config.js purge already includes the compat module's view/frontend/templates path

The first JIT run picks up every Tailwind utility used in the new template and compiles it into the merchant's styles.css. After this phase the sidebar matches the Hyvä storefront visually — same spacing, same brand colours, same dark-mode behaviour.

Phase 5 — Magewire for server-side reactivity (when you need it)

Phase 3's Alpine.js + fetch() implementation handles the common case. The edge case is when filter counts depend on data the client cannot compute — stock levels that change as another shopper checks out, B2B customer-group-specific filter visibility, or a saved-search badge that ticks up when a new product matches the active filter set.

For those, Magewire is the right tool. It renders server-side on every filter event and pushes only the changed DOM back to the client. Most merchants do not need it. Build phases 1 — 4 first and only add phase 5 if the spec demands it.[5]

The Magewire component

<?php
// Magewire/Catalog/LayerFilter.php
namespace YourAgency\MageplazaLayeredNavigationHyvaCompat\Magewire\Catalog;

use Magewirephp\Magewire\Component;
use Magento\Catalog\Model\Layer\Resolver;

class LayerFilter extends Component
{
    public array $activeFilters = [];
    public int $productCount = 0;

    public function __construct(private Resolver $layerResolver) {}

    public function mount(): void
    {
        $this->recalculate();
    }

    public function applyFilter(string $code, string $value): void
    {
        $this->activeFilters[$code] = $value;
        $this->recalculate();
    }

    public function removeFilter(string $code): void
    {
        unset($this->activeFilters[$code]);
        $this->recalculate();
    }

    private function recalculate(): void
    {
        $layer = $this->layerResolver->get();
        $collection = $layer->getProductCollection();
        foreach ($this->activeFilters as $code => $value) {
            $collection->addFieldToFilter($code, $value);
        }
        $this->productCount = $collection->getSize();
    }
}

The Magewire template

<!-- view/frontend/templates/magewire/catalog/layer-filter.phtml -->
<div wire:id="layer-filter" class="bg-container rounded-md p-3">
    <p class="text-sm text-secondary mb-2">
        Showing <span class="font-semibold text-primary">
            <?= (int)$magewire->productCount ?>
        </span> products
    </p>
    <?php foreach ($block->getFilters() as $filter): ?>
        <button wire:click="applyFilter('<?= $filter->getCode() ?>', '<?= $filter->getValue() ?>')"
                wire:loading.class="opacity-50"
                class="block w-full text-left px-2 py-1 hover:bg-primary/10 rounded">
            <?= $block->escapeHtml($filter->getName()) ?>
        </button>
    <?php endforeach; ?>
</div>

Pick one: Alpine.js or Magewire, not both

Running Alpine.js fetch() and Magewire side-by-side on the same component is a guaranteed race condition — both want to own the filter state. Pick Alpine.js for read-mostly filter UIs (the 95% case). Pick Magewire when the count must be server-authoritative on every click. Do not mix.

The decision matrix — port or buy Hyvä Catalog Add-on?

Before quoting the merchant, run this matrix. The Hyvä team sells a Catalog Add-on that replaces Mageplaza Layered Navigation entirely with a Hyvä-native implementation, and on many engagements that is the better answer.[6]

ConstraintPort Mageplaza to HyväBuy Hyvä Catalog Add-on
Merchant already paid Mageplaza license, refund not possibleYes — sunk cost favours portNo — duplicate spend
Custom Mageplaza features in use (SEO URLs, slug rules, price step config)Yes — port preserves admin configNo — features must be re-built
Generic price + multi-select swatches onlyNo — over-engineeringYes — drop-in
Long-term support contract for Hyvä-only stackNo — vendor mismatch growsYes — single vendor
One-off ecommerce with a small budgetNo — port costs moreYes — lower TCO
Multi-store with both Luma and Hyvä storefronts activeYes — same extension serves bothNo — Hyvä-only
In-house team that maintains custom extensionsYes — port stays in repoEither
Budget under €1,500NoYes

The merchant scoring the engagement on kishansavaliya.com gets a one-page decision summary like the table above before any code is written. About 40% of the time the right answer turns out to be Catalog Add-on, the engagement turns into a migration sprint instead of a port, and the merchant saves 10–15 hours of work.

The compat-module ship checklist

Across roughly 20 Hyvä compatibility ports shipped in 2025–2026, these eight items are the ones that come up in code review every single time. Run through them before tagging the v1.0.0 release.

  • The original extension is untouched. No edits to vendor/mageplaza/. The compat module owns everything new.
  • The fallback.xml entry is committed. Without it, Hyvä loads the broken Luma template instead of the compat one.
  • Every data-bind, x-magento-init, and <!-- ko --> is gone. Run grep -r 'data-bind\|x-magento-init\|ko if\|ko foreach' view/frontend/templates.
  • Alpine directives are namespaced. Use x-data="layeredFilter()", not x-data="filter()" — collisions with other Hyvä components are a real problem.
  • Tailwind classes resolve through tokens. No text-black, bg-white, border-gray-200 — use text-primary, bg-container, border-container.
  • The AJAX endpoint is the existing Magento URL. No new webapi.xml route. The compat module does not add server endpoints — it consumes what is already there.
  • Graceful fallback on fetch() error. Network failure must trigger window.location.href = targetUrl so the page still functions.
  • Browser back button works. history.pushState on every filter click and a popstate listener that re-renders from the URL.

What the merchant pays for

The five-phase port above takes 18–28 hours on a fresh Mageplaza Layered Navigation engagement, 8–14 hours on the second similar extension we port for the same merchant. That includes the decision-matrix call, the compat-module scaffold, phases 2 — 4, the QA matrix on three browsers, and a 30-day patch window. Phase 5 adds 4–8 hours when the spec needs it. Compare against a full rebuild of the filter behaviour from scratch (~80 hours) or a buy-the-add-on migration (6–10 hours but loses Mageplaza-specific features).

Common variations we ship

Multi-select swatches with brand-specific styling

Most Mageplaza filter customers run colour + size swatches. The phase-4 table covers the generic styling — when the merchant has brand-specific swatch sizing (40×40 instead of 24×24, square instead of round, label visible on hover), customize through Tailwind variants rather than CSS overrides: md:w-10 md:h-10 lg:w-12 lg:h-12 group-hover:opacity-100.

Price slider with two handles

The default Mageplaza price slider uses jQuery UI Slider. Replace with the Alpine.js + noUiSlider pattern — same look, no jQuery dependency, 4 KB instead of 80 KB.

Single class swap on the container: md:sticky md:top-20 md:self-start. Tailwind handles the sticky behaviour with no JavaScript. Mageplaza shipped this as a paid feature in Luma — on Hyvä it is one class.

FAQ

Does Hyvä officially support porting Mageplaza Layered Navigation?

Hyvä provides a compatibility-module pattern and a fallback.xml mechanism, but it does not ship an official port of Mageplaza Layered Navigation. The Hyvä Catalog Add-on is the recommended replacement when the merchant is open to swapping vendors. Otherwise the port is a five-phase compat-module job as documented above.

Will the original Mageplaza module still work on Luma stores after we install the compat module?

Yes. The compat module only adds templates inside its own view/frontend/templates/ directory and registers a fallback for the Hyvä theme. Luma storefronts continue to use the original Mageplaza templates because Luma does not read the Hyvä fallback config.

How long does the full port take?

18–28 hours for the first Mageplaza port on a new merchant. About 8–14 hours for the second similar extension on the same store because the compat-module scaffold and Tailwind token mapping are already in place.

Do we need Magewire at all?

No, in 80% of cases. Phase 3's Alpine.js + fetch() pattern handles toggle, AJAX refresh, count display, and URL pushState. Magewire only earns its keep when filter counts depend on server-state that changes outside the current shopper's session — stock thresholds, B2B group visibility, saved-search badges.

What if Mageplaza ships an update to the original extension after our port?

Updates that touch only PHP (block classes, models, plugins) work transparently — the compat module does not override them. Updates that change the original phtml structure require us to diff the new vendor/mageplaza/.../view.phtml against the compat copy and merge the new markup. Pin the Mageplaza version in composer.json with ^4.0 not * so updates are reviewable.

Why not just disable the Mageplaza module entirely and re-implement?

The Mageplaza PHP layer runs the filter logic, the SEO URL rewriting, the admin config, the indexer hooks. Re-implementing all of that costs 80+ hours. The port keeps every line of working PHP and replaces only the broken frontend. That is the value of the compat-module pattern — minimum surface area changes.

Does the port survive Magento 2 upgrades from 2.4.4 through 2.4.9?

Yes. The compat module pins to the Mageplaza module's interface, not Magento core. Across the 2.4.4 → 2.4.9 upgrade path we have run this port through three Mageplaza minor versions and one Magento minor version with zero merge conflicts. The Alpine.js and Tailwind layers do not touch Magento core APIs.

References

  1. Production port engagements from kishansavaliya.com Hyvä compatibility audits, October 2025 — May 2026. Mageplaza Layered Navigation versions 4.0.1 through 4.0.7.
  2. Hyvä Themes documentation, Extension Compatibility, hyva.io/docs/compatibility-modules. Module-naming convention, fallback.xml mechanism, and supported Magento versions.
  3. Mageplaza GitHub, module-layered-navigation, github.com/mageplaza/magento-2-layered-navigation. Luma-only template references and AJAX endpoint definitions.
  4. Hyvä Themes documentation, Tailwind configuration and theme tokens, hyva.io/docs/tailwind-config. Primary, container, and secondary palette tokens compiled from tailwind.config.js.
  5. Magewire project, Magewire — Livewire for Magento 2, github.com/magewirephp/magewire. Component lifecycle, wire:click, wire:loading directives.
  6. Hyvä Themes commerce page, Hyvä Catalog Add-on, hyva.io/hyva-catalog. Drop-in replacement for Mageplaza, Amasty, and Manadev layered-navigation extensions on Hyvä stores.
Need a Mageplaza or Amasty extension ported to Hyvä this sprint?

I am Kishan Savaliya, an Adobe-Certified Magento + Hyvä developer. I run fixed-scope Hyvä compatibility ports with the five-phase commit pattern above, the decision-matrix call, and a 30-day post-deploy patch window. Fixed quote from $499 audit · $2,499 sprint · ~24h @ $25/hr. See Hyvä theme development service or hire me.