How to Add a Real SEO Counter in PrestaShop 8 (60/160 chars)

The previous post in this series covered the problem: PrestaShop 8 shows a counter on meta fields based on the database maxlength (128/512), not the real SEO target (60/160). This post builds the solution from scratch. Technical tutorial, ready-to-use code, no unusual dependencies. At the end we cover the shortcut: the Zeyvro Meta Counter module already packaged for those who prefer not to write it.

What we need to achieve

  • Detect all meta_title and meta_description fields that appear in back-office forms (Products, Categories, Brands, Suppliers, CMS, etc.).
  • Inject below each one a counter with format SEO: X / Y.
  • Change the counter color based on distance to target: green if more than 10 characters remain, amber if 10 or fewer remain, red if over the limit.
  • Hide the native counter on the V2 product page, which shows the maxlength and takes up space without adding value.
  • Work in SEO tabs loaded lazily (the V2 product page loads them via Symfony after a click).
  • No aggressive theme overrides. No database changes. Clean to uninstall.

Minimal module structure

Create a minimal PS8 module that registers a media hook to inject CSS+JS on every admin page. Directory structure:

zeyvrometacounter/
├── zeyvrometacounter.php
├── config.xml
├── logo.png
├── views/
│   ├── css/meta-counter.css
│   └── js/meta-counter.js
└── index.php (anti-listing)

The main file:

<?php
if (!defined('_PS_VERSION_')) exit;

class Zeyvrometacounter extends Module
{
    public function __construct()
    {
        $this->name = 'zeyvrometacounter';
        $this->tab = 'seo';
        $this->version = '1.0.0';
        $this->author = 'Zeyvro';
        $this->ps_versions_compliancy = ['min' => '8.0.0', 'max' => '8.99.99'];
        $this->bootstrap = true;
        parent::__construct();
        $this->displayName = $this->l('Zeyvro Meta Counter');
        $this->description = $this->l('SEO-oriented character counter for meta fields.');
    }

    public function install()
    {
        return parent::install() && $this->registerHook('actionAdminControllerSetMedia');
    }

    public function hookActionAdminControllerSetMedia($params)
    {
        $controller = $this->context->controller;
        if (!is_object($controller)) return;
        $controller->addCSS($this->_path . 'views/css/meta-counter.css');
        $controller->addJS($this->_path . 'views/js/meta-counter.js');
    }
}

The hook actionAdminControllerSetMedia fires on every admin controller before rendering. That’s where you inject the CSS and JS that do the work.

The JavaScript that detects and counts

The JS needs to: (a) find the meta fields in the DOM, (b) attach a counter to each, (c) update the counter on keypress, (d) re-scan when new fields appear via lazy loading.

(function () {
    'use strict';

    var LIMIT_TITLE = 60;
    var LIMIT_DESC = 160;

    function isMetaTitle(el) {
        var k = ((el.name || '') + ' ' + (el.id || '')).toLowerCase();
        return k.indexOf('meta_title') !== -1 || k.indexOf('metatitle') !== -1;
    }
    function isMetaDesc(el) {
        var k = ((el.name || '') + ' ' + (el.id || '')).toLowerCase();
        return k.indexOf('meta_description') !== -1 || k.indexOf('metadescription') !== -1;
    }
    function classify(el) {
        if (isMetaTitle(el)) return { max: LIMIT_TITLE };
        if (isMetaDesc(el))  return { max: LIMIT_DESC };
        return null;
    }
    function findFields() {
        return document.querySelectorAll(
            'input[name*="meta_title"], input[id*="meta_title"], '
            + 'textarea[name*="meta_description"], textarea[id*="meta_description"], '
            + 'input[name*="meta_description"], input[id*="meta_description"]'
        );
    }
    function attach(el) {
        if (el.dataset.zvCounter === '1') return;
        var info = classify(el);
        if (!info) return;
        el.dataset.zvCounter = '1';

        var counter = document.createElement('small');
        counter.className = 'zv-meta-counter';
        if (el.nextSibling) {
            el.parentNode.insertBefore(counter, el.nextSibling);
        } else {
            el.parentNode.appendChild(counter);
        }

        function update() {
            var len = (el.value || '').length;
            var left = info.max - len;
            counter.textContent = 'SEO: ' + len + ' / ' + info.max;
            counter.classList.remove('zv-meta-counter--ok','zv-meta-counter--warn','zv-meta-counter--over');
            if (left < 0)        counter.classList.add('zv-meta-counter--over');
            else if (left <= 10) counter.classList.add('zv-meta-counter--warn');
            else                 counter.classList.add('zv-meta-counter--ok');
        }
        el.addEventListener('input', update);
        el.addEventListener('change', update);
        update();
    }
    function scan() {
        var fields = findFields();
        for (var i = 0; i < fields.length; i++) attach(fields[i]);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', scan);
    } else {
        scan();
    }

    // Lazy-loaded SEO tab in V2 product form
    var observer = new MutationObserver(function () { scan(); });
    observer.observe(document.documentElement, { childList: true, subtree: true });
})();

Details to note:

  • Flexible detection: we search in both name and id with indexOf. PS varies the field suffix depending on context (Symfony V2 product adds [1] at the end, legacy forms use meta_title_1, the Symfony category editor another variant). Searching by contains covers all cases.
  • Marked to avoid duplication: data-zv-counter="1" prevents attaching two counters to the same input when the observer fires more than once.
  • MutationObserver: necessary because the V2 product form in PS8 loads the SEO tab lazily (the input HTML doesn’t exist on page load). Without the observer, the counter wouldn’t appear on that tab until a page refresh.
  • No jQuery: although PS8 back office loads jQuery, we avoid it so the module remains valid when PS fully migrates to Symfony and removes jQuery from admin.

The CSS that paints the counter and hides the native one

.zv-meta-counter {
    display: block;
    margin-top: 4px;
    font-size: 12px;
    font-weight: 500;
    line-height: 1.2;
    font-variant-numeric: tabular-nums;
}
.zv-meta-counter--ok    { color: #00b88a; }
.zv-meta-counter--warn  { color: #F59E0B; }
.zv-meta-counter--over  { color: #EF4444; }

/* Hide PS native char counter in V2 product form */
.js-text-with-length-counter .input-group-append {
    display: none !important;
}

The selector .js-text-with-length-counter .input-group-append targets the span that PS8 V2 injects below the field when it has a maxlength. Hiding it prevents the reader from seeing two contradictory counters — PS’s showing 0/128 and ours showing SEO: 0/60. If your store uses the legacy form (not V2), that selector doesn’t affect anything.

The font-variant-numeric: tabular-nums rule prevents the counter from ‘jumping’ when switching between digits of different widths. Small detail, noticeable improvement.

Cases not covered by default

  • Meta fields from third-party modules that do NOT use the meta_title / meta_description pattern in their name or id. If module X puts an SEO field with name seo_title, we don’t detect it. Extend the findFields regex based on the modules you have installed.
  • Multi-language with tabs: each language renders its own input. The observer picks up new ones as they load, but the counter for an inactive language stays with the last value. Advanced solution: re-evaluate on tab change. Not needed for v1.0.0 since most SEO writers review the active language’s meta and little else.
  • Frontend: this module does NOT touch the frontend. Frontend metas are rendered via theme + Rank Math or equivalent — that’s a different layer.

Packaging and publishing

To distribute the module in a PS8 store: zip -r zeyvrometacounter-1.0.0.zip zeyvrometacounter/ -x "zeyvrometacounter/.git/*". Upload from Back Office → Modules → Upload a module → drag the zip → Install. No configuration. No tables. No overrides.

Shortcut: Zeyvro Meta Counter module

If you prefer not to write the module from scratch, we’ve published Zeyvro Meta Counter v1.0.0 with the implementation described in this post: free PS8 module, MIT, downloadable from our store. Repo on GitHub zeyvro/zeyvro-prestashop-metacounter (private initially, will open to public with the tagged v1.0.0 release).

Differences vs. writing it yourself: you save the curve of detecting fields in V2 product + MutationObserver edge cases + clean packaging structure. The source code is the same as what you’ve seen above.

The next post in this series is the real case: a store that went from the maxlength counter to the SEO target in an afternoon and measured the impact for 30 days. If your experience with PS8 meta fields contradicts anything in this tutorial, write to us at hola@zeyvro.com.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top