A .NET developer who went astray into PHP/Laravel/Statamic land — and eventually found their way back. This is the story of rebuilding this blog from scratch in .NET 10 and Blazor, while keeping every single article file intact.
If you have been following this blog for a while (or just happened to read the Choosing Statamic over WordPress article), you know the backstory. To briefly recap: a few years ago I grew tired of WordPress, learned PHP and Laravel for the occasion, built this blog on Statamic, and was honestly quite happy about it. Flat-file content, Git-powered, blazingly fast. What was not to love?
As it turns out — quite a lot. Not about Statamic itself, but about what it means to maintain a PHP-based application when your entire professional life runs on .NET.
The slow erosion of enthusiasm
To be fair to Statamic: the framework is excellent. And I still think my reasoning back then was sound. The flat-file content model, the Git-first approach, the quality of the admin panel — none of that has gone away. The problem was purely about me.
Every time I wanted to make a change to this site — add a feature, fix something in the newsletter logic, tweak the deployment pipeline — I had to context-switch. Pull up the Statamic docs. Remember how Blade templates work. Figure out what version of PHP is running on the server. Run composer update and pray nothing breaks. And then, after having context-switched hard enough, sit down and write PHP in a language server that never quite felt as snappy as Rider or Visual Studio.
I once spent a Saturday afternoon chasing a [Deployer](https://deployer.org/) + SSH + PHP artisan problem that turned out to be a line-ending issue in a shell script. That afternoon cured me.
The honest truth: I am a .NET developer. I think in C#, I dream in interfaces, and I feel most at home in an IDE with full IntelliSense, a proper debugger, and a test runner that can profile memory. Running a public-facing application in a language and framework I only visit occasionally is a liability I no longer wanted to carry.
What I wanted to keep
Before throwing everything away, I sat down and thought about what was actually good about the current setup that I wanted to preserve:
- The flat-file content model. Every article on this blog is a Markdown file with YAML frontmatter. That is a format that will outlive any CMS. I did not want to migrate 30+ articles into a database.
- The deployment strategy. Push to a branch, GitHub Actions builds and deploys. Simple, boring, reliable.
- The features I had already built: scheduled auto-publishing, newsletter sending, contact form with captcha.
What I did want to throw away: every line of PHP. Deployer. The Statamic admin panel (honestly, I barely used it — I write articles in my IDE). The composer.lock file that somehow had 2,000 lines.
The new stack
The new site is a Blazor application targeting .NET 10, using static server-side rendering. No WASM, no interactive render mode — just fast, pre-rendered HTML served from a container running on DigitalOcean.
Here is what the service registrations in Program.cs look like:
// Content (singleton — loaded once at startup)
builder.Services.AddSingleton<IContentService, ContentService>();
// Article queries (scoped)
builder.Services.AddScoped<IArticleRepository, ArticleRepository>();
// Operational data (EF Core + PostgreSQL)
builder.Services.AddDbContext<DateoDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DateoDbContext")));
// Background worker for auto-publish + newsletter
builder.Services.AddHostedService<ScheduledJobWorker>();
Clean. Typed. No Laravel service providers, no PHP event subscribers. Just the .NET DI container doing what it always does.
The content layer
This is the part I was most nervous about, and it turned out to be the most satisfying to build.
The ContentService is a singleton that loads all Markdown files from the content/ directory at application startup. It parses the YAML frontmatter using YamlDotNet and converts the Markdown body to HTML using Markdig. In development, a FileSystemWatcher automatically reloads content whenever you save a file — so the inner loop is: edit Markdown, switch to browser, content is already updated.
private List<Article> LoadArticles()
{
var pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
foreach (var file in Directory.GetFiles(articlesPath, "*.md"))
{
var content = File.ReadAllText(file);
var (fm, body) = ParseFrontMatter<ArticleFrontMatter>(content);
var renderedHtml = Markdown.ToHtml(body, pipeline);
// ...
}
}
The interesting challenge was the split between content and state. The Markdown files tell you what an article says. They don't tell you whether it has been published or whether a newsletter has already been sent for it. That state lives in PostgreSQL — a BlogPost entity keyed by the same GUID that appears in the article's YAML frontmatter. The content service joins these two together at load time.
---
id: 3fae61fa-6c19-4f43-bf71-cd17e6ae1773
title: 'Using Testing.Platform with NET 9'
...
---
That id field was already present in every Statamic article file. Statamic used it for its own internal purposes. In the new system, it becomes the foreign key that links a file on disk to a row in the database. Zero content migration needed.
Auto-publishing and newsletters — now as a BackgroundService
In the Statamic world, these were PHP EventSubscriber classes triggered by a custom Artisan command on a cron schedule. In .NET, a BackgroundService is the natural fit:
public class ScheduledJobWorker(IServiceScopeFactory scopeFactory, ...) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(TimeUntilNextHour(), ct);
using var scope = scopeFactory.CreateScope();
var autoPublish = scope.ServiceProvider.GetRequiredService<IAutoPublishService>();
var newsletter = scope.ServiceProvider.GetRequiredService<INewsletterSendService>();
await autoPublish.RunAsync(ct);
await newsletter.RunAsync(ct);
}
}
}
Every hour, the worker wakes up, checks whether any articles have crossed their scheduled publish time, publishes them if so, then checks whether any published articles haven't had their newsletter sent yet. It is a grand total of 35 lines of code and it replaced a cron job, a custom Artisan command, a service provider, and two PHP event subscriber classes.
Deployment
Previously: Deployer reads a YAML file, SSH-es into the server, runs a sequence of PHP and shell commands, and deploys. Powerful, but one more thing to maintain.
Now: a three-stage Dockerfile. Stage one builds the Tailwind CSS with Node. Stage two publishes the .NET application. Stage three creates a runtime image that bundles the content/ directory alongside the compiled app. GitHub Actions builds the image and pushes it to DigitalOcean's container registry on every push to the branch.
# Stage 1: Build CSS
FROM node:20-alpine AS css-build
WORKDIR /app
COPY src/src/Dateo.Web/package*.json ./
RUN npm ci
COPY src/src/Dateo.Web/wwwroot ./wwwroot
COPY src/src/Dateo.Web/tailwind.config.js ./
COPY src/src/Dateo.Web/Components ./Components
RUN npm run build:css
# Stage 2: Publish .NET app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /app
COPY src/src/Dateo.Web/Dateo.Web.csproj ./
RUN dotnet restore
COPY src/src/Dateo.Web/ ./
COPY --from=css-build /app/wwwroot/css/app.min.css ./wwwroot/css/app.min.css
RUN dotnet publish -c Release -o /publish
# Stage 3: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /publish .
COPY content ./content
EXPOSE 8080
ENTRYPOINT ["dotnet", "Dateo.Web.dll"]
The entire deployment pipeline is now about 30 lines of YAML and a Dockerfile. I can reproduce it from scratch in an afternoon.
How the rebuild actually went
The core application took about two weekends. I started with the content layer, because that was the highest-risk part — if the Markdown files could not be parsed cleanly, the whole premise fell apart. As it turned out, YamlDotNet handled the Statamic frontmatter with almost no friction. The one thing that did take longer than expected was image paths. Statamic stored featured images under an assets/articles/ prefix; the new setup serves them from wwwroot/images/posts/. Rather than renaming 30+ images, I wrote a small normalisation step in ContentService that translates legacy paths at load time. It is about ten lines of code and it meant I never had to touch the article files.
The trickier moment was realising that Statamic's published field in the frontmatter YAML could not simply carry over. In the old system, Statamic owned the published state and wrote it back to the file. In the new system, the file is read-only from the application's perspective — published state lives in the database. That required seeding a BlogPost row for every existing article, which I did with a one-time migration script. Not complicated, but easy to overlook if you design the content layer in isolation.
What I lost
In the spirit of fairness: a few things did not make the cut, and I should acknowledge them.
The Statamic admin panel is genuinely good. Being able to edit content in a browser, with a rich-text editor and live preview, is a real feature. I chose to give that up because I write articles in Rider anyway, but if you are not a developer running your own blog, this matters a lot.
The static site generation option is also something Statamic offered that the new setup does not (at least not yet). The current application is a running ASP.NET Core process, not a bunch of HTML files on a CDN. For my traffic levels this is completely fine, but it is a different operational model.
What I gained
Back in home territory. That is the short version.
The longer version: every feature in this codebase is now something I can debug, profile, and extend without switching mental gears. The newsletter service, the auto-publish worker, the sitemap endpoint, the RSS feed — all of it is typed C# with full IDE support. When something breaks, I open a familiar debugger. When something is slow, I attach a familiar profiler.
The content files are more portable than ever. They are just Markdown with YAML frontmatter. They have always been that, and they will remain that regardless of what the underlying rendering stack looks like. If I ever decide to migrate again — perhaps to a static site generator, perhaps to something that does not exist yet — the content will survive unchanged. That is a nice property to have.
Conclusion
Was this migration necessary? No, probably not. Statamic was doing its job. But maintaining a public-facing application in a language that is not your primary language creates a subtle, slow-burning friction. Over years, that friction adds up. One rebuild later, that friction is gone.
If you are a .NET developer running a side project on a PHP CMS and you recognise that slow-burning friction — maybe it is time to come home.
The articles are the same. The content files are the same. Only the engine changed. And the engine is now something I am genuinely happy to work with.
