Behind the Scenes

What Powers This Site

RC
Robin Collet Jul 22, 2026 · ~5 min read

The whole site is homemade: the blog, the portfolio, and the admin screen I’m typing this into. I didn’t install a ready-made CMS and skin it with a theme. I wrote a small one myself. For a personal website that’s an absurd amount of work, and whether it paid off is something I’ll hash out with myself in the next post. This one is just the tour. A few things turned out really well. One or two nearly cost me a weekend.

The foundation

Symfony, modern PHP, Twig, and Live Components. Assets go through AssetMapper, which among other things keeps a Node build step out of my deploys. The project is deliberately kept “boring”. Boring is cheap to keep alive, it saves my patience for the parts that are actually mine, and it makes trying out a new idea or feature that much easier.

A post is made of… blocks?

I didn’t want a post to be one big lump of HTML. That’s fine right up until you want to reorder things, or show the same content in two places, or stop one forgotten tag from taking half the page down with it. So a post here is a list of small, typed pieces: blocks. This paragraph is a text block, the heading above it a heading block. Then there are code blocks, callouts, quotes, images, and a divider whose only job is to break a post up visually.

Each block registers itself. I don’t keep a list of all the block types, because that list simply doesn’t exist. The interface carries a tag, and Symfony automatically collects everything that has it:

php
#[AutoconfigureTag('app.content_block_type')]
interface BlockTypeInterface
{
    public function createDefaults(): array;
    public function normalize(array $data): array;
    // …
}

Each block sets its own name through an attribute. That string gets stored, and it’s at the same time the name of the Twig template:

php
#[AsTaggedItem(index: 'callout', priority: 60)]
final class CalloutBlockType implements BlockTypeInterface
{
    // 'callout' is now this block's name everywhere: in the
    // stored document, in the insert menu, and as the template
    // blocks/callout.html.twig
}

It didn’t start out this tidy. Every block used to carry a getName()method that the registry called to build the index. Then Symfony 8.1 deprecated that whole route in favour of the attribute and rewarded me with a deprecation warning: my getName() was doing what the attribute already handled anyway. So I threw them all out. It’s the rare deprecation I was happy about, because I ended up with less code than before.

Nine block types exist so far: text, heading, callout, code, quote, list, image, table, and divider.

Tip

With this setup, one more block is just a class and a template, and it would show up in the editor’s insert menu all on its own.

Every language in one column

The site has to work in several languages. The obvious route would be a column per language, or a whole table per language, or one table with all the translations mixed together, but that gets a little more unwieldy with every language you add. I solved it differently. Translatable text is a single value object, a TranslatedString, that holds all the language versions together in one JSONcolumn. A post stays a single row and just carries its translations along with it. The blocks got this for free, because a block’s content is nothing other than a TranslatedStringtoo.

The issue with timezones

I caught this one while testing on the dev environment, before it ever made it to production. You schedule a post in the editor through an ordinary date field, at the time you see in front of you. I stored exactly that value, unchanged. Except the job that decides when a post is due compares against UTC. The editor assumes Central European time (Europe/Berlin), so in summer that’s two hours ahead of UTC, and a post set for 9am would quietly go online at 7. No error, no log line, nothing. I only noticed because, testing it through, the timestamps no longer matched what I’d actually set.

The fix is pretty unspectacular. The editor’s field is now read as Europe/Berlin time, converted to UTC immediately on save, and only converted back for display. UTC in the database, UTC in the comparison, local time only where a human looks at it. Doctrine handles the round-trip without complaint, as long as you consistently give it UTC. So it’s very much possible that this post gets published while I’m asleep or otherwise busy.

The dumbest bug gets its own heading

Then there was the featured slot. The blog’s index page pulls one post up top and shows it large, namely whichever is newest. Newest means sorted by publish date, descending. Sounds harmless. Except one of my test posts was marked “published” but had no date at all, and in Postgres a NULL lands at the very top under a descending sort. So this dateless, half-finished thing pushed its way to the top of the page and wouldn’t leave. Every real post sat below it.

The fix: everywhere the “effective date” gets worked out, fall back to the creation date when there’s no publish date. So in the ordering, in the previous/next links, and in the templates. There’s a test now that throws in a dateless post and checks it doesn’t jump the queue. I’d much rather write that test than spend my time on the quirks of Postgres.

So much for the look under the hood. Two of these, the self-registering blocks and the editor you assemble them in, deserve their own posts, which I’ll still write. And the bigger question, why I built the whole thing myself instead of grabbing one of the many good tools that have been around for ages, comes next. Thanks for reading this far.