<?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>Content-Management on Web Developer Blog</title><link>https://jonesrussell.github.io/blog/tags/content-management/</link><description>Recent content in Content-Management 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.169.0</generator><language>en-us</language><lastBuildDate>Tue, 03 Nov 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jonesrussell.github.io/blog/tags/content-management/feed.xml" rel="self" type="application/rss+xml"/><item><title>Treat content withdrawal as a reversible migration</title><link>https://jonesrussell.github.io/blog/reversible-content-withdrawal/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0000</pubDate><guid>https://jonesrussell.github.io/blog/reversible-content-withdrawal/</guid><category>devops</category><blog:tag>content-management</blog:tag><blog:tag>migrations</blog:tag><blog:tag>testing</blog:tag><blog:tag>publishing</blog:tag><description>Withdraw published content without destroying its source, revision history, or the evidence needed to move it safely.</description><content:encoded><![CDATA[<p>Ahnii!</p>
<p>Removing a page from a website is easy. Withdrawing it safely is not.</p>
<p>A real content withdrawal has to change every public surface while preserving the material needed for review, recovery, or migration. This post uses a recent <a href="https://github.com/jonesrussell/rhtcircle/commit/bba42a11b9f3eac2a49321dbc305278403c700b0">RHT Circle change</a> to show how to treat that work as a reversible migration instead of a deletion.</p>
<h2 id="define-what-withdrawal-means">Define what withdrawal means</h2>
<p>Start with an explicit contract. In this case, five managed articles and four static pages had to leave the public site before their material moved elsewhere.</p>
<p>Withdrawal meant all of the following:</p>
<ul>
<li>Existing public URLs return 404</li>
<li>News lists and community hubs stop linking to the material</li>
<li>Static and generated sitemaps omit the URLs</li>
<li>Search indexing drops the old public chunks during the normal reindex</li>
<li>Managed article bodies remain stored as unpublished records</li>
<li>Original seed files and static templates remain available for migration</li>
<li>No redirect exists until a verified destination is actually live</li>
</ul>
<p>That last point avoids a common mistake. A planned destination is not a working destination. Redirecting early turns a controlled withdrawal into a broken handoff.</p>
<h2 id="preserve-content-remove-publication-state">Preserve content, remove publication state</h2>
<p>The managed articles were not deleted. Their slugs were added to an explicit <code>unpublishedSlugs()</code> list, and the seeder created them with <code>status: false</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:#e6db74">&#39;status&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#f92672">!</span><span style="color:#a6e22e">in_array</span>(
</span></span><span style="display:flex;"><span>    $slug,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">ArticleSeedData</span><span style="color:#f92672">::</span><span style="color:#a6e22e">unpublishedSlugs</span>(),
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>),
</span></span></code></pre></div><p>This keeps the body and record available while removing anonymous access. It also gives new installations the same state as production instead of letting a fresh seed accidentally republish withdrawn material.</p>
<p>The refresh path needed a second guard. A slug cannot be both withdrawn and eligible for publication refresh. The seeder now skips any unpublished slug, and the test suite asserts that the unpublished and publication-refresh lists do not intersect.</p>
<p>This is the critical invariant:</p>
<blockquote>
<p>Re-running installation or seed refresh must not reverse the withdrawal.</p>
</blockquote>
<p>Without that rule, the next deployment can quietly undo the change.</p>
<h2 id="handle-managed-and-static-content-differently">Handle managed and static content differently</h2>
<p>The site had two kinds of public material:</p>
<table>
	<thead>
			<tr>
					<th>Content type</th>
					<th>Withdrawal action</th>
					<th>Preserved evidence</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Managed articles</td>
					<td>Keep the entity, set it unpublished</td>
					<td>Database record, body, revision history, seed source</td>
			</tr>
			<tr>
					<td>Static pages</td>
					<td>Remove the route from public catalogues</td>
					<td>Twig template and Git history</td>
			</tr>
	</tbody>
</table>
<p>Trying to force both through one mechanism would hide important differences. A managed article has publication state. A static route does not, so the public route registration itself has to disappear.</p>
<p>The static templates stayed in the repository. That makes the withdrawal reversible without leaving the old URLs reachable. It also keeps the material available for a later migration where the destination may use a different content model.</p>
<h2 id="remove-every-discovery-path">Remove every discovery path</h2>
<p>Returning 404 from the old URL is necessary, but it is not enough. A withdrawn page can still appear public if the rest of the site advertises it.</p>
<p>The change removed references from:</p>
<ul>
<li>Home and news listings</li>
<li>Community cards and accountability hubs</li>
<li>Manually maintained sitemap XML</li>
<li>The application&rsquo;s generated sitemap route list</li>
<li>Contextual links embedded in other content</li>
</ul>
<p>Search requires separate attention because an index is another copy of the content. The release procedure relies on the normal <code>app:reindex</code> deployment step to remove old page chunks, then calls for production verification that those chunks are gone.</p>
<p>This is why content withdrawal belongs in release planning. Code can remove routes and links, but the deployed search corpus is runtime state that still has to be checked.</p>
<h2 id="test-absence-and-preservation-together">Test absence and preservation together</h2>
<p>The strongest regression test does not merely ask whether a page disappeared. It proves that the page is unavailable while its source remains intact.</p>
<p>The integration test runs article migration, boots the application, and then checks each withdrawn article:</p>
<ul>
<li>Exactly one stored record still exists</li>
<li>Its publication status is false</li>
<li>Its body is not empty</li>
<li>Its public URL returns 404</li>
</ul>
<p>It also requests the home page, news list, community hub, and sitemap, then asserts that none contains a withdrawn path.</p>
<p>The test name calls out repeated seeding because idempotency is the real risk. A one-time successful withdrawal is not enough. The same release operations must keep producing the same private state.</p>
<h2 id="keep-the-scope-auditable">Keep the scope auditable</h2>
<p>The release note records what moved and what did not. Other authors&rsquo; articles, petition records, and consent instruments were explicitly outside the withdrawal.</p>
<p>That boundary matters for operational safety. A vague request to remove related content can grow into an accidental purge. A slug list, route list, and named exclusions give reviewers a finite change to inspect.</p>
<p>A safe withdrawal therefore leaves four kinds of evidence:</p>
<ol>
<li><strong>Scope:</strong> the exact records and routes being withdrawn</li>
<li><strong>State:</strong> unpublished records and preserved source files</li>
<li><strong>Public absence:</strong> 404 responses, clean listings, and clean sitemaps</li>
<li><strong>Operational follow-up:</strong> reindexing and production verification</li>
</ol>
<p>Deletion destroys options. A reversible migration removes publication authority while keeping the source and history needed for whatever comes next.</p>
<p>Baamaapii</p>
]]></content:encoded></item></channel></rss>