<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: MEG Venture &amp; Consulting Ltd.</title>
    <description>The latest articles on DEV Community by MEG Venture &amp; Consulting Ltd. (@megventure).</description>
    <link>https://dev.to/megventure</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4092984%2F016c214b-f326-47cf-a38a-b6901a0055cd.png</url>
      <title>DEV Community: MEG Venture &amp; Consulting Ltd.</title>
      <link>https://dev.to/megventure</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/megventure"/>
    <language>en</language>
    <item>
      <title>Your database dumps are protected by an .htaccess. Your server may not read it.</title>
      <dc:creator>MEG Venture &amp; Consulting Ltd.</dc:creator>
      <pubDate>Thu, 27 Aug 2026 06:40:02 +0000</pubDate>
      <link>https://dev.to/megventure/your-database-dumps-are-protected-by-an-htaccess-your-server-may-not-read-it-cgb</link>
      <guid>https://dev.to/megventure/your-database-dumps-are-protected-by-an-htaccess-your-server-may-not-read-it-cgb</guid>
      <description>&lt;p&gt;We ran an audit of our own shop this summer. Three &lt;code&gt;.sql&lt;/code&gt; files were sitting in the web root, downloadable by anyone who typed the right URL. Nobody had been careless in any dramatic way. They were the ordinary residue of ordinary work: an export taken before an upgrade, a copy made to test something locally, a dump someone pulled through a hosting panel and never came back for.&lt;/p&gt;

&lt;p&gt;That is the honest version of how this happens, and it is worth writing down, because the usual response — "PrestaShop protects the backup folder" — is true and does not help.&lt;/p&gt;

&lt;h2&gt;
  
  
  What PrestaShop actually does
&lt;/h2&gt;

&lt;p&gt;Credit where it is due. A fresh install ships &lt;code&gt;admin-dev/backups/.htaccess&lt;/code&gt;, and it contains exactly what you would want:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="c"&gt;# Apache 2.2&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;IfModule&lt;/span&gt;&lt;span class="sr"&gt; !mod_authz_core.c&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="nc"&gt;Order&lt;/span&gt; deny,allow
    &lt;span class="nc"&gt;Deny&lt;/span&gt; &lt;span class="ss"&gt;from&lt;/span&gt; &lt;span class="ss"&gt;all&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;IfModule&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;
&lt;span class="c"&gt;# Apache 2.4&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;IfModule&lt;/span&gt;&lt;span class="sr"&gt; mod_authz_core.c&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="nc"&gt;Require&lt;/span&gt; &lt;span class="ss"&gt;all&lt;/span&gt; denied
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;IfModule&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is an &lt;code&gt;index.php&lt;/code&gt; stub next to it, so a directory listing gives you nothing either. The DB Backup page under Advanced Parameters writes into that folder, and &lt;code&gt;PrestaShopBackup&lt;/code&gt; builds the name with a random component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="nv"&gt;$backupDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'/backups/'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="nv"&gt;$rand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;dechex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;mt_rand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0xFFFFFFFF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;mt_getrandmax&lt;/span&gt;&lt;span class="p"&gt;())));&lt;/span&gt;
&lt;span class="nv"&gt;$date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$backupfile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRealBackupPath&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$date&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$rand&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'.sql'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the design is: the folder denies everything, and the filename is not guessable at a glance. Two layers. Fine.&lt;/p&gt;

&lt;p&gt;Now the part that matters. &lt;strong&gt;The folder is inside the web root&lt;/strong&gt; — &lt;code&gt;_PS_ADMIN_DIR_&lt;/code&gt; is a directory your web server serves. Nothing about that path is outside the document root. The entire protection is one text file that the web server has to choose to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four ways the one text file stops working
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Your server does not read &lt;code&gt;.htaccess&lt;/code&gt; at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;nginx has no such mechanism. It never had one. An &lt;code&gt;.htaccess&lt;/code&gt; file on an nginx host is a text file with no special meaning whatsoever — the deny-all block is inert, and the URL resolves straight to the dump. The same is true of Apache configured with &lt;code&gt;AllowOverride None&lt;/code&gt;, which is the recommended production setting in Apache's own documentation for performance reasons.&lt;/p&gt;

&lt;p&gt;A large share of managed PrestaShop hosting is nginx, or nginx in front of Apache with static files served by nginx directly, which produces exactly the same outcome for a &lt;code&gt;.sql&lt;/code&gt; file. If you have never checked which one you are on, you do not know whether that file is doing anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The folder can come back without it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PrestaShopBackup&lt;/code&gt; writes the dump. It does not write the &lt;code&gt;.htaccess&lt;/code&gt;. That file exists because it shipped with the install, so it survives only as long as nobody removes the folder. Delete the backups folder to clear space and let something recreate it, deploy with a hand-written file list that leaves dotfiles out, restore from an archive built with a glob that skipped them, and the folder comes back with dumps in it and nothing guarding it.&lt;/p&gt;

&lt;p&gt;We know this happens because our own cleanup module writes the pair back if they are missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;is_dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;file_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'.htaccess'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;file_put_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'.htaccess'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;Files ~ &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;\.(sql|gz)$\"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;\nOrder&lt;/span&gt; &lt;span class="nc"&gt;Allow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;Deny\nDeny&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="n"&gt;all\n&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Files&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;");
}
if (is_dir(&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="s2"&gt;) &amp;amp;&amp;amp; !file_exists(&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="s2"&gt;.'index.php')) {
    @file_put_contents(&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="s2"&gt;.'index.php', "&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&lt;/span&gt;&lt;span class="nf"&gt;php\nheader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Location: ../'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="n"&gt;\nexit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="n"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;");
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You do not write that check unless you have seen the folder without them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The shop's main &lt;code&gt;.htaccess&lt;/code&gt; does not cover it either.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It is tempting to assume PrestaShop's generated root &lt;code&gt;.htaccess&lt;/code&gt; — the one the Traffic &amp;amp; SEO page rewrites — has a rule for this. It does not. In 8.2 the only &lt;code&gt;&amp;lt;Files&amp;gt;&lt;/code&gt; block it generates protects &lt;code&gt;composer.lock&lt;/code&gt;. There is no rule for &lt;code&gt;.sql&lt;/code&gt;, no rule for the backups path, no &lt;code&gt;Options -Indexes&lt;/code&gt;. So regenerating your &lt;code&gt;.htaccess&lt;/code&gt;, which merchants do fairly often, adds nothing here and (if you had hand-edited a rule in) removes it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Most dumps never went through that page.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is where ours came from, and I suspect where most of them come from. A dump produced by phpMyAdmin's "save on server", by a hosting panel's export button, by &lt;code&gt;mysqldump &amp;gt; backup.sql&lt;/code&gt; in whatever directory the SSH session happened to open in — none of those land in the protected folder, and none of them get a random name. They get the name you chose, which is &lt;code&gt;backup.sql&lt;/code&gt;, or &lt;code&gt;db.sql&lt;/code&gt;, or &lt;code&gt;shop-2026-08.sql.gz&lt;/code&gt;, in the directory where you were working, which is the web root.&lt;/p&gt;

&lt;p&gt;No amount of correctness in &lt;code&gt;PrestaShopBackup&lt;/code&gt; touches this case.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is in the file
&lt;/h2&gt;

&lt;p&gt;Worth being specific, because "a database dump" sounds abstract and the contents are not.&lt;/p&gt;

&lt;p&gt;A PrestaShop dump contains &lt;code&gt;ps_customer&lt;/code&gt; and &lt;code&gt;ps_address&lt;/code&gt;: names, email addresses, postal addresses, phone numbers, and customer password hashes, for every account the shop has ever had. It contains &lt;code&gt;ps_orders&lt;/code&gt; with what everyone bought and &lt;code&gt;ps_order_payment&lt;/code&gt; with transaction references. It contains &lt;code&gt;ps_employee&lt;/code&gt;, which is your back-office accounts and their password hashes. And it contains &lt;code&gt;ps_configuration&lt;/code&gt;, which is where PrestaShop and every module you have installed keep their settings — including the SMTP credentials your shop sends mail with, webservice keys, and whatever API credentials your payment and shipping modules were configured with.&lt;/p&gt;

&lt;p&gt;That last one is why "we deleted the file" is not the end of the incident. If a dump was reachable, the credentials in it have to be treated as disclosed, and that means rotating them, not just removing the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking your own shop, in about two minutes
&lt;/h2&gt;

&lt;p&gt;All of this is on your own server, so there is nothing clever involved:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Find them.&lt;/strong&gt; From the shop root: &lt;code&gt;find . -maxdepth 3 \( -name '*.sql' -o -name '*.sql.gz' -o -name '*.sql.zip' \)&lt;/code&gt;. Include your archive extensions. A full-site &lt;code&gt;.zip&lt;/code&gt; or &lt;code&gt;.tar.gz&lt;/code&gt; from a migration is the same problem wearing a different extension.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ask your own server for one.&lt;/strong&gt; Request the exact URL of a file you just found, from outside, with a browser that is not logged in. This is the only test that answers the question, because it is the server's actual configuration answering, not your assumption about it. A &lt;code&gt;403&lt;/code&gt; is what you want. A &lt;code&gt;200&lt;/code&gt; with a download is the finding.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Check the backups folder specifically&lt;/strong&gt;, including whether &lt;code&gt;.htaccess&lt;/code&gt; and &lt;code&gt;index.php&lt;/code&gt; are still there.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Know which server you are on.&lt;/strong&gt; &lt;code&gt;nginx -v&lt;/code&gt;, or the &lt;code&gt;Server&lt;/code&gt; response header, or ask your host. If the answer is nginx, every &lt;code&gt;.htaccess&lt;/code&gt; on that host is decoration.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The fix that does not depend on the answer
&lt;/h2&gt;

&lt;p&gt;Move dumps out of the document root. That is the whole fix, and it is the only one that survives a server migration, a control-panel change, or a colleague who does not know the rule. A directory one level above the web root, or an object store, or your laptop. If the file is not addressable by URL, no server configuration can accidentally serve it.&lt;/p&gt;

&lt;p&gt;Everything else is mitigation, in descending order of reliability:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A server-level rule&lt;/strong&gt;, in the nginx &lt;code&gt;server&lt;/code&gt; block or the Apache vhost, not in an &lt;code&gt;.htaccess&lt;/code&gt;. On nginx: a &lt;code&gt;location&lt;/code&gt; matching your dump extensions with &lt;code&gt;deny all;&lt;/code&gt; and &lt;code&gt;return 404;&lt;/code&gt;. In a vhost it cannot be silently disabled by &lt;code&gt;AllowOverride&lt;/code&gt;, and it does not vanish when a folder is recreated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete the dump when you are done with it.&lt;/strong&gt; Almost every exposed dump was needed for one afternoon two years ago. A backup you are keeping deliberately belongs in your backup system, which is not a folder in your shop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate what was in it&lt;/strong&gt; if it was ever reachable. Employee passwords, webservice keys, module API credentials, SMTP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if the file is reachable, do not stop at the shop. Search engines index what they can reach, so a dump that sat in the open for a while may be findable independently of your server, which means removing the file is step one of two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our own module is not innocent here
&lt;/h2&gt;

&lt;p&gt;Since this is a field note and not marketing: our cleanup module writes its backups into that same folder, and its filenames look like &lt;code&gt;prestacleaner_check-fix-20260827-084500.sql.gz&lt;/code&gt;. That is a prefix, a date and a time. It is entirely predictable to anyone who knows the format, which is now everyone reading this.&lt;/p&gt;

&lt;p&gt;That is a deliberate trade — the files have to be recognisable in a list so the merchant can tell which is which, and so cleanup never touches a backup somebody else's tool wrote. But it means the naming contributes nothing to secrecy and the folder-level deny is carrying all of it, which is precisely the arrangement this post is about. On an nginx host, that is one layer, and the layer is not there.&lt;/p&gt;

&lt;p&gt;The point of writing it down is that we found this in our own shop, by looking. The looking is the part that generalises.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;From maintaining around sixty PrestaShop modules at &lt;a href="https://megventure.com" rel="noopener noreferrer"&gt;MEG Venture&lt;/a&gt;. Verified against PrestaShop 8.2 source: &lt;code&gt;classes/PrestaShopBackup.php&lt;/code&gt;, &lt;code&gt;classes/Tools.php&lt;/code&gt;, &lt;code&gt;admin-dev/backups/.htaccess&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>PrestaShop's Configuration table is global, and uninstalling your module can delete someone else's settings</title>
      <dc:creator>MEG Venture &amp; Consulting Ltd.</dc:creator>
      <pubDate>Wed, 26 Aug 2026 11:36:32 +0000</pubDate>
      <link>https://dev.to/megventure/prestashops-configuration-table-is-global-and-uninstalling-your-module-can-delete-someone-elses-3ac2</link>
      <guid>https://dev.to/megventure/prestashops-configuration-table-is-global-and-uninstalling-your-module-can-delete-someone-elses-3ac2</guid>
      <description>&lt;p&gt;&lt;code&gt;Configuration::updateValue('width', 40)&lt;/code&gt; looks harmless. It is one row in &lt;code&gt;ps_configuration&lt;/code&gt;, it holds your module's setting, and it works.&lt;/p&gt;

&lt;p&gt;It works until a second module does the same thing. &lt;code&gt;ps_configuration&lt;/code&gt; is a shop-wide key/value table with no namespace and no owner column. Two modules that pick the same name share one row: whichever saves last wins, and neither has any way to notice. Then one of them is uninstalled, runs its tidy-up, and deletes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;uninstall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Configuration&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;deleteByName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'width'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// whose width?&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;uninstall&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the bug we found in one of our own modules. It stored &lt;code&gt;theme&lt;/code&gt;, &lt;code&gt;background&lt;/code&gt;, &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;effect&lt;/code&gt; as bare names. Fixing that one module was easy. The interesting part was the question it raised: how many of the others do this?&lt;/p&gt;

&lt;h2&gt;
  
  
  The sweep, and why the first one was wrong
&lt;/h2&gt;

&lt;p&gt;We have 57 module repositories. Grepping them for &lt;code&gt;Configuration::&lt;/code&gt; calls produces a lot of hits, most of them fine, so the sweep needs a filter. Our first filter was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A name that is UPPERCASE with underscores is prefixed. Anything else is suspect.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is wrong, and it is wrong in the direction that hides bugs. It passed three names that are uppercase, underscored, and completely unprefixed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;API_DATE_FROM&lt;/code&gt; and &lt;code&gt;PRODUCT_PAGE_FRONT_ENABLE&lt;/code&gt; — in a seller-dashboard module&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VIDEO_PRODUCTS_NBR&lt;/code&gt; — in a product-video module&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;API_*&lt;/code&gt; is about as generic as a key can get. It looks disciplined. It is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The right test is not capitalisation, it is derivation: does the prefix come from the module's own name?&lt;/strong&gt; &lt;code&gt;CG_&lt;/code&gt; for &lt;code&gt;combinationgrid&lt;/code&gt;, &lt;code&gt;CSF_&lt;/code&gt; for &lt;code&gt;customerservicefile&lt;/code&gt;, &lt;code&gt;PCT_&lt;/code&gt; for &lt;code&gt;productcustomtab&lt;/code&gt; — initialisms are fine, because they derive. &lt;code&gt;API_&lt;/code&gt; derives from nothing.&lt;/p&gt;

&lt;p&gt;We re-ran the sweep with that criterion. Total: five real cases across the catalogue, four found in the first pass and one found later, by accident, during unrelated work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not every cross-module read is a bug
&lt;/h2&gt;

&lt;p&gt;The sweep surfaced plenty of modules reading keys with another module's prefix. Almost all of those were deliberate — two modules that integrate on purpose, one reading the other's setting to decide whether to render something. That is a dependency, and it should be documented, but it is not this bug.&lt;/p&gt;

&lt;p&gt;The distinction that matters is &lt;strong&gt;write and delete, not read&lt;/strong&gt;. Reading a foreign key is a coupling decision. Writing one is a collision. Deleting one on uninstall is data loss in someone else's module.&lt;/p&gt;

&lt;p&gt;One finding was a false positive worth mentioning because of what it turned out to be. A module appeared to touch a bare key named &lt;code&gt;theme&lt;/code&gt;. The hit was in &lt;code&gt;tests/ReviewNudgeTest.php&lt;/code&gt;, in a fixture that plants a foreign unprefixed key and then asserts that uninstall left it alone. It was not the bug; it was the test that proves the bug is absent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the risk actually sits
&lt;/h2&gt;

&lt;p&gt;In all five cases, the modules' &lt;em&gt;current&lt;/em&gt; settings were properly prefixed. Nobody was actively writing bare names. The damage was concentrated in one place: legacy cleanup in &lt;code&gt;uninstall()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The pattern goes like this. Version 1.x stored &lt;code&gt;show_notavailable&lt;/code&gt; and &lt;code&gt;colorshape&lt;/code&gt;. Version 2.x migrated to &lt;code&gt;COLORSONPRODUCTLIST_*&lt;/code&gt; and, being tidy, kept deleting the old names on uninstall so a reinstall would start clean. The intent is right. The effect is that uninstalling this module deletes a row that, on some other shop, belongs to a different module entirely.&lt;/p&gt;

&lt;p&gt;The same lines had also been copied into the &lt;code&gt;upgrade-2.0.0.php&lt;/code&gt; scripts — same bug class, second location, and easy to miss if you only audit &lt;code&gt;uninstall()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Deleting your own historical leftovers is not worth risking another module's data. Two options, in order of preference:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Stop deleting them.&lt;/strong&gt; The residue costs a few bytes in &lt;code&gt;ps_configuration&lt;/code&gt; and harms nothing. This is what we did in every case.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;uninstall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Only this module's own prefixed keys. The bare 1.x names&lt;/span&gt;
    &lt;span class="c1"&gt;// ('show_notavailable', 'colorshape', ...) are left alone: configuration&lt;/span&gt;
    &lt;span class="c1"&gt;// is shop-wide and the module cannot prove another module does not own&lt;/span&gt;
    &lt;span class="c1"&gt;// a row by one of those names.&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;defaultSettings&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Configuration&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;deleteByName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;uninstall&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment is load-bearing. Without it, the next person to read this file sees dead cleanup code and helpfully restores it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. If you must delete, prove ownership first.&lt;/strong&gt; Check that the stored value is a shape only your module writes. This is fragile, and it is only worth it when the leftover actually causes a problem.&lt;/p&gt;

&lt;p&gt;Full prefix migration — read the bare key, write the prefixed one, keep the old row — is a bigger change and it is only warranted when the module is still &lt;em&gt;reading&lt;/em&gt; bare names. If your current keys are already prefixed, the uninstall step is the whole bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it stay fixed
&lt;/h2&gt;

&lt;p&gt;A grep sweep finds this once. A test keeps it found. Ours plants foreign keys and asserts uninstall left them alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Configuration&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nv"&gt;$store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'MYMODULE_SETTING'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'MYMODULE_OTHER'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'tok'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'theme'&lt;/span&gt;             &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'another-modules-value'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'OTHERMODULE_THING'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'another-modules-value'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nv"&gt;$module&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;uninstall&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Configuration&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'theme'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'another-modules-value'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="s1"&gt;'a bare foreign key was not touched'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Configuration&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'OTHERMODULE_THING'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'another-modules-value'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
   &lt;span class="s1"&gt;'another module prefixed key was not touched'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Configuration&lt;/code&gt; is a small enough surface to stub — get, updateValue, deleteByName over an array — so this runs as plain &lt;code&gt;php tests/ConfigurationKeyTest.php&lt;/code&gt; with no PrestaShop and no database. Cheap enough that every module can carry one.&lt;/p&gt;

&lt;p&gt;If you are running the sweep yourself, a per-module negative match is more reliable than a global positive one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deleteByName\('(?!MYPREFIX_)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it once per module with that module's own prefix substituted in. Semi-generic names like &lt;code&gt;API_*&lt;/code&gt; slip past hand-written positive patterns; they do not slip past "anything that is not my prefix".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worth an hour of your time
&lt;/h2&gt;

&lt;p&gt;The failure mode is invisible from inside either module. Module A's setting resets and its author blames a caching layer. Module B's uninstall is the cause and its author never finds out, because it happened on someone else's shop, three months later, after an unrelated support ticket.&lt;/p&gt;

&lt;p&gt;Nothing errors. Nothing is logged. The support conversation goes nowhere, because both authors are looking at code that is correct in isolation.&lt;/p&gt;

&lt;p&gt;Grep your own modules for &lt;code&gt;deleteByName&lt;/code&gt; and read every line of what follows. It is a short list.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We maintain around sixty PrestaShop modules at &lt;a href="https://megventure.com" rel="noopener noreferrer"&gt;MEG Venture&lt;/a&gt;. The audit above took an afternoon and closed five bugs that no customer had reported and no test could have caught, because the damage happens in someone else's code.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>prestashop</category>
      <category>php</category>
      <category>refactoring</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Your PrestaShop hook renders nothing, and nothing is logged</title>
      <dc:creator>MEG Venture &amp; Consulting Ltd.</dc:creator>
      <pubDate>Mon, 24 Aug 2026 21:40:40 +0000</pubDate>
      <link>https://dev.to/megventure/your-prestashop-hook-renders-nothing-and-nothing-is-logged-37c8</link>
      <guid>https://dev.to/megventure/your-prestashop-hook-renders-nothing-and-nothing-is-logged-37c8</guid>
      <description>&lt;p&gt;A module hook that returns an empty string looks exactly like a module hook that was never called. PrestaShop gives you nothing to tell them apart: no error, no log entry, no stack trace, no fallback text. The page renders fine. Your block is just absent.&lt;/p&gt;

&lt;p&gt;We spent three releases of one module chasing this, and the cause turned out to be three different mechanisms stacked on top of each other. Each one alone is enough to make output vanish silently. This is what they are, in the order we peeled them off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The module registers &lt;code&gt;displayHeader&lt;/code&gt; and renders a small template: a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block that carries a public site key into the page, and a &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; block that hides a third-party badge. Roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;hookDisplayHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;smarty&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'recaptcha_pubkey'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getActivePublicKey&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s1"&gt;'recaptcha_hide_badge'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$hideBadge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;display&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;__FILE__&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'views/templates/front/header_script.tpl'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deployed, cache cleared, hook registered, &lt;code&gt;Design &amp;gt; Positions&lt;/code&gt; shows the module attached. Page source: nothing. Not the script, not the style, not even a stray whitespace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mechanism 1: core swallows the exception
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Hook::callHookOn()&lt;/code&gt; wraps every module hook call in a try/catch. When debug mode is off, it catches whatever the hook throws and returns an empty string. No error, no log, no trace.&lt;/p&gt;

&lt;p&gt;That is a defensible design decision — one broken module should not take down a storefront — but as a debugging experience it is brutal. Every possible failure inside your hook, from a typo to a missing file to a template that will not compile, arrives at your screen as the exact same symptom: nothing.&lt;/p&gt;

&lt;p&gt;The first thing to do, before theorising about causes, is to stop letting core swallow it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;display&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;__FILE__&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'views/templates/front/header_script.tpl'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'mymodule header_script.tpl render failed: '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' in '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getFile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;':'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getLine&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;PrestaShopLogger&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Mymodule'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;!-- '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'--'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'- -'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;' --&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two outputs on purpose. The log entry survives after the page is gone and shows up under &lt;strong&gt;Advanced Parameters &amp;gt; Logs&lt;/strong&gt;. The HTML comment is right there in view-source while you are looking at the page, and it survives filters that strip &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; tags specifically — which matters, because the thing you are debugging may itself be a script tag.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Throwable&lt;/code&gt;, not &lt;code&gt;Exception&lt;/code&gt;. A &lt;code&gt;TypeError&lt;/code&gt; is an &lt;code&gt;Error&lt;/code&gt;, and &lt;code&gt;catch (Exception $e)&lt;/code&gt; sails straight past it. That distinction has cost us time in more than one module.&lt;/p&gt;

&lt;p&gt;Turning debug mode on would also have surfaced it, and if you can reproduce the problem locally you should. We could not: this only appeared on a hosted environment we do not control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mechanism 2: one CSS brace kills the whole template
&lt;/h2&gt;

&lt;p&gt;With the try/catch in place, the log finally said something. The template would not compile.&lt;/p&gt;

&lt;p&gt;The offending line was plain CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.grecaptcha-badge&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;visibility&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Smarty's default delimiters are &lt;code&gt;{&lt;/code&gt; and &lt;code&gt;}&lt;/code&gt;. It reads &lt;code&gt;{visibility:hidden!important;}&lt;/code&gt; as a template tag, tries to parse &lt;code&gt;visibility:hidden&lt;/code&gt; as a Smarty expression, chokes on the colon, and fails to compile the file.&lt;/p&gt;

&lt;p&gt;The part that turns a small bug into a mystery: &lt;strong&gt;Smarty compiles a template as a single unit.&lt;/strong&gt; A syntax error anywhere fails the whole file. The &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block sitting above that CSS was perfectly valid and had nothing to do with the problem, and it never rendered either — for three releases we were debugging the script, which was fine, because the thing breaking it was thirty lines below.&lt;/p&gt;

&lt;p&gt;The fix is Smarty's own mechanism for raw text that contains braces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight smarty"&gt;&lt;code&gt;&lt;span class="k"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;literal&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
.grecaptcha-badge { visibility: hidden !important; }
&lt;span class="k"&gt;{/&lt;/span&gt;&lt;span class="nb"&gt;literal&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you would rather not think about it every time: any &lt;code&gt;{&lt;/code&gt; immediately followed by a letter is a Smarty tag. &lt;code&gt;{ visibility&lt;/code&gt; with a space is not. But &lt;code&gt;{literal}&lt;/code&gt; states the intent, and intent is what you want in a file someone else will edit.&lt;/p&gt;

&lt;p&gt;This applies to inline JavaScript too — object literals, arrow function bodies, anything with a brace next to a word.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mechanism 3: the platform rejected &lt;code&gt;nofilter&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Before we found the brace, we had already removed a &lt;code&gt;nofilter&lt;/code&gt; from the same template. That one is worth recording because the reasoning that led us to it was sound and the fix was correct, even though it was not the bug we were hunting.&lt;/p&gt;

&lt;p&gt;The template embedded a value into inline JavaScript the way PrestaShop's own theme templates do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;$recaptcha_pubkey&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nx"&gt;json_encode&lt;/span&gt; &lt;span class="nx"&gt;nofilter&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PrestaShop Cloud's integration checklist explicitly requires &lt;code&gt;{$variable|escape:'javascript':'UTF-8'}&lt;/code&gt; for values used in inline JS, and forbids &lt;code&gt;nofilter&lt;/code&gt;. A platform-side Smarty security policy rejecting &lt;code&gt;nofilter&lt;/code&gt; at compile time would produce exactly the signature we were seeing: a template that will not compile, on that host only, silently.&lt;/p&gt;

&lt;p&gt;We could not prove that was happening — the brace bug was masking everything — but the guidance holds on its own terms. Use the documented escaper.&lt;/p&gt;

&lt;p&gt;The general lesson is narrower than "avoid &lt;code&gt;nofilter&lt;/code&gt;": a template that compiles on your machine can fail to compile on a managed host with a stricter Smarty policy, and it fails the same silent way. If your module ships to hosts you do not control, the try/catch from mechanism 1 is not a debugging aid you remove afterwards. Leave it in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed permanently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every hook that renders a template is wrapped.&lt;/strong&gt; Log plus HTML comment, catching &lt;code&gt;Throwable&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No hand-built HTML or JS inside PHP.&lt;/strong&gt; PrestaShop's integration checklist asks for this anyway, and it means every failure of this class now happens in a template, where the try/catch can see it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assets carry a version.&lt;/strong&gt; Separate problem, same family of "I fixed it but nothing changed": &lt;code&gt;registerJavascript($id, $path, ['version' =&amp;gt; $this-&amp;gt;version])&lt;/code&gt;. Without it there is no cache-buster, and returning visitors keep running the previous file. There is a further trap with PrestaShop's combined asset cache that the &lt;code&gt;version&lt;/code&gt; parameter does &lt;em&gt;not&lt;/em&gt; solve — that is its own post.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The shape of the lesson
&lt;/h2&gt;

&lt;p&gt;Silent failure is not one bug, it is a category. When a system is designed to keep serving pages no matter what a plugin does, it will also keep serving pages when your plugin is broken, and it owes you nothing in the way of an explanation.&lt;/p&gt;

&lt;p&gt;The response is not to guess better. It is to make the silence impossible: catch what the platform would have swallowed, write it somewhere that outlives the request, and put a marker in the page you are already looking at. We got three releases of guessing before we did that, and roughly one afternoon of actual debugging afterwards.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We build and maintain around sixty PrestaShop modules at &lt;a href="https://megventure.com" rel="noopener noreferrer"&gt;MEG Venture&lt;/a&gt;, which is a productive way to accumulate stories like this one. If you have hit a variant of this — especially the Smarty brace, which we suspect is more common than its search results suggest — I would like to hear about it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>prestashop</category>
      <category>php</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
