<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:blog="https://jonesrussell.github.io/blog/ns"><channel><title>Database on Web Developer Blog</title><link>https://jonesrussell.github.io/blog/tags/database/</link><description>Recent content in Database on Web Developer Blog</description><image><title>Web Developer Blog</title><url>https://jonesrussell.github.io/blog/images/og-default.png</url><link>https://jonesrussell.github.io/blog/images/og-default.png</link></image><generator>Hugo -- 0.166.0</generator><language>en-us</language><lastBuildDate>Wed, 09 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jonesrussell.github.io/blog/tags/database/feed.xml" rel="self" type="application/rss+xml"/><item><title>Fixing a schema-mutating access check in Waaseyaa's taxonomy package</title><link>https://jonesrussell.github.io/blog/taxonomy-schema-mutating-access-check/</link><pubDate>Wed, 09 Sep 2026 00:00:00 +0000</pubDate><guid>https://jonesrussell.github.io/blog/taxonomy-schema-mutating-access-check/</guid><category>php</category><category>waaseyaa</category><blog:tag>php</blog:tag><blog:tag>waaseyaa</blog:tag><blog:tag>database</blog:tag><blog:tag>schema</blog:tag><description>How Waaseyaa&amp;rsquo;s taxonomy package let ordinary request traffic ALTER a table to add a foreign key, and the fix that moved that DDL exclusively into coordinated schema sync.</description><content:encoded><![CDATA[<p>Ahnii!</p>
<p><a href="https://github.com/waaseyaa/framework">Waaseyaa</a>&rsquo;s <code>packages/taxonomy</code> package manages vocabularies and terms, with a foreign key tying every term row back to the vocabulary it belongs to. Two places in that package called the same &ldquo;make sure this foreign key exists&rdquo; helper unconditionally, and one of them ran on every delete-access check against a vocabulary — meaning ordinary request traffic could issue an <code>ALTER TABLE</code> under load. Here&rsquo;s the bug, the fix, and why &ldquo;no DDL on the request path&rdquo; needs to be a <em>contract</em>, not just a habit.</p>
<h2 id="the-bug-ddl-behind-an-access-check">The Bug: DDL Behind an Access Check</h2>
<p><code>TaxonomyServiceProvider::boot()</code> and <code>VocabularyAccessPolicy::access()</code> both called <code>VocabularyReferenceConstraint::ensure()</code> unconditionally. <code>ensure()</code> issues DDL: it adds the <code>taxonomy_term</code> → <code>taxonomy_vocabulary</code> foreign key if it&rsquo;s missing.</p>
<p>Calling that from <code>boot()</code> is already risky — a production deployment whose schema hadn&rsquo;t finished a coordinated sync yet could have a request trigger the <code>ALTER TABLE</code> under live traffic. But the call inside <code>VocabularyAccessPolicy::access()</code> was worse, because it wasn&rsquo;t bounded to boot at all. It ran on <em>every</em> delete-access check against a vocabulary, for the lifetime of the process:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">access</span>(<span style="color:#a6e22e">EntityInterface</span> $entity, <span style="color:#a6e22e">string</span> $operation, <span style="color:#a6e22e">AccountInterface</span> $account)<span style="color:#f92672">:</span> <span style="color:#a6e22e">AccessResultInterface</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> ($operation <span style="color:#f92672">!==</span> <span style="color:#e6db74">&#39;delete&#39;</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">AccessResult</span><span style="color:#f92672">::</span><span style="color:#a6e22e">neutral</span>();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> ($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">database</span> <span style="color:#f92672">!==</span> <span style="color:#66d9ef">null</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">VocabularyReferenceConstraint</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">database</span>)<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">ensure</span>();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    $terms <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">entityTypeManager</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getRepository</span>(<span style="color:#e6db74">&#39;taxonomy_term&#39;</span>)<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">findBy</span>(
</span></span><span style="display:flex;"><span>        [<span style="color:#e6db74">&#39;vid&#39;</span> <span style="color:#f92672">=&gt;</span> (<span style="color:#a6e22e">string</span>) $entity<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">id</span>()],
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">limit</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1</span>,
</span></span><span style="display:flex;"><span>    );
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>An access policy&rsquo;s job is to answer &ldquo;is this delete allowed,&rdquo; not to mutate the schema on the way to answering. The framework had already closed this exact class of defect once, in <code>AttachmentServiceProvider</code> (issue <strong>#2478</strong>) — this was the same mistake resurfacing in a package that hadn&rsquo;t been through that fix.</p>
<h2 id="the-fix-ddl-belongs-only-to-coordinated-schema-sync">The Fix: DDL Belongs Only to Coordinated Schema Sync</h2>
<p>Both unconditional <code>ensure()</code> calls were removed (issue <strong>#2761</strong>). <code>VocabularyAccessPolicy</code> no longer accepts a database at all — its existing <code>findBy()</code> check (does any term still reference this vocabulary?) is the real enforcement. The foreign key becomes a storage-level backstop, installed exclusively by coordinated schema sync (<code>db:init</code>, <code>schema:sync</code>):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">VocabularyAccessPolicy</span> <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">AccessPolicyInterface</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">readonly</span> <span style="color:#a6e22e">EntityTypeManagerInterface</span> $entityTypeManager,
</span></span><span style="display:flex;"><span>    ) {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">access</span>(<span style="color:#a6e22e">EntityInterface</span> $entity, <span style="color:#a6e22e">string</span> $operation, <span style="color:#a6e22e">AccountInterface</span> $account)<span style="color:#f92672">:</span> <span style="color:#a6e22e">AccessResultInterface</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($operation <span style="color:#f92672">!==</span> <span style="color:#e6db74">&#39;delete&#39;</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">AccessResult</span><span style="color:#f92672">::</span><span style="color:#a6e22e">neutral</span>();
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        $terms <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">entityTypeManager</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getRepository</span>(<span style="color:#e6db74">&#39;taxonomy_term&#39;</span>)<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">findBy</span>(
</span></span><span style="display:flex;"><span>            [<span style="color:#e6db74">&#39;vid&#39;</span> <span style="color:#f92672">=&gt;</span> (<span style="color:#a6e22e">string</span>) $entity<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">id</span>()],
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">limit</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1</span>,
</span></span><span style="display:flex;"><span>        );
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// ...
</span></span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>No new migration was needed for the sync path — the entity type already declares its <code>_foreignKeys</code>, and <code>SqlSchemaHandler</code>&rsquo;s generic <code>ensureDeclaredForeignKeys()</code> (used by every entity type with declared foreign keys) picks it up automatically once the unconditional call in <code>boot()</code> is gone.</p>
<p><code>TaxonomyServiceProvider::boot()</code> keeps a <strong>local/development-only</strong> convenience materialization, gated the same way <code>AttachmentServiceProvider</code> gates its own schema convenience:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">boot</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ... event listener wiring unchanged ...
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    $database <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">resolveOptional</span>(<span style="color:#a6e22e">DatabaseInterface</span><span style="color:#f92672">::</span><span style="color:#a6e22e">class</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> ($database <span style="color:#a6e22e">instanceof</span> <span style="color:#a6e22e">DatabaseInterface</span> <span style="color:#f92672">&amp;&amp;</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">allowsConvenientSchemaMaterialization</span>()) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">VocabularyReferenceConstraint</span>($database)<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">ensure</span>();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">allowsConvenientSchemaMaterialization</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">bool</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">RuntimePolicy</span><span style="color:#f92672">::</span><span style="color:#a6e22e">resolve</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">config</span>)<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">isDevelopment</span>();
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Production and staging boot no longer touch the foreign key at all, and neither does any request that reaches the access policy.</p>
<h2 id="closing-the-silent-skip-gap">Closing the &ldquo;Silent Skip&rdquo; Gap</h2>
<p>Removing the DDL calls raises an obvious question: what happens in production if the foreign key is genuinely missing — say, a deploy where schema sync hasn&rsquo;t run yet? Silently skipping the constraint would be its own bug. The framework already had a no-DDL runtime contract, <code>SqlSchemaHandler::assertRuntimeSchema()</code>, which every <code>getRepository()</code> resolution runs and which already asserted declared unique keys were present. It now asserts declared foreign keys too:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">assertDeclaredForeignKeysReady</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span>$this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">entityType</span> <span style="color:#a6e22e">instanceof</span> <span style="color:#a6e22e">EntityTypeForeignKeyDefinitionInterface</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    $schema <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">database</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">schema</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span>$schema <span style="color:#a6e22e">instanceof</span> <span style="color:#a6e22e">ForeignKeySchemaInterface</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">foreach</span> ($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">entityType</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getStorageForeignKeys</span>() <span style="color:#66d9ef">as</span> $definition) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span>$schema<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">tableExists</span>($definition[<span style="color:#e6db74">&#39;table&#39;</span>])) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">continue</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($schema<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">foreignKeyExists</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">tableName</span>, $definition[<span style="color:#e6db74">&#39;name&#39;</span>])) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">continue</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\RuntimeException</span>(<span style="color:#a6e22e">sprintf</span>(
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#39;[S1-DB106] Required runtime schema is unavailable for table &#34;%s&#34;; missing: foreign key %s. Apply migration &#34;waaseyaa schema:sync&#34; through the schema coordinator.&#39;</span>,
</span></span><span style="display:flex;"><span>            $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">tableName</span>,
</span></span><span style="display:flex;"><span>            $definition[<span style="color:#e6db74">&#39;name&#39;</span>],
</span></span><span style="display:flex;"><span>        ));
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>That required a new read-only capability: <code>ForeignKeySchemaInterface</code> already had <code>addForeignKey()</code> (DDL) but nothing to check whether a key already exists without mutating anything. A <code>foreignKeyExists()</code> companion method was added to the interface — contract-only, since the concrete <code>DBALSchema</code> implementation already had the underlying check available.</p>
<p>A declared key whose <em>referenced</em> table doesn&rsquo;t exist yet is skipped rather than treated as an error, because entity type registration order isn&rsquo;t guaranteed (<code>taxonomy_term</code> registers before <code>taxonomy_vocabulary</code>) — that table&rsquo;s own readiness is a separate concern.</p>
<h2 id="verifying-it">Verifying It</h2>
<p>The risk with a &ldquo;read-only&rdquo; schema check is that it quietly isn&rsquo;t, on some database platform. That got its own test, proving <code>foreignKeyExists()</code> and <code>tableExists()</code> never call <code>executeStatement()</code> — using mocked DBAL connections that report MySQL, PostgreSQL, and SQLite platforms, the same technique the existing DDL-generation tests already used for portability:</p>
<table>
	<thead>
			<tr>
					<th>Check</th>
					<th>What it proves</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>foreignKeyExistsIsReadOnlyOnRealSqlite</code></td>
					<td>Real SQLite in-memory database: false before creating the key, true after</td>
			</tr>
			<tr>
					<td><code>foreignKeyExistsNeverIssuesDdlAndReportsAbsence</code></td>
					<td>MySQL, PostgreSQL, SQLite: reports <code>false</code>, never calls <code>executeStatement()</code></td>
			</tr>
			<tr>
					<td><code>foreignKeyExistsNeverIssuesDdlAndReportsPresence</code></td>
					<td>Same three platforms: reports <code>true</code>, never calls <code>executeStatement()</code></td>
			</tr>
			<tr>
					<td><code>tableExistsNeverIssuesDdl</code></td>
					<td>Same guarantee for the existing <code>tableExists()</code> check</td>
			</tr>
	</tbody>
</table>
<p>On the <code>assertRuntimeSchema()</code> side, new unit tests cover a table materialized <em>without</em> its declared foreign key, proving the assertion throws with the <code>[S1-DB106]</code> message instead of silently passing. There&rsquo;s no live MySQL or PostgreSQL server in this environment, so the mocked-connection tests are what stand in for cross-platform proof; real fresh-install and concurrent-upgrade behavior against live servers is explicitly called out as deferred.</p>
<h2 id="the-general-lesson">The General Lesson</h2>
<p>&ldquo;No DDL on the request path&rdquo; sounds obvious once you say it. It&rsquo;s easy to violate by accident anyway, because DDL doesn&rsquo;t announce itself — <code>ensure()</code> reads like a harmless idempotent helper, not a schema mutation. The tell was where the call lived. A <code>boot()</code> method is at least bounded to process startup; an access-check method runs on every matching request for as long as the process is up. So anywhere your code &ldquo;makes sure X exists&rdquo; as a side effect of answering an unrelated question, ask whether that assurance is DDL — and if it is, ask who&rsquo;s actually allowed to run it. The fix here wasn&rsquo;t just deleting the two bad calls. It was making the runtime <em>assert</em> the schema is already correct and fail loudly if it isn&rsquo;t, so removing the convenience path couldn&rsquo;t quietly turn into a silent skip.</p>
<p>Baamaapii</p>
]]></content:encoded></item></channel></rss>