Moving this site from Laravel to Hugo

For most of my career, when I’ve needed a website I’ve reached for PHP. So when I put together an online CV earlier this year, I built it the way I build everything: as a Laravel app. A few months later it’s a static Hugo site with no PHP at all. This post covers why, how the port went, and what I learned along the way.

Where it started

The first version of this site wasn’t even its own project. It was a holding page inside a Laravel application I was building for something else entirely. The root route pointed at a HomeController, a second route served individual project pages, and the content lived in a PHP class called CvProfile:

class CvProfile
{
    private const HERO = [
        'name' => 'Ian Wilson',
        'title' => 'Software Engineer',
        'summary' => 'Senior PHP engineer focused on Laravel, e-commerce, ...',
    ];

    private const SKILLS = [
        ['label' => 'Core stack', 'body' => 'PHP, Laravel, JavaScript, ...'],
        // ...
    ];
}

Two Blade templates rendered it, styled with Tailwind. It worked, and it was quick to build because everything it needed was already there.

Why it had to move

The problem wasn’t the code. It was everything that came with it.

  • The CV was tied to an unrelated app. Every change to my CV meant committing to that app’s repository and deploying the whole app. Every change to the app put my CV at risk.
  • Far too much machinery for one page. Behind that single page sat a full Laravel install: authentication, an admin panel, database migrations, a Vite and Node build for Tailwind, and a PHP runtime to keep patched.
  • Content was code. Fixing a typo in my job history meant editing a PHP constant. That is fine for me, but it’s the wrong tool for text.
  • Nothing on the page was dynamic. The same HTML went to every visitor. There was no form, no login, no personalisation. A server was building identical pages on every request.

A personal site that never changes between requests is the textbook case for a static site generator.

Why Hugo

I looked at a few options and chose Hugo for boring reasons:

  • It’s a single binary. No Node, no Ruby, no dependency tree to keep up to date.
  • It’s fast. The whole site builds in well under a second.
  • Its content model matched what I already had. Data files for structured content, Markdown for pages, and templates for layout mapped almost one-to-one onto CvProfile, the project pages and Blade.
  • No theme required. I wanted to own every line of HTML and CSS, not fight a theme’s opinions.

The port

The goal was a faithful port first and improvements later: the same pages, the same URLs and the same look, just without PHP.

The CV data became a YAML file. Everything in CvProfile moved to data/cv.yaml, which Hugo exposes to templates as .Site.Data.cv:

hero:
  name: "Ian Wilson"
  title: "Software Engineer"

skills:
  - label: "Core stack"
    body: "PHP, Laravel, JavaScript, ..."

Projects became Markdown files. Each project page is now a file in content/projects/. Short projects keep their details in front matter (a description, highlights, quick-start commands and demo videos). Longer ones, like the Zen Cart write-up, use the Markdown body. The template decides which to show, using the same conditions the Blade version used.

Blade became Go templates. The logic translated directly, even if the syntax is less friendly. @foreach became range, @if became if, and layout inheritance became a baseof.html with a main block.

Routes needed a small trick. The Laravel app had individual project pages but no projects index. Hugo creates a list page for every section by default, so the section’s _index.md switches it off:

---
title: "Projects"
_build:
  render: never
  list: never
---

Tailwind became hand-written CSS. Keeping Tailwind would have meant keeping a Node build step, which was one of the things I wanted to get rid of. I rewrote the styles as plain CSS instead. For a site this size it’s a few hundred lines and much easier to read.

Hosting became a static site, and free. The site now deploys to DigitalOcean App Platform as static files from Hugo’s public/ output. App Platform hosts static sites on its free tier, so the site costs nothing to run. The Laravel version needed a paid server, or at least a paid app container, just to keep PHP running. Now there’s no server process to monitor, nothing to patch, and no monthly bill.

A lesson about static files

On the day I launched the Hugo version, I removed the “Download CV” button. The PDF behind it included my mobile number.

In the Laravel app I hadn’t thought of that file as public. In a static site there is no such thing as a private file. Anything in static/ is published, indexed and cached as soon as it’s deployed. It’s an easy mistake to make when moving from an app, where access is decided by code, to a site where the folder structure is the access control. Check everything you put in static/ as if it will end up on the front page of a search engine, because it might.

Growing a blog

The site started as a CV, but a CV only says what you’ve done. I wanted somewhere to write about how I work, so the next step was a blog, and it turned out to need very little.

Posts are Markdown files in content/posts/, with a title, date, description and draft flag in the front matter. hugo new posts/my-post.md creates one from a template.

The homepage shows one post in full and lists the rest. By default the main post is the newest one. Adding featured: true to a post pins it instead:

{{ $posts := where .Site.RegularPages "Section" "posts" }}
{{ $main := index (where $posts "Params.featured" true) 0 | default (index $posts 0) }}
{{ $others := complement (slice $main) $posts }}

The CV moved to its own page at /cv/, linked from the header on every page, and the project pages now link back to it.

The design changed too. The original dark gradient theme suited a single-page CV, but it wasn’t comfortable for long-form reading. The new design is light by default with an automatic dark mode. It uses one accent colour, a serif typeface for post text, and a monospaced face for dates and code. All colours are defined once as CSS custom properties, so the dark theme only redefines those values.

One small fix is worth sharing. Hugo’s default syntax highlighting writes inline colours into every code block, and those colours ignored the site’s theme and left some code unreadable. One setting makes Hugo output CSS classes instead:

[markup.highlight]
  noClasses = false

I did the blog and redesign work with an AI coding agent. It followed the same approach I describe in AI-assisted maintenance of a legacy PHP codebase: agree the plan first, keep each change small, and check the build after every step.

What I’d tell someone considering the same move

  • If nothing on the page changes per visitor, it probably doesn’t need a server. It probably doesn’t need a hosting bill either. Static hosting is free on several platforms, including DigitalOcean App Platform.
  • Keep content out of code. YAML and Markdown are easier to edit, review and diff than PHP arrays.
  • Port first, improve second. Getting an identical site working on the new stack made every later change safer and easier to judge.
  • Treat every static file as public, because it is.
  • Fewer moving parts is a feature. This site now has no runtime, no database, no package manager and no build pipeline beyond one Hugo command.

Laravel is still what I reach for when I’m building an application. This just isn’t one.