Hollis Creek Family Dental · Repair report

14 faults found, fixed and proven on the Hollis Creek site

Case study · fictional clinic · measured 2026-11-07 UTC

On phones the booking button was off the screen and the home page downloaded 4.6 MB of images (now 58 KB). The appointment form thanked patients but sent nothing, and the Insurance page showed only an error. Each exhibit below shows what was wrong, the commit that fixed it, the test that proves it and the command to measure it again.

A Mobile & speed

Exhibit A-1 The booking button was off the screen on phones

On a 390px phone the page scrolled sideways and “Request an appointment” sat past the right edge, so a patient had to know to swipe to find it.

Exhibit A-1: Measure
MeasureBeforeAfter
Page width at 390px1180px390px
Right edge of the booking button542px370px

Root cause A 2019 child-theme rule fixed the header at 1180px, stopped the menu from wrapping and hid the small-screen menu toggle.

The fix Removed the override; the parent theme’s fluid header collapses the menu under 900px and gives the button its own row.

Exhibit A-2 Phones downloaded full-size camera images

The home page sent the original uploads to every phone (a 3600px hero, a 3000px waiting room, 1600px portraits) without sizes to choose from or lazy loading.

Exhibit A-2: Measure
MeasureBeforeAfter
Image bytes on first load (390px)4.6 MB58 KB
Images without srcset60
Home page weight (Lighthouse)4.7 MB146 KB
Largest contentful paint, desktop4.0 s382 ms

Lighthouse: median of 3 runs

Root cause The child theme’s home template and the team loop printed bare <img src> tags pointing at the originals: no srcset, sizes, width/height or loading="lazy".

The fix Back to wp_get_attachment_image() at the size of each slot; the hero loads first with fetchpriority="high", everything else lazily.

Exhibit A-3 Scripts and styles held back the first paint

Before anything appeared, the browser had to fetch a chain of stylesheets and run jQuery, four jQuery UI widgets and a slider for markup that no longer exists.

Exhibit A-3: Measure
MeasureBeforeAfter
Render-blocking files in <head>115
Synchronous scripts in <head>70
First contentful paint, mobile1.7 s901 ms
Lighthouse performance, desktop79100
Lighthouse performance, mobile99100

Lighthouse: median of 3 runs

Root cause The child stylesheet @imported the parent (a serial download), all six font weights loaded without font-display, and the slider and its dependencies were enqueued in the head.

The fix Child stylesheet enqueued with the parent as a dependency, three font weights with swap, slider and jQuery UI deleted; the menu script is deferred.

Exhibit A-4 Zoom, focus, contrast and labels were broken

Patients could not pinch to zoom, keyboard users could not see where they were, the menu was pale grey on white and the form fields had no labels.

Exhibit A-4: Measure
MeasureBeforeAfter
axe violations on 5 pages (WCAG 2.2 AA)430
Zoom disabledyesno
Lighthouse accessibility, mobile84100

Lighthouse: median of 3 runs

Root cause An old copy of header.php disabled zoom and dropped the skip link; child CSS removed focus outlines and greyed the menu; the form printed placeholders instead of labels.

The fix Deleted the stale header copy, the contrast and outline overrides, and gave every field its label back. Checked against WCAG 2.2 AA with axe-core.

B PHP 8 errors

Exhibit B-1 PHP notices printed above every page

Every page opened with lines of “Deprecated:” text showing server paths, and the same lines were written to a log anyone could download at /wp-content/debug.log.

Exhibit B-1: Measure
MeasureBeforeAfter
PHP notices on screen, 8 pages230
Deprecations logged during the crawl890
/wp-content/debug.logHTTP 200HTTP 404

Root cause PHP 7 code in the child theme that PHP 8 deprecates (optional-before-required parameters, ${} strings, a dynamic property, null passed to trim and strlen), with error display left on after the host move.

The fix Removed the dead code, turned display off, log to /var/www/logs outside the web root, and a migration deletes the old public log.

Exhibit B-2 The team page raised a PHP warning per person

Each visit to “Our team” logged one “Trying to access array offset on false” warning for every team member.

Exhibit B-2: Measure
MeasureBeforeAfter
Warnings logged during the crawl80
All PHP log lines during the crawl1000

Root cause The loop read a long-deleted option as an array. PHP 7 stayed silent; PHP 8 warns.

The fix Treat the missing option as empty settings; the home page also only shows the next slot when one exists.

Exhibit B-3 The Insurance page was a white screen

Opening “Insurance and payment” showed only “There has been a critical error on this website.” with HTTP 500.

Exhibit B-3: Measure
MeasureBeforeAfter
Insurance page statusHTTP 500HTTP 200
Plans shown to the patient07

Root cause The insurance plugin sorted plans with create_function(), removed in PHP 8.0, before the page rendered.

The fix Sort with a closure, and drop the pre-render hook.

C Database

Exhibit C-1 The team list ran a query per person, per detail

Rendering “Our team” cost a database query for every member’s row, role, photo, languages and each service, and grew with every hire.

Exhibit C-1: Measure
MeasureBeforeAfter
Queries for the Our team page10934
Queries for the home page8044

Root cause A 2016 loop with raw SQL per member (N+1), bypassing WordPress’s caches.

The fix One WP_Query with meta and term caches primed, all photos in one call, all services in one query.

Exhibit C-2 Every request loaded megabytes of dead cache

Before WordPress could start on any page, it read and unserialized the cached responses of a social feed that closed in 2023.

Exhibit C-2: Measure
MeasureBeforeAfter
Autoloaded options, every request3.5 MB22 KB
Server time for the home page170 ms43 ms

Root cause The feed plugin saved each response as a new option with autoload “yes” and never cleaned up.

The fix Removed the dead plugin; a repair migration deletes its options (wp hollis migrate, run by deploy.sh).

Exhibit C-3 The next free slot was found by reading every slot since 2019

The “Next open appointment” line on the home page scanned and sorted tens of thousands of old slot rows on every view.

Exhibit C-3: Measure
MeasureBeforeAfter
Rows read per lookup (ANALYZE)69,8641
Query time, median of 783 ms0.12 ms
Index usedmeta_keystatus_slot

Root cause Slots were stored as post meta and matched on meta_value, which has no usable index.

The fix A slots table indexed on (status, slot_at); a migration moves the rows. The lookup now reads one index entry.

D Security & mail

Exhibit D-1 No security headers

Any site could frame the clinic’s pages, browsers could guess content types, and nothing limited where scripts could load from.

Exhibit D-1: Measure
MeasureBeforeAfter
Security headers on the home page0 of 55 of 5
PHP version announcedPHP/8.3.36none

Root cause Nothing on the site sent them; WordPress does not by default.

The fix A hardening mu-plugin sends CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy and Permissions-Policy, HSTS over HTTPS, and drops X-Powered-By.

Exhibit D-2 Anyone could list the login names

/?author=1 redirected to the owner’s login name, and /wp-json/wp/v2/users listed every author, which leaves only the passwords to guess.

Exhibit D-2: Measure
MeasureBeforeAfter
Login names an anonymous visitor collects20
/wp-json/wp/v2/usersHTTP 200HTTP 404

Root cause WordPress defaults: author archives, the public REST users route, the users sitemap.

The fix Author queries are a 404 for visitors, the users routes exist only for logged-in users, no users sitemap, no author in oEmbed.

Exhibit D-3 XML-RPC was open

xmlrpc.php answered with its full method list, including system.multicall, which tries hundreds of passwords in one request.

Exhibit D-3: Measure
MeasureBeforeAfter
xmlrpc.php responseHTTP 200HTTP 403
Methods offered800

Root cause A WordPress default; nothing on the site uses XML-RPC.

The fix Requests to xmlrpc.php get a 403 before any method runs; no pingback header or RSD link.

Exhibit D-4 The form said thank you and sent nothing

A patient who asked for an appointment was thanked, and the front desk never received the request.

Exhibit D-4: Measure
MeasureBeforeAfter
Requests that reached the inbox0 of 11 of 1
“Thank you” shownyesyes

Root cause The SMTP plugin still pointed at the previous host’s relay, and the form ignored the failed send and stopped logging failures.

The fix The relay reads its host from the site configuration (Mailpit in this demo) and logs failures; the form only says thank you when the email was handed over.

Procedure

The repair was done the way it would be on a live site: nothing changed on production until a copy had been taken and the fix had passed its tests on staging.

  1. scripts/clone-to-staging.sh Clone production to staging: database export and import, URL search-replace through serialized data, uploads copied, staging hidden from search engines.
  2. scripts/backup.sh production Back up production before touching anything: database, uploads and code, timestamped, with SHA-256 sums.
  3. scripts/measure.sh Diagnose and measure: PHP error log, per-page query counts, Lighthouse, axe, response headers, mail capture.
  4. site/ → staging Fix on staging, one commit per fault, each with the test that proves it.
  5. phpunit · playwright Test: PHPUnit 41/41, Playwright 34/34 (both sites, both directions), ops script tests: 25 of 25 passed.
  6. scripts/deploy.sh Deploy staging to production: pre-deploy backup, maintenance mode, code swap, repair migrations, smoke check; a failed smoke check rolls back.
  7. scripts/restore.sh Restore proven: a deleted page and image come back from the backup; a damaged backup is refused before anything changes.

Full runs from empty volumes: run 1 · PHPUnit 41/41 · Playwright 30/30 · ops 25/25; run 2 · PHPUnit 41/41 · Playwright 30/30 · ops 25/25; run 3 · PHPUnit 41/41 · Playwright 34/34 · ops 25/25

Lighthouse, home page, median of 3 runs

Lighthouse
MetricMobile beforeMobile afterDesktop beforeDesktop after
Performance9910079100
Accessibility8410084100
LCP1.69 s0.90 s4.01 s0.38 s
FCP1.66 s0.90 s0.40 s0.25 s
CLS0.0000.0000.0000.000
Page weight4.7 MB146 KB4.7 MB146 KB

Lighthouse 13.6.0, Chromium in Docker, 2026-11-07. The mobile score was already high before the repair: on a narrow screen the largest paint is text, not the hero image (on the before site, the PHP notices printed at the top of the page). The difference shows in page weight and desktop LCP.

Trace index

Every commit and test, back to the exhibit it answers. Each commit opens its evidence page (message, diff, proving test); commit messages carry the same link in an “Exhibit:” trailer.

Commit → Exhibit
CommitExhibit
c4c8ced fluid header so the booking button fits a 390px phoneA-1
31bc0c1 serve images at the size of their slot, with srcset and lazy loadingA-2
d4e6502 remove render-blocking scripts, the @import chain and unused font weightsA-3
adfefb2 restore zoom, the skip link, contrast, focus outlines and form labelsA-4
141fe03 PHP 8 deprecations in the child theme; stop printing errors into pagesB-1
d27aeda PHP 8 warnings when the team option or the next slot is missingB-2
031d47e Insurance page white screen (create_function removed in PHP 8)B-3
3524955 team list N+1 queries; one query per kind of dataC-1
3652956 remove ~3.5 MB of autoloaded options left by a dead feed pluginC-2
854f62c next open slot by index lookup instead of scanning 70k meta rowsC-3
bd639a1 send security headers; disable the file editorD-1
8df9c85 stop user enumeration through ?author=, REST, sitemaps and oEmbedD-2
2d168c3 close xmlrpc.phpD-3
84d556e appointment requests reach the inbox again; a failed send is shownD-4
d532fbd allow WordPress's blob: worker in the CSPD-1
fe60ced delete the public debug.log the old configuration left behindB-1
243f153 collapse the menu before the first paint (no layout shift)A-1
82b2c06 hero size that keeps the upload's ratio, so srcset is not emptyA-2
Test → Exhibit
TestExhibit
e2e/tests/a1-mobile-layout.spec.tsA-1
tests/phpunit/repair/ImagesTest.phpA-2
tests/phpunit/repair/RenderBlockingTest.phpA-3
e2e/tests/a4-accessibility.spec.tsA-4
tests/phpunit/repair/Php8DeprecationsTest.phpB-1
e2e/tests/b-php8.spec.tsB-1, B-3
tests/phpunit/repair/Php8WarningsTest.phpB-2
tests/phpunit/repair/InsuranceTableTest.phpB-3
tests/phpunit/repair/TeamQueriesTest.phpC-1
tests/phpunit/repair/AutoloadTest.phpC-2
tests/phpunit/repair/BookingSlotsTest.phpC-3
tests/phpunit/repair/SecurityHeadersTest.phpD-1
e2e/tests/d-security-mail.spec.tsD-1, D-2, D-3, D-4
tests/phpunit/repair/UserEnumerationTest.phpD-2
tests/phpunit/repair/XmlrpcTest.phpD-3
tests/phpunit/repair/MailTest.phpD-4

Sites and repository

Both versions run side by side from the same content. The appointment form on either sends its email to a local test inbox, never to a real address.

After: the repaired site