One Saturday last month I sat down with a coffee and wrote a blog system in PHP from scratch. Just one index.php file, about 300 lines, no database, no dependencies, no Composer, no build step. I called it tinycms in the folder and forgot about it by Monday. The point wasn’t to use it. The point was to remember how it feels.
Pivot itself, back in 2002, was structurally not that different. One main file, flat-file storage, a templating system glued together with string replacement, no database. We added MySQL later because everyone was adding MySQL to everything in 2003, but the original idea was “a blog should be a folder full of files.” I still think that idea is mostly correct, just impractical past a certain scale.
The design choices, in order
I gave myself one constraint: under 500 lines, no dependencies. The shape that fell out of that was something like:
- One Markdown file per post, in a
posts/folder. Filename is the slug. Front-matter at the top for title, date, tags. - A single
index.phpthat handles routing. - One template file (
template.php) that wraps everything. - A folder for static assets.
- No admin UI. You write posts in your text editor and drop them in the folder.
- No auth. The auth is “you have SSH access to the server.”
Total time: about six hours, including a long break to walk the dog.
Front-matter parsing
The front-matter format is just YAML-style key-value at the top of the file, separated from the body by ---. Parsing it is genuinely twenty lines of PHP. Read the file, split on ---, take the first chunk, split on newlines, split each line on :, build an associative array. Done.
I deliberately didn’t pull in a Markdown library at first. I wanted to feel the absence. Then around hour three I gave up and used Parsedown, which is one PHP file you can drop in and require. It’s not really a dependency, it’s just “a file I copied,” and that’s a meaningful distinction in a world where composer install can pull down 400 packages for a hello-world.
Routing in a switch statement
The whole router is one switch statement on the URL path. Something like:
switch ($path) { case '/': render_index(); break; case '/feed': render_rss(); break; default: render_post($path); break; }
That’s the entire routing layer. No middleware, no controllers, no service container. If the URL doesn’t match index or feed, assume it’s a post slug, look for a file with that name in the posts folder, render it or 404.
This is so much less code than I’d ever write for a client project. And honestly it’s also so much easier to debug. When something goes wrong, there are 300 lines of code. The bug is one of them.
Single template, no fragments
One template file. It has a header, a content slot, a footer. The content slot is filled by whatever the route renders – the post list, a single post, or the feed. There’s no partials, no layout inheritance, no slots system. If I wanted a sidebar I’d hardcode it.
What you give up by not having partials is reuse. What you gain is being able to read the entire template, top to bottom, in one screen. For a blog with one layout this is the right trade. The minute you have two different page types, it falls apart, but you don’t, so it doesn’t.
The auth model is the filesystem
This is the part most people balk at. There’s no login, no admin password, no editor UI. To publish a post you SCP a Markdown file to the server. To edit a post you SCP an updated version. To delete a post you SSH in and rm it.
This is wildly underrated as a design choice. The auth surface area is whatever your SSH config is, which you should be locking down anyway. There’s no admin UI to phish. There’s no password to leak. There’s no “forgot password” flow to attack. The only way in is the same way you’d get into any server.
It’s also a terrible design choice for non-technical writers. If you can’t operate a terminal, the system has no front door. Which brings me to the obvious caveat.
Why I’d never ship this to a client
A client doesn’t want to SCP files. A client wants to log in, see a list of their posts, click “new post,” type into a rich-text editor, hit publish, and feel a small dopamine hit. They want a media library, drafts, a preview button, scheduled publishing. They want to undo a typo at 11pm without opening a terminal.
None of that is in tinycms. To add it you’d need a database (or some clever flat-file indexing), an auth system, an admin UI, file uploads, scheduling, drafts, previews. By the time you’ve added all of it, you’ve reinvented WordPress. Badly.
So tinycms is a learning exercise. It’s also, secretly, a useful exercise even if you’re a working dev who hasn’t shipped a CMS in years. It reminds you what the hard parts are when you don’t have a framework to hide them. Every problem you solve in 300 lines is a problem you understood before you wrote it. Every problem you solve in 300,000 lines is mostly a problem the framework solved while you weren’t looking.
Try it sometime
If you’ve been writing Laravel or Next.js apps for a few years and you’ve forgotten what raw PHP feels like, take a Saturday. Pick a tiny scope. No npm install, no composer require, no scaffolding tool. Just a blank PHP file and a problem that fits in your head.
You’ll probably hate the first hour. You’ll definitely enjoy the fifth.
For a refresher on the language itself there’s an older piece on PHP Tips and Tricks, which is showing its age but most of it still holds up. And the broader Web Design and Development Tips piece covers some of the same “keep it simple” mindset.
I’m not going to open-source tinycms. It’s not good enough. It was never supposed to be.