Moving a fifteen-year-old site into the site editor

A theme whose oldest file dates from 2008, sixty-one template files, and an editorial team that files a ticket to change a phone number in the footer. WordPress 6.2 took the beta label off the site editor in March, which changed nothing technically and changed what could be proposed.

The symptom

$ ls *.php | wc -l
61

$ head -3 header.php
<?php
/**
 * @since 1.0 (2008-11-04)

$ grep -rc 'dynamic_sidebar' *.php
  footer.php:4
  sidebar.php:2

# four widget areas in the footer, configured through
# Appearance → Widgets, which an editor can technically
# reach and has been told not to touch.
the tickets, one quarter:

  "change the phone number in the footer"        3
  "add a link to the footer"                    2
  "change the copyright year"                   1  (annual)
  "reorder the footer columns"                  1
  "change the header CTA text"                  4

11 tickets, all of them content, all of them a deploy.

Why it happens

Fifteen years of templates, each added for one page, none ever removed. The header and footer are PHP because in 2008 there was no alternative, and everything built since has assumed they are PHP.

The fix

The inventory, measured rather than guessed

// log the resolved template for every front-end request,
// for a month
add_filter( 'template_include', function ( $template ) {
    if ( ! is_admin() ) {
        turkerdev_log_template( basename( $template ), $_SERVER['REQUEST_URI'] );
    }

    return $template;
}, PHP_INT_MAX );
one month, 61 templates:

  template              requests   distinct URLs
  single.php             182,004      1,412
  page.php                88,120        188
  index.php               41,208        204
  archive.php             12,884         44
  single-product.php      11,204        302
  page-contact.php         4,102          1
  ... 14 more with > 100 requests
  ... 41 with fewer than 10 URLs a month

41 of 61 templates render fewer than 10 URLs a month.
six of them rendered zero.

Measuring rather than reading the theme is what made this tractable. Six templates had rendered nothing in a month — they were for post types removed in 2016 — and forty-one more were serving a handful of URLs each, which meant the migration only had to be good on six.

The hybrid step

a theme with templates/index.html is a BLOCK theme, and
the site editor appears. templates that exist as PHP and
not as HTML still resolve through the old hierarchy.

so the migration is per-template:

  templates/index.html          ← block
  templates/single.html         ← block
  templates/page.html           ← block
  templates/archive.html        ← block
  templates/single-product.html ← block
  templates/404.html            ← block
  page-contact.php              ← still PHP
  archive-event.php             ← still PHP
  ... 39 more, still PHP

and both work, on the same site, at the same time.

This is the fact that makes a fifteen-year-old site migratable at all. A block theme is not an all-or-nothing conversion; it is a directory that takes precedence where it has an answer, and the PHP hierarchy handles everything else.

theme.json as the styling boundary

{
  "version": 2,
  "settings": {
    "appearanceTools": true,
    "color": {
      "custom": false,
      "palette": [
        { "slug": "brand", "color": "#1f6feb", "name": "Brand" },
        { "slug": "ink",   "color": "#101418", "name": "Ink" }
      ]
    },
    "spacing": { "units": ["px", "rem", "%"] },
    "layout": { "contentSize": "46rem", "wideSize": "72rem" }
  }
}

"custom": false is the setting that makes this a design system rather than a colour picker — an editor may use the brand palette and may not invent a colour. That single line prevented more drift than any documentation would have.

and the stylesheet has to agree, which is the work:

  the 2008 stylesheet          2,104 lines
  values that are now tokens     188 occurrences
  values that stayed hardcoded    17 (documented)
  rules made redundant by
    theme.json layout            412 lines deleted
<!-- parts/footer.html -->
<!-- wp:group {"tagName":"footer","layout":{"type":"constrained"}} -->
<footer class="wp-block-group">
  <!-- wp:columns -->
  <!-- wp:column -->
  <!-- wp:navigation {"ref":41} /-->
  <!-- /wp:column -->
  <!-- wp:column -->
  <!-- wp:paragraph --><p>+44 20 7946 0000</p><!-- /wp:paragraph -->
  <!-- /wp:column -->
  <!-- /wp:columns -->
</footer>
<!-- /wp:group -->
the four widget areas, resolved:

  footer-1  a navigation menu    → wp:navigation
  footer-2  a text widget with
            the address          → wp:paragraph
  footer-3  a custom widget
            listing recent posts → wp:query, and the
                                   widget deleted
  footer-4  empty since 2019     → removed

and the widget area registrations, and the custom widget
class, and its stylesheet: 340 lines deleted.

The custom recent-posts widget being replaced by a query loop is the pattern for most legacy widgets — they exist because there was no block that did it, and now there is. The one that had been empty since 2019 is the more common finding.

A custom post type the site editor can template

register_post_type( 'case_study', array(
    'public'       => true,
    'show_in_rest' => true,     // required for the block editor
    'has_archive'  => true,     // and for an archive template
    'supports'     => array( 'title', 'editor', 'thumbnail', 'excerpt' ),
) );
<!-- templates/archive-case_study.html -->
<!-- wp:query {"query":{"postType":"case_study","perPage":12}} -->
<!-- wp:post-template -->
  <!-- wp:post-featured-image {"isLink":true} /-->
  <!-- wp:post-title {"isLink":true,"level":2} /-->
  <!-- wp:post-excerpt /-->
<!-- /wp:post-template -->
<!-- wp:query-pagination --><!-- /wp:query-pagination -->
<!-- /wp:query -->

The archive template that had been a hundred and forty lines of PHP with a custom WP_Query, a manual pagination function and its own markup is nine lines of block comments. That is the case where the site editor is straightforwardly better, and it is most archive templates.

What has no equivalent

two templates stayed PHP, and will:

  page-pricing.php
    renders a table whose rows come from a third-party
    API, with a fallback, a cache and error handling.
    the query loop cannot express "call this service".
    → could become a dynamic block. costed at 3 days
      against a template nobody edits. deferred.

  archive-event.php
    a query with a meta comparison, a date fallback and
    an ordering that depends on whether the event is
    past. the query block cannot express the condition.
    → a pre_get_posts filter plus a block template was
      tried and the conditional ordering did not survive.

the rule: if the template contains an `if` that changes
what is queried, it stays PHP.

That rule — a conditional affecting the query keeps it in PHP — is the clearest line we found, and it is worth stating because the alternative is discovering it three days into a conversion. Conditionals affecting presentation are fine; conditionals affecting the query are not.

The content migration that was not needed

1,412 posts written in the classic editor, none of them
block content.

what happens when a block theme renders them: the
post-content block outputs the raw content, exactly as
before. classic content renders identically.

what we did NOT do:
  bulk-convert posts to blocks
  run the block converter over 1,412 posts
  touch the content at all

the styling had to accommodate it: a .entry-content
fallback stylesheet for markup that has no block classes.
74 lines, kept.

Verifying it worked

# rendered markup, diffed template by template
$ ./bin/render-diff --urls=urls.txt --before=old --after=new
  188 URLs compared
  identical:        171
  differing:         17
    of which intentional (spacing tokens):  15
    genuine regressions:                     2

# the two: a missing rel="noopener" on footer links,
# and a heading level change on the archive template.

$ npx backstop test
  Passed: 44   Failed: 3   (all three approved)

# and the actual test
# an editor changed the footer phone number, alone,
# in 4 minutes, with no deploy.

Diffing rendered markup for a hundred and eighty-eight URLs is the only verification that scales here, and the two regressions it found were both invisible to a visual comparison. The editor changing a phone number unaided is the outcome the eleven tickets a quarter were paying for.

What this costs

A site whose appearance is now partly in the database and partly in git. A template part edited in the site editor is stored as a post, which means the git version is a default rather than the truth, and a deploy does not restore it. That is a genuine change to how the site is operated and it has to be explained.

It also means a staging-to-production content migration now moves appearance as well as content, which the previous arrangement kept cleanly separated. There is no good tooling for this: exporting the modified template parts is a manual step, and getting it wrong means a design change made on staging is silently absent in production.

Thirty-nine PHP templates remain, so the theme is now two systems rather than one. That is a stable state rather than a half-finished migration — six templates serve ninety-four per cent of traffic and are in the site editor, and the rest are the long tail — but somebody joining the project has to learn both.