PrestaShop Hreflang Done Right: A Practical Guide, for Multilingual and Multistore SEO

 A client adds Spanish to a store that already sells in English and French. Translations go in the switcher works every page looks right. Three weeks later somebody asks why Google is still serving the page to shoppers in Madrid.

That question comes up on our desk often.. The problem is almost never a missing tag. Look at the source code and the PrestaShop hreflang tags are there sitting right where they should be.. They’re quietly disagreeing with something else. The language was never properly attached to that shop.. Someone used a region code that doesn’t match what Google expects.. Maybe there’s an alternate 301 redirect pointing to a category listing instead of the correct localized page.

It’s not about the tag being wrong. It’s about the setup not matching up. The tag says Spanish. The system still thinks the French version is the main one, for that location. That’s what confuses search engines.

Sometimes the canonical is pointing at a different language entirely. Once, memorably, a theme update had dropped the whole block three weeks earlier and nobody had looked at the source since.

That is why it is important to think of hreflang as a way that URLs connect with each other of just seeing it as something you add to a page. The URLs, the language codes, the canonical tags, the translations and the shop settings all need to match each other before a search engine will do anything with any of it. 

1.0 What Hreflang Does and Why Multilingual PrestaShop Stores Need It

What Hreflang Does and Why Multilingual PrestaShop Stores Need It

Hreflang helps a search engine know that several URLs are language or regional versions of the same page. Say your catalogue has these three:

  • English: https://example.com/en/running-shoes
  • French: https://example.com/fr/chaussures-running
  • Spanish: https://example.com/es/zapatillas-running

The markup that connects them looks like this:

         
<link rel="alternate" href="https://example.com/en/running-shoes" hreflang="en">   
<link rel="alternate" href="https://example.com/fr/chaussures-running" hreflang="fr">   
<link rel="alternate" href="https://example.com/es/zapatillas-running" hreflang="es">     

Google has three rules about versions that catch most stores out and the second rule is the most expensive. Every localized version has to list itself alongside all the other localized versions. Feels backwards at first — why would a page link to itself? But that’s the rule. On top of that, the pages need to reference each other both ways: page X links to page Y, page Y links back to X. Skip that step anywhere in the chain and Google’s known to just disregard the whole set of annotations. And don’t forget, alternate URLs can’t be relative — no shorthand like /foo, it has to be the full https://example.com/foo. That middle requirement is really the catch: look at just one page by itself and you won’t actually know if the setup’s working. Judging an hreflang implementation means walking the whole loop.

2.0 How PrestaShop Generates Hreflang, and When It Quietly Doesn’t

Four languages listed on the Languages page does not mean four alternates in your source, and assuming it does costs people a lot of debugging time. The core assembles the set in FrontController::getAlternativeLangsUrl(), a method short enough to read in one go:

    $languages = Language::getLanguages(true, $this->context->shop->id);   
       
    if (count($languages) < 2) {   
        return $alternativeLangs;  // fewer than two languages, nothing emitted   
    }   
       
    foreach ($languages as $lang) {   
        $alternativeLangs[$lang['language_code']]   
            = $this->context->link->getLanguageLink($lang['id_lang']);   
    }      

Three things fall out of those few lines. The shop ID is passed in, so a language enabled globally but never associated with this shop simply won’t appear. Fewer than two active languages produces an empty array and no tags whatsoever. And the array key comes from language_code, which is not the same field as iso_code, a distinction we’ll come back to.

The classic theme then renders whatever it was handed through the head_hreflang block in _partials/head.tpl. One thing worth knowing before you go hunting. The core never emits x-default at all, so if your strategy calls for one it has to come from a theme override or a module.

We ran into a multistore case where English and French were attached to Shop A, Spanish existed in the installation, and Spanish had never been associated with that shop. No PHP error, no warning in the back office, no visible symptom. Shop A just never advertised a Spanish alternate.

2.1 Check the raw HTML before you touch anything

    curl -s https://example.com/fr/product-name | grep -i hreflang   

Empty output narrows things down fast. Work through whether a second language is actually enabled, whether it’s associated with this shop, whether the theme still renders the block, whether you’re being served a cached page, and whether some other component is rewriting the <head>.

3.0 ISO Code and Language Code Are Different Fields

This one produces genuinely confusing bugs, because both fields look correct in the back office and neither is obviously the one feeding your tags.

PrestaShop keeps an ISO code and a language code, and hreflang is built from the second. A store might hold es as its ISO code while the language code says es-MX, which targets Mexico specifically rather than Spanish generally. The catch is the default. Language::getFields() copies iso_code into language_code whenever language_code is empty, and getFields() runs on every add and update, so a language saved with that box blank silently inherits the ISO value. That’s why so many stores emit bare two-letter codes when they meant to target regions, and why the fix is usually one field in the back office rather than anything in code.

There’s a limit here that catches people out on Asian and Latin American rollouts. The language_code column is five characters, validated by Validate::isLanguageCode(), whose regex is:

         
    preg_match('/^[a-zA-Z]{2}(-[a-zA-Z]{2})?$/', $s)   
         

That grammar accepts pt-BR without complaint, but it rejects zh-Hans, where the subtag runs to four letters, and es-419 too, since digits never match. Any SEO plan resting on script or numeric-region subtags therefore runs into a language table that cannot store them, and the tags have to be injected some other way.

The permissive side of the same regex is worth as much attention. Two letters, a hyphen and two more letters will pass whether or not the region actually exists, so PrestaShop stores en-UK quite happily despite the ISO 3166-1 code for the United Kingdom being GB. Shape is all the validator checks. It has no opinion about meaning, and it will not catch an invalid region for you.

4.0 Hreflang and Canonical Are Separate Signals

Mixing these two up is one of the most common mistakes we see, and honestly, they answer completely different questions. A canonical tells search engines which URL should represent a page. Hreflang does something it chooses which URL to serve according to the visitors language or region.

A coherent French page says:

         
    <link rel="canonical" href="https://example.com/fr/chaussures-running">   
    <link rel="alternate" href="https://example.com/fr/chaussures-running" hreflang="fr">   
    <link rel="alternate" href="https://example.com/en/running-shoes" hreflang="en">   
         

Now imagine that same French page: the canonical of that page points to the English URL but the hreflang of that page still says it is the French version. The page is announcing itself as French and simultaneously nominating an English URL as the one that matters. Search engines resolve that contradiction in ways you won’t enjoy, which is why canonical and hreflang have to be audited together rather than one after the other.

5.0 The Multistore Hreflang Trap

Multistore makes international SEO considerably more interesting. Picture shop-fr.example.com, shop-de.example.com and shop-es.example.com running as separate stores, each carrying its own context, none of them holding any inherent knowledge of what the others contain. The French shop has no idea that the German shop holds the German version of a given page, so what you end up with is two stores that both work, both render hreflang, and never declare the relationship between them.

The mechanism explains why. getAlternativeLangsUrl() passes $this->context->shop->id into Language::getLanguages(), so the set it builds can only ever contain languages attached to the shop currently being served. A shop running one language per domain therefore hits the two-language guard and emits nothing at all, which is exactly the configuration many international rollouts choose. The store looks correct in every browser and publishes no international signal whatsoever.

Two setups behave very differently here, and it’s worth knowing which one you have. One shop serving several languages under /fr/ and /de/ prefixes gets hreflang from the core for free. Several single-language shops on separate domains get nothing automatic, and the relationships have to come from a module that understands the domain mapping, or from a theme override that builds the set from your own configuration.

Presence of a tag proves nothing here. What you need to verify is reciprocity.

6.0 Reciprocity: The Rule That Breaks Most Implementations

If the French page declares a German alternate, the German page has to declare a French one. Where that return link is missing, Google’s documentation says the annotations may be ignored, which in practice means planning for them to be. The usual explanation offered for the rule is defensive, that without return links anyone could name your page as an alternate of theirs, though Google’s current page states the requirement rather than the reasoning.

Checking one URL therefore gives you false confidence. On a catalogue of any size this is crawler work, because broken relationships cluster in exactly the products nobody thought to spot-check.

For a quick manual pass, pull the alternates off one page and follow each one back:

    curl -s https://example.com/fr/chaussures-running \   
      | grep -o '<link rel="alternate"[^>]*>'   

PrestaShop writes href before hreflang in that tag, so match the whole <link> element. A pattern that starts at hreflang= will silently return nothing and read like missing tags.

Then repeat it against every URL that comes back and confirm the French page appears in each result. If a language shows up going out but never coming back, that pairing is being discarded, and no amount of correct markup on the French side will change that. For anything past a handful of pages, a crawler that reports hreflang return links will find in minutes what manual checking misses entirely.

7.0 Untranslated Slugs and Alternate URLs That Don’t Resolve

Two failures worth separating here, because from the outside they look much the same.

First problem: untranslated rewrites. Take a French product living at /fr/chaussures-running, the English version ought to be /en/running-shoes, but skip filling in that rewrite and you’re left with /en/chaussures-running instead, French words sitting under an English URL. The mechanism behind that gets described wrongly often enough to be worth spelling out. Product pages go from getLanguageLink() to getProductLink(), which pulls link_rewrite for the target language out of ps_product_lang via ObjectModel::getFieldByLang(), and no fallback exists anywhere in that path. Whatever sits in that language’s row is what you get. In practice the row usually holds a copy of the source language’s slug, because nobody translated it. Where the row is genuinely empty you get a malformed URL instead, which the dispatcher may not route at all. Either way the page returns 200, the content displays in English, the customer notices nothing, and checking six products tells you nothing about the other four thousand.

The second is the alternate that doesn’t land where you think. Every URL in an hreflang set should return 200, in the right language, with a sensible canonical, without an unexpected redirect and without a stray noindex. An alternate that 301s from /es/product to /es/category still looks perfectly valid in the source. Audit where the URL lands, then worry about how the tag is written.

8.0 Where SEO Modules Fit In

Real stores rarely have one component owning SEO output. Canonical handling, meta titles, robots directives, structured data, URL rewriting, sitemaps and hreflang get spread across the core, the theme, and two or three modules, with caching sitting on top of all of it. We have picked apart plenty of stores where every individual piece was technically valid and the combined output still wasn’t what the merchant expected. When several systems write to the same <head>, debug the final HTML rather than reasoning about which one ought to be responsible.

Caching deserves particular suspicion during this kind of work. PrestaShop’s Smarty compilation and full-page cache can both keep serving a <head> built before your change, which makes a correct fix look like a failed one and sends people back to edit templates that were already right. Clear the cache and re-fetch with curl before drawing any conclusion about whether a change worked.

Getting a baseline first also helps. Something like Knowband’s SEO Audit Manager scores pages against on-page signals such as titles, meta descriptions, headings, alt text and link structure, and handles redirects, the sitemap and robots.txt. It doesn’t manage hreflang, so treat it as the layer that tells you whether a page is healthy enough to be worth an alternate, rather than as an hreflang fix.

9.0 A Practical Hreflang Debugging Workflow

When a client reports that hreflang isn’t working, resist the urge to open a template. The configuration is more often at fault than the markup, and the seven steps below run cheapest-first so you find that out before editing anything.

9.1 Start from a real product URL

Homepages hide problems. Product pages pull in translated names, translated rewrites, availability and canonical logic all at once.

9.2 Read what the server actually sent

    curl -s https://example.com/fr/product-name | grep -Ei "hreflang|canonical|robots"   

Browser devtools show you the DOM after JavaScript has had its way with it. For SEO work you want the raw response.

9.3 Count the languages, per shop

How many are active, which ones belong to this shop, and is this a multistore install? Remember the core emits nothing at all below two languages.

9.4 Read the language codes, not the ISO codes

Check that regional targeting matches the actual strategy, and that nobody has stored a region code that doesn’t exist.

9.5 Open every alternate

Anything other than a 200 is a finding, whether that’s a 301, a 302, a 404 or something in the 5xx range.

    curl -I https://example.com/es/product-name   

9.6 Verify reciprocity and canonical together

Walk the loop in both directions, then confirm each language version’s canonical points at itself rather than across languages.

9.7 Check what changed

If this started after a theme update, look at themes/yourtheme/templates/_partials/head.tpl and confirm the hreflang block survived. If a module was installed, updated or reconfigured around the same time, isolate it on staging rather than production, and go in without a verdict already formed. Module and core are both plausible, and what settles it is the expected HTML against the actual HTML with one variable changed at a time. That produces evidence rather than a hypothesis, and it is faster than arguing about which component is more likely to be at fault.

10.0 A Before-and-After Example

Here’s output with several problems in it:

    <link rel="canonical" href="https://example.com/fr/chaussures-running">   
    <link rel="alternate" href="/es/zapatillas-running" hreflang="es">   
    <link rel="alternate" href="https://example.com/fr/chaussures-running" hreflang="fr">   
    <link rel="alternate" href="https://example.com/en/chaussures-running" hreflang="en-UK">   

Every line in that block has something wrong with it. The Spanish alternate is relative where Google requires a fully-qualified URL, and the region code en-UK should read en-GB, which as we saw the validator will never object to. The English URL is still carrying the French slug, so that rewrite was never translated. Nothing there declares an x-default either, leaving visitors who match none of the listed languages to whatever Google decides.

Cleaned up:

    <link rel="canonical" href="https://example.com/fr/chaussures-running">   
    <link rel="alternate" href="https://example.com/fr/chaussures-running" hreflang="fr">   
    <link rel="alternate" href="https://example.com/es/zapatillas-running" hreflang="es">   
    <link rel="alternate" href="https://example.com/en/running-shoes" hreflang="en-GB">   
    <link rel="alternate" href="https://example.com/en/running-shoes" hreflang="x-default">   

Don’t paste that into every store. The point is that language, region, URL and canonical all agree with one another.

11.0 Robots, Noindex and the Sitemap Layer

We’ve watched developers spend a day perfecting hreflang only to find the Spanish page carries <meta name=”robots” content=”noindex”>, or that robots.txt blocks the path. An alternate relationship won’t rescue a page that can’t be indexed in the first place.

Audit three layers instead of one. The page HTML gives you canonical, hreflang and robots. The URLs themselves give you status codes, correct language and unwanted redirects. The sitemap tells you whether the URLs you’re submitting match the ones you’re declaring canonical and alternate. When those three disagree, you’re looking at inconsistent architecture rather than a single broken tag.

12.0 Hreflang Across PrestaShop 1.7, 8 and 9

The mechanism has been broadly stable, with two exceptions worth knowing. getAlternativeLangsUrl() and the head_hreflang block work the same way in 1.7.7.8 and 9.1.3, and the two-language guard is identical in those two.

The first exception is early 1.7. In 1.7.5.0 the guard reads if ($languages < 2), comparing an array against an integer instead of counting it, so it never fires and a single-language shop emits one self-referencing alternate rather than nothing. By 1.7.7.8 the line is count($languages) < 2. If you are on an early 1.7.x release, read your own copy before trusting the rule.

The second is that 8.2.4 and 9.1.3 pass each generated URL through a sanitizeUrl() method that escapes query-string values before they reach the tag. That method does not exist in 1.7.7.8. It changes how the values are written, not which parameters survive.

What varies most between installs, though, is everything around the core: theme implementation, overrides, custom modules, multistore configuration, caching, and whatever an upgrade quietly changed.

So after any upgrade, re-check representative product, category and CMS URLs. A store working before an upgrade is not evidence that its SEO output survived one.

13.0 The PrestaShop Hreflang Checklist

    [ ] At least two languages enabled, and enabled for THIS shop   
    [ ] language_code set deliberately, not left to fall back to iso_code   
    [ ] Region codes are real ISO 3166-1 values (GB, not UK)   
    [ ] No language needing a subtag longer than the 5-char column   
    [ ] Every alternate URL is absolute   
    [ ] Every alternate returns 200, no redirect, right language   
    [ ] Every page lists itself as well as the others   
    [ ] Relationships are reciprocal in both directions   
    [ ] Canonical points within the same language version   
    [ ] No alternate is noindexed or blocked in robots.txt   
    [ ] Translated URL rewrites exist where strategy requires them   
    [ ] Theme still renders the head_hreflang block   
    [ ] x-default added deliberately if needed (core won't emit it)   
    [ ] Multistore domains mapped correctly   
    [ ] Cache cleared after configuration changes   

14.0 Final Thoughts: Debug the Whole Relationship

A store can carry a perfectly formed hreflang=”es” and still have a broken implementation, because the target redirects, or 404s, or serves the wrong language, or contradicts its own canonical, or sits behind a noindex, or belongs to a different shop entirely. Equally, a flawless canonical strategy tells you nothing about whether the alternate set is complete.

Getting PrestaShop hreflang right has very little to do with adding tags. It’s about the whole chain agreeing with itself, from language configuration through URL generation, content, canonical, indexability and finally the sitemap.

When those signals line up, search engines understand your multilingual store. When they don’t, another module or another <link> rarely helps. Start with the raw HTML, check the language configuration against the shop, open every alternate, walk the loop both ways, compare canonical with hreflang, and only then go looking at theme and module changes.

For a large catalogue that shift matters more than any individual fix, because it turns SEO troubleshooting into something repeatable rather than a pile of one-off patches.

If you have questions or need assistance with your website performance or migration, our experts are here to help. Contact the Knowband team at [email protected] today for reliable eCommerce plugins tailored to your eCommerce needs.

Leave a Reply