Day 30 : Content Versioning: Managing history without read degradation.

Designing and Building a High-Scale PHP Content Management System ~8 min read 60 min hands-on

Day 30: Content Versioning: Managing history without read degradation.

Alright, engineers, settle in. Today, we're tackling a beast that many systems grapple with: content versioning. Not just storing history, but doing it in a way that doesn't bring your ultra-high-scale CMS to its knees every time someone tries to read a published article. In our journey to architect a CMS capable of handling hundreds of millions of requests per second, this isn't a "nice-to-have"; it's a foundational pillar.

The "Bootload Tax" and its Sibling, the "Historical Weight"

We've discussed the PHP "bootload tax" – the cost of re-initializing everything on every request in traditional shared-nothing architectures. The modern PHP landscape, with persistent runtimes, helps mitigate this. But there's a different kind of tax that sneaks into content-heavy systems: the "Historical Weight."

Imagine a single articles table. Every time an editor makes a change, you either update the row (losing history) or insert a new row with an is_current flag. If you go the is_current route, your articles table quickly balloons. Now, every public read query, which might be SELECT * FROM articles WHERE slug = 'my-article' AND is_current = TRUE, has to scan through potentially millions of historical versions. Even with indexes, this creates significant pressure on your database. The more versions, the heavier the historical weight, and the slower your reads become. This is unacceptable for a system designed for 100M RPS.

The Core Problem: Read Degradation

The essence of the problem is this: the public-facing read path, which constitutes 99% of your traffic, should be lightning fast. It needs to serve the current, published version of content with minimal latency. Introducing the complexity of historical versions directly into this path is a common, yet fatal, design flaw in high-scale systems. You don't want your users waiting because your database is busy sifting through drafts from three years ago.

The Elite Strategy: Dual-Table Segregation

The solution, elegant in its simplicity and profound in its impact on scale, is Dual-Table Segregation. We separate the concerns not just in our code (hello, Repository Pattern!) but in our data storage itself.

  1. The content Table: The Public Face

  • This table holds only the currently published version of each piece of content.

  • It's lean, mean, and optimized for blindingly fast reads.

  • Think of it as your high-traffic cache for public consumption, though it's the source of truth for "current."

  • It contains essential fields: id, slug, title, body, author_id, published_at, current_version_id. The current_version_id is a foreign key to the specific version in our archive table.

  1. The content_versions Table: The Historical Archive

  • This table stores every single version of content: drafts, published revisions, reverted states, everything.

  • It's designed for writes and occasional reads (e.g., editors viewing history, reverting content).

  • Fields: version_id (primary key), content_id (foreign key to the content table), title, body, author_id, created_at, status (draft, published, archived), parent_version_id (for lineage).

How It Fits in the Overall System

When a public user requests an article, our ContentRepository (or a specialized PublishedContentRepository) queries only the content table. This query is fast because the table is small and indexed for direct lookups by id or slug.

When an editor makes a change:

Keep going with the full lesson

The rest of this lesson β€” plus its implementation guide, runnable scripts and source code β€” is included with the course or an All Access plan. Create a free account to start the free lessons in every course.

Already a member? Sign in