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=1Frontend 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-runGitHub 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).