Symfony as a Static Site Generator — How holas.pl Works
symfony,php,static-site,nginx,architecture,performancePart 2 of 2
- 19 Years of WordPress — Why I Finally Quit
- Symfony as a Static Site Generator — How holas.pl Works
This is part 2 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 1 covers why WordPress had to go.
After deciding to leave WordPress, the question was what to replace it with. Hugo, Jekyll, and Eleventy were obvious candidates. I chose to build a custom Symfony application instead — not because the existing tools are inadequate, but because I already know Symfony well, and owning the full stack turned out to have real advantages.
The Core Idea
The site works in two modes:
- Development — Symfony handles requests dynamically. Edit a Markdown file, refresh the browser, see the result. Standard Symfony dev workflow with the profiler toolbar.
- Production build — a single console command renders every URL to an HTML file on disk. nginx serves those files directly. PHP is never called for content delivery.
The same templates, controllers, and content pipeline serve both modes. There is no "build-time template engine" separate from the "runtime template engine." It's just Symfony.
Content Pipeline
All content lives in Markdown files with YAML frontmatter:
content/
├── blog/
│ └── tutorials/
│ └── my-post/
│ ├── en.md ← English version
│ ├── pl.md ← Polish version
│ └── files/ ← images, served at /media/my-post/
└── pages/
└── about/
├── en.md
└── pl.md
ContentTreeBuilder scans the filesystem, parses each file with league/commonmark (GitHub Flavoured Markdown + YAML frontmatter), and builds a typed ContentTree — an in-memory index of all posts and pages for a given locale. Tags, categories, archive months, and URL maps are computed from that index.
ContentItem is a final readonly value object. No database, no ORM, no migrations. Adding a post means creating a directory with two Markdown files and running the build.
The Build Command — Symfony Sub-requests
The static build uses a technique that's specific to Symfony and distinguishes this approach from Hugo or Jekyll: it calls HttpKernelInterface::handle() to make sub-requests — internal PHP calls that go through the full Symfony kernel without touching the network.
$request = Request::create($url);
$request->attributes->set('_static_build', true);
$response = $this->kernel->handle($request, HttpKernelInterface::SUB_REQUEST, false);
if ($response->getStatusCode() < 400) {
file_put_contents($outputPath, $response->getContent());
}
For every URL — blog posts, category pages, tag pages, archive months, static pages, error pages, RSS feeds, the sitemap — the build command creates a request, runs it through Symfony, and writes the HTML to disk. The URL /blog/ becomes public/static/blog/index.html. The URL /sitemap.xml becomes public/static/sitemap.xml.
No separate template engine to learn. No build configuration. If a URL works in development, it will be in the static build.
nginx Serves Everything
In production, nginx handles all content delivery:
location / {
try_files /static$uri/index.html /static$uri/index.xml /static$uri $uri =404;
}
# PHP only for the contact form
location ~ ^/(api|pl/api)/ {
fastcgi_pass $php_upstream;
}
For any incoming URL, nginx tries the pre-rendered HTML file first. PHP-FPM is only invoked for /api/contact — the single dynamic endpoint. Every blog post, category listing, tag page, and static page is a file served directly from disk. A Raspberry Pi 5 handles this trivially.
No Database
The content tree is built from Markdown files at build time. In production it's cached indefinitely in Symfony's filesystem cache (invalidated and rebuilt on every deploy). In development it's rebuilt on every request so file changes are immediately visible.
There is no MySQL, no schema, no migrations, no connection pooling, no slow queries. Adding content means creating files and running ddev build. Removing content means deleting files. The entire site history is in git.
Multi-language Without a Database
Both Polish and English content lives in the same directory:
content/blog/tutorials/my-post/
en.md → slug: "blog/my-post" → /blog/my-post/
pl.md → slug: "wpisy/moj-wpis" → /pl/wpisy/moj-wpis/
files/ → images served at /media/my-post/
Co-location is the translation link. Two locale files in the same directory are automatically treated as translations of each other. TranslationMapBuilder constructs {directoryKey → {locale → url}} for hreflang tags and the language switcher. No translation_key field, no join table, no synchronisation to manage.
Search with Pagefind
Search is a WASM-based static index built by Pagefind after the HTML files are generated:
npx pagefind --site public/static --output-path public/pagefind
Pagefind reads the pre-rendered HTML, indexes data-pagefind-body regions, and generates a binary index in public/pagefind/. The search page loads this index client-side via a dynamic import(). No Elasticsearch, no Algolia, no server-side search query. The index is a set of static files.
Redesign Simplicity
Changing the site's appearance means editing Twig templates and SCSS. No React component library to update. No Node.js build pipeline. No WordPress child theme hierarchy. The entire frontend is:
assets/styles/app.scss— one entrypoint importing partialstemplates/— Twig templatessymfonycasts/sass-bundle— compiles SCSS with dart-sass, no Node required
To change the colour scheme: edit _variables.scss. To change the layout: edit a Twig template. Run ddev build and it's done.
Trade-offs
The main trade-off compared to Hugo or Jekyll is that this is custom code — I maintain it. The upside is that it's exactly what's needed, nothing more. There's no plugin ecosystem to navigate, no upgrade path to worry about, no feature flags for things I'll never use.
The only genuinely dynamic feature — the contact form — is covered in the next post.