What It Actually Takes to Build a Local Plumber a WordPress Site
A few weeks ago I took on a small project: a marketing website for Hackett Hydro Solutions, a residential and commercial plumbing business run by Daniel Hackett in Delaware County, PA. On paper it's about as simple as web projects get — a handful of pages, a services list, a phone number, a contact form. The kind of site a page builder could spit out in an afternoon.
I didn't build it that way. I built it like software: version-controlled, tested, and repeatable. Here's what that meant in practice, and why it was worth it even for something this small.
The site is a theme and a plugin, not a database export
Most small WordPress sites live entirely in a database — pages, settings, and content all
tangled together, with no history and no easy way to recreate the site from scratch. Instead,
this project is two things checked into git: a custom block theme (hackett-hydro)
and a small plugin (hhs-core) that adds the business-specific pieces WordPress
doesn't ship with.
"Block theme" is WordPress's current approach (also called Full Site Editing) where the
structure of the site — header, footer, homepage layout — is itself made
of the same blocks you'd use to write a page, stored as versionable template files instead of
PHP templates. For a developer that means the header and footer live in plain files
(parts/header.html, parts/footer.html) I can diff, review, and roll
back like any other code change.
The plugin adds two custom content types on top of that: Services (Drain Cleaning, Water Heater Repair, Repiping, and so on) and Projects (a before/after job gallery). Each is just a WordPress post type under the hood, which means Daniel gets a normal "Add New" screen in wp-admin, but the shape of that content — what fields it has, how it's displayed — is defined in code, not clicked together in a UI.
Faking the content so the site is real before it's finished
Here's a problem every freelance/agency web project has: you can't build and test a site against content that doesn't exist yet, and clients are slow to hand over final copy, pricing, and photos. The usual fix is "Lorem ipsum," which looks obviously fake and doesn't help you catch layout bugs that only show up with realistic content lengths.
Instead there's a single script, seed-content.php, that populates the whole site
— every service, every project, the About/Contact/Service Area pages, even generated
placeholder photos with labels baked in — in one command:
wp eval-file wp-content/plugins/hhs-core/bin/seed-content.php
It's built to be safely re-run: run it again and it deletes what it created last time (matched by title) before recreating it, so it's safe to re-run as the theme evolves without piling up duplicate "Drain Cleaning-2," "Drain Cleaning-3" posts. That matters because this script isn't a one-off throwaway — it doubles as the exact same tool used to load real content onto the production site later, just with real values swapped in for placeholder ones.
The client-facing counterpart to this is a plain-language questionnaire
(docs/client-questionnaire.md) that walks Daniel through every placeholder on the
live-looking demo site — the fake phone number, the .example email address,
the placeholder testimonials — and asks for the real version of each one. Instead of a
vague "send me your content," he's looking at his actual site and telling us, item by item,
what's wrong with it.
Testing what actually matters for a lead-gen site
A plumbing site has exactly one job: turn visitors into phone calls and quote requests. So the
automated tests aren't generic "does WordPress work" checks — they're built around that
job specifically. A Playwright suite (tests/e2e/smoke.spec.js) checks that:
- every page (Home, About, Services, Gallery, Contact, Service Area) actually loads,
- a click-to-call emergency number is visible on every single page, not just the homepage,
- a newly created Service or Project shows up on its archive page, and
- the "Request a Quote" form actually submits and shows a confirmation message — not just that the form renders.
That last one caught something worth knowing about: the form plugin does a full page reload on submit rather than an instant AJAX response, which needed extra timeout headroom in the test — the kind of detail you only learn by testing the real user flow instead of just checking that a page returns "200 OK." Alongside that, PHPUnit tests cover the plugin's PHP directly (e.g., that the custom post types register with the settings the theme depends on). Both suites run automatically on every push via GitHub Actions, so a change that breaks the contact form gets caught in CI, not by a customer discovering it after Daniel's missed a lead.
A bug that only existed in CI
Custom fields (service pricing, in this case) are defined in code using Advanced Custom Fields,
then exposed to the front end through WordPress's block bindings API — a newer feature
that lets a block ("show this text") pull its value from post metadata ("...and that text is
price_range") without any custom PHP templating. That's a nice trick: the FSE
template just says "display this meta field," and the plugin's job is only to make sure that
field exists and is registered for the REST API.
The more interesting bug showed up in the CI pipeline, not locally. The seeding script needs
pretty permalinks (/services/drain-cleaning/ rather than /?p=123)
because the theme hardcodes links in that format. Calling WordPress's internal
$wp_rewrite->flush_rules() directly worked fine on a real Apache server, but
silently failed under the automated test environment — because only WP-CLI's own
wp rewrite structure command wires up the specific filter that tells WordPress
"yes, mod_rewrite is available here." Skip that command and call the lower-level function
instead, and every URL except the homepage quietly 404s — no error, just a missing page,
and only in CI. Swapping in the CLI command fixed it. It's a good reminder that "it works on my
machine" and "it works in the identical-looking automated environment" are two different
claims, and the gap between them is usually some assumption the tooling made for you without
telling you.
Local dev speed is a filesystem problem, not a WordPress problem
Running WordPress locally, page loads felt sluggish, and not because the site itself is doing much. WordPress loads hundreds of PHP files per request — core, every active plugin, the theme. Locally, Docker Desktop runs everything inside a lightweight Linux VM (via WSL2), and the project lived on the Windows filesystem — meaning every one of those file reads crossed the Windows-to-Linux boundary Docker has to bridge. Moving the project's files onto the Linux side directly turns that into a native Linux-to-Linux file access, which is dramatically faster. Nothing about the WordPress config or the CI setup needed to change for this — it's purely about where the files physically sit relative to where Docker actually runs them.
Content discipline before "go live"
The deployment plan for this site is written down as a literal checklist, not tribal knowledge,
and it opens with something easy to forget: find and replace every placeholder before
pointing a real domain at this. The seeded phone number is (555) 123-4567
— 555 numbers are reserved as fictional in the US, so it genuinely won't
ring anyone. The seeded email domain is .example — an address that's
guaranteed to bounce, by design, because .example is reserved by internet
standards to never resolve. For a site whose entire purpose is capturing leads, shipping either
of those live wouldn't just look unfinished — it would silently eat every phone call and
email a customer tried to send.
The same checklist calls out something less obvious: WordPress's default mail sending frequently gets filtered as spam or dropped outright when sent from a generic server IP, with no visible error to anyone. So step one after going live isn't "add analytics" — it's configuring a real transactional email service and sending an actual test submission through the live contact form to confirm a lead notification really arrives in an inbox, not just that the form displayed "Thank you."
Why bother, for five pages?
None of this — version control, a provisioning script, two test suites, CI, a written deployment runbook — is necessary to put a plumber's phone number on the internet. A page builder gets you there faster on day one. But every piece here is paying down a specific, real risk: content that never gets updated because updating it is scary, a redesign that quietly breaks the contact form, a launch that looks done but silently fails at the one thing the site exists to do. For a business that lives or dies on whether the phone rings, "boring and repeatable" is the feature, not "impressive architecture" for its own sake.