<?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>"Duplicate entry '0' for key PRIMARY" usually means your database lost AUTO_INCREMENT</title>
      <dc:creator>MEG Venture &amp; Consulting Ltd.</dc:creator>
      <pubDate>Tue, 29 Sep 2026 13:15:06 +0000</pubDate>
      <link>https://dev.to/megventure/duplicate-entry-0-for-key-primary-usually-means-your-database-lost-autoincrement-472f</link>
      <guid>https://dev.to/megventure/duplicate-entry-0-for-key-primary-usually-means-your-database-lost-autoincrement-472f</guid>
      <description>&lt;p&gt;Installing a plugin fails with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Duplicate entry '0' for key 'PRIMARY'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The obvious reading is that the plugin tried to insert a row with id 0. The actual reading is usually the opposite: the plugin inserted a row &lt;strong&gt;without&lt;/strong&gt; naming the id column, relied on &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; to supply one, and got &lt;code&gt;0&lt;/code&gt; because the column no longer has &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; on it. The second such insert then collides with the first.&lt;/p&gt;

&lt;p&gt;This is not a plugin bug, and chasing it as one wastes a lot of time. Here is how it looks, why it spreads, and how to repair it without setting a second trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tell: it moves
&lt;/h2&gt;

&lt;p&gt;We first saw this installing a module on a PrestaShop shop. The failure walked through the tables in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;ps_configuration&lt;/code&gt; — first setting the module writes&lt;/li&gt;
&lt;li&gt;then &lt;code&gt;ps_log&lt;/code&gt; — the "starting module install" row&lt;/li&gt;
&lt;li&gt;then &lt;code&gt;ps_module&lt;/code&gt; — the row that registers the module itself&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Fix one table, run the install again, fail on the next. That whack-a-mole is the diagnostic. &lt;strong&gt;A plugin bug does not migrate to core tables.&lt;/strong&gt; If your failure changes table each time you fix one, the corruption is database-wide.&lt;/p&gt;

&lt;p&gt;Later the same shop failed in the storefront: adding anything to the cart threw the same error on &lt;code&gt;INSERT INTO ps_cart&lt;/code&gt;. Same cause, another table nobody had touched yet. If it had got as far as checkout it would have been &lt;code&gt;ps_orders&lt;/code&gt; next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it comes from
&lt;/h2&gt;

&lt;p&gt;A bad import or restore. &lt;code&gt;mysqldump&lt;/code&gt;, phpMyAdmin exports and various migration tools can produce a schema that recreates the primary key &lt;strong&gt;without&lt;/strong&gt; the &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; attribute — and such a restore often leaves the internal counters stale as well, with rows stranded at id 0.&lt;/p&gt;

&lt;p&gt;On the shop we repaired, &lt;em&gt;every&lt;/em&gt; &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; in the database was gone: products, combinations, modules, configuration, employees, shops, languages, and around fifty-five module tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap when you fix it
&lt;/h2&gt;

&lt;p&gt;The natural fix is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;`ps_log`&lt;/span&gt; &lt;span class="k"&gt;MODIFY&lt;/span&gt; &lt;span class="nv"&gt;`id_log`&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;which fails with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#1062 - ALTER TABLE causes auto_increment resequencing,
        resulting in duplicate entry 'N' for key 'PRIMARY'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; makes MySQL renumber the existing &lt;code&gt;id = 0&lt;/code&gt; row, and the stale counter hands it an id that is already in use.&lt;/p&gt;

&lt;p&gt;Prevent the renumber:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;SESSION&lt;/span&gt; &lt;span class="n"&gt;sql_mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'NO_AUTO_VALUE_ON_ZERO'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;`ps_log`&lt;/span&gt;           &lt;span class="k"&gt;MODIFY&lt;/span&gt; &lt;span class="nv"&gt;`id_log`&lt;/span&gt;           &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;`ps_configuration`&lt;/span&gt; &lt;span class="k"&gt;MODIFY&lt;/span&gt; &lt;span class="nv"&gt;`id_configuration`&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;`ps_module`&lt;/span&gt;        &lt;span class="k"&gt;MODIFY&lt;/span&gt; &lt;span class="nv"&gt;`id_module`&lt;/span&gt;        &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&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;NO_AUTO_VALUE_ON_ZERO&lt;/code&gt; tells MySQL to treat a literal &lt;code&gt;0&lt;/code&gt; as a real value rather than a request for the next id, so the existing row keeps its id and nothing is renumbered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run the &lt;code&gt;SET&lt;/code&gt; and the &lt;code&gt;ALTER&lt;/code&gt;s in the same submission.&lt;/strong&gt; &lt;code&gt;SET SESSION&lt;/code&gt; lasts for one connection; phpMyAdmin may hand you a different connection for a separate query, and then the &lt;code&gt;ALTER&lt;/code&gt; fails exactly as before while you are certain you just set the mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding every affected table
&lt;/h2&gt;

&lt;p&gt;Do not do this by hand. Ask the schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TABLE_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COLUMN_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;COLUMN_TYPE&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COLUMNS&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;TABLE_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your_database_name'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;COLUMN_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PRI'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;EXTRA&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%auto_increment%'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;DATA_TYPE&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'int'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'bigint'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'smallint'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'mediumint'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'tinyint'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;TABLE_NAME&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Hardcode the schema name.&lt;/strong&gt; A generator built on &lt;code&gt;DATABASE()&lt;/code&gt; returns nothing when phpMyAdmin is sitting in the &lt;code&gt;information_schema&lt;/code&gt; context, which it often is if you arrived through the schema browser. The query runs, returns zero rows, and you conclude the database is fine.&lt;/p&gt;

&lt;p&gt;That query lists candidates. It does not list &lt;em&gt;fixes&lt;/em&gt; — and the difference matters, because some of those primary keys are legitimately not auto-increment.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to exclude
&lt;/h2&gt;

&lt;p&gt;Blindly ALTERing everything the query returns will damage your database. Exclude:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every composite primary key.&lt;/strong&gt; &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; applies to a single column that is the leftmost part of a key. Multi-column PKs in that list are joins and pivots and must be left alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-column integer PKs that are by design not auto-increment&lt;/strong&gt; — a foreign key doubling as the primary key. In PrestaShop those include &lt;code&gt;ps_address_format&lt;/code&gt; (&lt;code&gt;id_country&lt;/code&gt;), &lt;code&gt;ps_product_sale&lt;/code&gt; (&lt;code&gt;id_product&lt;/code&gt;), &lt;code&gt;ps_ip2location&lt;/code&gt; (&lt;code&gt;ip_to&lt;/code&gt;), the &lt;code&gt;ps_layered_indexable_*&lt;/code&gt; tables, &lt;code&gt;ps_pscheckout_address&lt;/code&gt; / &lt;code&gt;ps_pscheckout_customer&lt;/code&gt; (&lt;code&gt;id_customer&lt;/code&gt;), &lt;code&gt;ps_psshipping_address&lt;/code&gt;, and the &lt;code&gt;ps_eventbus_*&lt;/code&gt; tables. Your schema will have its own equivalents: the test is whether the value is &lt;em&gt;assigned by another table&lt;/em&gt; or &lt;em&gt;generated here&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two special cases worth knowing, because they look like exclusions and are not: &lt;code&gt;ps_customization.id_customization&lt;/code&gt; and &lt;code&gt;ps_cms_role.id_cms_role&lt;/code&gt; sit in composite primary keys but are legitimately &lt;code&gt;AUTO_INCREMENT&lt;/code&gt;, as the leftmost column.&lt;/p&gt;

&lt;p&gt;The workable process: generate the &lt;code&gt;ALTER&lt;/code&gt; statements with a query that CONCATs them, &lt;strong&gt;read the output&lt;/strong&gt;, delete the rows that belong to the exclusion list, then run what is left. Two failure modes we hit doing exactly this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Running the generator and believing that fixed something. It only prints statements. You have to run its output.&lt;/li&gt;
&lt;li&gt;Deleting the &lt;code&gt;id = 0&lt;/code&gt; rows &lt;em&gt;instead of&lt;/em&gt; running the ALTERs. That clears the immediate collision and leaves the schema still broken, so it comes back on the next insert.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more operational detail: if you paste a large batch into phpMyAdmin and the newlines are lost, a &lt;code&gt;--&lt;/code&gt; comment swallows the statement that follows it. Ship comment-free SQL for bulk paste.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do instead, if you can
&lt;/h2&gt;

&lt;p&gt;Repairing in place works — we did it across about 130 tables and verified the storefront afterwards — but think about what it means. A restore that dropped &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; from every primary key was not selective. It may also have dropped foreign keys, defaults, or column attributes you have not noticed yet, and no amount of ALTERing primary keys tells you about those.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If a clean dump from before the bad import exists, restore that instead.&lt;/strong&gt; The in-place repair is what you do when it does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-second version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Plugin install fails on a core table with &lt;code&gt;Duplicate entry '0'&lt;/code&gt; → suspect the schema, not the plugin.&lt;/li&gt;
&lt;li&gt;Failure moves to a different table each time you fix one → confirmed, it is database-wide.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SET SESSION sql_mode = 'NO_AUTO_VALUE_ON_ZERO'&lt;/code&gt; in the same submission as the ALTERs.&lt;/li&gt;
&lt;li&gt;Drive it from &lt;code&gt;information_schema&lt;/code&gt;, hardcode the schema name, and curate the list before running it.&lt;/li&gt;
&lt;li&gt;Prefer restoring a good dump.&lt;/li&gt;
&lt;/ul&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;. We lost a fortnight to this one in two separate incidents before recognising the shape.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>php</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>What your comments changed in my AI-agent office (Cubicle v0.7)</title>
      <dc:creator>MEG Venture &amp; Consulting Ltd.</dc:creator>
      <pubDate>Mon, 28 Sep 2026 18:09:08 +0000</pubDate>
      <link>https://dev.to/megventure/what-your-comments-changed-in-my-ai-agent-office-cubicle-v07-4265</link>
      <guid>https://dev.to/megventure/what-your-comments-changed-in-my-ai-agent-office-cubicle-v07-4265</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fekkn0c4nefyuiq389pkn.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fekkn0c4nefyuiq389pkn.gif" alt="Cubicle: a live pixel-art office for AI agents"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Yesterday I wrote about &lt;a href="https://dev.to/megventure/i-gave-my-ai-agents-an-office-and-made-it-read-only-on-purpose-5dpd"&gt;Cubicle&lt;/a&gt;, a live pixel-art office for AI agents that is read-only on purpose. A few of the comments were better code reviews than I get from most tools, so here's what they changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The hook was losing updates
&lt;/h2&gt;

&lt;p&gt;ContentClips pointed out that several Claude Code sessions fire hooks at the same moment, and every hook run rewrites the same &lt;code&gt;claude-code.json&lt;/code&gt;. I was already writing to a temp file and renaming it, so a reader never sees half a file. But that only solves torn reads, not lost updates: two runs can both read the file, both change it, and the second rename silently throws away the first one's change.&lt;/p&gt;

&lt;p&gt;I measured it before touching anything. 30 hook runs started at once, each registering a different session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sessions recorded: 20 of 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A third of the updates were gone. With subagents in the mix (more on that below), bursts like this are normal.&lt;/p&gt;

&lt;p&gt;The fix is a lock file created with &lt;code&gt;O_EXCL&lt;/code&gt; around the read-modify-write. The constraint that shaped it: this hook runs on every tool call, so it must never hold Claude up. It waits at most one second and then writes anyway, and a lock left behind by a crashed run is taken over after two seconds.&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;function&lt;/span&gt; &lt;span class="nf"&gt;withLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;LOCK_WAIT_MS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LOCK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;break&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EEXIST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;statSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LOCK&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;mtimeMs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;STALE_LOCK_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlinkSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LOCK&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&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="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// write anyway rather than block Claude&lt;/span&gt;
    &lt;span class="nf"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&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="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closeSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlinkSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LOCK&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="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&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;After: 40 of 40, three runs in a row, no lock file left behind. Both cases are in the test suite now.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The office was showing more than it should
&lt;/h2&gt;

&lt;p&gt;The same comment raised a second point: in kiosk mode the office ends up on a shared TV, and task titles or tool calls can contain things you don't want on a wall.&lt;/p&gt;

&lt;p&gt;When I looked, the problem was bigger than the bubbles. The proxy passed Paperclip's responses through unchanged. A single issue on my own instance had more than 70 fields: descriptions, workspace settings, run ids, monitor notes. The page uses about five of them. Read-only was never the same as minimal.&lt;/p&gt;

&lt;p&gt;Two changes in v0.7:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Always:&lt;/strong&gt; the server now forwards only the fields the page draws. Descriptions, adapter and workspace configuration, run ids and closed issues never leave the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--redact&lt;/code&gt;:&lt;/strong&gt; task titles, commands and error text are stripped on the server too. The office shows &lt;code&gt;MEG-35&lt;/code&gt;, &lt;code&gt;Edit&lt;/code&gt;, &lt;code&gt;allow Bash&lt;/code&gt; and statuses, and the "needs you" signal still works. Because it happens server-side, changing the page URL doesn't bring the details back, and neither does someone calling the API on that port directly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test for it feeds in an AWS key, an SSH key path, an API key in adapter config and a bearer token inside a &lt;code&gt;curl&lt;/code&gt; command, then checks that none of them come out of any endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Observability hooks vs. enforcement hooks
&lt;/h2&gt;

&lt;p&gt;Hamid Ahmadian made a point I wish I'd written down myself: a hook that draws the office and a hook that blocks tool calls have opposite contracts. The first must never fail loudly; the second exists to fail loudly (exit 2). If one script does both, a bug in the drawing code can start blocking tool calls.&lt;/p&gt;

&lt;p&gt;Cubicle keeps to the first contract: it always exits 0, prints nothing, and &lt;code&gt;install-hooks&lt;/code&gt; adds its own separate entries next to whatever hooks you already have instead of merging into them. Reid Marlow's comment on ignoring the one-minute idle reminder was the same idea from the user's side: the office is only useful if the raised hands mean something.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The first community PR
&lt;/h2&gt;

&lt;p&gt;A day after launch, &lt;a href="https://github.com/omeruyanik03" rel="noopener noreferrer"&gt;@omeruyanik03&lt;/a&gt; opened the first pull request. Claude Code subagents share their parent's session id, so a session that fans out to four subagents showed up as one character whose bubble flickered between five tool calls. The PR gives every subagent its own character, keyed on the &lt;code&gt;agent_id&lt;/code&gt; that Claude Code puts on hook events, with tests for the awkward cases: a subagent first seen halfway through its run, older Claude Code versions without &lt;code&gt;agent_id&lt;/code&gt;, and a session ending while its subagents are still at their desks.&lt;/p&gt;

&lt;p&gt;Because that hook runs on every tool call on my machine, I read it line by line before merging: no network, no child processes, no new dependencies, still exits 0 on every error. It shipped in v0.6.0 the same evening, and it's also why the lock in section 1 mattered so quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Paperclip and Claude Code in one office, details hidden for a shared screen&lt;/span&gt;
npx @caglarutkuguler/cubicle@latest install-hooks
npx @caglarutkuguler/cubicle@latest &lt;span class="nt"&gt;--source&lt;/span&gt; paperclip,claude-code &lt;span class="nt"&gt;--redact&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open &lt;code&gt;http://127.0.0.1:3200/?kiosk&lt;/code&gt;. Or try the &lt;a href="https://caglarutkuguler.github.io/cubicle/?lang=en" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; without installing anything.&lt;/p&gt;

&lt;p&gt;Thanks to everyone who took the time to read the code and not just the post. The repo is &lt;a href="https://github.com/caglarutkuguler/cubicle" rel="noopener noreferrer"&gt;github.com/caglarutkuguler/cubicle&lt;/a&gt;, MIT licensed, and the open issues labelled &lt;code&gt;good first issue&lt;/code&gt; are a nice place to start.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I gave my AI agents an office (and made it read-only on purpose)</title>
      <dc:creator>MEG Venture &amp; Consulting Ltd.</dc:creator>
      <pubDate>Sun, 27 Sep 2026 20:44:36 +0000</pubDate>
      <link>https://dev.to/megventure/i-gave-my-ai-agents-an-office-and-made-it-read-only-on-purpose-5dpd</link>
      <guid>https://dev.to/megventure/i-gave-my-ai-agents-an-office-and-made-it-read-only-on-purpose-5dpd</guid>
      <description>&lt;p&gt;I run several AI agents at once: a handful of Claude Code sessions, plus a small "company" of agents on &lt;a href="https://github.com/paperclipai/paperclip" rel="noopener noreferrer"&gt;Paperclip&lt;/a&gt; that works on our online store. Giving them work is easy. The hard part is knowing, at a glance, which one is working, which one is done, and which one has been waiting for my answer for twenty minutes.&lt;/p&gt;

&lt;p&gt;Logs don't answer that at a glance. So I drew an office.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fekkn0c4nefyuiq389pkn.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fekkn0c4nefyuiq389pkn.gif" alt="Cubicle demo" width="720" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://caglarutkuguler.github.io/cubicle/?lang=en" rel="noopener noreferrer"&gt;Live demo, no install&lt;/a&gt;&lt;/strong&gt; · &lt;a href="https://github.com/caglarutkuguler/cubicle" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;code&gt;npx @caglarutkuguler/cubicle&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the office shows
&lt;/h2&gt;

&lt;p&gt;Every agent is a character:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Working:&lt;/strong&gt; sits at its desk and types, with the current task or tool call above its head&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Needs you:&lt;/strong&gt; raises a hand, and the monitor turns amber&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Done:&lt;/strong&gt; says "✓ done" and walks to the lounge for a coffee&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error:&lt;/strong&gt; slumps at the desk, red screen&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The header keeps a count of how many agents need you. That single number turned out to be the most useful thing on the screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three design decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Zero dependencies
&lt;/h3&gt;

&lt;p&gt;The server is a single Node file of under 200 lines, using only the standard library: it serves one HTML page and proxies a few requests. The page draws everything with &lt;code&gt;fillRect&lt;/code&gt; on a 352×208 canvas. There are no image assets, no framework, and no build step.&lt;/p&gt;

&lt;p&gt;This wasn't minimalism for its own sake. A tool that sits next to your agents all day should be something you can read in ten minutes and trust.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Read-only by design
&lt;/h3&gt;

&lt;p&gt;Cubicle only ever makes &lt;code&gt;GET&lt;/code&gt; requests. In Paperclip mode it forwards exactly three endpoints and refuses everything else:&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;const&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;api&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;health$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;api&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;companies$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;api&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;companies&lt;/span&gt;&lt;span class="se"&gt;\/[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\/(&lt;/span&gt;&lt;span class="sr"&gt;agents|issues&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&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;&lt;code&gt;POST&lt;/code&gt;, &lt;code&gt;PATCH&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; get a &lt;code&gt;405&lt;/code&gt;, any other path gets a &lt;code&gt;403&lt;/code&gt;. If you point it at an authenticated Paperclip, the API key is added on the server side only: it never reaches the browser, and the browser's own cookies are never forwarded upstream. There is deliberately no &lt;code&gt;--token&lt;/code&gt; flag, because flags show up in the process list.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. "Needs you" from real signals
&lt;/h3&gt;

&lt;p&gt;For Claude Code, Cubicle uses the official hooks. A tiny script rewrites &lt;code&gt;~/.cubicle/claude-code.json&lt;/code&gt; on every event:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Claude Code event&lt;/th&gt;
&lt;th&gt;In the office&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;PreToolUse&lt;/code&gt;, &lt;code&gt;PostToolUse&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Types at the desk; bubble shows e.g. &lt;code&gt;Edit checkout.php&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PermissionRequest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Raises its hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Stop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Goes to the lounge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SessionEnd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Leaves the office&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The hook always exits 0 and prints nothing, so it can never block a tool call or add tokens to the context. One thing I had to handle: Claude Code also sends a notification after a minute of idling ("waiting for your input"). Treating that as "needs you" made every idle session raise its hand, so those are ignored.&lt;/p&gt;

&lt;p&gt;For Paperclip, the interesting signal was hiding in the issue data. When an agent asks the board a question, Paperclip records it in the issue's &lt;code&gt;reviewAttention.paths&lt;/code&gt; with the board as the responder. Often the agent is already busy with another issue, so its own status says &lt;code&gt;running&lt;/code&gt;. Cubicle keeps it typing, but its bubble flashes the waiting issue's ID every few seconds and the card links straight to it. The first time I turned this on, it found two questions I didn't know were waiting for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Paperclip (default, expects it on :3100)&lt;/span&gt;
npx @caglarutkuguler/cubicle

&lt;span class="c"&gt;# Claude Code&lt;/span&gt;
npx @caglarutkuguler/cubicle install-hooks
npx @caglarutkuguler/cubicle &lt;span class="nt"&gt;--source&lt;/span&gt; claude-code

&lt;span class="c"&gt;# anything that can write a small JSON file&lt;/span&gt;
npx @caglarutkuguler/cubicle &lt;span class="nt"&gt;--source&lt;/span&gt; ./agents.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://127.0.0.1:3200&lt;/code&gt;. Add &lt;code&gt;?kiosk&lt;/code&gt; for a full-screen view on a TV or second monitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;A few issues are open and labelled for first-time contributors: a new UI language, per-agent spend for Paperclip, an example adapter for Codex or Gemini CLI, and a replay mode to watch a whole day in 30 seconds. PRs welcome, as long as they keep the two rules: no dependencies, and read-only.&lt;/p&gt;

&lt;p&gt;It's MIT licensed: &lt;a href="https://github.com/caglarutkuguler/cubicle" rel="noopener noreferrer"&gt;github.com/caglarutkuguler/cubicle&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>jQuery's .trigger('submit') drops the submit button, and your backend may be checking for it</title>
      <dc:creator>MEG Venture &amp; Consulting Ltd.</dc:creator>
      <pubDate>Tue, 15 Sep 2026 07:36:53 +0000</pubDate>
      <link>https://dev.to/megventure/jquerys-triggersubmit-drops-the-submit-button-and-your-backend-may-be-checking-for-it-nif</link>
      <guid>https://dev.to/megventure/jquerys-triggersubmit-drops-the-submit-button-and-your-backend-may-be-checking-for-it-nif</guid>
      <description>&lt;p&gt;A contact form was posting and leaving nothing behind. No message stored, no e-mail sent, no thread created — and no error either. The page just reloaded, empty and calm, as if the visitor had never pressed anything.&lt;/p&gt;

&lt;p&gt;The cause is a one-line detail about how forms are submitted from JavaScript, and it is not specific to the framework we hit it in. If any part of your backend decides whether to act by testing for a submit button's name, you can hit this too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the code did
&lt;/h2&gt;

&lt;p&gt;The form was protected by an invisible CAPTCHA. Tokens for that kind of CAPTCHA expire in about two minutes and are single-use, so the sensible design is to fetch a fresh token at the moment of submit rather than on page load:&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="nx"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;submit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;grecaptcha&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[name="g-recaptcha-response"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;val&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;submit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// &amp;lt;- here&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;Intercept, get the token, put it in the hidden field, submit for real. That is the standard shape, and it appears in a lot of tutorials.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually goes over the wire
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;$form.trigger('submit')&lt;/code&gt; does not simulate a click. jQuery, finding no more handlers to run, calls the element's &lt;strong&gt;native&lt;/strong&gt; &lt;code&gt;form.submit()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A native submit has no &lt;em&gt;submitter&lt;/em&gt;. The HTML spec is explicit that the entry list is built from the form's controls, and a submit button contributes its &lt;code&gt;name&lt;/code&gt;/&lt;code&gt;value&lt;/code&gt; pair &lt;strong&gt;only when it is the button that activated the submission&lt;/strong&gt;. Nothing activated anything here, so:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the clicked button's &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;value&lt;/code&gt; are simply absent from the POST body;&lt;/li&gt;
&lt;li&gt;every other field is present and correct.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So you get a POST that looks complete in the Network tab. Unless you know which key is missing, nothing jumps out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why that is fatal for some backends
&lt;/h2&gt;

&lt;p&gt;Plenty of server code — PrestaShop, older WordPress plugins, hand-rolled PHP, anything with several forms posting to the same controller — decides &lt;em&gt;what to do&lt;/em&gt; by asking which button was pressed:&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="nc"&gt;Tools&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;isSubmit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'submitMessage'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// validate, store, send mail&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;isSubmit('submitMessage')&lt;/code&gt; is a presence test on &lt;code&gt;$_POST['submitMessage']&lt;/code&gt;. With the submitter dropped, that key is not there, the branch never runs, and the controller falls through to rendering the page again.&lt;/p&gt;

&lt;p&gt;This is the worst possible failure shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No error.&lt;/strong&gt; The controller did not fail; it was never asked to do anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No validation message.&lt;/strong&gt; Validation lives inside the branch that did not run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No log line.&lt;/strong&gt; Nothing threw.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CAPTCHA looks innocent.&lt;/strong&gt; Our module's own "blocked submission" counter never moved, which was the clue that finally mattered: the request had not even reached the verification step. We had been tuning the score threshold for a week. The threshold was never involved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We reproduced it on PrestaShop 9.1.4: the contact form posted, left no thread, no message and no mail, and displayed nothing.&lt;/p&gt;

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

&lt;p&gt;Re-attach the activating button before submitting:&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;function&lt;/span&gt; &lt;span class="nf"&gt;submitPreservingButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FORM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;form&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&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="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;$form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;submit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;submitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;originalEvent&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;originalEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;submitter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button[type="submit"][name], input[type="submit"][name]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;submitter&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;submitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input[type="hidden"][name="&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;submitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;keep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;submitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;// Buttons often carry an empty value; a presence test is happy either&lt;/span&gt;
        &lt;span class="c1"&gt;// way, but an empty string is easy to lose in transit.&lt;/span&gt;
        &lt;span class="nx"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;submitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&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;Three details worth keeping:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;event.submitter&lt;/code&gt; first.&lt;/strong&gt; Modern browsers put the activating element on the submit event. Use it when it is there; it is the only source that is actually correct when a form has several named submit buttons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fall back to the first named submit control.&lt;/strong&gt; Older browsers, and synthetic submits with no event, need something. It is a guess, but on a single-button form it is the right guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not add the hidden input twice.&lt;/strong&gt; A user who submits, fails validation and submits again would otherwise accumulate duplicates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are not carrying jQuery for other reasons, the modern equivalent is &lt;code&gt;form.requestSubmit(submitter)&lt;/code&gt;, which does the right thing natively — it fires the submit event &lt;em&gt;and&lt;/em&gt; includes the submitter. It is widely supported now. &lt;code&gt;requestSubmit()&lt;/code&gt; is what &lt;code&gt;form.submit()&lt;/code&gt; should always have been.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that generalises
&lt;/h2&gt;

&lt;p&gt;Two APIs that look interchangeable are not:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;fires &lt;code&gt;submit&lt;/code&gt; event&lt;/th&gt;
&lt;th&gt;includes submitter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;user clicks the button&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;form.requestSubmit(btn)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;form.submit()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;$(form).trigger('submit')&lt;/code&gt; with no handlers left&lt;/td&gt;
&lt;td&gt;ends in &lt;code&gt;form.submit()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And a backend convention that looks harmless — "check which button was pressed" — turns that difference into a silent no-op.&lt;/p&gt;

&lt;p&gt;If you are debugging a form that posts and does nothing, before you touch validation, tokens or thresholds: open the Network tab, look at the actual form data, and check whether the submit button's own name is in there. It is a ten-second check that we did not do for an embarrassingly long time.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found while maintaining PrestaShop modules at &lt;a href="https://megventure.com" rel="noopener noreferrer"&gt;MEG Venture&lt;/a&gt;. The same shape affects account creation, newsletter sign-up and checkout on that platform, since all four are gated on a button name.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>php</category>
      <category>webdev</category>
      <category>jquery</category>
    </item>
    <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>
