<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.3.3">Jekyll</generator><link href="https://blog.karimratib.me/feed/drupal.xml" rel="self" type="application/atom+xml" /><link href="https://blog.karimratib.me/" rel="alternate" type="text/html" /><updated>2026-04-13T10:33:50+00:00</updated><id>https://blog.karimratib.me/feed/drupal.xml</id><title type="html">infojunkie | Drupal</title><subtitle></subtitle><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><entry><title type="html">Drupal 10: Getting Config Ignore and Webform to play nice together</title><link href="https://blog.karimratib.me/2025/06/05/drupal-config-ignore-webform.html" rel="alternate" type="text/html" title="Drupal 10: Getting Config Ignore and Webform to play nice together" /><published>2025-06-05T00:00:00+00:00</published><updated>2025-06-05T00:00:00+00:00</updated><id>https://blog.karimratib.me/2025/06/05/drupal-config-ignore-webform</id><content type="html" xml:base="https://blog.karimratib.me/2025/06/05/drupal-config-ignore-webform.html"><![CDATA[<p>In my role as Systems Architect, I devote a lot of effort to configuration management. In Drupal-land, this means making sure that the site’s configuration synchronization runs smoothly and idempotently, across all deployment stages. We’ve been using a <code class="language-plaintext highlighter-rouge">drush</code>-based deployment sequence that has served us well across the many sites that we maintain. Here’s the magic incantation:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>drush cr<span class="p">;</span> drush updb <span class="nt">-y</span><span class="p">;</span> drush cim <span class="nt">-y</span><span class="p">;</span> drush deploy:hook <span class="nt">-y</span><span class="p">;</span> drush cr<span class="p">;</span>
</code></pre></div></div>
<p>This little sequence allows us to reliably update Drupal core, the site configuration and our own custom modules without running into dependency loops. <a href="https://www.drupal.org/docs/drupal-apis/configuration-api">Drupal’s Configuration API</a> is a very well-designed system that has greatly simplified this process since Drupal 8, especially with its plugin-based architecture that allows contrib modules to fine-tune the process.</p>

<p>For us, the <a href="https://www.drupal.org/project/config_ignore">Config Ignore contrib module</a> is invaluable because business users typically require control over <em>some aspects</em> the site’s configuration, typically when it comes to end-user-facing settings like labels and titles. By using Config Ignore’s excellent support for wildcards, individual subkeys and exclusion operator, we have a powerful toolset to give business users what they need.</p>

<h2 id="overriding-config_ignoresettings-in-settingsphp">Overriding <code class="language-plaintext highlighter-rouge">config_ignore.settings</code> in <code class="language-plaintext highlighter-rouge">settings.php</code></h2>
<p>During development, it’s common to want to override the official configuration with different settings. The usual approach is to use the <code class="language-plaintext highlighter-rouge">settings.local.php</code> file with a hard-coded <code class="language-plaintext highlighter-rouge">$config</code> entry - in our case <code class="language-plaintext highlighter-rouge">$config['config_ignore.settings']</code>. However, I quickly discovered that these overridden settings don’t get picked up by Config Ignore! Here we go, a new debugging dive 🤿… It turns out that <a href="https://git.drupalcode.org/project/config_ignore/-/blob/149db17d375e78ec79245d08a71a062953dbc8c3/src/EventSubscriber/ConfigIgnoreEventSubscriber.php#L141-155">the default Drupal config factory is only consulted if the <code class="language-plaintext highlighter-rouge">config_ignore.settings</code> entry is NOT present in the sync folder</a>. I am pretty sure this is the opposite of the usual expectation, and I may submit an issue to discuss that. In the meantime, here’s a small workaround that will pick up your overridden settings:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// my_custom_module.module</span>
<span class="kn">use</span> <span class="nf">Drupal\config_ignore</span><span class="nc">\ConfigIgnoreConfig</span><span class="p">;</span>

<span class="cd">/**
 * Implements hook_config_ignore_ignored_alter().
 */</span>
<span class="k">function</span> <span class="n">my_custom_module_config_ignore_ignored_alter</span><span class="p">(</span><span class="o">&amp;</span><span class="nv">$ignoreConfig</span><span class="p">)</span> <span class="p">{</span>
  <span class="nv">$override</span> <span class="o">=</span> <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">config</span><span class="p">(</span><span class="s1">'config_ignore.settings'</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="k">empty</span><span class="p">(</span><span class="nv">$override</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">try</span> <span class="p">{</span>
      <span class="nv">$ignoreConfig</span> <span class="o">=</span> <span class="nc">ConfigIgnoreConfig</span><span class="o">::</span><span class="nf">fromConfig</span><span class="p">(</span><span class="nv">$override</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">catch</span> <span class="p">(</span><span class="nc">\Throwable</span> <span class="nv">$e</span><span class="p">)</span> <span class="p">{</span>
      <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">logger</span><span class="p">(</span><span class="s1">'my_custom_module'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">error</span><span class="p">(</span><span class="s1">'Invalid value for config_ignore.settings override. Ignoring.'</span><span class="p">);</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p>⚠️ BUT! How to <em>remove</em> config entries, instead of adding them? Consider the following <code class="language-plaintext highlighter-rouge">config_ignore.settings.yml</code> file in your config sync:</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config_ignore.settings.yml</span>
<span class="na">mode</span><span class="pi">:</span> <span class="s">simple</span>
<span class="na">ignored_config_entities</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">mimemail.settings</span>
  <span class="pi">-</span> <span class="s">openid_connect.settings</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">openid_connect.settings.*'</span>
  <span class="pi">-</span> <span class="s">system.maintenance</span>
  <span class="pi">-</span> <span class="s">system.performance</span>
  <span class="pi">-</span> <span class="s">system.site</span>
  <span class="pi">-</span> <span class="s">update.settings</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">webform.webform.abilities_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">webform.webform.interests_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">webform.webform.learning_styles_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">webform.webform.multiple_intelligences_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">webform.webform.work_preferences_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">webform.webform.work_values_quiz:third_party_settings.my_custom_module.*'</span>
</code></pre></div></div>
<p>What happens if you declare the following override in your <code class="language-plaintext highlighter-rouge">settings.local.php</code>:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// settings.local.php</span>
<span class="c1">// INCORRECT VERSION!</span>
<span class="nv">$config</span><span class="p">[</span><span class="s1">'config_ignore.settings'</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
  <span class="s1">'mode'</span> <span class="o">=&gt;</span> <span class="s1">'simple'</span><span class="p">,</span>
  <span class="s1">'ignored_config_entities'</span> <span class="o">=&gt;</span> <span class="p">[</span>
    <span class="s1">'mimemail.settings'</span><span class="p">,</span>
    <span class="s1">'openid_connect.settings'</span><span class="p">,</span>
    <span class="s1">'openid_connect.settings.*'</span><span class="p">,</span>
    <span class="s1">'system.maintenance'</span><span class="p">,</span>
    <span class="s1">'system.performance'</span><span class="p">,</span>
    <span class="c1">// 'system.site',</span>
    <span class="s1">'update.settings'</span><span class="p">,</span>
    <span class="c1">// 'webform.webform.abilities_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="c1">// 'webform.webform.interests_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="c1">// 'webform.webform.learning_styles_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="c1">// 'webform.webform.multiple_intelligences_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="c1">// 'webform.webform.work_preferences_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="c1">// 'webform.webform.work_values_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="p">]</span>
<span class="p">];</span>
</code></pre></div></div>
<p>Will <code class="language-plaintext highlighter-rouge">system.site</code> and the <code class="language-plaintext highlighter-rouge">webform.webform.*</code> be now kept out of the ignore list? ❌ NO!! As per the module code linked above, the <code class="language-plaintext highlighter-rouge">$config</code> array is <strong>merged</strong> with the original, resulting in the original bottom keys being kept. In order to truly override the settings, you would write the <code class="language-plaintext highlighter-rouge">$config</code> array to contain at least as many entries as the original:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// settings.local.php</span>
<span class="nv">$config</span><span class="p">[</span><span class="s1">'config_ignore.settings'</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
  <span class="s1">'mode'</span> <span class="o">=&gt;</span> <span class="s1">'simple'</span><span class="p">,</span>
  <span class="s1">'ignored_config_entities'</span> <span class="o">=&gt;</span> <span class="p">[</span>
    <span class="s1">'mimemail.settings'</span><span class="p">,</span>
    <span class="s1">'openid_connect.settings'</span><span class="p">,</span>
    <span class="s1">'openid_connect.settings.*'</span><span class="p">,</span>
    <span class="s1">'system.maintenance'</span><span class="p">,</span>
    <span class="s1">'system.performance'</span><span class="p">,</span>
    <span class="s1">''</span><span class="p">,</span> <span class="c1">// 'system.site',</span>
    <span class="s1">'update.settings'</span><span class="p">,</span>
    <span class="s1">''</span><span class="p">,</span> <span class="c1">// 'webform.webform.abilities_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="s1">''</span><span class="p">,</span> <span class="c1">// 'webform.webform.interests_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="s1">''</span><span class="p">,</span> <span class="c1">// 'webform.webform.learning_styles_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="s1">''</span><span class="p">,</span> <span class="c1">// 'webform.webform.multiple_intelligences_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="s1">''</span><span class="p">,</span> <span class="c1">// 'webform.webform.work_preferences_quiz:third_party_settings.my_custom_module.*',</span>
    <span class="s1">''</span> <span class="c1">// 'webform.webform.work_values_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="p">]</span>
<span class="p">];</span>
</code></pre></div></div>
<p>Now we are correctly overriding Config Ignore settings :tada:</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/config-ignore-ignore.jpg">
      <img src="https://blog.karimratib.me/assets/config-ignore-ignore.jpg" style="max-width: 100%;" alt="Is that recursive enough for you?" />
    </a>
    <figcaption>Is that recursive enough for you?</figcaption>
  </figure>
</div>

<h2 id="ignoring-webform-element-titles">Ignoring Webform element titles</h2>
<p>With this out of the way, let’s go back to the initial business requirement: Allowing admin users to modify webform element titles without these changes getting reverted during the next config sync.</p>

<p>Here’s what a typical webform config looks like:</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># webform.webform.interests_quiz.yml</span>
<span class="na">langcode</span><span class="pi">:</span> <span class="s">en</span>
<span class="na">status</span><span class="pi">:</span> <span class="s">open</span>
<span class="na">dependencies</span><span class="pi">:</span>
  <span class="na">module</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">webformautosave</span>
<span class="na">third_party_settings</span><span class="pi">:</span>
  <span class="na">webformautosave</span><span class="pi">:</span>
    <span class="na">auto_save</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">auto_save_time</span><span class="pi">:</span> <span class="m">5000</span>
    <span class="na">optimistic_locking</span><span class="pi">:</span> <span class="kc">false</span>
<span class="na">weight</span><span class="pi">:</span> <span class="m">0</span>
<span class="na">open</span><span class="pi">:</span> <span class="kc">null</span>
<span class="na">close</span><span class="pi">:</span> <span class="kc">null</span>
<span class="na">uid</span><span class="pi">:</span> <span class="m">1</span>
<span class="na">template</span><span class="pi">:</span> <span class="kc">false</span>
<span class="na">archive</span><span class="pi">:</span> <span class="kc">false</span>
<span class="na">id</span><span class="pi">:</span> <span class="s">interests_quiz</span>
<span class="na">title</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Interests</span><span class="nv"> </span><span class="s">Quiz'</span>
<span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">'</span>
<span class="na">categories</span><span class="pi">:</span> <span class="pi">{</span>  <span class="pi">}</span>
<span class="na">elements</span><span class="pi">:</span> <span class="pi">|-</span>
  <span class="s">page_1:</span>
    <span class="s">'#type': webform_wizard_page</span>
    <span class="s">'#title': 'Page 1'</span>
    <span class="s">'#prev_button_label': Back</span>
    <span class="s">'#next_button_label': Next</span>
    <span class="s">i_would_like_to_building_kitchen_cabinets:</span>
      <span class="s">'#type': radios</span>
      <span class="s">'#title': 'I like building kitchen cabinets.'</span>
      <span class="s">'#options': options_interests</span>
      <span class="s">'#category': Realistic</span>
      <span class="s">'#required': true</span>
    <span class="s">i_would_enjoy_laying_brick_or_tile:</span>
      <span class="s">'#type': radios</span>
      <span class="s">'#title': 'I would enjoy laying brick or tile.'</span>
      <span class="s">'#options': options_interests</span>
      <span class="s">'#category': Realistic</span>
      <span class="s">'#required': true</span>
    <span class="s">i_would_like_to_develop_a_new_medicine:</span>
      <span class="s">'#type': radios</span>
      <span class="s">'#title': 'I would like to develop a new medicine.'</span>
      <span class="s">'#options': options_interests</span>
      <span class="s">'#category': Investigative</span>
      <span class="s">'#required': true</span>
<span class="pi">[</span><span class="nv">...</span><span class="pi">]</span>
</code></pre></div></div>
<p>As you can see, there’s no individual YAML key for each element title - instead, all elements are stored together in the <code class="language-plaintext highlighter-rouge">elements</code> key, with each element title specified in a <code class="language-plaintext highlighter-rouge">#title</code> subentry. How to ignore these <code class="language-plaintext highlighter-rouge">#title</code> entries while keeping the rest of the <code class="language-plaintext highlighter-rouge">elements</code> under config sync?</p>

<p>I don’t know about you, but the thought of hacking Drupal Configuration API + Config Ignore to handle synchronization of array sub-entries does not sound like a productive approach to me. Instead, I decided to reuse Webform’s Third Party Settings mechanism to store entries for each element label individually, and apply those labels instead of the originals during rendering. Here’s how the webform config would then look:</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># webform.webform.interests_quiz.yml</span>
<span class="na">langcode</span><span class="pi">:</span> <span class="s">en</span>
<span class="na">status</span><span class="pi">:</span> <span class="s">open</span>
<span class="na">dependencies</span><span class="pi">:</span>
  <span class="na">module</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">webformautosave</span>
    <span class="pi">-</span> <span class="s">my_custom_module</span> <span class="c1"># THIS IS NEW</span>
<span class="na">third_party_settings</span><span class="pi">:</span>
  <span class="na">webformautosave</span><span class="pi">:</span>
    <span class="na">auto_save</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">auto_save_time</span><span class="pi">:</span> <span class="m">5000</span>
    <span class="na">optimistic_locking</span><span class="pi">:</span> <span class="kc">false</span>
  <span class="na">my_custom_module</span><span class="pi">:</span> <span class="c1"># THIS IS NEW</span>
    <span class="na">i_would_like_to_building_kitchen_cabinets</span><span class="pi">:</span> <span class="s1">'</span><span class="s">I</span><span class="nv"> </span><span class="s">REALLY</span><span class="nv"> </span><span class="s">💙</span><span class="nv"> </span><span class="s">building</span><span class="nv"> </span><span class="s">kitchen</span><span class="nv"> </span><span class="s">cabinets.'</span>
    <span class="na">i_would_enjoy_laying_brick_or_tile</span><span class="pi">:</span>
    <span class="na">i_would_like_to_develop_a_new_medicine</span><span class="pi">:</span> <span class="s1">'</span><span class="s">I</span><span class="nv"> </span><span class="s">would</span><span class="nv"> </span><span class="s">like</span><span class="nv"> </span><span class="s">to</span><span class="nv"> </span><span class="s">develop</span><span class="nv"> </span><span class="s">a</span><span class="nv"> </span><span class="s">new</span><span class="nv"> </span><span class="s">medicine</span><span class="nv"> </span><span class="s">and</span><span class="nv"> </span><span class="s">make</span><span class="nv"> </span><span class="s">💰💰💰.'</span>
<span class="na">weight</span><span class="pi">:</span> <span class="m">0</span>
<span class="na">open</span><span class="pi">:</span> <span class="kc">null</span>
<span class="na">close</span><span class="pi">:</span> <span class="kc">null</span>
<span class="na">uid</span><span class="pi">:</span> <span class="m">1</span>
<span class="na">template</span><span class="pi">:</span> <span class="kc">false</span>
<span class="na">archive</span><span class="pi">:</span> <span class="kc">false</span>
<span class="na">id</span><span class="pi">:</span> <span class="s">interests_quiz</span>
<span class="na">title</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Interests</span><span class="nv"> </span><span class="s">Quiz'</span>
<span class="na">description</span><span class="pi">:</span> <span class="s1">'</span><span class="s">'</span>
<span class="na">categories</span><span class="pi">:</span> <span class="pi">{</span>  <span class="pi">}</span>
<span class="na">elements</span><span class="pi">:</span> <span class="pi">|-</span>
  <span class="s">page_1:</span>
    <span class="s">'#type': webform_wizard_page</span>
    <span class="s">'#title': 'Page 1'</span>
    <span class="s">'#prev_button_label': Back</span>
    <span class="s">'#next_button_label': Next</span>
    <span class="s">i_would_like_to_building_kitchen_cabinets:</span>
      <span class="s">'#type': radios</span>
      <span class="s">'#title': 'I like building kitchen cabinets.'</span>
      <span class="s">'#options': options_interests</span>
      <span class="s">'#category': Realistic</span>
      <span class="s">'#required': true</span>
    <span class="s">i_would_enjoy_laying_brick_or_tile:</span>
      <span class="s">'#type': radios</span>
      <span class="s">'#title': 'I would enjoy laying brick or tile.'</span>
      <span class="s">'#options': options_interests</span>
      <span class="s">'#category': Realistic</span>
      <span class="s">'#required': true</span>
    <span class="s">i_would_like_to_develop_a_new_medicine:</span>
      <span class="s">'#type': radios</span>
      <span class="s">'#title': 'I would like to develop a new medicine.'</span>
      <span class="s">'#options': options_interests</span>
      <span class="s">'#category': Investigative</span>
      <span class="s">'#required': true</span>
</code></pre></div></div>
<p>With this in place, it’s now trivial to add the third party settings to <code class="language-plaintext highlighter-rouge">config_ignore.settings</code>, as we’ve seen above:</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config_ignore.settings.yml</span>
<span class="na">mode</span><span class="pi">:</span> <span class="s">simple</span>
<span class="na">ignored_config_entities</span><span class="pi">:</span>
  <span class="pi">[</span><span class="nv">..</span><span class="pi">]</span>
  <span class="pi">-</span> <span class="s1">'</span><span class="s">webform.webform.interests_quiz:third_party_settings.my_custom_module.*'</span>
  <span class="pi">[</span><span class="nv">..</span><span class="pi">]</span>
</code></pre></div></div>
<p>Here’s the code needed to create the new title settings:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// my_custom_module.module</span>

<span class="cd">/**
 * Implements hook_webform_third_party_settings_form_alter().
 */</span>
<span class="k">function</span> <span class="n">my_custom_module_webform_third_party_settings_form_alter</span><span class="p">(</span><span class="kt">array</span> <span class="o">&amp;</span><span class="nv">$form</span><span class="p">,</span> <span class="kt">FormStateInterface</span> <span class="nv">$form_state</span><span class="p">)</span> <span class="p">{</span>
  <span class="cd">/** @var \Drupal\webform\WebformInterface $webform */</span>
  <span class="nv">$webform</span> <span class="o">=</span> <span class="nv">$form_state</span><span class="o">-&gt;</span><span class="nf">getFormObject</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getEntity</span><span class="p">();</span>

  <span class="c1">// Add an entry for the title of each element.</span>
  <span class="nv">$questions</span> <span class="o">=</span> <span class="nb">array_filter</span><span class="p">(</span><span class="nv">$webform</span><span class="o">-&gt;</span><span class="nf">getElementsInitializedAndFlattened</span><span class="p">(),</span> <span class="n">some_condition_function</span><span class="p">);</span>
  <span class="k">foreach</span> <span class="p">(</span><span class="nv">$questions</span> <span class="k">as</span> <span class="nv">$key</span> <span class="o">=&gt;</span> <span class="nv">$question</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$form</span><span class="p">[</span><span class="s1">'third_party_settings'</span><span class="p">][</span><span class="s1">'my_custom_module'</span><span class="p">][</span><span class="nv">$key</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s1">'#type'</span> <span class="o">=&gt;</span> <span class="s1">'textfield'</span><span class="p">,</span>
      <span class="s1">'#title'</span> <span class="o">=&gt;</span> <span class="nf">t</span><span class="p">(</span><span class="s1">'Override: @question'</span><span class="p">,</span> <span class="p">[</span><span class="s1">'@question'</span> <span class="o">=&gt;</span> <span class="nv">$question</span><span class="p">[</span><span class="s1">'#title'</span><span class="p">]]),</span>
      <span class="s1">'#required'</span> <span class="o">=&gt;</span> <span class="kc">false</span><span class="p">,</span>
      <span class="s1">'#default_value'</span> <span class="o">=&gt;</span> <span class="nv">$webform</span><span class="o">-&gt;</span><span class="nf">getThirdPartySetting</span><span class="p">(</span><span class="s1">'my_custom_module'</span><span class="p">,</span> <span class="nv">$key</span><span class="p">,</span> <span class="s1">''</span><span class="p">)</span>
    <span class="p">];</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p>And here’s a rudimentary way to display them:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// my_custom_module.module</span>

<span class="cd">/**
 * Implements template_preprocess_fieldset().
 */</span>
<span class="k">function</span> <span class="n">my_custom_module_preprocess_fieldset</span><span class="p">(</span><span class="o">&amp;</span><span class="nv">$variables</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="k">isset</span><span class="p">(</span><span class="nv">$variables</span><span class="p">[</span><span class="s1">'element'</span><span class="p">][</span><span class="s1">'#webform'</span><span class="p">]))</span> <span class="p">{</span>
    <span class="cd">/** @var \Drupal\webform\WebformInterface $webform */</span>
    <span class="nv">$webform</span> <span class="o">=</span> <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">entityTypeManager</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getStorage</span><span class="p">(</span><span class="s1">'webform'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">load</span><span class="p">(</span><span class="nv">$variables</span><span class="p">[</span><span class="s1">'element'</span><span class="p">][</span><span class="s1">'#webform'</span><span class="p">]);</span>

    <span class="c1">// Override the element title with the corresponding third party setting.</span>
    <span class="nv">$variables</span><span class="p">[</span><span class="s1">'element'</span><span class="p">][</span><span class="s1">'#title'</span><span class="p">]</span> <span class="o">=</span> <span class="nv">$webform</span><span class="o">-&gt;</span><span class="nf">getThirdPartySetting</span><span class="p">(</span><span class="s1">'my_custom_module'</span><span class="p">,</span> <span class="nv">$variables</span><span class="p">[</span><span class="s1">'element'</span><span class="p">][</span><span class="s1">'#webform_key'</span><span class="p">],</span> <span class="nv">$variables</span><span class="p">[</span><span class="s1">'element'</span><span class="p">][</span><span class="s1">'#title'</span><span class="p">]);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<div class="flex-center">
  <figure class="image">
    <a href="/assets/config-ignore-webform.png">
      <img src="https://blog.karimratib.me/assets/config-ignore-webform.png" style="max-width: 100%;" alt="The webform with overridden element titles." />
    </a>
    <figcaption>The webform with overridden element titles.</figcaption>
  </figure>
</div>

<p>Et voilà ! Happy site builders and happy business users 👷‍♀️🤝🤵‍♀️</p>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[I describe a technique for ignoring some, not all, webform settings from config sync. This gives flexibility to business users to manage end-user-facing form labels without fully giving up on configuration management. Along the way, I solve a quirk in Config Ignore that prevents from hard-coding its own configuration in settings.php.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.karimratib.me/assets/ignore-config-ignore.jpg" /><media:content medium="image" url="https://blog.karimratib.me/assets/ignore-config-ignore.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Drupal 10: Fully local, open source Drupal AI setup, part 1: Search API</title><link href="https://blog.karimratib.me/2025/05/28/drupal-ai-open-source.html" rel="alternate" type="text/html" title="Drupal 10: Fully local, open source Drupal AI setup, part 1: Search API" /><published>2025-05-28T00:00:00+00:00</published><updated>2025-05-28T00:00:00+00:00</updated><id>https://blog.karimratib.me/2025/05/28/drupal-ai-open-source</id><content type="html" xml:base="https://blog.karimratib.me/2025/05/28/drupal-ai-open-source.html"><![CDATA[<table class="changelog">
  <thead>
    <th>Changelog</th>
    <th></th>
  </thead>
  <tbody>


  <tr>
    
    
      <td>Jul 29, 2025 </td>
    
      <td> Updated for the latest versions of the Drupal AI modules.</td>
    
  </tr>

  </tbody>
</table>

<p>A recent interaction on the <a href="https://drupal.slack.com">Drupal community’s Slack</a> prompted me to describe the work I’ve been doing to create a fully local, open source setup for Drupal AI tools. My use case is to provide relevant search results based on natural language (English) queries. There are deployment scenarios, such as government projects, where the full system needs to be deployed in the home country and to avoid communicating with API services located elsewhere - this is the scenario that interests me here. Since I received positive feedback on my system description, I thought I’d clean it up and share it here. Hope it helps someone!</p>

<h2 id="theory-of-operation">Theory of operation</h2>
<p>The general idea of using Search API with natural language queries is to create vector embeddings of the relevant content, which are then matched against the embedding of the incoming user query. Vector embeddings are computed by an LLM (Large Language Model, in case you just landed on our planet) that is served by a local instance of <a href="https://ollama.com/">Ollama</a> running on my CPU-only laptop. At this time, I am using the LLM <a href="https://ollama.com/library/mxbai-embed-large"><code class="language-plaintext highlighter-rouge">mxbai-embed-large</code></a> to generate the embedding vectors. These vectors are stored in the same database as Drupal - I always use PostgreSQL and its <a href="https://github.com/pgvector/pgvector">pgvector extension</a> turns it into a perfectly acceptable vector database. The pretty amazing <a href="https://project.pages.drupalcode.org/ai/">Drupal AI ecosystem</a> supports these tools out of the box, so there’s almost no coding involved in this setup. Drupal AI even provides a Search API connector that is able to perform vector indexing within the familiar Drupal search infrastructure.</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-search-api-workflow.svg">
      <img src="https://blog.karimratib.me/assets/drupal-ai-search-api-workflow-dark.svg" style="max-width: 100%;" alt="Indexing and querying using Drupal AI + Search API." />
    </a>
    <figcaption>Indexing and querying using Drupal AI + Search API.</figcaption>
  </figure>
</div>

<p>I’ll be illustrating this setup with content from <a href="https://workbc.ca">WorkBC.ca</a>, a large Drupal site for the Ministry of Post-Secondary Education and Future Skills, British Columbia, that my team and I have been building and maintaining for the past 2+ years. The content describes the <a href="https://www.workbc.ca/plan-career/explore-careers">500+ official careers</a> that are identified by the Federal Government of Canada as representing the Canadian workforce.</p>

<h2 id="docker-setup">Docker setup</h2>
<p>I always start with Docker Compose. I use the excellent <a href="https://wodby.com/stacks/drupal10">Wodby Drupal stack</a> as a starting point - it includes all needed services and has intelligent defaults. In my case, I want to add <code class="language-plaintext highlighter-rouge">ollama</code> to the services, as well as inject the <code class="language-plaintext highlighter-rouge">pgvector</code> extension into Postgres - here are the relevant bits:</p>
<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># docker-compose.yml</span>
<span class="na">services</span><span class="pi">:</span>
  <span class="na">ollama</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ollama/ollama:${OLLAMA_TAG}</span>
    <span class="na">ports</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s2">"</span><span class="s">11434:11434"</span>
    <span class="na">volumes</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">/usr/share/ollama/.ollama:/root/.ollama</span> <span class="c1"># To store models on my local host</span>
  <span class="na">postgres</span><span class="pi">:</span>
    <span class="na">build</span><span class="pi">:</span>
      <span class="na">context</span><span class="pi">:</span> <span class="s">.</span>
      <span class="na">dockerfile</span><span class="pi">:</span> <span class="s">pgvector.Dockerfile</span>
      <span class="na">args</span><span class="pi">:</span>
        <span class="na">POSTGRES_TAG</span><span class="pi">:</span> <span class="s">${POSTGRES_TAG}</span>
        <span class="na">PGVECTOR_TAG</span><span class="pi">:</span> <span class="s">${PGVECTOR_TAG}</span>
</code></pre></div></div>
<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># pgvector.Dockerfile</span>
<span class="k">ARG</span><span class="s"> POSTGRES_TAG</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">wodby/postgres:${POSTGRES_TAG}</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">pgvector-builder</span>
<span class="k">ARG</span><span class="s"> PGVECTOR_TAG</span>
<span class="k">RUN </span>apk add git
<span class="k">RUN </span>apk add build-base
<span class="k">RUN </span>apk add clang
<span class="k">RUN </span>apk add llvm-dev
<span class="k">WORKDIR</span><span class="s"> /home</span>
<span class="k">RUN </span>git clone <span class="nt">--branch</span> v<span class="k">${</span><span class="nv">PGVECTOR_TAG</span><span class="k">}</span> https://github.com/pgvector/pgvector.git
<span class="k">WORKDIR</span><span class="s"> /home/pgvector</span>
<span class="k">RUN </span>make
<span class="k">RUN </span>make <span class="nb">install</span>

<span class="k">FROM</span><span class="s"> wodby/postgres:${POSTGRES_TAG}</span>
<span class="k">COPY</span><span class="s"> --from=pgvector-builder /usr/local/lib/postgresql/bitcode/vector.index.bc /usr/local/lib/postgresql/bitcode/vector.index.bc</span>
<span class="k">COPY</span><span class="s"> --from=pgvector-builder /usr/local/lib/postgresql/vector.so /usr/local/lib/postgresql/vector.so</span>
<span class="k">COPY</span><span class="s"> --from=pgvector-builder /usr/local/share/postgresql/extension /usr/local/share/postgresql/extension</span>
</code></pre></div></div>
<p>We are now ready to download the embedding model:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker-compose run ollama
docker-compose <span class="nb">exec </span>ollama ollama pull mxbai-embed-large:latest <span class="c"># in a different console</span>
</code></pre></div></div>
<h2 id="drupal-modules-setup">Drupal modules setup</h2>
<p>Here is the relevant configuration in my <code class="language-plaintext highlighter-rouge">composer.json</code> file:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"require"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"drupal/ai"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^1.2@alpha"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"drupal/ai_provider_ollama"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^1.1@beta"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"drupal/ai_vdb_provider_postgres"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^1.0@alpha"</span><span class="p">,</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>and the enabled modules:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wodby@php.container:/var/www/html $ drush pml | grep AI
  AI                                            AI Core (ai)                                                                     Enabled    1.2.0-alpha1
  AI Providers                                  DropAI Provider (dropai_provider)                                                Disabled   1.2.0-alpha1
  AI                                            AI API Explorer (ai_api_explorer)                                                Enabled    1.2.0-alpha1
  AI Tools                                      AI Assistant API (ai_assistant_api)                                              Disabled   1.2.0-alpha1
  AI                                            AI Automators (ai_automators)                                                    Disabled   1.2.0-alpha1
  AI Tools                                      AI Chatbot (ai_chatbot)                                                          Disabled   1.2.0-alpha1
  AI                                            AI CKEditor integration (ai_ckeditor)                                            Disabled   1.2.0-alpha1
  AI                                            AI Content Suggestions (ai_content_suggestions)                                  Disabled   1.2.0-alpha1
  AI                                            AI ECA integration (ai_eca)                                                      Disabled   1.2.0-alpha1
  AI                                            AI External Moderation (Deprecated) (ai_external_moderation)                     Disabled   1.2.0-alpha1
  AI                                            AI Logging (ai_logging)                                                          Disabled   1.2.0-alpha1
  AI (Experimental)                             AI Search (ai_search)                                                            Enabled    1.2.0-alpha1
  AI                                            AI Translate (ai_translate)                                                      Disabled   1.2.0-alpha1
  AI                                            AI Validations (ai_validations)                                                  Disabled   1.2.0-alpha1
  AI Providers                                  Ollama Provider (ai_provider_ollama)                                             Enabled    1.1.0-beta2
  AI Vector Database Providers (Experimental)   Postgres VDB Provider (ai_vdb_provider_postgres)                                 Enabled    1.0.0-alpha2
</code></pre></div></div>
<h2 id="drupal-ai-setup">Drupal AI setup</h2>
<p>With the infrastructure ready, it’s now time to configure the Drupal AI components and wire them together. I’ll be using the Admin UI with URIs and screenshots to better illustrate the setup.</p>

<h4 id="adminconfigaiprovidersollama">/admin/config/ai/providers/ollama</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-providers-ollama.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-providers-ollama.png" style="max-width: 100%;" alt="Accessing the local Ollama service." />
    </a>
    <figcaption>Accessing the local Ollama service.</figcaption>
  </figure>
</div>

<h4 id="adminconfigaisettings">/admin/config/ai/settings</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-settings.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-settings.png" style="max-width: 100%;" alt="The only AI provider we need is the LLM for embeddings." />
    </a>
    <figcaption>The only AI provider we need is the LLM for embeddings.</figcaption>
  </figure>
</div>

<h4 id="adminconfigaivdb_providerspostgres">/admin/config/ai/vdb_providers/postgres</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-vdb-providers-postgres.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-vdb-providers-postgres.png" style="max-width: 100%;" alt="Accessing the Drupal PostgreSQL database running the pgvector extension." />
    </a>
    <figcaption>Accessing the Drupal PostgreSQL database running the pgvector extension.</figcaption>
  </figure>
</div>

<h4 id="adminconfigsearchsearch-api">/admin/config/search/search-api</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-search-api.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-search-api.png" style="max-width: 100%;" alt="We will create a new Search API server along with its index." />
    </a>
    <figcaption>We will create a new Search API server along with its index.</figcaption>
  </figure>
</div>

<h4 id="adminconfigsearchsearch-apiserverragedit">/admin/config/search/search-api/server/rag/edit</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-search-api-server.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-search-api-server.png" style="max-width: 100%;" alt="The Search API server settings." />
    </a>
    <figcaption>The Search API server settings.</figcaption>
  </figure>
</div>

<p>Note the <strong>Vector Database Configuration &gt; Collection</strong> setting <code class="language-plaintext highlighter-rouge">search_api_rag</code> which is the name of a database table created to hold the vector embeddings.</p>

<h4 id="adminconfigsearchsearch-apiindexcareer_profiles_ragedit">/admin/config/search/search-api/index/career_profiles_rag/edit</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-search-api-index.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-search-api-index.png" style="max-width: 100%;" alt="The Search API index settings." />
    </a>
    <figcaption>The Search API index settings.</figcaption>
  </figure>
</div>

<h4 id="adminconfigsearchsearch-apiindexcareer_profiles_ragfields">/admin/config/search/search-api/index/career_profiles_rag/fields</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-search-api-fields.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-search-api-fields.png" style="max-width: 100%;" alt="The Search API index field settings." />
    </a>
    <figcaption>The Search API index field settings.</figcaption>
  </figure>
</div>

<p>The tricky bit here is understanding the <strong>Indexing option</strong> setting - namely, the difference between <strong>Contextual content</strong> and <strong>Main content</strong> for indexing purposes. During indexing, each content item is divided into several chunks whose size is chosen to generate vector embeddings that accurately reflect its semantic meaning without overwhelming the LLM’s context window limit. For example, the open source vector database <a href="https://milvus.io/ai-quick-reference/what-is-the-optimal-chunk-size-for-rag-applications">Milvus mentions a chunk size of 128-512 tokens</a> - a token roughly corresponds to subwords of ~4 characters. But by subdividing the item, some chunks may miss the information that reflects the nature of the overall content item. The solution offered here is to repeat some fields in all the chunks - such fields are labeled as <strong>Contextual</strong>, whereas the <strong>Main</strong> content is the one being subdivided.</p>

<p>:warning: A word of caution: This is probably the part that requires the most tweaking to get good search results!!</p>

<h2 id="results">Results</h2>

<p>Once we’ve indexed the content in the index above, we’re ready to test the search:</p>

<h4 id="adminconfigaiexplorersvector_db_generator">/admin/config/ai/explorers/vector_db_generator</h4>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-vector-db-generator.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-vector-db-generator.png" style="max-width: 100%;" alt="The Vector DB Explorer is useful to test your Drupal AI + Search API configuration." />
    </a>
    <figcaption>The Vector DB Explorer is useful to test your Drupal AI + Search API configuration.</figcaption>
  </figure>
</div>

<p>In case you’re curious, here’s the database table that the Search API server creates to store vector embeddings. You can notice that each node is broken up in several chunks, each starting with the node title which was selected as one of the <strong>Contextual</strong> fields above:</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-ai-vector-db.png">
      <img src="https://blog.karimratib.me/assets/drupal-ai-vector-db.png" style="max-width: 100%;" alt="The search_api_rag table contains the vector embeddings for content chunks." />
    </a>
    <figcaption>The search_api_rag table contains the vector embeddings for content chunks.</figcaption>
  </figure>
</div>

<p>This concludes part 1 of my Drupal AI setup. Next time, I’ll look at more specialized Search API use case before getting into the treacherous waters of generated responses. Happy vibing :robot:</p>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[I describe a Drupal AI setup based on open source tools and running locally. The use case is to provide search results based on natural language queries using the Search API ecosystem. The constraint is to avoid communicating with external APIs and rely only on services that are co-located with the Drupal deployment.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.karimratib.me/assets/drupal-ai-search-api-workflow.png" /><media:content medium="image" url="https://blog.karimratib.me/assets/drupal-ai-search-api-workflow.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Drupal 10: Fix AJAX-related error with Views exposed forms</title><link href="https://blog.karimratib.me/2025/05/21/drupal-ajax-views-exposed-filters.html" rel="alternate" type="text/html" title="Drupal 10: Fix AJAX-related error with Views exposed forms" /><published>2025-05-21T00:00:00+00:00</published><updated>2025-05-21T00:00:00+00:00</updated><id>https://blog.karimratib.me/2025/05/21/drupal-ajax-views-exposed-filters</id><content type="html" xml:base="https://blog.karimratib.me/2025/05/21/drupal-ajax-views-exposed-filters.html"><![CDATA[<p>I don’t mind fixing the bugs that I or my team introduce into our codebase - those bugs are expected and par for the course. But bugs in Drupal core are totally unacceptable!! /s</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/what-me-entitled.jpg">
      <img src="https://blog.karimratib.me/assets/what-me-entitled.jpg" style="max-width: 100%;" alt="In reality, these blog posts are just excuses for me to make more silly memes." />
    </a>
    <figcaption>In reality, these blog posts are just excuses for me to make more silly memes.</figcaption>
  </figure>
</div>

<p>This one was pretty confusing. I needed to dynamically update a drop-down every time a “parent” drop-down changed (think 2-level taxonomy vocabulary), which is a <a href="https://www.drupal.org/docs/develop/drupal-apis/javascript-api/ajax-forms">well-documented feature in the Forms API</a>. In a nutshell, the parent element gets an <code class="language-plaintext highlighter-rouge">#ajax</code> callback that is called upon user interaction, and that returns the updated child element from the <code class="language-plaintext highlighter-rouge">$form</code> structure. The Drupal AJAX frontend code takes care of replacing the child element in the HTML form. Neat and simple. In my case, though, I needed this behaviour in a Views exposed form, and that’s when the trouble started. When changing the parent element, the callback was not being called, and instead, an unrelated error was displayed, saying <code class="language-plaintext highlighter-rouge">An unrecoverable error occurred. The uploaded file likely exceeded the maximum file size (XXX) that this server supports</code>.</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/views-ajax-exception.gif">
      <img src="https://blog.karimratib.me/assets/views-ajax-exception.gif" style="max-width: 100%;" alt="Hi Drupal, which uploaded file are you talking about? Source: Drupal user ajits." />
    </a>
    <figcaption>Hi Drupal, which uploaded file are you talking about? Source: Drupal user ajits.</figcaption>
  </figure>
</div>

<p>Fortunately, I was able to find <a href="https://www.drupal.org/project/drupal/issues/2658718">an existing issue</a> (submitted in Jan 2016 :sob:) which was useful to confirm I was not vastly misunderstanding the problem. The workarounds mentioned in this ticket did not work for me, though, so I had to keep digging on my own. Here’s the result of my analysis:</p>

<h2 id="why-this-error">Why this error?</h2>
<p>The displayed error has absolutely nothing to do with the situation at hand: There’s no uploaded file, and there’s not even a <code class="language-plaintext highlighter-rouge">POST</code>‘ed form, since the AJAX request uses the <code class="language-plaintext highlighter-rouge">GET</code> method. My approach to find the source of an error is to start by locating the text of the error in the codebase and work backwards up the call stack - in this case, it is thrown by <code class="language-plaintext highlighter-rouge">FormAjaxSubscriber::onException</code> which is itself triggered by <a href="https://api.drupal.org/api/drupal/core%21lib%21Drupal%21Core%21Form%21FormBuilder.php/function/FormBuilder%3A%3AbuildForm/10"><code class="language-plaintext highlighter-rouge">FormBuilder::buildForm</code></a> under an unexpected condition:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="c1">// In case the post request exceeds the configured allowed size</span>
    <span class="c1">// (post_max_size), the post request is potentially broken. Add some</span>
    <span class="c1">// protection against that and at the same time have a nice error message.</span>
    <span class="k">if</span> <span class="p">(</span><span class="nv">$ajax_form_request</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'form_id'</span><span class="p">))</span> <span class="p">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nc">BrokenPostRequestException</span><span class="p">(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">getFileUploadMaxSize</span><span class="p">());</span>
    <span class="p">}</span>
</code></pre></div></div>
<p>I don’t know about you, but to me the condition of a missing <code class="language-plaintext highlighter-rouge">form_id</code> seems unrelated to a file limit issue. By examining the AJAX <code class="language-plaintext highlighter-rouge">GET</code> request in the browser, I was able to verify that no <code class="language-plaintext highlighter-rouge">form_id</code> query argument is actually sent - which means that further down this function, the form builder will be unable to find the form object that should be built. Looks like a legitimate error and the AJAX frontend seems to be at fault.</p>

<h2 id="the-workaround-needed-a-workaround">The workaround needed a workaround</h2>
<p>At this point, I had the choice of debugging and fixing the <a href="https://git.drupalcode.org/project/drupal/-/blob/10.5.x/core/misc/ajax.js">Drupal AJAX frontend code</a>, or find a workaround that would allow me to keep working on my business feature. Although I am a firm believer that we should allocate some of our professional time to contribute to the open source software that we use, this seemed a deeper dive than I could afford at that point. Instead, I opted for the most generic workaround that I could reuse in similar future scenarios. Here’s what I came up with:</p>

<p>The general idea is to simply send the missing <code class="language-plaintext highlighter-rouge">form_id</code> in the AJAX request. The <a href="https://www.drupal.org/docs/develop/drupal-apis/javascript-api/ajax-forms#s-full-list-of-available-ajax-properties">Form API <code class="language-plaintext highlighter-rouge">#ajax</code> properties</a> helpfully include a customizable <code class="language-plaintext highlighter-rouge">url</code> entry, so I decided to augment the current URL with the <code class="language-plaintext highlighter-rouge">form_id</code> query argument. Something like that, maybe?</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">function</span> <span class="n">my_module_form_views_exposed_form_alter</span><span class="p">(</span><span class="o">&amp;</span><span class="nv">$form</span><span class="p">,</span> <span class="nc">FormStateInterface</span> <span class="nv">$form_state</span><span class="p">,</span> <span class="nv">$form_id</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// DANGER: THIS WILL NOT WORK!</span>
    <span class="nv">$uri</span> <span class="o">=</span> <span class="nc">\Drupal\Component\Utility\UrlHelper</span><span class="o">::</span><span class="nf">parse</span><span class="p">(</span><span class="nc">\Drupal</span><span class="o">::</span><span class="nf">request</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getRequestUri</span><span class="p">());</span>
    <span class="nv">$uri</span><span class="p">[</span><span class="s1">'query'</span><span class="p">][</span><span class="s1">'form_id'</span><span class="p">]</span> <span class="o">=</span> <span class="nv">$form</span><span class="p">[</span><span class="s1">'#id'</span><span class="p">];</span>
    <span class="nv">$uri</span><span class="p">[</span><span class="s1">'query'</span><span class="p">][</span><span class="s1">'ajax_form'</span><span class="p">]</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
    <span class="nv">$form</span><span class="p">[</span><span class="s1">'my_parent_element'</span><span class="p">][</span><span class="s1">'#ajax'</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s1">'callback'</span> <span class="o">=&gt;</span> <span class="s1">'my_parent_element_callback'</span><span class="p">,</span>
      <span class="s1">'wrapper'</span> <span class="o">=&gt;</span> <span class="s1">'my-parent-element-container'</span><span class="p">,</span>
      <span class="s1">'url'</span> <span class="o">=&gt;</span> <span class="nc">Url</span><span class="o">::</span><span class="nf">fromUri</span><span class="p">(</span><span class="s1">'internal:'</span> <span class="mf">.</span> <span class="nv">$uri</span><span class="p">[</span><span class="s1">'path'</span><span class="p">],</span> <span class="p">[</span><span class="s1">'query'</span> <span class="o">=&gt;</span> <span class="nv">$uri</span><span class="p">[</span><span class="s1">'query'</span><span class="p">],</span> <span class="s1">'fragment'</span> <span class="o">=&gt;</span> <span class="nv">$uri</span><span class="p">[</span><span class="s1">'fragment'</span><span class="p">]]),</span>
    <span class="p">];</span>
<span class="p">}</span>
</code></pre></div></div>
<p>If only things were that simple! This did not work - the AJAX request kept missing ALL query arguments after this change. How on earth could <code class="language-plaintext highlighter-rouge">URL</code> options get ignored?? More hours, more digging revealed <a href="https://git.drupalcode.org/project/drupal/-/blob/10.5.x/core/lib/Drupal/Core/Render/Element/RenderElementBase.php#L381-388">this code deep inside <code class="language-plaintext highlighter-rouge">RenderElementBase::preRenderAjaxForm</code></a> - someone decided to overwrite the incoming URL options with those from another key THAT IS NOT EVEN DOCUMENTED :angry: - I’m sure it seemed like a good idea at the time and I’ve edited the documentation to reflect this quirk :angel:</p>

<p>So the final code looks like:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">function</span> <span class="n">my_module_form_views_exposed_form_alter</span><span class="p">(</span><span class="o">&amp;</span><span class="nv">$form</span><span class="p">,</span> <span class="nc">FormStateInterface</span> <span class="nv">$form_state</span><span class="p">,</span> <span class="nv">$form_id</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// Override the AJAX request to include `form_id` and `ajax_form`.</span>
    <span class="nv">$uri</span> <span class="o">=</span> <span class="nc">\Drupal\Component\Utility\UrlHelper</span><span class="o">::</span><span class="nf">parse</span><span class="p">(</span><span class="nc">\Drupal</span><span class="o">::</span><span class="nf">request</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getRequestUri</span><span class="p">());</span>
    <span class="nv">$uri</span><span class="p">[</span><span class="s1">'query'</span><span class="p">][</span><span class="s1">'form_id'</span><span class="p">]</span> <span class="o">=</span> <span class="nv">$form</span><span class="p">[</span><span class="s1">'#id'</span><span class="p">];</span>
    <span class="nv">$uri</span><span class="p">[</span><span class="s1">'query'</span><span class="p">][</span><span class="s1">'ajax_form'</span><span class="p">]</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
    <span class="nv">$form</span><span class="p">[</span><span class="s1">'my_parent_element'</span><span class="p">][</span><span class="s1">'#ajax'</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s1">'callback'</span> <span class="o">=&gt;</span> <span class="s1">'my_parent_element_callback'</span><span class="p">,</span>
      <span class="s1">'wrapper'</span> <span class="o">=&gt;</span> <span class="s1">'my-parent-element-container'</span><span class="p">,</span>
      <span class="s1">'url'</span> <span class="o">=&gt;</span> <span class="nc">Url</span><span class="o">::</span><span class="nf">fromUri</span><span class="p">(</span><span class="s1">'internal:'</span> <span class="mf">.</span> <span class="nv">$uri</span><span class="p">[</span><span class="s1">'path'</span><span class="p">]),</span>
      <span class="s1">'options'</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="s1">'query'</span> <span class="o">=&gt;</span> <span class="nv">$uri</span><span class="p">[</span><span class="s1">'query'</span><span class="p">],</span> <span class="s1">'fragment'</span> <span class="o">=&gt;</span> <span class="nv">$uri</span><span class="p">[</span><span class="s1">'fragment'</span><span class="p">]]</span>
    <span class="p">];</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And this, my friends, is how I fixed the file size limit error that occurs on AJAXified Views exposed form elements :tada:</p>

<h2 id="sober-concluding-thoughts">Sober concluding thoughts</h2>
<p>In a codebase as large as Drupal’s, it is normal to expect inconsistencies and edge cases. Since this is the second issue that involves Views exposed forms (the first one being an <a href="/2024/08/29/drupal-bigpipe-reset.html">unwanted interaction with Big Pipe</a>), I am now expecting more bugs to emanate from this area - namely, the intersection between Views exposed forms and advanced Drupal features. I wonder if anyone’s done an analysis of open Drupal issues to find clusters of bugs based on Drupal core components or recurring keywords.</p>

<p>In this particular case, the error that Drupal reports is not only useless, it is actively misleading. This is not particularly unusual either, as error handling is notoriously one of the harder aspects of programming, and much <a href="https://www.google.com/search?q=error+handling+in+software+development">virtual ink has been spilled to try to make sense of it</a>. What should be reported to the user? What should be logged? What should be handled silently? As a software architect who interacts a lot with business users, I can tell you that core Drupal has its own share of confusing and unhelpful error messages. The most egregious one for me is the infamous message <code class="language-plaintext highlighter-rouge">An illegal choice has been detected. Please contact the site administrator.</code> which only serves to confuse users but offers them no help. In our own software process, I make sure to review the errors thrown by the developers and ask myself the following questions in each case:</p>

<ul>
  <li><strong>UX (User eXperience)</strong>: Should end users see an error, a warning, or should the UI keep functioning silently? What information will best help end users to accomplish their task at hand?</li>
  <li><strong>DX (Developer eXperience)</strong>: Should site builders see an error, a warning, or should the application keep functioning silently? What information will best help site builders to develop the application?</li>
  <li><strong>DevOps</strong>: Should system engineers see an error, a warning, or should the system keep functioning silently? What information will best help system engineers to manage the site’s operation?</li>
</ul>

<p>The detail about the <code class="language-plaintext highlighter-rouge">URL</code> options being overridden by an undocumented <code class="language-plaintext highlighter-rouge">['#ajax']['options']</code> key not only illustrates the difficulty of keeping documentation in sync with the code, but also the importance of thinking about DX when designing APIs, to minimize surprises and inconsistencies which directly translate to bugs or wasted effort.</p>

<p>In the spirit of contributing back, I <a href="https://www.drupal.org/project/drupal/issues/2658718#comment-16099799">documented my workaround in the original issue</a> and updated the AJAX Forms documentation accordingly - hoping it will prevent further unnecessary hair pulling!</p>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[Enabling AJAX callbacks on Views exposed forms causes a cryptic error that "the uploaded file likely exceeded the maximum file size". In this post, I explain why this happens, and present a functioning workaround.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.karimratib.me/assets/what-me-entitled.jpg" /><media:content medium="image" url="https://blog.karimratib.me/assets/what-me-entitled.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Drupal 10: Fix Views Reset button with Big Pipe</title><link href="https://blog.karimratib.me/2024/08/29/drupal-bigpipe-reset.html" rel="alternate" type="text/html" title="Drupal 10: Fix Views Reset button with Big Pipe" /><published>2024-08-29T00:00:00+00:00</published><updated>2024-08-29T00:00:00+00:00</updated><id>https://blog.karimratib.me/2024/08/29/drupal-bigpipe-reset</id><content type="html" xml:base="https://blog.karimratib.me/2024/08/29/drupal-bigpipe-reset.html"><![CDATA[<p>I was <strong>flabbergasted</strong> to discover that Big Pipe breaks the Views Reset button. In fact, Big Pipe breaks <strong>all</strong> form redirects. Not sure how other Drupal devs feel about that, but this was a big smh moment for me. Just imagine the collective time wasted debugging one’s code until one associates this failure to a core module bug!! :facepalm:</p>

<p>Now that my rant’s over, let’s get into the technical details of this story.</p>

<h2 id="detecting-the-bug">Detecting the bug</h2>
<p>The tell-tale sign that you hit this bug is when you enable the Reset button on a view’s exposed form, and instead of resetting the view filters, you get a blank page. The log says something like:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Drupal\Core\Form\EnforcedResponseException: in Drupal\Core\Form\FormBuilder-&gt;buildForm() (line 357 of /var/www/html/web/core/lib/Drupal/Core/Form/FormBuilder.php)
#0 /var/www/html/web/core/modules/views/src/Plugin/views/exposed_form/ExposedFormPluginBase.php(134): Drupal\Core\Form\FormBuilder-&gt;buildForm()
#1 /var/www/html/web/core/modules/views/src/ViewExecutable.php(1243): Drupal\views\Plugin\views\exposed_form\ExposedFormPluginBase-&gt;renderExposedForm()
</code></pre></div></div>

<h2 id="solution-1-applying-the-patch">Solution 1: Applying the patch</h2>
<p>The <a href="https://www.drupal.org/project/drupal/issues/3304746">relevant bug report</a> has a patch that worked for me. I had to apply the patch manually to Drupal 9.x (please, don’t shoot me because I’m not in charge of our Drupal update schedule!!) but the code changes are exactly the same.</p>

<p>When you apply this patch, the Reset button works again. But clumsily: First, you see the URL changing to your current filters followed by <code class="language-plaintext highlighter-rouge">&amp;op=Reset</code>, then the browser redirects to the page’s bare URL, thereby resetting the filters. This is of course a consequence of using Big Pipe, which optimizes page rendering by returning all cached blocks first, and deferring uncacheable blocks to be requested by the front-end. A marvel of engineering by <strong>Wim Leers</strong>! Still, the flickering leaves to be desired.</p>

<p>In my case, this particular view is the principal component of the page, so I feel OK disabling Big Pipe for just this page if at all possible. But how?</p>

<h2 id="solution-2a-disable-big-pipe-for-a-specific-route">Solution 2a: Disable Big Pipe for a specific route</h2>
<p>The standard approach to disabling Big Pipe is to inject the setting <code class="language-plaintext highlighter-rouge">_no_big_pipe: TRUE</code> in the options of the relevant route. If your page’s route is unique, then all you need is to follow the official guide on <a href="https://www.drupal.org/docs/drupal-apis/routing-system/altering-existing-routes-and-adding-new-routes-based-on-dynamic-ones#s-altering-existing-routes">altering existing routes</a>. Specifically, for a view page, the route is of the form <code class="language-plaintext highlighter-rouge">view.view_id.page_id</code>. So you would have something like the following:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="k">protected</span> <span class="k">function</span> <span class="n">alterRoutes</span><span class="p">(</span><span class="kt">RouteCollection</span> <span class="nv">$collection</span><span class="p">)</span> <span class="p">{</span>

    <span class="c1">// Disable Big Pipe for my view.</span>
    <span class="k">if</span> <span class="p">(</span><span class="nv">$route</span> <span class="o">=</span> <span class="nv">$collection</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'view.view_id.page_id'</span><span class="p">))</span> <span class="p">{</span>
      <span class="nv">$route</span><span class="o">-&gt;</span><span class="nf">setOption</span><span class="p">(</span><span class="s1">'_no_big_pipe'</span><span class="p">,</span> <span class="kc">TRUE</span><span class="p">);</span>
    <span class="p">}</span>

  <span class="p">}</span>
</code></pre></div></div>

<p>But in my case, the view is a block that’s embedded in a node page. I cannot simply alter the route <code class="language-plaintext highlighter-rouge">entity.node.canonical</code>, because this would disable it on 99% of the site!!</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/disable-bigpipe.jpg">
      <img src="https://blog.karimratib.me/assets/disable-bigpipe.jpg" style="max-width: 100%;" alt="What do you mean, my memes are obsolete??" />
    </a>
    <figcaption>What do you mean, my memes are obsolete??</figcaption>
  </figure>
</div>

<h1 id="solution-2b-disable-big-pipe-for-a-specific-url">Solution 2b: Disable Big Pipe for a specific URL</h1>
<p>I turned to good old <a href="https://drupal.stackexchange.com/q/320680/767">Stack Overflow (technically, Drupal Answers)</a> to query the hive-mind. Thanks to the ever-helpful and super-knowledgeable <strong>4uk4</strong> for his suggestion! Although I ended up taking a different approach, I will remember that I can override parameterized routes with specific ones because this will surely come in handy in the future.</p>

<p>The approach I ended up following is based on Wim Leer’s <a href="https://git.drupalcode.org/project/big_pipe_demo">Big Pipe Strategy demo</a>, where he catches every request in real-time and decides whether to return the Big Pipe placeholders or to ignore them. In my case, instead of examining the request’s query arguments for a specific “disable” signal, I compare the URI itself with the target page’s URL:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="k">public</span> <span class="k">function</span> <span class="n">processPlaceholders</span><span class="p">(</span><span class="kt">array</span> <span class="nv">$placeholders</span><span class="p">)</span> <span class="p">{</span>

    <span class="c1">// Ignore Big Pipe for my page URL.</span>
    <span class="nv">$current_uri</span> <span class="o">=</span> <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">request</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getRequestUri</span><span class="p">();</span>
    <span class="k">if</span> <span class="p">(</span><span class="nf">str_starts_with</span><span class="p">(</span><span class="nv">$current_uri</span><span class="p">,</span> <span class="s1">'/path/to/page-to-ignore'</span><span class="p">))</span> <span class="p">{</span>
      <span class="k">return</span> <span class="p">[];</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">bigPipeStrategy</span><span class="o">-&gt;</span><span class="nf">processPlaceholders</span><span class="p">(</span><span class="nv">$placeholders</span><span class="p">);</span>
  <span class="p">}</span>
</code></pre></div></div>
<p>Et voilà ! Another bug bites the dust.</p>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[Big Pipe on Drupal 9+ breaks form redirects. In this post, I explain how I fixed it for a specific but common case.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.karimratib.me/assets/disable-bigpipe.jpg" /><media:content medium="image" url="https://blog.karimratib.me/assets/disable-bigpipe.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Drupal 10: From cookies to user sessions</title><link href="https://blog.karimratib.me/2024/08/20/drupal-sessions.html" rel="alternate" type="text/html" title="Drupal 10: From cookies to user sessions" /><published>2024-08-20T00:00:00+00:00</published><updated>2024-08-20T00:00:00+00:00</updated><id>https://blog.karimratib.me/2024/08/20/drupal-sessions</id><content type="html" xml:base="https://blog.karimratib.me/2024/08/20/drupal-sessions.html"><![CDATA[<p>When you need to examine user session tokens, you know you’re deep in the bowels of the CMS. That’s what happened to me recently, as I was debugging why CloudFlare was mixing up user sessions and giving admin access to otherwise unpermissioned users :scream:</p>

<p>To help debug this, I needed a way to associate user cookies with entries from the <code class="language-plaintext highlighter-rouge">sessions</code> table. I wrote a drush script to do exactly that: Given the value of the SESSXXX cookie in your browser, the script will find the corresponding <code class="language-plaintext highlighter-rouge">sessions</code> entry and dump its information, decoding the session metadata in the process:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>drush scr export_sessions.php <span class="nt">--</span> <span class="nt">--cookie</span><span class="o">=</span>5XvW3NGG8q1PcCrEXn676THvQBitaUwDiPw8XzAgXtihV43u
</code></pre></div></div>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
        </span><span class="nl">"uid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"sid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"-Xcm0ar3mWcMhIhhBANA3K-jUx3JNOsu190LPEUzIN8"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"hostname"</span><span class="p">:</span><span class="w"> </span><span class="s2">"172.24.0.1"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"timestamp"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1724179990"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"session"</span><span class="p">:</span><span class="w"> </span><span class="s2">"_sf2_attributes|a:1:{s:3:</span><span class="se">\"</span><span class="s2">uid</span><span class="se">\"</span><span class="s2">;s:1:</span><span class="se">\"</span><span class="s2">1</span><span class="se">\"</span><span class="s2">;}_sf2_meta|a:4:{s:1:</span><span class="se">\"</span><span class="s2">u</span><span class="se">\"</span><span class="s2">;i:1724179990;s:1:</span><span class="se">\"</span><span class="s2">c</span><span class="se">\"</span><span class="s2">;i:1723574737;s:1:</span><span class="se">\"</span><span class="s2">l</span><span class="se">\"</span><span class="s2">;i:2000000;s:1:</span><span class="se">\"</span><span class="s2">s</span><span class="se">\"</span><span class="s2">;s:43:</span><span class="se">\"</span><span class="s2">OCpNT7IvSsWNfPeYXam7E7XFPTKqb-8qWPUTMe8MFlQ</span><span class="se">\"</span><span class="s2">;}"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"sf2"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
            </span><span class="p">{</span><span class="w">
                </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"attributes"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"uid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1"</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">},</span><span class="w">
            </span><span class="p">{</span><span class="w">
                </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"meta"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"u"</span><span class="p">:</span><span class="w"> </span><span class="mi">1724179990</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"c"</span><span class="p">:</span><span class="w"> </span><span class="mi">1723574737</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"l"</span><span class="p">:</span><span class="w"> </span><span class="mi">2000000</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"s"</span><span class="p">:</span><span class="w"> </span><span class="s2">"OCpNT7IvSsWNfPeYXam7E7XFPTKqb-8qWPUTMe8MFlQ"</span><span class="w">
                </span><span class="p">}</span><span class="w">
            </span><span class="p">}</span><span class="w">
        </span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p>The same script will dump ALL sessions if you don’t pass in a cookie value. Here’s the source code of <code class="language-plaintext highlighter-rouge">export_sessions.php</code>:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span>

<span class="cd">/**
 * Retrieve session entry for given cookie.
 * Based on https://drupal.stackexchange.com/a/231726/767
 */</span>

<span class="kn">use</span> <span class="nc">Drupal\Component\Utility\Crypt</span><span class="p">;</span>

<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="k">empty</span><span class="p">(</span><span class="nv">$extra</span><span class="p">))</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nf">str_starts_with</span><span class="p">(</span><span class="nv">$extra</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="s1">'--cookie='</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">die</span><span class="p">(</span><span class="s2">"Usage: drush scr export_sessions.php [-- --cookie=&lt;value of SESSxxxx cookie&gt;]</span><span class="se">\n</span><span class="s2">"</span><span class="p">);</span>
  <span class="p">}</span>
  <span class="k">else</span> <span class="p">{</span>
    <span class="nv">$cookie</span> <span class="o">=</span> <span class="nb">trim</span><span class="p">(</span><span class="nb">str_replace</span><span class="p">(</span><span class="s1">'--cookie='</span><span class="p">,</span> <span class="s1">''</span><span class="p">,</span> <span class="nv">$extra</span><span class="p">[</span><span class="mi">0</span><span class="p">]));</span>
    <span class="nv">$cookie</span> <span class="o">=</span> <span class="nb">urldecode</span><span class="p">(</span><span class="nv">$cookie</span><span class="p">);</span>
    <span class="nv">$sid</span> <span class="o">=</span> <span class="nc">Crypt</span><span class="o">::</span><span class="nf">hashBase64</span><span class="p">(</span><span class="nv">$cookie</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
<span class="nv">$connection</span> <span class="o">=</span> <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">database</span><span class="p">();</span>
<span class="k">if</span> <span class="p">(</span><span class="k">isset</span><span class="p">(</span><span class="nv">$sid</span><span class="p">))</span> <span class="p">{</span>
  <span class="nv">$query</span> <span class="o">=</span> <span class="nv">$connection</span><span class="o">-&gt;</span><span class="nf">query</span><span class="p">(</span><span class="s1">'SELECT * FROM {sessions} WHERE sid = :sid'</span><span class="p">,</span> <span class="p">[</span><span class="s1">':sid'</span> <span class="o">=&gt;</span> <span class="nv">$sid</span><span class="p">]);</span>
<span class="p">}</span>
<span class="k">else</span> <span class="p">{</span>
  <span class="nv">$query</span> <span class="o">=</span> <span class="nv">$connection</span><span class="o">-&gt;</span><span class="nf">query</span><span class="p">(</span><span class="s1">'SELECT * FROM {sessions}'</span><span class="p">);</span>
<span class="p">}</span>

<span class="k">echo</span> <span class="nb">json_encode</span><span class="p">(</span><span class="nb">array_map</span><span class="p">(</span><span class="k">function</span><span class="p">(</span><span class="nv">$session</span><span class="p">)</span> <span class="p">{</span>
  <span class="nb">preg_match_all</span><span class="p">(</span><span class="s1">'/_sf2_(\w+)\|/'</span><span class="p">,</span> <span class="nv">$session</span><span class="o">-&gt;</span><span class="n">session</span><span class="p">,</span> <span class="nv">$matches</span><span class="p">,</span> <span class="no">PREG_OFFSET_CAPTURE</span> <span class="o">|</span> <span class="no">PREG_SET_ORDER</span><span class="p">);</span>
  <span class="nv">$session</span><span class="o">-&gt;</span><span class="n">sf2</span> <span class="o">=</span> <span class="nb">array_map</span><span class="p">(</span><span class="k">function</span><span class="p">(</span><span class="nv">$match</span><span class="p">,</span> <span class="nv">$index</span><span class="p">)</span> <span class="k">use</span> <span class="p">(</span><span class="nv">$session</span><span class="p">,</span> <span class="nv">$matches</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$offset</span> <span class="o">=</span> <span class="nv">$match</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="mi">1</span><span class="p">]</span> <span class="o">+</span> <span class="nb">strlen</span><span class="p">(</span><span class="nv">$match</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="mi">0</span><span class="p">]);</span>
    <span class="nv">$length</span> <span class="o">=</span> <span class="nv">$index</span> <span class="o">+</span> <span class="mi">1</span> <span class="o">&lt;</span> <span class="nb">count</span><span class="p">(</span><span class="nv">$matches</span><span class="p">)</span> <span class="o">?</span>
      <span class="nv">$matches</span><span class="p">[</span><span class="nv">$index</span> <span class="o">+</span> <span class="mi">1</span><span class="p">][</span><span class="mi">0</span><span class="p">][</span><span class="mi">1</span><span class="p">]</span> <span class="o">-</span> <span class="nv">$offset</span><span class="o">:</span>
      <span class="nb">strlen</span><span class="p">(</span><span class="nv">$session</span><span class="o">-&gt;</span><span class="n">session</span><span class="p">)</span> <span class="o">-</span> <span class="mi">1</span> <span class="o">-</span> <span class="nv">$match</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="mi">1</span><span class="p">];</span>
    <span class="k">return</span> <span class="p">[</span>
      <span class="s1">'name'</span> <span class="o">=&gt;</span> <span class="nv">$match</span><span class="p">[</span><span class="mi">1</span><span class="p">][</span><span class="mi">0</span><span class="p">],</span>
      <span class="s1">'value'</span> <span class="o">=&gt;</span> <span class="nb">unserialize</span><span class="p">(</span><span class="nb">substr</span><span class="p">(</span><span class="nv">$session</span><span class="o">-&gt;</span><span class="n">session</span><span class="p">,</span> <span class="nv">$offset</span><span class="p">,</span> <span class="nv">$length</span><span class="p">)),</span>
    <span class="p">];</span>
  <span class="p">},</span> <span class="nv">$matches</span><span class="p">,</span> <span class="nb">array_keys</span><span class="p">(</span><span class="nv">$matches</span><span class="p">));</span>
  <span class="k">return</span> <span class="nv">$session</span><span class="p">;</span>
<span class="p">},</span> <span class="nv">$query</span><span class="o">-&gt;</span><span class="nf">fetchAll</span><span class="p">()),</span> <span class="no">JSON_PRETTY_PRINT</span><span class="p">)</span> <span class="mf">.</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="p">;</span>
</code></pre></div></div>
<p>That’s it. Short and sweet! :candy:</p>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[In this post, I show a script that relates user cookies to Drupal session information.]]></summary></entry><entry><title type="html">Drupal 9: Troubleshooting Cache API issues, Part 1: Xdebug, wodby/drupal, VS Code</title><link href="https://blog.karimratib.me/2023/10/25/xdebug.html" rel="alternate" type="text/html" title="Drupal 9: Troubleshooting Cache API issues, Part 1: Xdebug, wodby/drupal, VS Code" /><published>2023-10-25T00:00:00+00:00</published><updated>2023-10-25T00:00:00+00:00</updated><id>https://blog.karimratib.me/2023/10/25/xdebug</id><content type="html" xml:base="https://blog.karimratib.me/2023/10/25/xdebug.html"><![CDATA[<p>In my 30+ years of programming, my go-to debugging tool has been the judicious usage of <code class="language-plaintext highlighter-rouge">print</code> commands on the appropriate variables at the appropriate times. Of course, <code class="language-plaintext highlighter-rouge">print</code> takes many different forms depending on the technology stack and the application model, but the principle remains the same. In very few cases did this approach fail me, and I stumbled across one such case as I was debugging the notoriously tricky Drupal <a href="https://www.drupal.org/docs/8/api/cache-api/cache-api">Cache API</a>. In a nutshell, there was one module, among the dozens of core, contrib and custom modules making up that particular site, that was invalidating the static page cache and preventing pages from being cached. I wanted to find which module was the culprit.</p>

<p>The problem with this issue is that the Cache API is called thousands of times per request - for pretty much every theming function participating in a page render. Further, the caching logic is complex as it involves combinations of cache tags, <code class="language-plaintext highlighter-rouge">max-age</code> settings, and various other mechanisms that affect the decisions of which caching tables to use and which caching headers to return in the HTTP response.</p>

<p>Trying to pinpoint the particular condition that caused the cache invalidation in this case using <code class="language-plaintext highlighter-rouge">print</code> statements would have been an inefficient and tedious process, and the client wouldn’t have liked to pay for that inefficiency. Kind of like the game of 20 questions, but with incomplete information and many, many decision branches. So I decided to bite the bullet and set up my Xdebug environment to catch the bug red-handed, so to speak. With its pants down, so to speak. To catch it in the act, so to speak.</p>

<p>Here’s a high level diagram of the various components at play here. I slightly modified it from the original at <a href="https://blog.devsense.com/2019/debugging-php-on-docker-with-visual-studio-code">this other tutorial on the same topic</a>.</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/xdebug.png">
      <img src="https://blog.karimratib.me/assets/xdebug.png" style="max-width: 100%;" alt="Xdebug within php-fpm container communicates with VS Code IDE on host via port 9003." />
    </a>
    <figcaption>Xdebug within php-fpm container communicates with VS Code IDE on host via port 9003.</figcaption>
  </figure>
</div>

<p>My development environment is made up of the excellent <a href="https://github.com/wodby/docker4drupal">Docker-based Drupal stack</a> by Wodby. I can’t say enough good things about this framework, which has allowed me to start new Drupal projects, and even adopt legacy ones, on a solid footing without breaking a sweat. The architecture is simple, documentation is clear, customization is easy. I’ve been able to share development environments with team members using macOS and Windows systems with minimal changes.</p>

<p>The <a href="https://github.com/wodby/drupal-php">wodby/drupal-php</a> image comes loaded with the Xdebug extension, and it’s “only” necessary to configure the right environment variables to activate it. I say “only” because many of the settings are non-obvious and required some experimentation before I could get them running, in addition to a VS Code configuration to match.</p>

<p>Here’s my current setup, in the main <code class="language-plaintext highlighter-rouge">docker-compose.yml</code> file running the full Drupal stack:</p>
<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">php</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">wodby/drupal-php:$PHP_TAG</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">PHP_EXTENSIONS_DISABLE</span><span class="pi">:</span> <span class="s1">'</span><span class="s">'</span> <span class="c1"># or any value that does NOT include xdebug</span>
      <span class="na">PHP_XDEBUG</span><span class="pi">:</span> <span class="m">1</span>
      <span class="na">PHP_XDEBUG_MODE</span><span class="pi">:</span> <span class="s">debug</span>
      <span class="na">PHP_XDEBUG_START_WITH_REQUEST</span><span class="pi">:</span> <span class="s">yes</span>
      <span class="na">PHP_XDEBUG_CLIENT_HOST</span><span class="pi">:</span> <span class="s">host.docker.internal</span>
      <span class="na">PHP_XDEBUG_LOG</span><span class="pi">:</span> <span class="s">/tmp/php-xdebug.log</span>
    <span class="na">extra_hosts</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">host.docker.internal:host-gateway"</span>
</code></pre></div></div>
<p>Here’s what the non-obvious settings mean:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">PHP_EXTENSIONS_DISABLE: ''</code> prevents the PHP container from disabling the <code class="language-plaintext highlighter-rouge">xdebug</code> extension - which for some reason is the default in <a href="https://github.com/wodby/php?tab=readme-ov-file#php-extensions"><code class="language-plaintext highlighter-rouge">wodby/php</code></a>.</li>
  <li><code class="language-plaintext highlighter-rouge">PHP_XDEBUG_MODE: debug</code> enables <a href="https://xdebug.org/docs/step_debug#configure">Xdebug step debugging</a>, which is our purpose here.</li>
  <li><code class="language-plaintext highlighter-rouge">PHP_XDEBUG_START_WITH_REQUEST: yes</code> means that Xdebug is activated at every request, automatically.</li>
  <li><code class="language-plaintext highlighter-rouge">PHP_XDEBUG_CLIENT_HOST: host.docker.internal</code> is the all-important address of the machine running the debugging client - in my case, VS Code on my local machine. <a href="https://docs.docker.com/desktop/networking/#i-want-to-connect-from-a-container-to-a-service-on-the-host">According to documentation</a>, the name <code class="language-plaintext highlighter-rouge">host.docker.internal</code> is automatically available in Docker 18.03+ Mac/Win, <strong>but not on Linux</strong>. For Linux, we add the stanza <code class="language-plaintext highlighter-rouge">extra_hosts: "host.docker.internal:host-gateway"</code> which maps that domain name to Docker’s gateway IP, which is the Docker host, which is my laptop OS running VS Code :sweat_smile:</li>
</ul>

<p>But that’s only half of the story. The other half is convincing VS Code to act as a debugging client to Xdebug. To do that, we use the <a href="https://marketplace.visualstudio.com/items?itemName=xdebug.php-debug">PHP Debug VS Code extension</a> and we <a href="https://code.visualstudio.com/docs/editor/debugging#_launch-configurations">customize the Launch configurations</a> to add the Xdebug endpoint. Basically, we create a <code class="language-plaintext highlighter-rouge">.vscode/launch.json</code> file in the project root with the following content:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0.2.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"configurations"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
   </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Listen for Xdebug"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"php"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"request"</span><span class="p">:</span><span class="w"> </span><span class="s2">"launch"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"port"</span><span class="p">:</span><span class="w"> </span><span class="mi">9003</span><span class="p">,</span><span class="w">
      </span><span class="nl">"pathMappings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"/var/www/html/"</span><span class="p">:</span><span class="w"> </span><span class="s2">"${workspaceFolder}/src"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>Here’s what the non-obvious settings mean:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">"port": 9003</code> is the default port that Xdebug hits on the client, and that’s where VS Code should be listening for debug events.</li>
  <li><code class="language-plaintext highlighter-rouge">"pathMappings": { "/var/www/html/": "${workspaceFolder}/src" }</code> maps the Docker filesystem path <code class="language-plaintext highlighter-rouge">/var/www/html</code> where the app resides to the actual host path <code class="language-plaintext highlighter-rouge">"${workspaceFolder}/src"</code> where <code class="language-plaintext highlighter-rouge">${workspaceFolder}</code> is a <a href="https://code.visualstudio.com/docs/editor/variables-reference">VS Code variable</a>.</li>
</ul>

<p>With these in place, it should be now possible to place a breakpoint in, say, <code class="language-plaintext highlighter-rouge">src/web/index.php</code> (the Drupal main entrypoint) and catch every request! Select <strong>Run &gt; Start Debugging</strong> or or click the <strong>Listen for Xdebug</strong> configuration in the bottom status bar. We are finally ready to start debugging the Drupal Cache API :ghost:</p>

<h2 id="troubleshooting">Troubleshooting</h2>
<p>Of course, this setup didn’t come by without many failures and much head-scratching, perhaps even some teeth-clenching. If your 100% guaranteed breakpoint (like one in <code class="language-plaintext highlighter-rouge">src/web/index.php</code>) is not being hit, then it’s time to put on your sleuthing hat :detective:</p>

<p>Check that the Xdebug log is active and connected. Running <code class="language-plaintext highlighter-rouge">docker-compose exec php tail -f /tmp/php-xdebug.log</code> should show messages like:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[112] Log opened at 2023-10-25 06:42:12.885888
[112] [Step Debug] INFO: Connecting to configured address/port: host.docker.internal:9003.
[112] [Step Debug] INFO: Connected to debugging client: host.docker.internal:9003 (through xdebug.client_host/xdebug.client_port). :-)
</code></pre></div></div>
<p>Yes, that final smiley is part of the log :-)</p>

<p>If instead, you see a message like:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tail: can't open '/tmp/php-xdebug.log': No such file or directory
tail: no files
</code></pre></div></div>
<p>Then the Xdebug extension is not active, which could mean <code class="language-plaintext highlighter-rouge">PHP_EXTENSIONS_DISABLE</code> is still set to include <code class="language-plaintext highlighter-rouge">xdebug</code>.</p>

<p>If you see a sad smiley message like:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[112] [Step Debug] ERR: Could not connect to debugging client. Tried: host.docker.internal:9003 (through xdebug.client_host/xdebug.client_port) :-(
</code></pre></div></div>
<p>Then check that a connection can be established between Xdebug and VS Code. Running <code class="language-plaintext highlighter-rouge">docker-compose exec php nc -zv host.docker.internal 9003</code> should return a successful response like:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>host.docker.internal (172.17.0.1:9003) open
</code></pre></div></div>
<p>Anything else is a sign that the Docker container is unable to connect to the host on port 9003. Check your <code class="language-plaintext highlighter-rouge">host.docker.internal</code> name resolution, check the <code class="language-plaintext highlighter-rouge">launch.json</code> port setting, turn it off and on again, talk to your rubber duck - you know the drill!</p>

<h2 id="appendix-annoying-drush-warnings">Appendix: Annoying drush warnings</h2>
<p>With Xdebug activated, you may be bombarded with multiple lines of warnings when running <code class="language-plaintext highlighter-rouge">drush</code> commands, especially when you are not debugging on the IDE side:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[20-Aug-2024 19:10:28 UTC] Xdebug: [Log Files] File '/tmp/php-xdebug.log' could not be opened.
[20-Aug-2024 19:10:28 UTC] Xdebug: [Step Debug] Could not connect to debugging client. Tried: host.docker.internal:9003 (through xdebug.client_host/xdebug.client_port).
</code></pre></div></div>
<p>In this case, you can run <code class="language-plaintext highlighter-rouge">export XDEBUG_MODE=off</code> in the <code class="language-plaintext highlighter-rouge">bash</code> session where you’re running <code class="language-plaintext highlighter-rouge">drush</code>, thereby deactivating Xdebug in the session, and saving a few bits from your eyes :sob:</p>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[In this post, I explain how to configure Xdebug with VS Code in the context of deep Drupal debugging.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.karimratib.me/assets/xdebug.png" /><media:content medium="image" url="https://blog.karimratib.me/assets/xdebug.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Drupal 9: Backup and Migrate - Drush 11 support</title><link href="https://blog.karimratib.me/2023/06/01/backup-migrate-drush.html" rel="alternate" type="text/html" title="Drupal 9: Backup and Migrate - Drush 11 support" /><published>2023-06-01T00:00:00+00:00</published><updated>2023-06-01T00:00:00+00:00</updated><id>https://blog.karimratib.me/2023/06/01/backup-migrate-drush</id><content type="html" xml:base="https://blog.karimratib.me/2023/06/01/backup-migrate-drush.html"><![CDATA[<p>Supporting content migrations across stages is a tricky subject, and most tools I reviewed seemed too fragile or too complex to be delivered to a client. We opted to use a simple workflow based on <a href="https://www.drupal.org/project/backup_migrate">BAM (Backup and Migrate)</a> coupled with config re-synchronization. To help automate the process, I wrote a set of <code class="language-plaintext highlighter-rouge">drush</code> commands that implement BAM backup and restore. It’s been tested extensively, but only with a specific set of sources and destinations, so I am reproducing the current code here until it gets published as a module. One design decision I made was to produce output as JSON, to make it easier for downstream automation.</p>

<p>The typical usage scenario is the following:</p>
<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>drush bamb default_db private_files
// <span class="o">=&gt;</span> <span class="o">{</span>
//    <span class="s2">"status"</span>: <span class="s2">"success"</span>,
//    <span class="s2">"message"</span>: <span class="s2">"Backup complete."</span>
//<span class="o">}</span>
<span class="nv">$ </span>drush bamls <span class="nt">--files</span><span class="o">=</span>private_files
// <span class="o">=&gt;</span> <span class="o">{</span>
//    <span class="s2">"sources"</span>: <span class="o">[</span>
//        <span class="o">{</span>
//            <span class="s2">"id"</span>: <span class="s2">"default_db"</span>,
//            <span class="s2">"label"</span>: <span class="s2">"Default Drupal Database"</span>,
//            <span class="s2">"type"</span>: <span class="s2">"DefaultDB"</span>
//        <span class="o">}</span>,
//        <span class="o">{</span>
//            <span class="s2">"id"</span>: <span class="s2">"entire_site"</span>,
//            <span class="s2">"label"</span>: <span class="s2">"Entire Site (do not use)"</span>,
//            <span class="s2">"type"</span>: <span class="s2">"EntireSite"</span>
//        <span class="o">}</span>,
//        <span class="o">{</span>
//            <span class="s2">"id"</span>: <span class="s2">"private_files"</span>,
//            <span class="s2">"label"</span>: <span class="s2">"Private Files Directory"</span>,
//            <span class="s2">"type"</span>: <span class="s2">"DrupalFiles"</span>
//        <span class="o">}</span>,
//        <span class="o">{</span>
//            <span class="s2">"id"</span>: <span class="s2">"public_files"</span>,
//            <span class="s2">"label"</span>: <span class="s2">"Public Files Directory"</span>,
//            <span class="s2">"type"</span>: <span class="s2">"DrupalFiles"</span>
//        <span class="o">}</span>,
//        <span class="o">{</span>
//            <span class="s2">"id"</span>: <span class="s2">"ssot_database"</span>,
//            <span class="s2">"label"</span>: <span class="s2">"SSoT Database"</span>,
//            <span class="s2">"type"</span>: <span class="s2">"PostgreSQL"</span>
//        <span class="o">}</span>
//    <span class="o">]</span>,
//    <span class="s2">"destinations"</span>: <span class="o">[</span>
//        <span class="o">{</span>
//            <span class="s2">"id"</span>: <span class="s2">"private_files"</span>,
//            <span class="s2">"label"</span>: <span class="s2">"Private Files Directory"</span>,
//            <span class="s2">"type"</span>: <span class="s2">"Directory"</span>
//        <span class="o">}</span>,
//        <span class="o">{</span>
//            <span class="s2">"id"</span>: <span class="s2">"s3_bucket"</span>,
//            <span class="s2">"label"</span>: <span class="s2">"S3 Bucket"</span>,
//            <span class="s2">"type"</span>: <span class="s2">"awss3"</span>
//        <span class="o">}</span>
//    <span class="o">]</span>,
//    <span class="s2">"files"</span>: <span class="o">{</span>
//        <span class="s2">"private_files"</span>: <span class="o">[</span>
//            <span class="o">{</span>
//                <span class="s2">"id"</span>: <span class="s2">"backup-2023-01-27T15-44-19.sql.gz"</span>,
//                <span class="s2">"filename"</span>: <span class="s2">"prod-2023-01-27T15-44-19.sql.gz"</span>,
//                <span class="s2">"filesize"</span>: 19499222,
//                <span class="s2">"datestamp"</span>: 1674869134
//            <span class="o">}</span>
//        <span class="o">]</span>
//    <span class="o">}</span>
//<span class="o">}</span>
<span class="nv">$ </span>drush bamr default_db private_files backup-2023-01-27T15-44-19.sql.gz
// <span class="o">=&gt;</span> <span class="o">{</span>
//    <span class="s2">"status"</span>: <span class="s2">"success"</span>,
//    <span class="s2">"message"</span>: <span class="s2">"Restore complete."</span>
//<span class="o">}</span>
</code></pre></div></div>

<p>And here’s the source for the command:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?php</span>

<span class="kn">namespace</span> <span class="nn">Drush\Commands</span><span class="p">;</span>

<span class="kn">use</span> <span class="nc">Drush\Drush</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Drush\Commands\DrushCommands</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Drush\Boot\DrupalBootLevels</span><span class="p">;</span>
<span class="kn">use</span> <span class="nf">Drupal\backup_migrate</span><span class="nc">\Core\Destination\ListableDestinationInterface</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">Symfony\Component\Console\Input\InputOption</span><span class="p">;</span>

<span class="kd">class</span> <span class="nc">BackupMigrateCommands</span> <span class="kd">extends</span> <span class="nc">DrushCommands</span>
<span class="p">{</span>
    <span class="cd">/**
     * List sources and destinations.
     *
     * @command backup_migrate:list
     * @aliases bamls
     *
     * @option sources Flag to list sources (default: yes, use --no-sources to hide)
     * @option destinations Flag to list destinations (default: yes, use --no-destinations to hide)
     * @option files Flag to list files for a comma-separated list of destination identifiers (default: none)
     *
     * @param options
     *
     * @return string JSON listing of sources, destinations, files
     *
     */</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">list</span><span class="p">(</span><span class="kt">array</span> <span class="nv">$options</span> <span class="o">=</span> <span class="p">[</span>
        <span class="s1">'sources'</span> <span class="o">=&gt;</span> <span class="kc">true</span><span class="p">,</span>
        <span class="s1">'destinations'</span> <span class="o">=&gt;</span> <span class="kc">true</span><span class="p">,</span>
        <span class="s1">'files'</span> <span class="o">=&gt;</span> <span class="nc">InputOption</span><span class="o">::</span><span class="no">VALUE_REQUIRED</span><span class="p">,</span>
    <span class="p">])</span><span class="o">:</span> <span class="n">string</span> <span class="p">{</span>
        <span class="nc">Drush</span><span class="o">::</span><span class="nf">bootstrapManager</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">doBootstrap</span><span class="p">(</span><span class="nc">DrupalBootLevels</span><span class="o">::</span><span class="no">FULL</span><span class="p">);</span>
        <span class="nv">$bam</span> <span class="o">=</span> <span class="nf">\backup_migrate_get_service_object</span><span class="p">();</span>
        <span class="nv">$output</span> <span class="o">=</span> <span class="p">[];</span>
        <span class="k">if</span> <span class="p">(</span><span class="nv">$options</span><span class="p">[</span><span class="s1">'sources'</span><span class="p">])</span> <span class="p">{</span>
            <span class="nv">$output</span><span class="p">[</span><span class="s1">'sources'</span><span class="p">]</span> <span class="o">=</span> <span class="nb">array_reduce</span><span class="p">(</span><span class="nb">array_keys</span><span class="p">(</span><span class="nv">$bam</span><span class="o">-&gt;</span><span class="nf">sources</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getAll</span><span class="p">()),</span> <span class="k">function</span><span class="p">(</span><span class="nv">$sources</span><span class="p">,</span> <span class="nv">$source_id</span><span class="p">)</span> <span class="p">{</span>
                <span class="nv">$source</span> <span class="o">=</span> <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">entityTypeManager</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getStorage</span><span class="p">(</span><span class="s1">'backup_migrate_source'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">load</span><span class="p">(</span><span class="nv">$source_id</span><span class="p">);</span>
                <span class="k">if</span> <span class="p">(</span><span class="nv">$source</span><span class="p">)</span> <span class="p">{</span>
                    <span class="nv">$sources</span><span class="p">[]</span> <span class="o">=</span> <span class="p">[</span>
                        <span class="s1">'id'</span> <span class="o">=&gt;</span> <span class="nv">$source_id</span><span class="p">,</span>
                        <span class="s1">'label'</span> <span class="o">=&gt;</span> <span class="nv">$source</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'label'</span><span class="p">),</span>
                        <span class="s1">'type'</span> <span class="o">=&gt;</span> <span class="nv">$source</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'type'</span><span class="p">),</span>
                    <span class="p">];</span>
                <span class="p">}</span>
                <span class="k">return</span> <span class="nv">$sources</span><span class="p">;</span>
            <span class="p">},</span> <span class="p">[]);</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="p">(</span><span class="nv">$options</span><span class="p">[</span><span class="s1">'destinations'</span><span class="p">])</span> <span class="p">{</span>
            <span class="nv">$output</span><span class="p">[</span><span class="s1">'destinations'</span><span class="p">]</span> <span class="o">=</span> <span class="nb">array_reduce</span><span class="p">(</span><span class="nb">array_keys</span><span class="p">(</span><span class="nv">$bam</span><span class="o">-&gt;</span><span class="nf">destinations</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getAll</span><span class="p">()),</span> <span class="k">function</span><span class="p">(</span><span class="nv">$destinations</span><span class="p">,</span> <span class="nv">$destination_id</span><span class="p">)</span> <span class="p">{</span>
                <span class="nv">$destination</span> <span class="o">=</span> <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">entityTypeManager</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getStorage</span><span class="p">(</span><span class="s1">'backup_migrate_destination'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">load</span><span class="p">(</span><span class="nv">$destination_id</span><span class="p">);</span>
                <span class="k">if</span> <span class="p">(</span><span class="nv">$destination</span><span class="p">)</span> <span class="p">{</span>
                    <span class="nv">$destinations</span><span class="p">[]</span> <span class="o">=</span> <span class="p">[</span>
                        <span class="s1">'id'</span> <span class="o">=&gt;</span> <span class="nv">$destination_id</span><span class="p">,</span>
                        <span class="s1">'label'</span> <span class="o">=&gt;</span> <span class="nv">$destination</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'label'</span><span class="p">),</span>
                        <span class="s1">'type'</span> <span class="o">=&gt;</span> <span class="nv">$destination</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'type'</span><span class="p">),</span>
                    <span class="p">];</span>
                <span class="p">}</span>
                <span class="k">return</span> <span class="nv">$destinations</span><span class="p">;</span>
            <span class="p">},</span> <span class="p">[]);</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="p">(</span><span class="nv">$options</span><span class="p">[</span><span class="s1">'files'</span><span class="p">])</span> <span class="p">{</span>
            <span class="k">foreach</span><span class="p">(</span><span class="nb">array_map</span><span class="p">(</span><span class="s1">'trim'</span><span class="p">,</span> <span class="nb">explode</span><span class="p">(</span><span class="s1">','</span><span class="p">,</span> <span class="nv">$options</span><span class="p">[</span><span class="s1">'files'</span><span class="p">]))</span> <span class="k">as</span> <span class="nv">$destination_id</span><span class="p">)</span> <span class="p">{</span>
                <span class="nv">$destination</span> <span class="o">=</span> <span class="nv">$bam</span><span class="o">-&gt;</span><span class="nf">destinations</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="nv">$destination_id</span><span class="p">);</span>
                <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nv">$destination</span><span class="p">)</span> <span class="p">{</span>
                    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">logger</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">warning</span><span class="p">(</span><span class="nf">dt</span><span class="p">(</span><span class="s1">'The destination !id does not exist.'</span><span class="p">,</span> <span class="p">[</span><span class="s1">'!id'</span> <span class="o">=&gt;</span> <span class="nv">$destination_id</span><span class="p">]));</span>
                    <span class="k">continue</span><span class="p">;</span>
                <span class="p">}</span>
                <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nv">$destination</span> <span class="k">instanceof</span> <span class="nc">ListableDestinationInterface</span><span class="p">)</span> <span class="p">{</span>
                    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">logger</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">warning</span><span class="p">(</span><span class="nf">dt</span><span class="p">(</span><span class="s1">'The destination !id is not listable.'</span><span class="p">,</span> <span class="p">[</span><span class="s1">'!id'</span> <span class="o">=&gt;</span> <span class="nv">$destination_id</span><span class="p">]));</span>
                    <span class="k">continue</span><span class="p">;</span>
                <span class="p">}</span>
                <span class="k">try</span> <span class="p">{</span>
                    <span class="nv">$files</span> <span class="o">=</span> <span class="nv">$destination</span><span class="o">-&gt;</span><span class="nf">listFiles</span><span class="p">();</span>
                    <span class="nv">$output</span><span class="p">[</span><span class="s1">'files'</span><span class="p">][</span><span class="nv">$destination_id</span><span class="p">]</span> <span class="o">=</span> <span class="nb">array_reduce</span><span class="p">(</span><span class="nb">array_keys</span><span class="p">(</span><span class="nv">$files</span><span class="p">),</span> <span class="k">function</span><span class="p">(</span><span class="nv">$files_info</span><span class="p">,</span> <span class="nv">$file_id</span><span class="p">)</span> <span class="k">use</span><span class="p">(</span><span class="nv">$files</span><span class="p">)</span> <span class="p">{</span>
                        <span class="nv">$files_info</span><span class="p">[]</span> <span class="o">=</span> <span class="nb">array_merge</span><span class="p">([</span>
                            <span class="s1">'id'</span> <span class="o">=&gt;</span> <span class="nv">$file_id</span><span class="p">,</span>
                            <span class="s1">'filename'</span> <span class="o">=&gt;</span> <span class="nv">$files</span><span class="p">[</span><span class="nv">$file_id</span><span class="p">]</span><span class="o">-&gt;</span><span class="nf">getFullName</span><span class="p">(),</span>
                        <span class="p">],</span> <span class="nv">$files</span><span class="p">[</span><span class="nv">$file_id</span><span class="p">]</span><span class="o">-&gt;</span><span class="nf">getMetaAll</span><span class="p">());</span>
                        <span class="k">return</span> <span class="nv">$files_info</span><span class="p">;</span>
                    <span class="p">},</span> <span class="p">[]);</span>
                    <span class="nb">usort</span><span class="p">(</span><span class="nv">$output</span><span class="p">[</span><span class="s1">'files'</span><span class="p">][</span><span class="nv">$destination_id</span><span class="p">],</span> <span class="k">function</span><span class="p">(</span><span class="nv">$file1</span><span class="p">,</span> <span class="nv">$file2</span><span class="p">)</span> <span class="p">{</span>
                        <span class="c1">// TODO What if datestamp is not available?</span>
                        <span class="nv">$a</span> <span class="o">=</span> <span class="nv">$file1</span><span class="p">[</span><span class="s1">'datestamp'</span><span class="p">];</span>
                        <span class="nv">$b</span> <span class="o">=</span> <span class="nv">$file2</span><span class="p">[</span><span class="s1">'datestamp'</span><span class="p">];</span>
                        <span class="k">if</span> <span class="p">(</span><span class="nv">$a</span> <span class="o">==</span> <span class="nv">$b</span><span class="p">)</span> <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
                        <span class="k">return</span> <span class="p">(</span><span class="nv">$a</span> <span class="o">&lt;</span> <span class="nv">$b</span><span class="p">)</span> <span class="o">?</span> <span class="o">-</span><span class="mi">1</span> <span class="o">:</span> <span class="mi">1</span><span class="p">;</span>
                    <span class="p">});</span>
                <span class="p">}</span>
                <span class="k">catch</span> <span class="p">(</span><span class="nc">\Exception</span> <span class="nv">$e</span><span class="p">)</span> <span class="p">{</span>
                    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">logger</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">error</span><span class="p">(</span><span class="nf">dt</span><span class="p">(</span><span class="s1">'The destination !id caused an error: !error'</span><span class="p">,</span> <span class="p">[</span>
                        <span class="s1">'!id'</span> <span class="o">=&gt;</span> <span class="nv">$destination_id</span><span class="p">,</span>
                        <span class="s1">'!error'</span> <span class="o">=&gt;</span> <span class="nv">$e</span><span class="o">-&gt;</span><span class="nf">getMessage</span><span class="p">()</span>
                    <span class="p">]));</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span>
        <span class="k">return</span> <span class="nb">json_encode</span><span class="p">(</span><span class="nv">$output</span><span class="p">,</span> <span class="no">JSON_PRETTY_PRINT</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="cd">/**
     * Backup.
     *
     * @command backup_migrate:backup
     * @aliases bamb
     *
     * @param source_id Identifier of the Backup Source.
     * @param destination_id Identifier of the Backup Destination.
     *
     * @return string Backup completion status
     *
     * @throws \Drupal\backup_migrate\Core\Exception\BackupMigrateException
     *
     */</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">backup</span><span class="p">(</span>
        <span class="nv">$source_id</span><span class="p">,</span>
        <span class="nv">$destination_id</span>
    <span class="p">):</span> <span class="kt">string</span>
    <span class="p">{</span>
        <span class="nc">Drush</span><span class="o">::</span><span class="nf">bootstrapManager</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">doBootstrap</span><span class="p">(</span><span class="nc">DrupalBootLevels</span><span class="o">::</span><span class="no">FULL</span><span class="p">);</span>
        <span class="nv">$bam</span> <span class="o">=</span> <span class="nf">\backup_migrate_get_service_object</span><span class="p">();</span>
        <span class="nv">$bam</span><span class="o">-&gt;</span><span class="nf">backup</span><span class="p">(</span><span class="nv">$source_id</span><span class="p">,</span> <span class="nv">$destination_id</span><span class="p">);</span>
        <span class="k">return</span> <span class="nb">json_encode</span><span class="p">([</span>
            <span class="s1">'status'</span> <span class="o">=&gt;</span> <span class="s1">'success'</span><span class="p">,</span>
            <span class="s1">'message'</span> <span class="o">=&gt;</span> <span class="nf">dt</span><span class="p">(</span><span class="s1">'Backup complete.'</span><span class="p">)</span>
        <span class="p">],</span> <span class="no">JSON_PRETTY_PRINT</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="cd">/**
     * Restore.
     *
     * @command backup_migrate:restore
     * @aliases bamr
     *
     * @param source_id Identifier of the Backup Source.
     * @param destination_id Identifier of the Backup Destination.
     * @param file_id optional Identifier of the Destination file.
     *
     * @return string Restore completion status
     *
     * @throws \Drupal\backup_migrate\Core\Exception\BackupMigrateException
     *
     */</span>
    <span class="k">public</span> <span class="k">function</span> <span class="n">restore</span><span class="p">(</span>
        <span class="nv">$source_id</span><span class="p">,</span>
        <span class="nv">$destination_id</span><span class="p">,</span>
        <span class="nv">$file_id</span> <span class="o">=</span> <span class="kc">null</span><span class="p">,</span>
    <span class="p">):</span> <span class="kt">string</span>
    <span class="p">{</span>
        <span class="nc">Drush</span><span class="o">::</span><span class="nf">bootstrapManager</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">doBootstrap</span><span class="p">(</span><span class="nc">DrupalBootLevels</span><span class="o">::</span><span class="no">FULL</span><span class="p">);</span>
        <span class="nv">$bam</span> <span class="o">=</span> <span class="nf">\backup_migrate_get_service_object</span><span class="p">();</span>
        <span class="nv">$bam</span><span class="o">-&gt;</span><span class="nf">restore</span><span class="p">(</span><span class="nv">$source_id</span><span class="p">,</span> <span class="nv">$destination_id</span><span class="p">,</span> <span class="nv">$file_id</span><span class="p">);</span>
        <span class="k">return</span> <span class="nb">json_encode</span><span class="p">([</span>
            <span class="s1">'status'</span> <span class="o">=&gt;</span> <span class="s1">'success'</span><span class="p">,</span>
            <span class="s1">'message'</span> <span class="o">=&gt;</span> <span class="nf">dt</span><span class="p">(</span><span class="s1">'Restore complete.'</span><span class="p">)</span>
        <span class="p">],</span> <span class="no">JSON_PRETTY_PRINT</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[In which I publish a small Drush 11 command for Backup and Migrate.]]></summary></entry><entry><title type="html">Drupal 9: Fixing Google Charts rendering in tabbed pages</title><link href="https://blog.karimratib.me/2023/05/01/google-charts-tabs.html" rel="alternate" type="text/html" title="Drupal 9: Fixing Google Charts rendering in tabbed pages" /><published>2023-05-01T00:00:00+00:00</published><updated>2023-05-01T00:00:00+00:00</updated><id>https://blog.karimratib.me/2023/05/01/google-charts-tabs</id><content type="html" xml:base="https://blog.karimratib.me/2023/05/01/google-charts-tabs.html"><![CDATA[<p>Google Charts has a <a href="https://stackoverflow.com/search?q=google+charts+hidden">long-standing, known issue rendering correctly in hidden divs</a>. This caused us much head scratching and debugging hours before we even landed on the correct diagnosis: a chart that renders correctly on the <a href="https://git.drupalcode.org/project/charts/-/tree/5.0.x/modules/charts_api_example">Charts API Example page</a> does not work inside a tab! Oh, the joys of programming sometimes.</p>

<p>Once diagnosed, the fix was obvious: Detect that a tab is selected to refresh the charts contained therein. The following JavaScript file can be added to your theme as is and should handle the standard Bootstrap tabs (it also fixes the window resize event handling). It does depend on a small patch made to the <a href="https://git.drupalcode.org/project/charts/-/tree/5.0.x/modules/charts_google"><code class="language-plaintext highlighter-rouge">charts_google</code> module</a>, to avoid leaking memory when the graph is redrawn:</p>
<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">function </span><span class="p">(</span><span class="nx">$</span><span class="p">,</span> <span class="nx">Drupal</span><span class="p">,</span> <span class="nx">once</span><span class="p">)</span> <span class="p">{</span>
  <span class="p">(</span><span class="dl">"</span><span class="s2">use strict</span><span class="dl">"</span><span class="p">);</span>

  <span class="kd">function</span> <span class="nf">redrawGoogleChart</span><span class="p">(</span><span class="nx">element</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">contents</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Drupal</span><span class="p">.</span><span class="nx">Charts</span><span class="p">.</span><span class="nc">Contents</span><span class="p">();</span>
    <span class="kd">const</span> <span class="nx">chartId</span> <span class="o">=</span> <span class="nx">element</span><span class="p">.</span><span class="nx">id</span><span class="p">;</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">Drupal</span><span class="p">.</span><span class="nx">googleCharts</span><span class="p">.</span><span class="nx">charts</span><span class="p">.</span><span class="nf">hasOwnProperty</span><span class="p">(</span><span class="nx">chartId</span><span class="p">))</span> <span class="p">{</span>
      <span class="nx">Drupal</span><span class="p">.</span><span class="nx">googleCharts</span><span class="p">.</span><span class="nx">charts</span><span class="p">[</span><span class="nx">chartId</span><span class="p">].</span><span class="nf">clearChart</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="kd">const</span> <span class="nx">dataAttributes</span> <span class="o">=</span> <span class="nx">contents</span><span class="p">.</span><span class="nf">getData</span><span class="p">(</span><span class="nx">chartId</span><span class="p">);</span>
    <span class="nx">Drupal</span><span class="p">.</span><span class="nx">googleCharts</span><span class="p">.</span><span class="nf">drawChart</span><span class="p">(</span><span class="nx">chartId</span><span class="p">,</span> <span class="nx">dataAttributes</span><span class="p">[</span><span class="dl">'</span><span class="s1">visualization</span><span class="dl">'</span><span class="p">],</span> <span class="nx">dataAttributes</span><span class="p">[</span><span class="dl">'</span><span class="s1">data</span><span class="dl">'</span><span class="p">],</span> <span class="nx">dataAttributes</span><span class="p">[</span><span class="dl">'</span><span class="s1">options</span><span class="dl">'</span><span class="p">])();</span>
  <span class="p">}</span>

  <span class="nx">Drupal</span><span class="p">.</span><span class="nx">behaviors</span><span class="p">.</span><span class="nx">redrawGoogleCharts</span> <span class="o">=</span> <span class="p">{</span>
    <span class="na">attach</span><span class="p">:</span> <span class="nf">function </span><span class="p">(</span><span class="nx">context</span><span class="p">,</span> <span class="nx">settings</span><span class="p">)</span> <span class="p">{</span>
      <span class="nf">$</span><span class="p">(</span><span class="dl">'</span><span class="s1">.nav-link</span><span class="dl">'</span><span class="p">,</span> <span class="nx">context</span><span class="p">).</span><span class="nf">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">shown.bs.tab</span><span class="dl">'</span><span class="p">,</span> <span class="nf">function </span><span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">Drupal</span><span class="p">.</span><span class="nx">Charts</span> <span class="o">&amp;&amp;</span> <span class="nx">Drupal</span><span class="p">.</span><span class="nx">googleCharts</span><span class="p">)</span> <span class="p">{</span>
          <span class="nf">$</span><span class="p">(</span><span class="dl">'</span><span class="s1">.charts-google</span><span class="dl">'</span><span class="p">,</span> <span class="nf">$</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">).</span><span class="nf">attr</span><span class="p">(</span><span class="dl">'</span><span class="s1">data-bs-target</span><span class="dl">'</span><span class="p">)).</span><span class="nf">each</span><span class="p">(</span><span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
            <span class="k">if </span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nf">hasOwnProperty</span><span class="p">(</span><span class="dl">'</span><span class="s1">chart</span><span class="dl">'</span><span class="p">))</span> <span class="p">{</span>
              <span class="nf">redrawGoogleChart</span><span class="p">(</span><span class="k">this</span><span class="p">);</span>
            <span class="p">}</span>
          <span class="p">});</span>
        <span class="p">}</span>
      <span class="p">});</span>

      <span class="nb">window</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">resize</span><span class="dl">'</span><span class="p">,</span> <span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
        <span class="k">if </span><span class="p">(</span><span class="nx">Drupal</span><span class="p">.</span><span class="nx">Charts</span> <span class="o">&amp;&amp;</span> <span class="nx">Drupal</span><span class="p">.</span><span class="nx">googleCharts</span><span class="p">)</span> <span class="p">{</span>
          <span class="nx">Drupal</span><span class="p">.</span><span class="nx">googleCharts</span><span class="p">.</span><span class="nf">waitForFinalEvent</span><span class="p">(</span><span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
            <span class="nf">$</span><span class="p">(</span><span class="dl">'</span><span class="s1">.charts-google</span><span class="dl">'</span><span class="p">).</span><span class="nf">each</span><span class="p">(</span><span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
              <span class="k">if </span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nf">hasOwnProperty</span><span class="p">(</span><span class="dl">'</span><span class="s1">chart</span><span class="dl">'</span><span class="p">))</span> <span class="p">{</span>
                <span class="nf">redrawGoogleChart</span><span class="p">(</span><span class="k">this</span><span class="p">);</span>
              <span class="p">}</span>
            <span class="p">});</span>
          <span class="p">},</span> <span class="mi">200</span><span class="p">,</span> <span class="dl">'</span><span class="s1">google-charts-redraw</span><span class="dl">'</span><span class="p">);</span>
        <span class="p">}</span>
      <span class="p">});</span>
    <span class="p">},</span>
  <span class="p">};</span>

<span class="p">})(</span><span class="nx">jQuery</span><span class="p">,</span> <span class="nx">Drupal</span><span class="p">,</span> <span class="nx">once</span><span class="p">);</span>
</code></pre></div></div>
<div class="language-patch highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gh">diff --git a/modules/charts_google/js/charts_google.js b/modules/charts_google/js/charts_google.js
index f7abe81..76143bc 100755
</span><span class="gd">--- a/modules/charts_google/js/charts_google.js
</span><span class="gi">+++ b/modules/charts_google/js/charts_google.js
</span><span class="p">@@ -6,7 +6,7 @@</span>
<span class="err">
</span>   'use strict';
<span class="err">
</span><span class="gd">-  Drupal.googleCharts = Drupal.googleCharts || {charts: []};
</span><span class="gi">+  Drupal.googleCharts = Drupal.googleCharts || {charts: {}};
</span><span class="err">
</span>   /**
    * Behavior to initialize Google Charts.
<span class="p">@@ -122,6 +122,7 @@</span>
         options['colorAxis'] = {colors: colors};
       }
       chart.draw(data, options);
<span class="gi">+      Drupal.googleCharts.charts[chartId] = chart;
</span>     };
   };
</code></pre></div></div>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[In which I describe a fix to a long-standing bug with Google Charts rendering inside hidden divs. This bug affects charts that are rendered in Boostrap tabs that are not active.]]></summary></entry><entry><title type="html">Drupal 9: Backup and Migrate - PostgreSQL support</title><link href="https://blog.karimratib.me/2023/04/01/backup-migrate-postgresql.html" rel="alternate" type="text/html" title="Drupal 9: Backup and Migrate - PostgreSQL support" /><published>2023-04-01T00:00:00+00:00</published><updated>2023-04-01T00:00:00+00:00</updated><id>https://blog.karimratib.me/2023/04/01/backup-migrate-postgresql</id><content type="html" xml:base="https://blog.karimratib.me/2023/04/01/backup-migrate-postgresql.html"><![CDATA[<p>I was suprised this hadn’t been already done, so I <a href="https://www.drupal.org/project/backup_migrate/issues/2930369">added PostgreSQL support to the venerable Backup and Migrate (BAM) module</a>. Instead of previous patches that implemented SQL generation and parsing manually, I opted for the much simpler and (imho) more robust approach of invoking the standard tools <code class="language-plaintext highlighter-rouge">pg_dump</code> and <code class="language-plaintext highlighter-rouge">pgsql</code> for the backup and restore operations. It took me less than a day to get that patch working, and we’ve been using it daily on this project for the past 8 months, so I have good confidence it is production-ready.</p>

<p>For example, the backup implementation is about 40 lines long:</p>
<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="cd">/**
   * Export this source to the given temp file.
   *
   * This should be the main back up function for this source.
   *
   * @return \Drupal\backup_migrate\Core\File\BackupFileReadableInterface
   *   A backup file with the contents of the source dumped to it.
   */</span>
  <span class="k">public</span> <span class="k">function</span> <span class="n">exportToFile</span><span class="p">()</span> <span class="p">{</span>
    <span class="nv">$adapter</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">DrupalTempFileAdapter</span><span class="p">(</span><span class="nc">\Drupal</span><span class="o">::</span><span class="nf">service</span><span class="p">(</span><span class="s1">'file_system'</span><span class="p">));</span>
    <span class="nv">$tempfilemanager</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">TempFileManager</span><span class="p">(</span><span class="nv">$adapter</span><span class="p">);</span>
    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">setTempFileManager</span><span class="p">(</span><span class="nv">$tempfilemanager</span><span class="p">);</span>
    <span class="nv">$file</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">getTempFileManager</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">(</span><span class="s1">'sql'</span><span class="p">);</span>

    <span class="c1">// A bit of PHP magic to get the configuration of the db_exclude plugin.</span>
    <span class="c1">// The PluginManagerInterface::get($plugin_id) method returns a PluginInterface which does not expose the confGet() method.</span>
    <span class="c1">// So we want to cast it to a PluginBase which does expose confGet().</span>
    <span class="c1">// Since PHP doesn't have an explicit casting operator for classes, we use an inline function whose return type is PluginBase.</span>
    <span class="c1">// https://stackoverflow.com/a/69771390/209184</span>
    <span class="nv">$exclude_tables</span> <span class="o">=</span> <span class="p">(</span><span class="k">array</span><span class="p">)</span> <span class="p">(</span><span class="k">fn</span><span class="p">(</span><span class="nv">$plugin</span><span class="p">):</span><span class="kt">PluginBase</span><span class="o">=&gt;</span><span class="nv">$plugin</span><span class="p">)(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">plugins</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'db_exclude'</span><span class="p">))</span><span class="o">-&gt;</span><span class="nf">confGet</span><span class="p">(</span><span class="s1">'exclude_tables'</span><span class="p">);</span>
    <span class="nv">$nodata_tables</span> <span class="o">=</span> <span class="p">(</span><span class="k">array</span><span class="p">)</span> <span class="p">(</span><span class="k">fn</span><span class="p">(</span><span class="nv">$plugin</span><span class="p">):</span><span class="kt">PluginBase</span><span class="o">=&gt;</span><span class="nv">$plugin</span><span class="p">)(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">plugins</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'db_exclude'</span><span class="p">))</span><span class="o">-&gt;</span><span class="nf">confGet</span><span class="p">(</span><span class="s1">'nodata_tables'</span><span class="p">);</span>

    <span class="nv">$process_args</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s1">'pg_dump'</span><span class="p">,</span>
      <span class="s1">'--host'</span><span class="p">,</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">confGet</span><span class="p">(</span><span class="s1">'host'</span><span class="p">),</span>
      <span class="s1">'--port'</span><span class="p">,</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">confGet</span><span class="p">(</span><span class="s1">'port'</span><span class="p">),</span>
      <span class="s1">'--user'</span><span class="p">,</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">confGet</span><span class="p">(</span><span class="s1">'username'</span><span class="p">),</span>
      <span class="s1">'--clean'</span>
    <span class="p">];</span>
    <span class="k">if</span> <span class="p">(</span><span class="nv">$exclude_tables</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">foreach</span><span class="p">(</span><span class="nv">$exclude_tables</span> <span class="k">as</span> <span class="nv">$table</span><span class="p">)</span> <span class="p">{</span>
        <span class="nb">array_push</span><span class="p">(</span><span class="nv">$process_args</span><span class="p">,</span> <span class="s1">'--exclude-table'</span><span class="p">,</span> <span class="nv">$table</span><span class="p">);</span>
      <span class="p">}</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="p">(</span><span class="nv">$nodata_tables</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">foreach</span><span class="p">(</span><span class="nv">$nodata_tables</span> <span class="k">as</span> <span class="nv">$table</span><span class="p">)</span> <span class="p">{</span>
        <span class="nb">array_push</span><span class="p">(</span><span class="nv">$process_args</span><span class="p">,</span> <span class="s1">'--exclude-table-data'</span><span class="p">,</span> <span class="nv">$table</span><span class="p">);</span>
      <span class="p">}</span>
    <span class="p">}</span>
    <span class="nv">$process</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Process</span><span class="p">(</span>
      <span class="nb">array_merge</span><span class="p">(</span><span class="nv">$process_args</span><span class="p">,</span> <span class="p">[</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">confGet</span><span class="p">(</span><span class="s1">'database'</span><span class="p">)]),</span>
      <span class="kc">null</span><span class="p">,</span>
      <span class="p">[</span>
        <span class="s1">'PGPASSWORD'</span> <span class="o">=&gt;</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">confGet</span><span class="p">(</span><span class="s1">'password'</span><span class="p">)</span>
      <span class="p">]</span>
    <span class="p">);</span>
    <span class="nv">$process</span><span class="o">-&gt;</span><span class="nf">run</span><span class="p">();</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nv">$process</span><span class="o">-&gt;</span><span class="nf">isSuccessful</span><span class="p">())</span> <span class="p">{</span>
      <span class="nv">$message</span> <span class="o">=</span> <span class="nv">$process</span><span class="o">-&gt;</span><span class="nf">getErrorOutput</span><span class="p">();</span>
      <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">logger</span><span class="p">(</span><span class="s1">'backup_migrate'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">error</span><span class="p">(</span><span class="nv">$message</span><span class="p">);</span>
      <span class="k">throw</span> <span class="k">new</span> <span class="nc">BackupMigrateException</span><span class="p">(</span><span class="nv">$message</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">write</span><span class="p">(</span><span class="nv">$process</span><span class="o">-&gt;</span><span class="nf">getOutput</span><span class="p">());</span>
    <span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">close</span><span class="p">();</span>
    <span class="k">return</span> <span class="nv">$file</span><span class="p">;</span>
  <span class="p">}</span>
</code></pre></div></div>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[In which I describe a simple and robust approach to support PostgreSQL with Backup and Migrate module.]]></summary></entry><entry><title type="html">Drupal 9: Showing an export link for each manually updated configuration item</title><link href="https://blog.karimratib.me/2023/03/01/export-link.html" rel="alternate" type="text/html" title="Drupal 9: Showing an export link for each manually updated configuration item" /><published>2023-03-01T00:00:00+00:00</published><updated>2023-03-01T00:00:00+00:00</updated><id>https://blog.karimratib.me/2023/03/01/export-link</id><content type="html" xml:base="https://blog.karimratib.me/2023/03/01/export-link.html"><![CDATA[<p>The <a href="https://www.drupal.org/docs/drupal-apis/configuration-api">Configuration API</a> is by far the best surprise I got about Drupal 9. Finally, a core system that is robust enough to hold any configuration set reliably, and extensible enough for contrib modules. Back in Drupal 7, maintaining a consistent configuration across stages had been the bane of my existence, and I was delighted to find it was now a solved problem.</p>

<p>One minor wrinkle I found is related to the scenario of admin users wanting to update the configs that are otherwise stored in source control:</p>
<ul>
  <li>Admin changes a permission on stage PROD via Admin UI</li>
  <li>Devops makes a code deployment on stages DEV =&gt; TEST =&gt; PROD</li>
  <li>The permission change is lost, unless Admin exports the updated permission config and hands it to Devops before deployment</li>
</ul>

<p>To support this scenario, Admin needs to go to <strong>Configuration synchronization</strong> <code class="language-plaintext highlighter-rouge">/admin/config/development/configuration</code>, examine the changed items, then head over to <strong>Single export</strong> <code class="language-plaintext highlighter-rouge">/admin/config/development/configuration/single/export</code> and GUESS how the name that they saw on the previous screen maps to a given configuration type/name pair on this one. User-unfriendly and error-prone!</p>

<p>My quick solution was to add an <strong>Export config</strong> action for each updated item in the <strong>Configuration synchronization</strong> screen, as per the attached screenshot. This was feasible to implement because <a href="https://git.drupalcode.org/project/drupal/-/blob/9.5.3/core/modules/config/config.routing.yml#L56-64">the <strong>Single export</strong> route actually accepts a specific configuration type/name pair</a>, which my code computes given the configuration item (and that was not terribly straightforward). Now Admin can easily export all modified configuration items without any guesswork!</p>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-config-sync.png">
      <img src="https://blog.karimratib.me/assets/drupal-config-sync.png" style="max-width: 100%;" alt="" />
    </a>
    <figcaption></figcaption>
  </figure>
</div>

<div class="flex-center">
  <figure class="image">
    <a href="/assets/drupal-config-export.png">
      <img src="https://blog.karimratib.me/assets/drupal-config-export.png" style="max-width: 100%;" alt="" />
    </a>
    <figcaption></figcaption>
  </figure>
</div>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">use</span> <span class="nc">Drupal\Core\Config\Entity\ConfigEntityInterface</span><span class="p">;</span>

<span class="cd">/**
 * Implements hook_form_FORM_ID_alter() for config_admin_import_form.
 *
 * Show export link for each modified config item.
 */</span>
<span class="k">function</span> <span class="n">MYMODULE_form_config_admin_import_form_alter</span><span class="p">(</span><span class="o">&amp;</span><span class="nv">$form</span><span class="p">,</span> <span class="nc">FormStateInterface</span> <span class="nv">$form_state</span><span class="p">,</span> <span class="nv">$form_id</span><span class="p">)</span> <span class="p">{</span>
  <span class="nv">$configs</span> <span class="o">=</span> <span class="p">[];</span>
  <span class="k">foreach</span> <span class="p">(</span><span class="nc">\Drupal</span><span class="o">::</span><span class="nf">service</span><span class="p">(</span><span class="s1">'entity_type.manager'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">getDefinitions</span><span class="p">()</span> <span class="k">as</span> <span class="nv">$entity_type</span> <span class="o">=&gt;</span> <span class="nv">$definition</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="nv">$definition</span><span class="o">-&gt;</span><span class="nf">entityClassImplements</span><span class="p">(</span><span class="nc">ConfigEntityInterface</span><span class="o">::</span><span class="n">class</span><span class="p">))</span> <span class="p">{</span>
      <span class="nv">$entity_storage</span> <span class="o">=</span> <span class="nc">\Drupal</span><span class="o">::</span><span class="nf">service</span><span class="p">(</span><span class="s1">'entity_type.manager'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">getStorage</span><span class="p">(</span><span class="nv">$entity_type</span><span class="p">);</span>
      <span class="k">foreach</span> <span class="p">(</span><span class="nv">$entity_storage</span><span class="o">-&gt;</span><span class="nf">loadMultiple</span><span class="p">()</span> <span class="k">as</span> <span class="nv">$entity</span><span class="p">)</span> <span class="p">{</span>
        <span class="nv">$configs</span><span class="p">[</span><span class="nv">$definition</span><span class="o">-&gt;</span><span class="nf">getConfigPrefix</span><span class="p">()</span> <span class="mf">.</span> <span class="s1">'.'</span> <span class="mf">.</span> <span class="nv">$entity</span><span class="o">-&gt;</span><span class="nf">id</span><span class="p">()]</span> <span class="o">=</span> <span class="p">[</span>
          <span class="s1">'config_type'</span> <span class="o">=&gt;</span> <span class="nv">$entity_type</span><span class="p">,</span>
          <span class="s1">'config_name'</span> <span class="o">=&gt;</span> <span class="nv">$entity</span><span class="o">-&gt;</span><span class="nf">id</span><span class="p">(),</span>
        <span class="p">];</span>
      <span class="p">}</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="nv">$collection</span> <span class="o">=</span> <span class="s1">''</span><span class="p">;</span>
  <span class="nv">$config_change_type</span> <span class="o">=</span> <span class="s1">'update'</span><span class="p">;</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="k">empty</span><span class="p">(</span><span class="nv">$form</span><span class="p">[</span><span class="nv">$collection</span><span class="p">][</span><span class="nv">$config_change_type</span><span class="p">][</span><span class="s1">'list'</span><span class="p">][</span><span class="s1">'#rows'</span><span class="p">]))</span> <span class="p">{</span>
    <span class="k">foreach</span> <span class="p">(</span><span class="nv">$form</span><span class="p">[</span><span class="nv">$collection</span><span class="p">][</span><span class="nv">$config_change_type</span><span class="p">][</span><span class="s1">'list'</span><span class="p">][</span><span class="s1">'#rows'</span><span class="p">]</span> <span class="k">as</span> <span class="o">&amp;</span><span class="nv">$config_change</span><span class="p">)</span> <span class="p">{</span>
      <span class="nv">$config_item</span> <span class="o">=</span> <span class="nv">$config_change</span><span class="p">[</span><span class="s1">'name'</span><span class="p">];</span>

      <span class="k">if</span> <span class="p">(</span><span class="nb">array_key_exists</span><span class="p">(</span><span class="nv">$config_item</span><span class="p">,</span> <span class="nv">$configs</span><span class="p">))</span> <span class="p">{</span>
        <span class="nv">$config_type</span> <span class="o">=</span> <span class="nv">$configs</span><span class="p">[</span><span class="nv">$config_item</span><span class="p">][</span><span class="s1">'config_type'</span><span class="p">];</span>
        <span class="nv">$config_name</span> <span class="o">=</span> <span class="nv">$configs</span><span class="p">[</span><span class="nv">$config_item</span><span class="p">][</span><span class="s1">'config_name'</span><span class="p">];</span>
      <span class="p">}</span>
      <span class="k">else</span> <span class="p">{</span>
        <span class="nv">$config_type</span> <span class="o">=</span> <span class="s1">'system.simple'</span><span class="p">;</span>
        <span class="nv">$config_name</span> <span class="o">=</span> <span class="nv">$config_item</span><span class="p">;</span>
      <span class="p">}</span>

      <span class="nv">$config_change</span><span class="p">[</span><span class="s1">'operations'</span><span class="p">][</span><span class="s1">'data'</span><span class="p">][</span><span class="s1">'#links'</span><span class="p">][</span><span class="s1">'export'</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
        <span class="s1">'title'</span> <span class="o">=&gt;</span> <span class="nf">t</span><span class="p">(</span><span class="s1">'Export config'</span><span class="p">),</span>
        <span class="s1">'url'</span> <span class="o">=&gt;</span> <span class="nc">Url</span><span class="o">::</span><span class="nf">fromRoute</span><span class="p">(</span><span class="s1">'config.export_single'</span><span class="p">,</span> <span class="p">[</span>
          <span class="s1">'config_type'</span> <span class="o">=&gt;</span> <span class="nv">$config_type</span><span class="p">,</span>
          <span class="s1">'config_name'</span> <span class="o">=&gt;</span> <span class="nv">$config_name</span><span class="p">,</span>
        <span class="p">]),</span>
      <span class="p">];</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>]]></content><author><name>Karim Ratib</name><email>karim.ratib@gmail.com</email></author><category term="drupal" /><summary type="html"><![CDATA[In which I describe how to add an export link to each out-of-sync configuration item right on the main Configuration synchronization page.]]></summary></entry></feed>