In short

Validation alone cannot stop a form erasing a field it never sent; merge-on-write semantics can. Pair that with drafts separate from published state, revisions that roll back through the single publish path, optimistic version locking, and a migration that must prove a byte-identical round trip.

Ask whoever runs your website one question: if someone saves the wrong thing to your About page tonight, how do you get yesterday’s version back? A good answer names a button. A worrying answer names a person. For one of my own clients, whose pages lived in a flat file CMS, the honest answer before I fixed it was a web server log.

Most business owners assume their content is safe because it is “in the system”. Whether it is safe depends on things no demo shows you. Does the system check what it stores? Does it keep history? Can two people editing at once overwrite each other? Can a form remove data it was never asked to touch? None of that is visible until the day a page goes missing, and by then the question is no longer how to prevent it but what you can still recover.

The client is Lampstand Edu Consult, an education consultancy that places Nigerian students at universities abroad. I built their public site and the content system behind it: a React front end, a flat PHP API, SQLite for storage, all running in Docker. The mistakes below were mine. So was the repair, and the repair is the useful part.

A CMS whose undo is a web server log has no undo

Lampstand’s six structured pages (Home, About, Services, Destinations, Student Stories and Contact) lived as flat JSON files, one file per page. The admin wrote the file and the public site read it. That is a reasonable way to start. It is simple, fast and easy to reason about.

It also had no validation, no drafts, no revisions and no locking. The API stored whatever JSON arrived. Saving a page published it immediately, because there was nowhere else for an edit to go. If two admins saved the same page, the second save won and nobody was told.

That design had already failed twice before I sat down to replace it. Both times a content push overwrote hero images on live pages, and both times I had to put them back by hand. For one of the two, the only surviving record of the image path was in the web server’s access logs. I recovered the filename because a visitor’s browser had once requested it.

A system that can only be undone by reading traffic logs has no undo. It has luck, and luck is not something I can sell to a client.

There was a quieter problem underneath. The copy of the content in the code repository had drifted three months out of date, so the repository was not a record of the site either. The live pages were still recoverable, but by luck rather than design. Nobody had built a safety net; one simply happened to exist.

A form can erase a field it never showed you

The failure I least expected was not the content push. It was the editor.

The page editor rebuilt each section from an explicit list of keys when it loaded, and on save it posted the whole section back. That looks harmless. The trouble is a key that exists in the stored content but is missing from the form’s list. The form never loads it, so it never sends it back, so the save writes a section without it. The field is gone. There is no error and no warning, and the person who pressed Save did nothing wrong.

It happened twice in one working session: once in the page editor, and once while I was converting a profile modal into a full screen. Both times, a field that existed in one place was dropped by a form I had not updated in another.

The root cause was structural. Adding one field to a page meant three coordinated edits: the public render, the editor form and the editor’s state loader. Miss the render and the field never appears. Miss the form and nobody can edit it. Miss the loader and the next save quietly deletes it. The third outcome is the dangerous one, because it looks like success.

If you own a site with a custom editor, ask directly: if the form does not show a field, what happens to that field when I save?

Validation cannot notice a key that was never sent

My first instinct was a schema: declare every field, check every write. I built one, and it matters, but on its own it does not fix the erasure. A form that forgets a key still posts a perfectly valid object that happens to be missing that key. The schema inspects the object, finds nothing wrong with what is there, and stores it. Validation answers “is this acceptable?” It cannot answer “is this everything?”

The fix lives in the write semantics. A save from the editor is now a PATCH, and a PATCH deep merges the fields it carries into the stored draft. A field the form does not mention is left exactly where it was. Only an explicit PUT replaces the whole document, and the editor does not use it.

function bf_merge(array $base, array $patch): array {
    foreach ($patch as $key => $val) {
        if (is_array($val)
            && isset($base[$key]) && is_array($base[$key])
            && !_bf_is_list($val) && !_bf_is_list($base[$key])) {
            $base[$key] = bf_merge($base[$key], $val);
            continue;
        }
        $base[$key] = $val;
    }
    return $base;
}

function _bf_is_list(array $a): bool {
    return $a === [] || array_keys($a) === range(0, count($a) - 1);
}

Two details in there carry weight. First, an explicit empty string still clears a field. Removing something on purpose has to stay possible, so the merge only protects what a form leaves out, not what it deliberately blanks.

Second, lists replace wholesale rather than merging by position. If you merge a repeater row by row and someone deletes the first of two rows, the second row’s data lands in the first row’s slot and the leftovers get stitched together. Replacing the list is the only behaviour that matches what the person saw on screen when they pressed Save.

The test script covers the exact failure that happened: a patch that omits a key must leave that key untouched.

One table with a JSON column holds pages and posts without two content models

The storage change was to move every page and blog post into a single SQLite table called documents. Anything the admin filters or sorts on is a real column: type, slug, status, title, version, timestamps. Everything else lives in a JSON column called data.

I considered two alternatives and rejected both. A pure JSON blob for everything would have forced the blog list, which filters and sorts on status, category and tags, to rebuild query paths that already worked. Keeping the old blog table and adding a documents table for pages alone would have left two content models in place permanently, and two content models was the problem I was trying to remove.

The schema is a PHP file that declares every field by type: text, textarea, rich text, image, select, boolean, number, list, group and repeater. Validation walks that schema and returns either a clean document or a map of errors keyed by field path, so the editor can put each message next to the field that caused it. It enforces maximum lengths and item counts, checks select values against their options, coerces numbers and booleans, sanitises rich text through an allowlist, and drops keys the schema does not declare.

Image fields accept only paths on the same site, starting with a slash. A stored value with a scheme or an absolute URL is refused, so a content field can never quietly become a script or a tracking beacon on someone else’s server.

One decision in the validator looks cosmetic and is not. It walks the keys of the input, not the keys of the schema. That preserves the order the content already had, and the reason becomes clear in the migration below.

A page’s status should not tell you whether it has unsaved edits

Every document now has two JSON columns. data holds what is published. draft_data holds the working copy, and it is empty when there is nothing unpublished. Saving writes the draft only. Publishing validates the draft, copies it into data, snapshots it as a revision, increments the version and clears the draft, all in one transaction.

The status column has exactly one job: it describes the document, not the draft. A published page with pending edits is still published. Whether it has unpublished work is derived from whether draft_data is present, and the admin receives that as a separate flag. I was strict about this because the two facts are independent, and folding them into one column is how a live page with pending edits ends up labelled as unpublished. Someone then “fixes” that by publishing, and ships a half-finished edit.

Revisions are written on publish, never on keystroke. A history made of every autosave is noise nobody can navigate. The largest page on the site is 8.6 KB, so keeping every published version of every page costs a few megabytes over years. There is no reason to prune.

Rollback is safest when it cannot publish anything by itself

The obvious way to build rollback is a function that copies an old revision straight back into the live content. I deliberately did not build that. It would be a second code path that changes what the public sees, and two paths drift. Sooner or later one of them skips a check the other still makes.

Instead, restoring a revision loads it into the draft and stops. The person reviews it, then publishes it through the ordinary publish, which validates it like any other edit.

function bf_doc_revert(string $type, string $slug, int $version, ?array $user): array
{
    $doc = bf_doc_get($type, $slug);
    if ($doc === null) return [null, ['_slug' => 'No such document.']];

    $st = getDB()->prepare('SELECT data FROM document_revisions WHERE document_id = ? AND version = ?');
    $st->execute([$doc['id'], $version]);
    $row = $st->fetch();
    if (!$row) return [null, ['_version' => "There is no revision $version of this document."]];

    // Stage only. Publishing is the one thing that changes what is live.
    getDB()->prepare('UPDATE documents SET draft_data = ?, updated_at = datetime(\'now\'), updated_by = ? WHERE id = ?')
           ->execute([$row['data'], $user['id'] ?? null, $doc['id']]);

    _bf_audit('document.rolled_back', $type, $doc['id'], $version, $user);
    return [bf_doc_get($type, $slug), []];
}

The result is one rule I can state without qualification: only publish writes the live content. Rollback cannot drift from publish, and it cannot skip validation, because it is publish.

Two editors saving the same page should collide loudly

Lampstand has three admins. Two of them opening the same page on the same afternoon is not an edge case, it is a Tuesday. Under the old files, the second person to save erased the first person’s work and neither of them knew.

Each document now carries a version number. The editor receives it when it loads a page and sends it back with every save. If the stored version has moved on in the meantime, the API refuses the write with a 409 and tells the editor, in plain words, that someone else changed the page while they were editing, which version they have and which version is current. The editor adds the instruction that matters: reload to get their version before saving again.

The failure moves from silent loss to a visible conflict the person can resolve. Every draft save, publish, rollback and deletion also lands in an audit log with the name of the admin who did it, so “who changed this?” has an answer that does not depend on anyone’s memory.

A migration that cannot prove it lost nothing is a gamble

All of this is worthless if moving the content into the new table damages it on the way. The two earlier losses came from exactly that kind of push: a bulk write that nobody checked afterwards. So the migration’s real job was not copying. It was proving.

For each of the six pages, the script reads the live file, validates it against the schema, re-encodes the result with the same JSON settings the old API used to write it, and compares that to the file on disk. If they match, the schema understood every field and changed nothing. If they do not, the page fails and the script refuses to call the migration lossless.

$lf        = static fn(string $s): string => str_replace("\r\n", "\n", $s);
$onDisk    = rtrim($lf($original));
$reencoded = json_encode($clean, $flags);

if (rtrim($reencoded) === $onDisk) {
    echo "  ok    $slug round trips byte for byte\n";
} else {
    $trims = [];
    $norm  = m_trim($raw, '', $trims);
    if (rtrim(json_encode($norm, $flags)) !== rtrim($reencoded) || !$trims) {
        $fail++;
        echo "  FAIL  $slug changed in a way that is not whitespace. NOT lossless.\n";
        continue;
    }
    echo "  ok    $slug round trips, with whitespace trimmed\n";
    foreach ($trims as $tp) echo "          normalised: whitespace at $tp\n";
}

This is where walking the input’s keys pays off. Had the validator rebuilt each document in schema order, the bytes would have changed on every page while the data stayed identical, and the comparison would have been useless. Walking the input keeps the order, which also turns the round trip into proof that the schema is complete. A field I forgot to declare gets dropped, the comparison fails, and the output names it. I did not edit a single piece of content to fit the schema. When a limit was too tight, I raised the limit.

Five of the six pages were byte identical. The About page was not: its founder biography carried a stray trailing newline, left behind by the admin’s text box, and validation trims text. Trimming is correct, so I did not weaken the check to hide it. I made it stricter. The allowed differences are leading and trailing whitespace only, every trim must be named by field path in the output, and an unexplained difference is a failure. A real change cannot hide behind a whitespace excuse.

The script is idempotent: running it twice imports nothing the second time. It deletes nothing and never writes to the content files it reads. It has a dry-run mode, and the dry run passed before anything was written for real. The seven blog posts, all soft deleted by then, came across with their deletion timestamps intact, so their previous state stayed recoverable.

Field note

The first proof passed files it had never checked

The first dry run reported four pages as “round trips, with whitespace trimmed” and then named no trimmed field for any of them. That is impossible. If whitespace was trimmed, something was trimmed somewhere.

The cause was line endings. Git had checked the content files out on my Windows machine with Windows line endings, while PHP’s JSON encoder writes Unix ones. The byte comparison failed on files that were in fact identical, fell through to the whitespace branch, and that branch waved them through because nothing in it required a named trim.

Two changes closed it. Both sides are normalised to the same line endings before comparing, and an empty trim list is now a failure, not a pass. A check that can go green without evidence is not a check.

I rolled it out in four steps, each safe on its own. The storage layer shipped with nothing reading it. The migration ran in production and the round trip was verified there. The page editor moved onto the new API, with Save draft, Publish and a history panel offering Restore. The blog followed separately. When the storage layer merged, the test script was passing 61 assertions with no test framework at all: a plain PHP file that exits non-zero when any assertion fails.

Publish has to send what is on the screen

The report was simple: the founder portrait they had picked on the About page was not showing on the website. That sounds like a rendering bug. It was not. The editor now had two buttons, Save draft and Publish, and Publish called the publish endpoint, which promoted the stored draft. When there was no draft, it republished the content that was already live. So the sequence “open the page, pick a portrait, press Publish” republished the old page, reported success, and never stored the portrait at all.

The audit log found it in one query. That page showed two publish events and not a single draft save. Every revision lacked the image, which ruled out the front end immediately.

The server model was right: draft and published really are two states. The mistake was letting that model reach the button. To the person pressing it, Publish means “make what I am looking at live”. Nothing on screen told them the form and the stored draft were different objects, and a Save draft button beside it reads as optional, not as a warning.

So Publish now writes first and publishes second. It sends the form as a draft, waits for the server to accept it, and only then promotes it. Both buttons build their payload through the same function, which is what stops saving and publishing from ever sending different things. If the write fails, with a validation error or a version conflict, nothing is published and the person sees why.

const publish = async () => {
  setPublishing(true); setErr(""); setFieldErrors({});

  // Publish means "make what I am looking at live". It has to write first.
  const staged = await apiPatch(docPath, { ...buildPayload(), version: docVersion });
  if (!staged || !staged.ok) {
    setPublishing(false);
    if (staged && staged.status === 409) {
      setErr((staged.error || "This page was changed by someone else.")
        + " Reload to get their version before publishing.");
      return;
    }
    setErr((staged && staged.error) || "Could not save before publishing. Nothing was published.");
    return;
  }
  setDocVersion(staged.data.version);

  const res = await apiPost(docPath + '/publish', {});
  // ...
};
Pro tip

When a value “does not show” on a site with drafts and revisions, read the stored document and its history before you open the front-end code. If no revision contains the value, the bug is in the write path, and the renderer is innocent.

The public site never noticed the storage underneath it change

One constraint shaped the whole design: visitors should not be able to tell any of this happened. Publishing still writes the same JSON file per page that the public renderer has always read. The public request path did not change and gained no latency. The database is the source of truth, and the file is a cache that can be regenerated from it whenever the two drift.

That bounds the risk in a way I value more than any feature above. If the new content layer fails, the public site keeps serving the last good file. The blast radius of a bug in the admin is the admin, not the website a student’s family is reading.

One thing it deliberately does not do: the schema does not render the editor. I kept hand-built forms per page so the bespoke layouts survive, which means a new field is still a code change. The schema is what would make a generated editor possible later.

If you want the shorter version of the whole engagement, the outcome sits in the Lampstand case study.

A page is a record, and records need history

We would never accept an accounting system that let one clerk overwrite another’s entry in silence, or one where correcting a mistake meant reading the building’s CCTV. Website content gets treated as softer than that, because it is “just the website”. For a consultancy whose pages are how families decide whether to trust it with a child’s future, it is not soft at all. The standard I hold a CMS to now is simple to state: every save is checked, every publish is kept, every rollback goes through the same door as every publish, every conflict is loud, and any migration proves it changed nothing before it is allowed to count. A system that meets that standard cannot lose a page. One that does not already has, and nobody has noticed yet.