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
npx -y skills add jsirish/silverstripe-skills --skill silverstripe-3-to-4-upgradeAssembled 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:
MigrateContentToElementmovesSiteTree.Contentinto Elemental and blanks the field — but SS4 still has$Contentnatively 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)
| Phase | Key Actions |
|---|---|
| 1. Assessment | Package audit (git diff branch-1 -- composer.json) · spin up legacy ddev instance · plan block-to-elemental migration |
| 2. Architecture | Move mysite to app, introduce public directory |
| 3. Dependencies | Update composer.json to ^4.0, update PHP constraints |
| 4. Namespaces | Apply PSR-4 namespaces, remap config class names |
| 5. DB Preflight Fixes | Clear __TEMP__ table collisions and FULLTEXT indices |
| 6. Data Migration | Run file migration, Elemental tasks, and custom SQL |
| 7. Templates | Fix HomePage resolution, update $Link to $URL |
| 8. Build & Verify | dev/build flush=1, QA frontend and admin |
| 9. Code Quality & CI | PHPCS, 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 showor packagist) - Whether it stored data that needs migrating (DataObject tables in the DB)
Common removals and their consequences:
| SS3 Package | What it provided | SS4 situation |
|---|---|---|
silverstripe/widgets | Blog 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-blocks | Arbitrary content blocks on pages | Replace with dnadesign/silverstripe-elemental. Requires BlockMigrationTask. |
dynamic/dynamic-blocks | Same as sheadawson blocks, Dynamic flavour | Same as above. |
dynamic/core-tools | GlobalSiteSetting, various helpers | Adopt 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/secureassets | Protected file storage | Merged into SS4 core (silverstripe/assets). No action needed, but verify .protected/ path. |
heyday/silverstripe-versioneddataobjects | Versioning for non-Page DataObjects | Replaced by Versioned extension (built into SS4). Remove and add $extensions = [Versioned::class] manually. |
dynamic/flexslider | Slider block type | Rebuilt as ElementPageSection or similar custom Elemental element. |
i-lateral/silverstripe-searchable | Site search | Version ^2.0 available for SS4. |
[!WARNING]
silverstripe/widgets+ blog: If the SS3 site used blog sidebar widgets, addsilverstripe/widgets ^2.4to the SS4 project. It is NOT dropped in blog ^3.x — it's optional. After requiring it: (1) applyWidgetPageExtensionto Blog + BlogPost in extensions.yml, (2) run dev/build, (3) backfillWidget_LiveandWidgetArea_Livefrom draft tables using explicit column lists (column order differs between draft and live tables). Don't rely onINSERT 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
-legacyinstance hits the same Mutagenupload_dirsconflict as the upgrade instance on its first prod sync (Mutagen sync completed with problems … unable to relocate staged file: file exists). Setupload_dirs: [assets]in both projects'.ddev/config.yaml, thenddev mutagen reset && ddev restart. See theddev-syncskill (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.siteinstead 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-legacyfor an earlier example. Both use the same ddev config structure.
1c. Package Assessment (original step)
- Audit Packages: Determine which legacy SS3 modules can be replaced with native SS4/Dynamic equivalents.
- Block Assessment: Legacy
dynamic/dynamic-blocks(orsheadawson/silverstripe-blocks) must be mapped todnadesign/silverstripe-elemental. - 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
mysitedirectory toapp. - Move
Page.phpandPageController.phptoapp/src/. - Introduce a
publicdirectory. Moveassets/intopublic/assets/, and createpublic/index.phpandpublic/.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-blocks➔dnadesign/silverstripe-elementaldynamic/core-tools➔dynamic/silverstripe-site-tools(viasilverstripe-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/silverstanto^2.1andsilverstripe/recipe-testingto^3.0. Check the latest release on Packagist if in doubt.
Phase 4: Code & Namespace Migration
SS4 heavily relies on PHP namespaces.
- Namespacing: Add namespaces to all classes in
app/src/(e.g.,namespace App\Pages;ornamespace Dynamic\Base\Page;). - 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-rectorto automate the bulk of the work. After requiring it (see dev-dependencies above), configurerector.phpand run:vendor/bin/rector --dry-run vendor/bin/rector # apply when readyThis 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/" }
}
-
DB ClassName Remapping (
DatabaseAdmin.classname_value_remapping): map every SS3 short class name stored in the database to its SS4 namespaced equivalent. This runs duringdev/buildand rewrites allClassNamecolumns across every DataObject table (including_Liveand_Versions) — not just SiteTree. Without it, SS4 can't resolve the stored class names and pages fall back toPage.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 toapp/_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
ClassNamevalues. A common SS3→SS4 smell is a hand-rolledBuildTaskdoingUPDATE ... SET ClassName = '...'. That's exactly whatclassname_value_remappingis for — it's idempotent, runs duringdev/build, and covers base +_Live+_Versionsautomatically. Custom SQL is redundant and error-prone.One exception:
classname_value_remappingonly touches theClassNamecolumn, notParentClassor other string fields that store class names. A task likeFormParentClassMigrationTaskthat fixesEditableFormField.ParentClassis still legitimate — but trim it to only fixParentClass, notClassName(which the config now handles). -
SSViewer themes config — include
$publicand$defaultso vendor module templates resolve:SilverStripe\View\SSViewer: themes: - '$public' - mytheme - '$default' -
Legacy Method Signatures: SS4 alters some Core method signatures.
- Example:
Permission::check($member, 'any')in SS3 is nowPermission::check('any', 'any', $member)or simpler. Remove legacy 'any' parameters if causing type errors.
- Example:
Phase 5: Database Schema Preflight Fixes
Before dev/build can succeed, you must resolve SS3 legacy schema blockers.
[!CAUTION] FULLTEXT Indexes on File: SS3
Filetables sometimes have a FULLTEXT index onFilenamewhich 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_Versionstables 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
/migrateslash 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.mdso a developer can type/migrateand 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 amigrate.shrunner (see thedevbuild.shconvention in Phase 8), not indevbuild.sh.
-
Fix Corrupted ParentClasses (Critical)
ddev sake dev/tasks/form-parent-migrationNote: Resolves un-namespaced ClassNames on EditableFormField records to prevent publishRecursive crashes.
-
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) movesSiteTree.Contentinto anElementContentblock and then blanksContent. 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 readSiteTree.Content. IfPage.ss/BlogPost.ss/ your page templates output$Contentdirectly — the Dynamic base-site default — it empties those fields and breaks blog listing excerpts ($Summary/$Excerptderive fromContent), content alignment, and anything else reading$Content, for no benefit.Decide with the minimal-transformation test from Philosophy:
SiteTree.Contentexists 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.)
-
Custom Block Migration
ddev sake dev/tasks/block-migrationNote: Dev/build renames obsolete classes to
_obsolete_PromoObject. The custom task must read from these_obsolete_tables viaDB::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
$Linkto$URLin template.ssfiles. - Elemental Areas: Replace
<% with $Blockarea(AreaName) %>with$ElementalAreaor 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,WidgetHolderstructure,SectionNavigationBlockreplacements, andMenuTitlevsTitlenav 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\HomePageexpectsthemes/mytheme/templates/Dynamic/Base/Page/HomePage.ss— NOTtemplates/HomePage.ss. Without this, the page falls back toPage.ss, potentially ruining full-width layout constraints.
[!NOTE] Template Variant Naming: Elemental uses
getAreaRelationName()for suffixing. If a page hashas_one: ['ElementalHomePage' => ElementalArea::class], the variant file must be namedElementPromos_ElementalHomePage.ss.
Phase 8: Build & Verify
[!IMPORTANT]
devbuild.shis cache-rebuild-only — keep migration tasks out of it. DeployHQ runsdevbuild.shafter 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/buildData-migration tasks belong in a separate
migrate.sh(or the/migrateslash command — see Phase 6), invoked manually after a fresh prod sync. Two reasons:
- No double
dev/build. A full-loop script that runs an explicitdev/buildand thenddev exec ./devbuild.shwill build twice ifdevbuild.shalso runsdev/buildplus 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-manufacturingandexample-multiareasettled on the 3-linedevbuild.sh; an earlyexample-customshipped 50 lines of tasks + SQL inside it and hit exactly this double-build/re-migrate trap.)
-
Run dev/build:
ddev sake dev/build flush=1 -
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_remappingentry or broken template resolution) - Template fallback issues — use
?showtemplate=1to confirm the correct template is resolving - Broken images —
jonom/focuspointmigrations require template updates
- Blank pages (likely a missing
-
CMS admin QA: Verify pages load in the CMS tree, elements render in the Elemental editor, and the SiteConfig / Settings section works.
-
Elemental areas: Confirm
_Livetables are populated. If ElementalArea_Live is empty, run the migration task's final SQL passes (see block-to-element-migration). -
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/reportSee the
visual-regression-upgradeskill (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.neonoften doesincludes: 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.distoften points at a vendor test dir that isn't installed (e.g.vendor/<org>/<tools>/tests), and there may be noapp/tests/directory at all.Run each tool once and confirm it executes — "the config is present" is not the same as "the gate runs."
-
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.
-
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-baselineto create aphpstan-baseline.neonfor 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/silverstanteaches PHPStan about SS's dynamic ORM/has_one/has_manycalls — install it if not already in your dev-deps (it's included in the Phase 3 standard toolkit):composer require --dev cambis/silverstanWith
phpstan/extension-installerpresent it auto-registers; otherwise add it tophpstan.neon:includes: - vendor/cambis/silverstan/extension.neonThen
--generate-baselineto adopt the gate incrementally rather than fixing every legacy finding at once. Match the silverstan major to the target CMS major (^1.0for SS4,^2.1for SS5+). -
Rector — automated refactoring validation:
ddev exec vendor/bin/rector --dry-runsilverstripe-rectorcatches deprecated API usage, class-rename patterns, and namespace issues that phpstan/phpcs miss. Always run with--dry-runfirst to review changes. See the Phase 4 tip for installation and configuration. -
PHPUnit — run the existing test suite:
ddev exec vendor/bin/phpunitIf 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.
-
Rector in CI — add a
rectorjob 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 -
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.ymlwith just PHPCS and PHPStan will catch 90% of regression issues. Add PHPUnit once the upgrade tests are written. Pin the workflow to run only onpull_requestto avoid redundant runs on every push to a feature branch. -
Commit the CI config to
.github/workflows/ci.ymlas part of the upgrade branch — it keeps quality enforcement in sync with the new codebase.
Key Discoveries & Gotchas
[!CAUTION] Global
Pageclass is required — do NOT delete it. Multiple vendor modules douse Pageand extend it:silverstripe/errorpage,silverstripe/blog,silverstripe/userforms,silverstripe/cms(SiteTree, RedirectorPage, VirtualPage),dnadesign/silverstripe-elemental, andsilverstripe/framework(Security). The fileapp/src/Page.phpmust define a global-namespacePage extends SiteTree. Custom project logic goes inApp\Pages\Page extends \Page. Deleting the global Page causes a fatal during class-manifest build.
[!CAUTION] A global
\PageControllermust exist for base Page controller resolution.SiteTree::getControllerName()walks the class ancestry appending"Controller". For genericPage/App\Pages\Pagerecords (including those remapped toApp\Pages\Page), it looks for aPageController. Without it, those records fall back toContentControllerand theme rendering breaks.Put the global controller in its own file
app/src/PageController.php(NOT insidePage.php) and register both in the composerclassmap— addclassmapas a sibling inside the sameautoloadblock that retainspsr-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
PageandPageControllershare one file, the class manifest + classmap can re-include it and fatal with "Cannot declare class Page." Also match the frameworkinit()contract:protected function init()with no return type —public function init(): voidfatals under PHP 8 against vendor controllers whose parent declaresprotected 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 likeClass "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_excludemarker 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$dbproperties. You cannot rely on$block->Titleduring migration. You must query the legacyBlockandBlock_Livetables using rawDB::query()andINSERT ON DUPLICATE KEY UPDATEinto 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.,EditableEmailFieldinstead ofSilverStripe\UserForms\Model\EditableFormField\EditableEmailField), the publish will fatal error. Always write a migration task to fixClassNamerows in the DB before publishing.
[!IMPORTANT] Image Resize Methods:
jonom/focuspointis 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/buildcreatesElementalArearows on the draft table only. Elements you placed during migration won't appear on the frontend until bothElementalArea_LiveandPage_Live.ElementalAreaIDare 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_Versionscauses "no history" errors when editing in the CMS and can causepublish()to silently strip the record from_Live. See theinsertVersionedRow()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$CSSClassesoutputselement app__elements__elementpagesectioninstead. 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: absolutechildren leave the parent at height 0 and the layout collapses. When migrating, removevert-centering(or equivalent) classes from element templates — don't try to revive the JS.
[!NOTE] Template debugging: in dev mode, append
?showtemplate=1to any URL to see which template SS4 resolved for that page, and?flush=1to clear the template cache. Installinglekoala/silverstripe-debugbar(SS4) adds a toolbar showing the resolved controller, the template chain, and DB queries — invaluable when a namespaced class silently falls back toPage.ss.
Related skills
- silverstripe-version-upgrade — the 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).