Skip to main content
Last updated: 23 Apr 2026

whitehall: Migrating to StandardEdition

We're gradually migrating all document types to use the StandardEdition model, as per ADR 006: Config-driven content types.

Steps to recreate a legacy content type

  1. Build the legacy content type as a new config-driven format with its own configuration under app/models/configurable_document_types.

  2. Backfill the behaviours (this is the difficult bit). You'll want to:

  • Analyse what features and behaviours exist on the legacy content type
  • Decide which should be carried over to the config-driven version
  • Consider whether any existing config-driven behaviours can be adapted for your needs. If not,
  • Build the new behaviour into StandardEdition in a generic way such that other content types could also 'opt in' to the behaviour if required
  1. "Launch" the new content type by changing the EditionWorkflowController to redirect users to the StandardEdition workflow (example)

Steps to migrate a legacy content type

These steps assume you have already recreated the legacy content type as a StandardEdition (above).

The legacy content type already uses the Edition model

If the legacy content type already uses the Edition model, the migration is made much simpler by use of the StandardEditionMigrator class.

The StandardEditionMigrator takes a 'recipe' (example) that tells the migrator service how to map the legacy fields to the config-driven block_content structure (map_legacy_fields_to_block_content). It also has built-in payload comparison, so that it can take the Publishing API payload of the legacy content item and 'diff' it against the StandardEdition Publishing API payload of the converted content item, aborting the migration if the diff isn't as expected.

Conceptually, the diff should be empty: converting legacy content types to being config-driven is a straight up refactor that shouldn't affect the end user experience. In practice, the diffs end up being subtly different, so we have to configure the recipe to state which bits of the diff are 'expected'.

The API is as follows:

  • configurable_document_type - the config-driven 'configurable_document_type', e.g. news_story
  • presenter - the presenter for the legacy content type, e.g. PublishingApi::NewsArticlePresenter
  • map_legacy_fields_to_block_content(edition, translation) - defines which top level 'fields' of the legacy edition should be injected into the block_content of the config-driven edition. E.g. { "body" => translation.body }.
  • ignore_legacy_content_fields(content) - defines which fields in the details hash of the legacy content item will not be carried over to the details hash of the config-driven content item. (Sometimes we consciously drop fields if they're no longer required). E.g. content[:details].delete(:first_public_at) (which is a legacy field no longer needed - see this PR description).
  • ignore_new_content_fields(content) - defines which fields in the details hash of the config-driven content item were not present in the legacy content item. E.g. content[:details].delete(:image)
  • ignore_legacy_links(links) - defines which links in the legacy content item will not be carried over to the links of the config-driven content item. E.g. links.delete(:worldwide_organisations)
  • ignore_new_links(links) - defines which links in the config-driven content item were not present on the legacy content item. If no changes, just return unchanged, i.e. links. The same applies to all of the above arguments.

The migrator itself offers two methods:

  • preview: to see how many unique documents and editions are in scope for migration
  • migrate!: to perform the actual migration. It takes two arguments:
    • compare_payloads (default true): runs the legacy content item through its Publishing API presenter and compares it with the converted content item through the StandardEdition presenter, raising an exception if the diff contains any differences not accounted for by the recipe. It is highly recommended to keep this on, but it does slow down the migration somewhat.
    • republish (default false): whether or not to republish the document after it has been migrated. This is a nice idea in theory, but in practice can cause race conditions with document slugs being changed, so use with caution.

With that in mind, the steps for migrating a legacy content type to being config-driven is roughly as follows:

  1. Write a recipe and associated tests.

  2. See how many unique documents and editions are in scope for migration: StandardEditionMigrator.new(scope: Document.where(document_type: "NewsArticle")).preview

  3. Try converting a legacy content item (or several) locally. Example:

    array_of_one_test_document = Document.where(id: NewsArticle.where(id: 1733267).map(&:document_id))
    StandardEditionMigrator.new(scope: array_of_one_test_document).migrate!
    

    If you want a clearer view of what's going on, you could call the job directly, synchronously:

    StandardEditionMigratorJob.new.perform(document.id, { "republish" => true, "compare_payloads" => true })
    
  4. Test the converted content item to make sure it still works as expected.

  5. Do the same on Integration so that you can also check the converted content item looks as it should on the frontend. Compare it with how it looks on Production.

  6. Repeat the steps locally for more content items. You will likely run into edge cases you haven't factored in to your recipe, which you'll want to tweak accordingly.

    StandardEditionMigrator.new(scope: Document.where(document_type: "NewsArticle").first(5000)).migrate!(republish: false, compare_payloads: true)
    
  7. Come up with a plan for the handful of stragglers. You'll likely run into a few instances that, for whatever reason, cannot be easily auto-migrated. See examples of issues we ran into with NewsArticles. Typically a little manual intervention is required, e.g. to patch up some data, before you can then invoke the StandardEditionMigratorJob to complete the migration of the document.

  8. Test running the full migration (and any manual patches) on Integration.

  9. Run the full migration (and any manual patches) on Production.

  10. Celebrate! 🥳 Then delete the recipe - you won't need it again.

The legacy content type does not use the Edition model

If the legacy content type doesn't use the Edition model (e.g. TopicalEvent), you can still use the StandardEditionMigrator — just pass a scope of the legacy records directly rather than a Document scope.

For each record in scope, the migrator creates a brand new Document and StandardEdition, preserving the original record's content_id on the new document. Note that for the base path overwrite to work in Publishing API you'll need to do the groundwork described in #11210 — this lets you interchangeably publish either the legacy content item or the new StandardEdition at the same path for testing purposes. The original record is left untouched, so you can remove it manually afterwards (or leave the two co-existing for a while if that's useful).

The recipe works the same way as for editionable content types, with two differences:

  • Use the same StandardEditionMigrator.recipe_for method (it accepts any model, not just editions)
  • Add title(record_translation) and summary(record_translation) methods — these are called once per source record translation, so if the record has multiple locales, each gets its own edition translation. The argument is the Globalize translation object, so you can read locale-specific attributes directly from it (e.g. record_translation.name).

map_legacy_fields_to_block_content takes (record, record_translation) — the first argument is your record (for any non-translated data) and the second is the source translation being processed. Everything else — presenter, configurable_document_type, and the ignore_* methods — works identically to the editionable recipe.

The steps from there are the same as above. To preview:

StandardEditionMigrator.new(scope: TopicalEvent.all).preview

To migrate a handful locally first:

StandardEditionMigrator.new(scope: TopicalEvent.first(5)).migrate!(compare_payloads: true, republish: false)

Or to call the job directly for a single record:

StandardEditionMigratorJob.new.perform(topical_event.id, { "model_class" => "TopicalEvent", "republish" => false, "compare_payloads" => true })