Back to Overview
MASSIVE ART_640x640_Martin Lagler
Martin Lagler

Core Developer

Sulu Core Developer and our sunny Swiss army knife, always in motion whether kitesurfing, running, or mastering whatever challenge we throw his way.

Back to Overview

What upgrading Sulu 3.0 taught us about controlled migrations

We run sulu.io on Sulu in production — so upgrading to Sulu 3.0 gave us a chance to see what actually works, what doesn’t, and what changes in practice.

Sulu 3.0 is the most significant architectural change we’ve made in years. PHPCR content storage is gone, replaced by Doctrine ORM. The ArticleBundle, SnippetBundle, PageBundle, and RouteBundle have moved to new hexagonal-architecture namespaces. MassiveSearchBundle is out, replaced by SEAL. So it was time to upgrade our own site and see how this plays out in a real-world project.

This isn’t a polished success story. It’s a look at what upgrading a production system actually involves including the issues we ran into, what we fixed along the way, and what worked better than expected.

If you’re planning a migration, this is less about following a checklist and more about understanding what changes with Sulu 3.0 and what that means for how you build and evolve your project going forward.
 

Initial setup:
If you want to follow along or apply the same approach to your own project, you'll need:

  • A Sulu 2.6 project (fully patched to the latest 2.6.x)
  • PHP 8.2+
  • Symfony 7.x
  • A local environment that can run two Sulu installations side by side
  • Access to your production database and Jackrabbit workspace

Upgrades start with clarity

Before touching Sulu 3.0, we brought our 2.6 installation fully up to date. That meant updating Sulu itself and all related bundles (automation-bundle, form-bundle, headless-bundle, redirect-bundle) to their latest 2.x versions first, then running all pending PHPCR migrations and cleaning up the repository

bin/adminconsole phpcr:migrations:migrate

bin/adminconsole sulu:document:phpcr-cleanup

The cleanup removes unused properties from the PHPCR repository — nodes that naturally accumulate over years of content changes. Starting the 3.0 migration from a clean 2.6 baseline makes a noticeable difference: less noise in the migration output, and much clearer visibility when something behaves differently than expected.

It's a small step, but an important one. The cleaner your starting point, the easier it is to understand what actually changes during the upgrade.

Another useful reference throughout the process was the Sulu skeleton compare between 2.x and 3.x. It gives a clear diff of what changed in dependencies, bundle registration, and configuration — a helpful sanity check alongside the upgrade guide.

The UPGRADE-3.x.md pre-upgrade checklist covers all of this in detail. If you're planning your own upgrade, start there — it saves time later on.

Two websites, running in parallel

Rather than upgrading the existing project in place, we cloned it and ran the Sulu 3.0 upgrade in isolation, keeping the original 2.6 installation untouched alongside it.

The old site ran on Jackrabbit. The new one used pure Doctrine ORM. Having both setups locally meant we could take production data, run the migration into the 3.0 setup, compare the results and repeat the process after each fix.

That ability to run the migration multiple times made a big difference. Instead of treating the upgrade as a one-time operation, we could iterate until the result was consistent.

This turned out to be the most important decision in the process. By the time we went live, the migration wasn't something new anymore, it was something we had already run and verified several times. 

This only works if the migration process is predictable — something we wanted to validate with Sulu 3.0.

Dependencies and the bundle cleanup

The dependency changes for Sulu 3.0 are substantial, but they also make the project cleaner.

On the removal side: sulu/article-bundle is now part of core, massive/search-bundle and handcraftedinthealps/zendsearch are gone, and the Jackalope/Jackrabbit packages are no longer needed. On the addition side, we added SEAL (cmsig/seal-symfony-bundle) with the Loupe adapter for local search, and Flysystem for media storage.

Bundle registration changed as well. The old namespaces are gone; the new ones follow a hexagonal architecture pattern:

Sulu\Article\Infrastructure\Symfony\HttpKernel\SuluArticleBundle::class => ['all' => true],
Sulu\Page\Infrastructure\Symfony\HttpKernel\SuluPageBundle::class => ['all' => true],
Sulu\Snippet\Infrastructure\Symfony\HttpKernel\SuluSnippetBundle::class => ['all' => true],
config/bundles.php

We also updated our project-specific bundles — automation-bundle, form-bundle, headless-bundle, and redirect-bundle — to their Sulu 3.0-compatible versions.

It looks like a lot at first, mostly because several long-standing dependencies disappear at once. But once you work through it systematically, each change is straightforward. The upgrade guide covers the steps in detail.

Some issues only show up with new content

Most config changes showed up locally while working through the upgrade guide. But one only surfaced in production: the route_schema parameter in the article template configuration.

In Sulu 3.0, article templates use group instead of type, and the route_schema parameter needs to be set explicitly:

<params>
    <param name="route_schema" value="/blog/{implode('-', object)}"/>
</params>
config/templates/articles/blog.xml

We only noticed this after publishing the first new blog post post-upgrade — it was missing the /blog prefix. Everything looked correct in the admin. The difference only appeared when creating new content.

That was a useful reminder: some changes don't show up when rendering existing content, only when new content is created.

Other changes worth double-checking in your templates include routePath → url property type, excerpt.images → excerpt.image (singular), and excerpt.icon is no longer an array. All of these are covered in the upgrade guide.

Migrating production data

With the code changes in place and the admin running without errors, we moved on to the data migration.

The process itself is straightforward:

  1. Export the production database and workspace files from Jackrabbit
  2. Import everything into the local 2.6 installation
  3. Run PHPCR migrations and cleanup on that data
  4. Export the SQL database
  5. Import into the local 3.0 installation
  6. Run the Doctrine and PHPCR-to-ORM migrations:
bin/adminconsole doctrine:migrations:migrate
bin/adminconsole sulu:phpcr-migration:migrate

The SuluPHPCRMigrationBundle handles the content migration from PHPCR to Doctrine ORM. From a tooling perspective, this part is well covered.

What matters more is what happens after the migration runs. The output is not something you can assume to be correct — it needs to be verified against real content and real use cases.

That's where the real work starts.

Testing: the diff script

After the first migration run, we needed a way to verify that every page still rendered correctly. Clicking through hundreds of URLs manually wasn't an option.

Daniel from our team built a small script that crawls every URL in the sitemap, compares the output against a production snapshot, and logs any differences. This gave us a precise view of what had actually changed — and it's where the real issues surfaced. 

The script itself is intentionally simple: fetch, compare, report. No additional tooling required beyond a working Sulu 2.6 production snapshot.

More importantly, it gave us a reliable feedback loop. Instead of guessing whether the migration worked, we could verify it.

We ran the migration multiple times: find a bug, fix it, re-run the migration from the exported production data, check the diff again. Each iteration got cleaner, until the result was consistent.

The bugs we found and fixed

Production data tends to surface issues that don't appear in development setups. Here's what we encountered during the migration of sulu.io — and how we fixed it.

Textarea encoding in the PHPCR migration

We use text_area fields for code blocks on sulu.io, some of which contain JSON-formatted strings. During the PHPCR migration, those strings were incorrectly decoded, which corrupted the content.

The fix skips the decoding step for text_area and text_line fields, preserving the original values. Shipped in SuluPHPCRMigrationBundle #47.

SmartContent tag filtering after migration

SmartContent blocks filtering by tag stopped working after the migration. The root cause: Sulu 3.0 changed the internal storage of tag filters from IDs to names, while the SmartContent providers still expected IDs. As a result, filtered article lists returned all entries instead of the intended subset.

Fixed in sulu/sulu #8704.

SmartContent template params ignored

XML-defined params such as templateKeys were not applied to SmartContent queries at runtime. Template-level filtering had no effect, meaning article listings were no longer properly scoped (e.g. blog posts vs. guides).

The fix ensures XML params are respected, introduces the new groups param for articles, and deprecates the old types/structureTypes params. Shipped in sulu/sulu #8716, with a corresponding update in SuluHeadlessBundle #158.

HeadlessWebsiteController API change

The resolveHeadlessData() method signature changed — it now requires a DimensionContentInterface object and locale string. The serializeData() helper was removed.

Our custom controllers needed to be updated accordingly, and the upgrade documentation now includes a note about this change. Covered in SuluHeadlessBundle #156.

Going live

By the time we went live, the migration had already been run and verified multiple times locally. The production deployment itself was straightforward: deploy the updated code, import the migrated database. No content re-entry, no complex cutover.

The upfront investment in the parallel setup and iterative testing paid off here. There were no surprises on the day.

What this upgrade confirmed

Running this upgrade on a real production system confirmed a few things for us:

  • The ecosystem is production-ready. We're running automation-bundle, form-bundle, headless-bundle, and redirect-bundle on Sulu 3.0 in production — and they work as expected.
  • Headless setups require a slightly different perspective. sulu.io runs a Remix frontend, so the Twig extension changes in the upgrade guide didn't apply to us. If you're working in a similar setup, you can skip that part.
  • Iterate before you cut over. Running the migration five times against real production data is what made the actual deployment uneventful. The parallel local setup was the single best decision we made.
  • Running your own software is the best test. The bugs above were only found because we ran the upgrade on real production data. Fixing them improved the platform for everyone — that's how open source evolves in practice.
     

What this upgrade showed us is that migrating to Sulu 3.0 isn’t about taking a risk — it’s about running a process you can understand, verify, and repeat.

If you're on Sulu 2.6 and planning your upgrade, start with the UPGRADE-3.x.md guide. For questions, GitHub Discussions and Slack are the best places to reach us.

And if you'd like a hand with your upgrade — whether it's a quick second opinion or someone to walk through it with you — just reach out. We're always happy to help, no matter how big or small your project is.

What's next

The bundles we used during this upgrade — automation-bundle, form-bundle, headless-bundle, and redirect-bundle — are currently in release candidate stage. We're looking forward to their stable releases and will update this post once they're out.

If you run into anything during your own upgrade, we'd love to hear about it. Every bug report and piece of feedback helps make the next upgrade smoother for everyone.

MASSIVE ART_640x640_Martin Lagler
Martin Lagler

Core Developer

Sulu Core Developer and our sunny Swiss army knife, always in motion whether kitesurfing, running, or mastering whatever challenge we throw his way.