agentsclimarketplace

Silverstripe 3 to 4 upgrade

Skill jsirish/silverstripe-skills/skills/silverstripe-3-to-4-upgrade

Agent skills for Silverstripe CMS development — version upgrades, Elemental, the Essentials stack, and deployment workflows

Install
npx -y skills add jsirish/silverstripe-skills --skill silverstripe-3-to-4-upgrade

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Complete workflow for upgrading legacy Silverstripe 3 projects to Silverstripe 4. Use when upgrading SS3 projects, migrating namespaces, or resolving database schema blockers. Covers directory structure changes, namespace migration, database schema preflight fixes, data migration tasks (Files, Blocks to Elemental), and template adjustments.

SKILL.md

34.2 KB, as published. Nobody here has run it

Silverstripe 3 to 4 Upgrade Skill

Repeatable workflow for upgrading legacy Silverstripe 3 projects to Silverstripe 4. Based on the successful migration of Example Manufacturing, this guide details the exact steps, architectural shifts, and critical gotchas when moving to the SS4 framework.

Scope: This skill is SS3 → SS4 specific — a one-time structural migration. Once a project is on SS4, use the silverstripe-version-upgrade skill for SS4 → SS5 and later major-version bumps.

Philosophy: parity, not redesign

[!IMPORTANT] Parity, not redesign — and minimal transformation. An SS3→SS4 upgrade is a data + markup-parity exercise. The goal is behavioural and visual parity with the SS3 site — achieved by migrating data and reproducing the existing markup — not by improving or modernising anything.

Apply one test before any action: "does this already exist natively in SS4?" If a field, class, or data shape already exists in the target version, it must not be transformed. Only run a migration task for data that a removed or renamed class genuinely requires.

  • Markup side: When a page looks wrong after the upgrade, the default hypothesis is a missing wrapper element or a dropped legacy CSS class — not a layout that needs re-authoring. The SS3 CSS almost always still works once the markup it targets is back. See references/page-layout-parity.md.
  • Data side: When a migration task transforms content that was already native in SS4, it breaks things for zero benefit. Example: MigrateContentToElement moves SiteTree.Content into Elemental and blanks the field — but SS4 still has $Content natively and the theme renders it directly. Running the task silently broke blog excerpts and page layout while adding no value, so it's been pulled from the default sequence — see the CAUTION in Phase 6 and issue #18.
  • Defer any genuine redesign to a separate, post-parity phase. This is the same "one rule" the block-to-element-migration skill applies at the block-template level — it holds for the whole project.

Upgrade Phases (Summary)

PhaseKey Actions
1. AssessmentPackage audit (git diff branch-1 -- composer.json) · spin up legacy ddev instance · plan block-to-elemental migration
2. ArchitectureMove mysite to app, introduce public directory
3. DependenciesUpdate composer.json to ^4.0, update PHP constraints
4. NamespacesApply PSR-4 namespaces, remap config class names
5. DB Preflight FixesClear __TEMP__ table collisions and FULLTEXT indices
6. Data MigrationRun file migration, Elemental tasks, and custom SQL
7. TemplatesFix HomePage resolution, update $Link to $URL
8. Build & Verifydev/build flush=1, QA frontend and admin
9. Code Quality & CIPHPCS, PHPStan, PHPUnit, GitHub Actions

Phase 1: Assessment & Discovery

1a. Package Audit (do this before writing any upgrade code)

Run the composer diff against the legacy branch immediately after branching:

git diff branch-1..feature/silverstripe-4-upgrade -- composer.json

For every package removed from require, document:

  • What feature/UI it provided on the frontend
  • Whether SS4 has a drop-in replacement (composer show or packagist)
  • Whether it stored data that needs migrating (DataObject tables in the DB)

Common removals and their consequences:

SS3 PackageWhat it providedSS4 situation
silverstripe/widgetsBlog sidebar widgets (archive, tags, categories, recent posts)silverstripe/blog ^3.x still supports widgets — use silverstripe/widgets ^2.x. Require it explicitly (composer require silverstripe/widgets "^2.4"), apply WidgetPageExtension to Blog + BlogPost in extensions.yml, and backfill Widget_Live/WidgetArea_Live tables after prod sync (Widget and Widget_Live have different column order — use explicit column lists).
sheadawson/silverstripe-blocksArbitrary content blocks on pagesReplace with dnadesign/silverstripe-elemental. Requires BlockMigrationTask.
dynamic/dynamic-blocksSame as sheadawson blocks, Dynamic flavourSame as above.
dynamic/core-toolsGlobalSiteSetting, various helpersAdopt dynamic/silverstripe-base-site ^4.0 (see Phase 3), which provides the equivalent settings + navigation natively on SiteConfig. Then migrate the legacy GlobalSiteSetting data — scalars and its nav relations (header UtilityLinks junction, footer NavigationColumns) — onto SiteConfig with an idempotent BuildTask. Don't hand-recreate GlobalSiteSetting as a custom DataObject (a dead-end that has to be unwound later). Full migration-task pattern: globalsitesetting-to-siteconfig.md.
silverstripe/secureassetsProtected file storageMerged into SS4 core (silverstripe/assets). No action needed, but verify .protected/ path.
heyday/silverstripe-versioneddataobjectsVersioning for non-Page DataObjectsReplaced by Versioned extension (built into SS4). Remove and add $extensions = [Versioned::class] manually.
dynamic/flexsliderSlider block typeRebuilt as ElementPageSection or similar custom Elemental element.
i-lateral/silverstripe-searchableSite searchVersion ^2.0 available for SS4.

[!WARNING] silverstripe/widgets + blog: If the SS3 site used blog sidebar widgets, add silverstripe/widgets ^2.4 to the SS4 project. It is NOT dropped in blog ^3.x — it's optional. After requiring it: (1) apply WidgetPageExtension to Blog + BlogPost in extensions.yml, (2) run dev/build, (3) backfill Widget_Live and WidgetArea_Live from draft tables using explicit column lists (column order differs between draft and live tables). Don't rely on INSERT INTO _Live SELECT * FROM table — it silently corrupts data.

1b. Legacy ddev instance

For any non-trivial upgrade, spin up a parallel local instance of the SS3 branch before starting the upgrade. This gives you a pixel-perfect reference to diff against when you reach the VR phase.

Naming convention: clone into ~/Sites/{project}-legacy so the upgrade lives in ~/Sites/{project} — the VR skill relies on this pattern.

cd ~/Sites
git clone <repo> {project}-legacy
cd {project}-legacy
git checkout 1  # or whatever the legacy branch is
ddev config --project-name {project}-legacy --project-type php --php-version 7.4
ddev start
ddev auth ssh
ddev exec ./sync.sh  # sync prod DB and assets

[!NOTE] The -legacy instance hits the same Mutagen upload_dirs conflict as the upgrade instance on its first prod sync (Mutagen sync completed with problems … unable to relocate staged file: file exists). Set upload_dirs: [assets] in both projects' .ddev/config.yaml, then ddev mutagen reset && ddev restart. See the ddev-sync skill (from jsirish/workflow-skills, installed separately), "Mutagen upload_dirs conflicts after prod sync", for details.

This gives you https://{project}-legacy.ddev.site — a running SS3 instance against the same data you're upgrading. Use it to:

  • Confirm what each URL renders on SS3 before starting SS4 work
  • Run VR captures against {project}-legacy.ddev.site instead of the live prod URL (eliminates content drift between your DB snapshot and live prod)
  • Diagnose "was this feature even working on prod?" without loading the live site

[!TIP] The example-custom project is the canonical reference for this pattern: ~/Sites/example-custom (SS4 upgrade) vs ~/Sites/example-custom-legacy (SS3 legacy). Also see ~/Sites/example-multiarea / ~/Sites/example-multiarea-legacy for an earlier example. Both use the same ddev config structure.

1c. Package Assessment (original step)

  1. Audit Packages: Determine which legacy SS3 modules can be replaced with native SS4/Dynamic equivalents.
  2. Block Assessment: Legacy dynamic/dynamic-blocks (or sheadawson/silverstripe-blocks) must be mapped to dnadesign/silverstripe-elemental.
  3. Database Backup: Ensure a complete local sync or backup before running dev/build, as obsolete tables will be renamed.

Phase 2: Architecture & Directory Structure

Silverstripe 4 requires a restructured root directory:

  • Rename the mysite directory to app.
  • Move Page.php and PageController.php to app/src/.
  • Introduce a public directory. Move assets/ into public/assets/, and create public/index.php and public/.htaccess.

Phase 3: Dependencies & Branching

git checkout -b feature/silverstripe-4-upgrade

# Update PHP requirement in composer.json to minimum PHP 7.4

Common Package Replacements:

  • dynamic/dynamic-blocksdnadesign/silverstripe-elemental
  • dynamic/core-toolsdynamic/silverstripe-site-tools (via silverstripe-base-site)
  • Add dynamic/silverstripe-base-site: ^4.0

Run composer update --with-all-dependencies and composer vendor-expose.

Add modern dev dependencies (the standard Dynamic toolkit for quality and debugging):

"require-dev": {
    "cambis/silverstan": "^1.0",
    "ergebnis/composer-normalize": "^2.44",
    "lekoala/silverstripe-debugbar": "^3.0",
    "phpstan/extension-installer": "^1.3",
    "phpunit/phpunit": "^9.6",
    "silverleague/ideannotator": "~3.5.1",
    "silverstripe/recipe-testing": "^2.0",
    "squizlabs/php_codesniffer": "^3.10",
    "wernerkrauss/silverstripe-rector": "^2.0"
}

[!NOTE] Version adjustments per SS major: The constraints above target SS4. For SS5+ projects bump cambis/silverstan to ^2.1 and silverstripe/recipe-testing to ^3.0. Check the latest release on Packagist if in doubt.

Phase 4: Code & Namespace Migration

SS4 heavily relies on PHP namespaces.

  1. Namespacing: Add namespaces to all classes in app/src/ (e.g., namespace App\Pages; or namespace Dynamic\Base\Page;).
  2. Add PSR-4 autoload to composer.json — required for the namespaced classes to load:

[!TIP] Automate namespace migration with silverstripe-rector: Instead of adding namespaces manually, use wernerkrauss/silverstripe-rector to automate the bulk of the work. After requiring it (see dev-dependencies above), configure rector.php and run:

vendor/bin/rector --dry-run
vendor/bin/rector  # apply when ready

This handles class-rename patterns, PSR-4 restructuring, and many SS3 deprecation fixes that are tedious to do by hand. See github.com/wernerkrauss/silverstripe-rector for available rule sets.

"autoload": {
  "psr-4": { "App\\": "app/src/" }
}
  1. DB ClassName Remapping (DatabaseAdmin.classname_value_remapping): map every SS3 short class name stored in the database to its SS4 namespaced equivalent. This runs during dev/build and rewrites all ClassName columns across every DataObject table (including _Live and _Versions) — not just SiteTree. Without it, SS4 can't resolve the stored class names and pages fall back to Page.ss (or render blank). This is separate from any Injector/config class aliasing — both may be needed. The remapping is idempotent and safe to leave in place during the migration window; remove it once all migrated data is confirmed working. Add to app/_config/app.yml:

    SilverStripe\ORM\DatabaseAdmin:
      classname_value_remapping:
        # Page types
        Page: 'App\Pages\Page'
        HomePage: 'App\Pages\HomePage'
        Blog: 'SilverStripe\Blog\Model\Blog'
        BlogPost: 'SilverStripe\Blog\Model\BlogPost'
        # Orphaned SS3 vendor pages with no SS4 equivalent — map to nearest base:
        EventHolder: 'App\Pages\Page'
        EventPage: 'App\Pages\Page'
        # UserForms field classes — include ALL editable field types the DB contains:
        EditableEmailField: 'SilverStripe\UserForms\Model\EditableFormField\EditableEmailField'
        EditableTextField: 'SilverStripe\UserForms\Model\EditableFormField\EditableTextField'
        EditableDropdown: 'SilverStripe\UserForms\Model\EditableFormField\EditableDropdown'
        EditableCheckbox: 'SilverStripe\UserForms\Model\EditableFormField\EditableCheckbox'
        EditableFormStep: 'SilverStripe\UserForms\Model\EditableFormField\EditableFormStep'
        EditableFormHeading: 'SilverStripe\UserForms\Model\EditableFormField\EditableFormHeading'
        EditableLiteralField: 'SilverStripe\UserForms\Model\EditableFormField\EditableLiteralField'
        EditableSpamProtectionField: 'SilverStripe\SpamProtection\EditableSpamProtectionField'
        # Custom DataObjects with renamed/namespaced classes:
        PromoObject: 'Dynamic\Elements\Promos\Model\PromoObject'
    

    Query the DB first to enumerate every value that needs remapping:

    # Page types
    ddev exec "mysql -udb -pdb db -e \"SELECT DISTINCT ClassName, COUNT(*) FROM SiteTree GROUP BY ClassName;\""
    # UserForms fields (if silverstripe/userforms is installed)
    ddev exec "mysql -udb -pdb db -e \"SELECT DISTINCT ClassName, COUNT(*) FROM EditableFormField GROUP BY ClassName;\""
    

    [!CAUTION] Don't write custom SQL tasks to fix ClassName values. A common SS3→SS4 smell is a hand-rolled BuildTask doing UPDATE ... SET ClassName = '...'. That's exactly what classname_value_remapping is for — it's idempotent, runs during dev/build, and covers base + _Live + _Versions automatically. Custom SQL is redundant and error-prone.

    One exception: classname_value_remapping only touches the ClassName column, not ParentClass or other string fields that store class names. A task like FormParentClassMigrationTask that fixes EditableFormField.ParentClass is still legitimate — but trim it to only fix ParentClass, not ClassName (which the config now handles).

  2. SSViewer themes config — include $public and $default so vendor module templates resolve:

    SilverStripe\View\SSViewer:
      themes:
        - '$public'
        - mytheme
        - '$default'
    
  3. Legacy Method Signatures: SS4 alters some Core method signatures.

    • Example: Permission::check($member, 'any') in SS3 is now Permission::check('any', 'any', $member) or simpler. Remove legacy 'any' parameters if causing type errors.

Phase 5: Database Schema Preflight Fixes

Before dev/build can succeed, you must resolve SS3 legacy schema blockers.

[!CAUTION] FULLTEXT Indexes on File: SS3 File tables sometimes have a FULLTEXT index on Filename which blocks SS4 column updates.

ALTER TABLE File DROP INDEX SearchFields;

[!WARNING] TEMP and _Versions Table Collisions: SS4 dev/build creates __TEMP__ tables to migrate data. SS3 _Versions tables from the source DB collide with SS4's rename target. On every fresh prod sync (and after any crashed dev/build), drop both kinds in a single statement before re-running dev/build.

See references/db-rebuild-conflicts.md for the bulk-drop snippet and edge cases. The TL;DR: a single DROP TABLE IF EXISTS \a`,`b`,`c`,...;` statement is dramatically faster than the per-table loop you'll find in older guides.

Phase 6: Data Migration Tasks

Run the following tasks sequentially. Custom tasks (BlockMigrationTask, FormParentClassMigrationTask) are usually required to handle project-specific business logic using raw SQL.

[!TIP] Capture the per-project task sequence as a /migrate slash command. Every project ends up with its own ordered task list, environment table, and gotchas. Rather than re-inventing the structure each time, formalize it as .claude/commands/migrate.md so a developer can type /migrate and load the full runbook. A copy-paste starter — environments table, breaking-changes table, repeatable bash workflow, expected outputs, and verification checklist — is in references/project-migration-command-template.md. Keep the migration tasks in a migrate.sh runner (see the devbuild.sh convention in Phase 8), not in devbuild.sh.

  1. Fix Corrupted ParentClasses (Critical)

    ddev sake dev/tasks/form-parent-migration
    

    Note: Resolves un-namespaced ClassNames on EditableFormField records to prevent publishRecursive crashes.

  2. Migrate Files to Hash-Based Storage

    ddev sake dev/tasks/MigrateFileTask
    

[!CAUTION] MigrateContentToElement — only run for an intentional Elemental-only architecture, after backup. This vendor task (dev/tasks/DNADesign-Elemental-Tasks-MigrateContentToElement) moves SiteTree.Content into an ElementContent block and then blanks Content. Do NOT run it unless you have a verified database backup/export and have confirmed that templates, search indexes, summaries/excerpts, feeds, and any custom code no longer read SiteTree.Content. If Page.ss / BlogPost.ss / your page templates output $Content directly — the Dynamic base-site default — it empties those fields and breaks blog listing excerpts ($Summary/$Excerpt derive from Content), content alignment, and anything else reading $Content, for no benefit.

Decide with the minimal-transformation test from Philosophy: SiteTree.Content exists natively in both SS3 and SS4, so it must not be transformed. Only migrate content into Elemental if the project is deliberately adopting an Elemental-only content architecture. (This task is not in the default sequence for exactly this reason.)

  1. Custom Block Migration

    ddev sake dev/tasks/block-migration
    

    Note: Dev/build renames obsolete classes to _obsolete_PromoObject. The custom task must read from these _obsolete_ tables via DB::query() and write to Elemental tables.

    ➡ Use the dedicated block-to-element-migration skill for the full workflow: discovery, page-model setup, the migration-task skeleton, the legacy-template → element-template duplication pattern, the area-suffix template convention (Element_RelationName.ss), and verification. The earlier inline reference at references/block-to-elemental-migration.md is preserved as the seed material the new skill was distilled from.

Phase 7: Templates & Front-End

  • Variables: Update $Link to $URL in template .ss files.
  • Elemental Areas: Replace <% with $Blockarea(AreaName) %> with $ElementalArea or specific area relations like $ElementalHomePage.

[!IMPORTANT] Page-layout parity is the biggest single source of VR FAILs — bigger than block/element parity. Empty block_area_* wrapper divs that control margin-collapse, WidgetHolder structure, SectionNavigationBlock replacements, and MenuTitle vs Title nav text all need reproducing at the layout-template level. See references/page-layout-parity.md for each fix with the SS3 markup shown beside the SS4 equivalent.

[!IMPORTANT] Namespaced Base Templates: SS4 resolves base templates by namespace path first. Dynamic\Base\Page\HomePage expects themes/mytheme/templates/Dynamic/Base/Page/HomePage.ss — NOT templates/HomePage.ss. Without this, the page falls back to Page.ss, potentially ruining full-width layout constraints.

[!NOTE] Template Variant Naming: Elemental uses getAreaRelationName() for suffixing. If a page has has_one: ['ElementalHomePage' => ElementalArea::class], the variant file must be named ElementPromos_ElementalHomePage.ss.

Phase 8: Build & Verify

[!IMPORTANT] devbuild.sh is cache-rebuild-only — keep migration tasks out of it. DeployHQ runs devbuild.sh after every code deploy, so it must do nothing but clear the cache and rebuild the schema. The canonical version is three lines:

#!/usr/bin/env bash
rm -rf silverstripe-cache
mkdir silverstripe-cache
vendor/bin/sake dev/build

Data-migration tasks belong in a separate migrate.sh (or the /migrate slash command — see Phase 6), invoked manually after a fresh prod sync. Two reasons:

  • No double dev/build. A full-loop script that runs an explicit dev/build and then ddev exec ./devbuild.sh will build twice if devbuild.sh also runs dev/build plus tasks — and worse, run migration tasks in a context that only meant to rebuild cache.
  • "Build" ≠ "migrate." DeployHQ deploys code and rebuilds cache; it should never fire migration tasks. Conflating the two means a routine deploy silently re-runs data migrations. (Both example-manufacturing and example-multiarea settled on the 3-line devbuild.sh; an early example-custom shipped 50 lines of tasks + SQL inside it and hit exactly this double-build/re-migrate trap.)
  1. Run dev/build:

    ddev sake dev/build flush=1
    
  2. Frontend QA: Walk through the primary page types — HomePage, standard pages, blog, any custom page types. Check for:

    • Blank pages (likely a missing classname_value_remapping entry or broken template resolution)
    • Template fallback issues — use ?showtemplate=1 to confirm the correct template is resolving
    • Broken images — jonom/focuspoint migrations require template updates
  3. CMS admin QA: Verify pages load in the CMS tree, elements render in the Elemental editor, and the SiteConfig / Settings section works.

  4. Elemental areas: Confirm _Live tables are populated. If ElementalArea_Live is empty, run the migration task's final SQL passes (see block-to-element-migration).

  5. Visual regression — prove pixel parity between the SS3 legacy site and the SS4 upgrade:

    # Path to the installed skill; adjust for your agent's skills dir
    VR=~/.claude/skills/visual-regression-upgrade
    
    # Using the legacy instance from Phase 1b
    cd ~/Sites/{project}-legacy
    python "$VR"/scripts/crawl_urls.py \
      --url https://{project}-legacy.ddev.site --limit 30 --out paths.txt
    
    cd ~/Sites/{project}
    python "$VR"/scripts/capture.py \
      --prod https://{project}-legacy.ddev.site \
      --local https://{project}.ddev.site \
      --paths-file ../{project}-legacy/paths.txt \
      --out ./vr-out
    
    python "$VR"/scripts/diff_report.py \
      --in ./vr-out --out ./vr-out/report
    

    See the visual-regression-upgrade skill (from jsirish/workflow-skills, installed separately) for setup, auth, mask config, and report interpretation. The legacy-vs-upgrade capture eliminates content-drift false positives and catches layout regressions manual QA misses.

Phase 9: Code Quality & CI

After the upgrade builds and renders, lock down code quality with automated tools and CI:

[!WARNING] Verify the shipped QA config actually runs before trusting the gate. The installer-provided config is frequently stale on an upgrade and fails silently:

  • phpstan.neon often does includes: phpstan-baseline.neon, but that baseline file doesn't exist — PHPStan won't run at all until you create it (--generate-baseline) or remove the include.
  • phpunit.xml.dist often points at a vendor test dir that isn't installed (e.g. vendor/<org>/<tools>/tests), and there may be no app/tests/ directory at all.

Run each tool once and confirm it executes — "the config is present" is not the same as "the gate runs."

  1. PHPCS — enforce coding standards:

    ddev exec vendor/bin/phpcs app/src/ app/tests/
    

    Common SS3→SS4 issues PHPCS catches: missing namespace declarations, outdated class references, PSR-2/PSR-12 formatting.

  2. PHPStan — static analysis (if configured):

    ddev exec vendor/bin/phpstan analyse app/src/
    

    Run at level 1–2 initially; the SS4 upgrade introduces many dynamic calls that require baseline configuration. Use --generate-baseline to create a phpstan-baseline.neon for known false positives.

    Wire in the SilverStripe extension or you'll drown in false positives. Plain PHPStan flags every SS magic method ($page->StaffMembers(), $this->UtilityLinks(), getStaffMembers()) as an error. cambis/silverstan teaches PHPStan about SS's dynamic ORM/has_one/has_many calls — install it if not already in your dev-deps (it's included in the Phase 3 standard toolkit):

    composer require --dev cambis/silverstan
    

    With phpstan/extension-installer present it auto-registers; otherwise add it to phpstan.neon:

    includes:
        - vendor/cambis/silverstan/extension.neon
    

    Then --generate-baseline to adopt the gate incrementally rather than fixing every legacy finding at once. Match the silverstan major to the target CMS major (^1.0 for SS4, ^2.1 for SS5+).

  3. Rector — automated refactoring validation:

    ddev exec vendor/bin/rector --dry-run
    

    silverstripe-rector catches deprecated API usage, class-rename patterns, and namespace issues that phpstan/phpcs miss. Always run with --dry-run first to review changes. See the Phase 4 tip for installation and configuration.

  4. PHPUnit — run the existing test suite:

    ddev exec vendor/bin/phpunit
    

    If no tests exist yet, this is the ideal time to add smoke tests for the upgraded page types and Elemental elements.

    [!NOTE] The PHPCS, PHPStan, and PHPUnit commands above are consistent with the shared code-quality reference in silverstripe-version-upgrade/references/code-quality.md, which also covers GitHub Actions CI setup and common fixes.

  5. Rector in CI — add a rector job to .github/workflows/ci.yml:

    rector:
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@v4
        - uses: shivammathur/setup-php@v2
          with:
            php-version: '8.1'
            extensions: intl, gd, mysqli
            coverage: none
        - run: composer install --no-interaction --prefer-dist
        - run: vendor/bin/rector --dry-run
    
  6. GitHub Actions CI — automate quality gates for every PR:

    # .github/workflows/ci.yml
    name: CI
    on: [pull_request]
    jobs:
      phpcs:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: silverstripe/gha-phpcs@v1
            with:
              path: app/src/
      phpstan:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: shivammathur/setup-php@v2
            with:
              php-version: '8.1'
              extensions: intl, gd, mysqli
              coverage: none
          - run: composer install --no-interaction --prefer-dist
          - run: vendor/bin/phpstan analyse app/src/ --level 2
      phpunit:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: shivammathur/setup-php@v2
            with:
              php-version: '8.1'
              extensions: intl, gd, mysqli
          - run: composer install --no-interaction --prefer-dist
          - run: vendor/bin/phpunit
    

    [!TIP] Start simple: a single ci.yml with just PHPCS and PHPStan will catch 90% of regression issues. Add PHPUnit once the upgrade tests are written. Pin the workflow to run only on pull_request to avoid redundant runs on every push to a feature branch.

  7. Commit the CI config to .github/workflows/ci.yml as part of the upgrade branch — it keeps quality enforcement in sync with the new codebase.

Key Discoveries & Gotchas

[!CAUTION] Global Page class is required — do NOT delete it. Multiple vendor modules do use Page and extend it: silverstripe/errorpage, silverstripe/blog, silverstripe/userforms, silverstripe/cms (SiteTree, RedirectorPage, VirtualPage), dnadesign/silverstripe-elemental, and silverstripe/framework (Security). The file app/src/Page.php must define a global-namespace Page extends SiteTree. Custom project logic goes in App\Pages\Page extends \Page. Deleting the global Page causes a fatal during class-manifest build.

[!CAUTION] A global \PageController must exist for base Page controller resolution. SiteTree::getControllerName() walks the class ancestry appending "Controller". For generic Page/App\Pages\Page records (including those remapped to App\Pages\Page), it looks for a PageController. Without it, those records fall back to ContentController and theme rendering breaks.

Put the global controller in its own file app/src/PageController.php (NOT inside Page.php) and register both in the composer classmap — add classmap as a sibling inside the same autoload block that retains psr-4:

// composer.json
"autoload": {
  "psr-4": { "App\\": "app/src/" },
  "classmap": ["app/src/Page.php", "app/src/PageController.php"]
}
// app/src/PageController.php
class PageController extends App\Controllers\PageController {}

If Page and PageController share one file, the class manifest + classmap can re-include it and fatal with "Cannot declare class Page." Also match the framework init() contract: protected function init() with no return type — public function init(): void fatals under PHP 8 against vendor controllers whose parent declares protected init().

[!WARNING] Gitignored SS3 module directories block dev/build. If old module dirs (silverstripe-versioneddataobjects/, widgets/, userforms/, etc.) are gitignored but still present on disk, SS4's class manifest scans them and finds SS3-incompatible classes — fatals like Class "Versioned" not found. Delete them (rm -rf silverstripe-versioneddataobjects) and document it for new devs (README/devbuild.sh). The same applies to stray PHP under .claude/worktrees/ — drop a .claude/_manifest_exclude marker so the manifest skips it.

[!WARNING] Raw SQL is Mandatory: When migrating from SS3 modules that are removed in SS4 (like dynamic-blocks), the ORM strips legacy $db properties. You cannot rely on $block->Title during migration. You must query the legacy Block and Block_Live tables using raw DB::query() and INSERT ON DUPLICATE KEY UPDATE into the new Elemental records.

[!TIP] PublishRecursive Dangers: Running publishRecursive() on a root page will validate all child elements, including forms. If a UserForms setup has obsolete SS3 class names in the database (e.g., EditableEmailField instead of SilverStripe\UserForms\Model\EditableFormField\EditableEmailField), the publish will fatal error. Always write a migration task to fix ClassName rows in the DB before publishing.

[!IMPORTANT] Image Resize Methods: jonom/focuspoint is rarely carried over to SS4. Update template tags from $Image.FocusFill() to $Image.Fill(X, Y) or native SS4 crop functions.

[!WARNING] ElementalArea_Live is empty after dev/build: SS4 dev/build creates ElementalArea rows on the draft table only. Elements you placed during migration won't appear on the frontend until both ElementalArea_Live and Page_Live.ElementalAreaID are populated. The block migration task must end with these two SQL passes. Full pattern: block-to-element-migration.

[!WARNING] Versioned writes need _Live AND _Versions: When inserting Elements via raw SQL, write to all three tables: base draft, _Live, and _Versions. Skipping _Versions causes "no history" errors when editing in the CMS and can cause publish() to silently strip the record from _Live. See the insertVersionedRow() helper in block-to-element-migration/references/migration-task-skeleton.md.

[!TIP] Legacy CSS still works if you keep the old class: SS3 themes scope CSS to block class names (.pagesectionblock, .promoblock, etc.). Elemental's $CSSClasses outputs element app__elements__elementpagesection instead. To retain existing CSS during migration, add the legacy class to the Elemental wrapper template: <div class="$CSSClasses pagesectionblock">. Defer full CSS rewrite until after migration is verified.

[!WARNING] JS-dependent CSS will collapse: SS3 themes often use JavaScript to set fixed heights on block containers, then position child elements absolutely within them. With the JS gone, position: absolute children leave the parent at height 0 and the layout collapses. When migrating, remove vert-centering (or equivalent) classes from element templates — don't try to revive the JS.

[!NOTE] Template debugging: in dev mode, append ?showtemplate=1 to any URL to see which template SS4 resolved for that page, and ?flush=1 to clear the template cache. Installing lekoala/silverstripe-debugbar (SS4) adds a toolbar showing the resolved controller, the template chain, and DB queries — invaluable when a namespaced class silently falls back to Page.ss.

Related skills

  • silverstripe-version-upgradethe next step. Once the project is on SS4, use it for SS4 → SS5 and later major-version bumps (a recurring, recipe-driven upgrade, mechanically different from this one-time SS3 → SS4 migration).
  • block-to-element-migration — the dedicated Blocks → Elemental workflow referenced throughout Phase 6 (discovery, migration-task skeleton, template duplication, area-suffix convention, verification).

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.