Backend & architecture
Syncing content on every deploy without overwriting edits made in the admin panel
5 min read
Syncing content on every deploy without overwriting edits made in the admin panel
The problem
Rewriting my portfolio meant a lot of new content at once: project categories, around twenty-six projects, experience, education, a skills cleanup and the first blog posts, all in three languages. Typing everything into the admin panel by hand on production would have been slow, error-prone and impossible to review. At the same time, once the site is live I also edit things directly in the panel. I needed both: content as code, reviewed in git and applied automatically, and the panel as a first-class editor whose changes are never silently undone.
Content as a versioned PHP file
All the prepared content lives in database/content/portfolio.php, a file that returns a plain array: categories, skills (with upsert, merge and delete lists), projects, experience, education, blog sections and posts, and my profile text. Translatable fields have exactly the same shape as in the database:
'laringe' => [
'match' => ['id' => 19],
'category' => 'health-cv',
'title' => [
'en' => 'Laryngeal region segmentation and tracking',
'es' => 'Segmentación y seguimiento de la región laríngea',
'zh' => '喉部区域分割与跟踪',
],
'destacado' => true,
'skills' => ['Python', 'OpenCV'],
],
An Artisan command, portfolio:sync-content, reads that file and applies it. The deploy script runs it right after the migrations and before the health check.
A transaction and a dry run
The command runs everything inside one database transaction:
DB::beginTransaction();
try {
$categoryIds = $this->syncCategories($content['categories'] ?? []);
$this->syncSkills($content['skills'] ?? []);
$this->syncProjects($content['projects'] ?? [], $owner, $categoryIds);
// experience, education, blog, profile...
$dryRun ? DB::rollBack() : DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
With --dry-run it prints the full change report (created, matched, kept) and then rolls back, so I can see exactly what a deploy would do. A real run is all or nothing: if something fails halfway, nothing is left half applied, and running it twice leaves the same final state as running it once.
The first lesson came from the very first deploy. The new code expected project categories to exist and threw an exception when there were none, but the sync was not part of the deploy yet. The health check failed and the deploy rolled back to the previous release. The migrations, however, stay applied. Adding the sync to the deploy pipeline, before the health check, fixed it. If the code depends on certain data, loading that data belongs in the deploy, not in a manual step afterwards.
Matching rows: stable keys and legacy ids
Every entity in the file has a stable key, such as laringe. Production, however, already had rows from the old site without any key. So matching works in three steps: first by key; if there is none, by the match.id declared in the file, adopting that existing row and stamping its key on it; and otherwise by creating a new row.
private function matchByKey(string $modelClass, string $key, ?int $matchId): Model
{
$byKey = $modelClass::query()->where('key', $key)->first();
if ($byKey) {
return $byKey;
}
if ($matchId) {
$byId = $modelClass::query()->find($matchId);
if ($byId && $byId->key === null) {
$byId->key = $key;
return $byId;
}
}
return new $modelClass(['key' => $key]);
}
The $byId->key === null check is the second lesson. Without it, the command worked on production, where the legacy ids already exist, but failed silently on a fresh database. There, auto-increment hands out ids during the same run. A project created early received id 1; a later entity whose match.id was 1 found that brand-new row, "adopted" it and overwrote it with its own data. One project quietly turned into another. Adopting by id only when the row has no key yet closes that door.
The tests changed too. They used to run against an empty database, which turned out to be the misleading case. Now they seed legacy rows at the real ids first, which is the state the command actually meets in production.
Protecting panel edits with per-field hashes
For every translatable field it writes, the command stores a SHA1 of the JSON value in a content_sync_hashes table, keyed by model, row and field. On the next run:
- if the current value no longer matches the stored hash, someone edited it in the panel since the last sync, so the command keeps it and reports "kept (edited in panel)";
- if the stored hash already matches the new value, there is nothing to do;
- otherwise it writes the new value and updates the hash.
if ($hashRow && $hashRow->hash !== $currentHash) {
$this->changeLog[] = "kept (edited in panel): {$this->label($model)}.{$field}";
return;
}
Working per field matters: fixing a Spanish title in the panel should not freeze the whole row against future content updates.
There is a limitation. The hash is only as reliable as the history behind it. On my local database, values written outside the command made two posts look "edited in panel" when nobody had touched them in the admin. That is the right direction to fail, though: the command keeps data instead of overwriting it, and the report says so explicitly.
The fields the hash did not cover
The hash protection only applied to translatable fields. published_at was simply assigned from the file on every run. Drafts in the file had null, I published one of them from the panel, and the next deploy unpublished it. That was the third lesson. The rule is now explicit:
if ($isNew && array_key_exists('published_at', $data)) {
$model->published_at = $data['published_at'];
}
The file only sets the date when a post is created. After that, publishing it, scheduling it or turning it back into a draft is decided in the panel, and that decision survives every later deploy.
The opposite case exists as well. Fields the file does not declare are never touched, which is right for data owned by the panel. It also means legacy content the file never mentions, such as old English-only project write-ups, stays as it is until the file explicitly takes ownership of that field.
What I would keep
- Content in git, reviewed like code, applied by an idempotent command that the deploy runs on its own.
- A dry run before anything else, and a single transaction around everything.
- Deciding ownership per field from the start: owned by the file, owned by the panel, or "the file only sets it on creation". Writing that table down early would have saved me two of these three bugs.