Jon Milley Creations

Software Development March 14, 2026

Rewriting a 2004 PHP Site with Claude Code

Rewriting a 2004 PHP Site with Claude Code

LotusEmpire.com has existed in roughly the same form since 2004. It’s the community hub for a text-based online game (a MUD), Lotus MUD, and over the years it accumulated the kind of technical debt you’d expect from PHP written by a college student two decades ago: MD5 passwords stored in plaintext-equivalent form, SQL queries built by string concatenation, hardcoded server paths, and a WYSIWYG editor (HTMLArea) that hadn’t worked in any modern browser for years.

The site was never broken enough to demand a rewrite. But it was never good, either. I finally decided to do something about it, and I used Claude Code to drive the entire process, from architecture to implementation.

Here’s what the original looked like:

Screenshot of the original LotusEmpire site


What the Site Does

LotusEmpire is a community portal for a MUD, a text-based multiplayer game that predates the modern MMORPG. The site serves several purposes:

  • Public content: lore, guides, area descriptions with a custom dynamic description engine
  • Wholist: a live-updating list of who’s online in the game (polled from the MUD server via TCP)
  • Area Explorer: an interactive browser for in-game areas, powered by that same dynamic description parser
  • Builder tools: a semi-private section for the volunteer builders who create in-game areas, including a to-do list and internal messaging
  • Admin panel: full content management: create/edit sections, manage images, manage builder accounts

The “dynamic description engine” deserves a mention. The MUD uses a custom markup language in area description files that lets descriptions change based on time of day, weather, flags, and other conditions. I had already built a TypeScript reimplementation of this parser in a separate project (dynparse), which became the foundation for the Area Explorer in the new stack.


Starting the Rewrite with Claude Code

I opened the project in Claude Code and ran /init to get it oriented. It analyzed the PHP codebase and produced a CLAUDE.md documenting the architecture, the 3-level content hierarchy (sections, subsections, subsubsections), the role system (admin vs. builder), and the known security problems.

From there I ran /plan to design the new stack. The conversation that followed was genuinely back-and-forth. Claude Code proposed an architecture, I pushed back on certain choices, and we iterated until we had something I was happy with.

The Stack Decision

The final plan settled on:

  • pnpm monorepo with apps/api (Fastify) and apps/web (Vite + React SPA)
  • MariaDB (unchanged) with Drizzle ORM replacing raw SQL
  • JWT access tokens in memory + httpOnly refresh cookie replacing the PHP session/cookie auth
  • TipTap replacing HTMLArea for rich text editing
  • fast-xml-parser for parsing MUD XML feeds (wholist on port 14501, area explorer on port 14500)
  • node-cron for a 5-minute wholist cache refresh

The dynparse package I’d already written got embedded as a workspace package (@lotusempire/dynparse). Both the Fastify server (for SSR-style area rendering) and the React AreaExplorer component (for live preview) consume it.

The Migration Strategy

One of the more interesting planning conversations was about how to migrate without taking the site down. The plan Claude Code proposed used nginx as a traffic router: PHP (Apache on :8080) and the new Node/React app (:3001/:5173) run in parallel, with nginx routing by path prefix. You shift traffic feature-by-feature, and rolling back is just a nginx config change.

Database migration was designed to be additive, no columns dropped, no tables renamed, so PHP could stay live during the rollout. Password migration was handled transparently: on login, if the stored hash is MD5, accept it, silently re-hash to bcrypt, and clear the MD5 field. After cutover, remove the fallback.


Execution and Iteration

The implementation proceeded in phases roughly matching the plan. What made Claude Code genuinely useful here wasn’t just code generation. It was the ability to describe a gap and have it figure out the right fix across multiple files at once.

A few examples of back-and-forth that shaped the final result:

The builder auth bug. The builder management page was calling apiPost('.../delete') but the backend route was DELETE /api/admin/builders/:id with a numeric ID. Claude Code caught this while reviewing the admin panel plan and fixed both the HTTP method and the ID extraction in one pass.

The images route mismatch. Images are registered at /api/images in the Fastify app, not /api/admin/images. When implementing the admin images page, the initial code had the wrong prefix in all three API calls. Flagged during review, fixed immediately.

The admin login redirect. RequireAdmin was redirecting unauthenticated requests to / instead of /admin/login, which didn’t exist yet. Claude Code added the login page, wired up the route, and fixed the redirect in one session.

dynparse integration. When I mentioned that I already had a TypeScript implementation of the dynamic description parser in a separate project, Claude Code updated the plan on the spot: instead of reimplementing it, copy parser.ts and evaluator.ts (zero external dependencies) into a workspace package. This saved a significant amount of work and ensured the parser behavior matched what the MUD actually produces.


The Result

Here’s the rewritten site:

Screenshot of the rewritten LotusEmpire site

The security picture is dramatically improved:

  • Passwords: bcrypt with transparent migration from MD5
  • SQL: Drizzle ORM; no string-concatenated queries
  • Auth: JWT + httpOnly cookie; no PHP session files
  • Input: validated at API boundaries

The editor works again. The Area Explorer runs the same parser the MUD uses. The wholist updates on a cron every five minutes without a page refresh.


Reflections on Claude Code as a Planning Partner

The most useful thing Claude Code did in this project wasn’t write code. It was maintain coherent context across a large, multi-session rewrite. A PHP-to-React migration touches authentication, database access, routing, rich text editing, real-time data, and file uploads simultaneously. Keeping all of those decisions consistent across a two-week effort is genuinely hard to do in your head.

The /plan workflow produced a document I could return to, update, and use as a checklist. When something changed (like the dynparse integration), updating the plan kept everything downstream consistent. When I found a bug or a mismatch, describing it in plain English got me a targeted fix across the relevant files rather than a generic suggestion.

It’s not magic. You still have to understand what you’re building. But as a tool for holding complexity and translating decisions into working code, it made a 20-year-old PHP site into something I’m not embarrassed to show people.