The default answer to "I need a website I can update myself" is WordPress. Sometimes that's right. This time it wasn't.
A therapy practice had an aging Jimdo site. The replacement needed to look current, lead more clearly to enquiries, and — this is the part that shaped everything — not carry the maintenance overhead of a full CMS.
So instead of asking "which CMS?" I asked a different question:
Which content does she genuinely need to edit?
The answer was short: seminars, and the enquiries that come from them. Everything else — therapy services, terms, the about page — changes maybe twice a year.
That answer is the entire architecture.
What actually got built
A PHP 8 website. No framework. No MySQL. Service pages live in plain, clearly structured templates. A small protected admin area handles the two things that move: seminars and enquiries.
Seminars and enquiries are stored as structured JSON in protected directories. For this data volume — one editor, a handful of records, no concurrent transactions — that is simpler to back up and reason about than adding a database server to the stack.
The whole data layer
// Writing: JSON_PRETTY_PRINT keeps the file readable, LOCK_EX prevents
// two processes from leaving half a file behind.
function seminare_save(array $seminare): bool {
$json = json_encode($seminare,
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return file_put_contents(SEMINARE_FILE, $json, LOCK_EX) !== false;
}
// Reading: older records had a single date field. The migration runs
// on load, not as a one-off script, so existing records stay compatible.
function seminare_load(): array {
$data = json_decode(file_get_contents(SEMINARE_FILE), true);
if (!is_array($data)) return [];
foreach ($data as &$s) {
if (empty($s['dates']) && !empty($s['date'])) {
$s['dates'] = [['date' => $s['date'], 'time' => $s['time'] ?? '']];
}
$s['dates'] = $s['dates'] ?? [];
}
return $data;
}
// Public listing: sorted by the next date that has not passed yet.
// A seminar with three dates stays visible until the last one is over.
function seminar_next_date(array $seminar): ?array {
$today = date('Y-m-d');
$future = array_filter($seminar['dates'] ?? [],
fn($d) => ($d['date'] ?? '') >= $today);
usort($future, fn($a, $b) => strcmp($a['date'], $b['date']));
return $future[0] ?? null;
}
Three things in there are worth more than they look.
LOCK_EX on the write. Flat-file storage without a lock is how you end up with a truncated JSON file and a site that 500s. One flag.
The migration runs on read, not as a script. Seminars originally had a single date. When they needed multiple dates, the shape changed. Instead of a one-off migration script that someone forgets to run on the backup restore, seminare_load() normalizes old records every time. Existing data stays compatible forever, and there's no migration state to track.
The public list sorts by next open date, not by file order. That's why a seminar with three dates stays visible until the last one passes, without the editor having to create it three times or hide it manually afterwards.
Where the boundary sits
An off-the-shelf seminar platform would have given more: online payment, automatic reminders, capacity management, participant exports.
None of that matched the workflow. It would have added recurring cost, another account, and a binding booking process — for a practice where participation is confirmed personally, by a human, on purpose.
So "Register now" doesn't open a checkout. It opens the contact form, pre-filled with the seminar title and topic. The enquiry lands in the admin area and in the inbox. She reads, replies, confirms.
That's an enquiry system, not a booking system. Naming it honestly kept the scope from drifting.
Security without a plugin stack
"No CMS" is not a security feature by itself. Fewer components means less update surface, not zero responsibility.
What's actually in there:
- password hashes, protected sessions, CSRF tokens on every writing action
- escaped output, sensitive directories blocked at the server config level
- contact form: server-side validation, honeypot, session rate limit, privacy confirmation
- image uploads validated by real MIME type via
finfo, size-capped, stored under a random filename, old files removed on replacement
And the part people skip: PHP version, server config and backups still need attention. The difference is that there's one small, readable codebase to reason about instead of a general-purpose CMS full of features this project never uses.
When this is the wrong call
I'd move to a database the moment any of these show up:
- more than one person editing at the same time
- binding bookings with real capacity management
- reporting across records
- data volume where "read the whole file" stops being sane
The nice part: that migration is contained. All data access sits in seminare_load() and seminare_save(). The public pages and the admin area only ever see arrays. Swapping to MySQL replaces that thin layer, not the templates.
Key Takeaways
- Ask "what needs editing?" before "which CMS?" The answer is usually much smaller than a CMS.
-
Flat-file JSON is a legitimate choice at small scale — with
LOCK_EX, and with a clear threshold for when it stops being one. - Run schema migrations on read. No script to forget, no state to track, old backups keep working.
- Name the feature honestly. "Enquiry system" instead of "booking system" prevented a month of scope creep.
- Concentrate data access in a few functions. It's what makes "we'll move to a database later" a real option instead of a comforting lie.
Full technical write-up on hafenpixel.de. The live site is friederikelindecke.de. Happy to argue about flat files in the comments.

Top comments (0)