Technical Guide

Pimcore Upgrade 10 to 12: The Real Migration Path

The definitive guide to upgrading Pimcore 10 to 12. PHP 8.3, Symfony 7, DBAL 4, collation migration, event system, ExtJS 7, Redis, OpenSearch, Kubernetes, Flysystem, and every undocumented bug we found.

March 20, 202645 min readOronts Engineering Team

Why You Can't Skip Pimcore 11

The first thing every team asks: can we jump straight from 10 to 12? No. Pimcore 11 is the mandatory stepping stone. The framework changes are too large to absorb in one step, and Pimcore's own migration tooling assumes the intermediate version is in place.

Here's the full scope of what changes across the two major versions:

ComponentPimcore 10.6Pimcore 11Pimcore 12
PHP8.0+8.1+8.3+
Symfony5.46.2+6.4 / 7.x
Doctrine DBAL2.x / 3.x3.x4.x
Admin UIBuilt-in (ExtJS 6)Separate bundle (ExtJS 6/7)Separate bundle (ExtJS 7) + Studio UI (React)
LicenseGPLv3GPLv3POCL (Pimcore Open Core License)
Core bundlesMonolithicExtractedExtracted
Search backendBuilt-inSimple Backend Search BundleGeneric Data Index + OpenSearch
WYSIWYGTinyMCE built-inTinyMCE bundleTinyMCE or Quill bundle
Product registrationNoneNoneRequired (offline validation)
Event system (JS)Plugin BrokerEvent ListenersEvent Listeners + Studio UI events
Collationutf8mb4_general_ciutf8mb4_general_ciutf8mb4_unicode_520_ci (required)
CachePimcore adapterSymfony adapterSymfony adapter (config change)
Full page cacheBuilt-inBuilt-inSeparate bundle option
Asset storageLocal / FlysystemLocal / FlysystemLocal / Flysystem (with bugs on remote)

We've done this upgrade on multiple enterprise Pimcore installations ranging from B2B PIMs to multi-site CMS platforms. The patterns described here are product-generic. For context on how we approach PIM implementations and system architecture, those guides cover our broader methodology. Our methodology page explains how we plan these kinds of high-risk migrations.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Pimcore 10.6   β”‚ ──▢ β”‚  Pimcore 11.x   β”‚ ──▢ β”‚  Pimcore 12.x   β”‚
β”‚  Symfony 5.4    β”‚     β”‚  Symfony 6.2+   β”‚     β”‚  Symfony 6.4/7  β”‚
β”‚  PHP 8.0+       β”‚     β”‚  PHP 8.1+       β”‚     β”‚  PHP 8.3+       β”‚
β”‚  DBAL 2.x/3.x   β”‚     β”‚  DBAL 3.x       β”‚     β”‚  DBAL 4.x       β”‚
β”‚  ExtJS 6        β”‚     β”‚  ExtJS 6/7      β”‚     β”‚  ExtJS 7 +      β”‚
β”‚                 β”‚     β”‚                 β”‚     β”‚  Studio UI      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Phase 1: Preparation (Before Touching Composer)

This phase takes the longest and saves the most time. Every hour spent here saves days of debugging later.

1.1 Pre-Upgrade Checklist

# Verify current version
bin/console --version

# Ensure all migrations are current
bin/console doctrine:migrations:up-to-date
bin/console doctrine:migrations:migrate

# Empty all message queues before upgrade
# Workers must finish processing before you change the codebase
bin/console messenger:consume --limit=0

Back up everything: database (full mysqldump), files (assets directory, var directory, config directory), and create a dedicated upgrade branch in version control. Test your restore procedure before starting.

1.2 Deprecated Doctrine Methods (DBAL 2/3 to 4)

This is the most labor-intensive preparation step. Every repository class, every raw SQL query, every custom service that touches the database needs updating. In a typical enterprise installation, expect 50-150 places.

Deprecated (DBAL 2/3)Replacement (DBAL 4)Notes
$db->query($sql)$db->executeQuery($sql)Return type changes to Result
$db->executeUpdate($sql)$db->executeStatement($sql)Returns affected row count as int
$db->fetchRow()$db->fetchAssociative()Returns associative array or false
$db->fetchAll()$db->fetchAllAssociative()Returns array of associative arrays
$db->fetchCol()$db->fetchFirstColumn()Returns indexed array
$db->fetchColumn()$db->fetchOne()Single value fetch
$db->fetchPairs()Pimcore\Db\Helper::fetchPairs()Pimcore helper
$db->insertOrUpdate()Pimcore\Db\Helper::upsert()Pimcore helper
$db->quoteInto()Pimcore\Db\Helper::quoteInto()Pimcore helper
$db->deleteWhere()$db->executeStatement() with DELETEManual SQL
$db->updateWhere()$db->executeStatement() with UPDATEManual SQL
$db->quote($val, $type)$db->quote($val)Strings only in DBAL 4, no type param
Pimcore\Db\ConnectionInterfaceDoctrine\DBAL\ConnectionType hint change in DI
Pimcore\Db\ConnectionDoctrine\DBAL\ConnectionClass replacement
# Find all deprecated method calls in your codebase
grep -rn "->query(" src/ --include="*.php"
grep -rn "->fetchRow(" src/ --include="*.php"
grep -rn "->fetchAll(" src/ --include="*.php" | grep -v "fetchAllAssociative"
grep -rn "->fetchCol(" src/ --include="*.php"
grep -rn "->insertOrUpdate(" src/ --include="*.php"
grep -rn "->quoteInto(" src/ --include="*.php"
grep -rn "->deleteWhere(" src/ --include="*.php"
grep -rn "->executeUpdate(" src/ --include="*.php"
grep -rn "Pimcore\\Db\\Connection" src/ --include="*.php"

Full migration example for a repository class:

// BEFORE (Pimcore 10 / DBAL 2-3)
use Pimcore\Db\ConnectionInterface;

class PriceExporter
{
    public function __construct(
        private readonly ConnectionInterface $connection
    ) {}

    public function export(int $limit, int $offset): array
    {
        $sql = "SELECT p.o_id, p.price FROM object_store_1 p WHERE p.o_published = 1 LIMIT ? OFFSET ?";
        return $this->connection->query($sql)->fetchAll();
    }
}

// AFTER (Pimcore 12 / DBAL 4)
use Doctrine\DBAL\Connection;

class PriceExporter
{
    public function __construct(
        private readonly Connection $connection
    ) {}

    public function export(int $limit, int $offset): array
    {
        $sql = "SELECT p.id, p.price FROM object_store_1 p WHERE p.published = 1 LIMIT ? OFFSET ?";
        return $this->connection->executeQuery($sql, [$limit, $offset])->fetchAllAssociative();
    }
}

Update your services.yaml if using explicit dependency injection:

services:
    App\Export\PriceExporter:
        arguments:
            $connection: '@doctrine.dbal.default_connection'

1.3 Return Type Declarations

Pimcore 11+ enforces strict return types on model classes. Every class extending Pimcore base classes needs updating:

// Before (Pimcore 10)
public function save()
{
    // custom logic
    parent::save();
}

// After (Pimcore 11+)
public function save(array $parameters = []): static
{
    // custom logic
    return parent::save($parameters);
}

This affects every DataObject model class, every custom listing, and every service that overrides Pimcore methods. Check all classes in your src/Model/ directory.

1.4 Remove the o_ Prefix

Pimcore 11 removes the o_ prefix from database column names in the objects table. Every raw SQL query and listing condition that references these columns needs updating:

Old (Pimcore 10)New (Pimcore 11+)Context
o_idid (or oo_id in object store tables)Primary key
o_creationDatecreationDateTimestamp
o_modificationDatemodificationDateTimestamp
o_pathpathTree path
o_keykeyObject key/slug
o_publishedpublishedPublication flag
o_parentIdparentIdParent reference
o_typetypeObject type
o_classNameclassNameClass name
o_classIdclassIdClass ID
o_userOwneruserOwnerCreator user ID
o_userModificationuserModificationLast modifier user ID
o_versionCountversionCountVersion counter
# Find all o_ prefix usage across PHP and JavaScript
grep -rn "o_id\|o_path\|o_key\|o_published\|o_parentId\|o_type\|o_className\|o_classId" src/ --include="*.php"
grep -rn "o_className\|o_id" public/ --include="*.js"

Example listing condition migration:

// Before
$listing->setCondition('o_path LIKE ? AND o_published = 1', ['/products/%']);

// After
$listing->setCondition('path LIKE ? AND published = 1', ['/products/%']);

1.5 PHP 8.3 / 8.4 Breaking Changes

Beyond what Pimcore requires, PHP 8.3+ introduces its own breaking changes:

Implicitly nullable parameters (deprecated in 8.4):

// DEPRECATED: implicit nullable
public function findByLocale(string $locale = null): array

// FIXED: explicit nullable
public function findByLocale(?string $locale = null): array

This affects every method signature with = null as default. In a typical enterprise codebase, expect 50+ occurrences. Use Rector to automate:

// rector.php
use Rector\Config\RectorConfig;
use Rector\Php84\Rector\Param\ExplicitNullableParamTypeRector;

return static function (RectorConfig $rectorConfig): void {
    $rectorConfig->paths([__DIR__ . '/src']);
    $rectorConfig->rule(ExplicitNullableParamTypeRector::class);
};
# Preview changes
vendor/bin/rector process --dry-run

# Apply changes
vendor/bin/rector process

Other PHP 8.3+ features you can adopt:

// Typed class constants (PHP 8.3)
public const string DEFAULT_LOCALE = 'en';
public const array SUPPORTED_TYPES = ['product', 'category'];

// #[\Override] attribute (PHP 8.3)
#[\Override]
public function normalize(mixed $object, ?string $format = null, array $context = []): array

// json_validate() (PHP 8.3)
if (json_validate($input)) { /* ... */ }

1.6 JavaScript Event System Migration

The old plugin broker system is removed in Pimcore 11. All admin UI JavaScript must migrate:

// BEFORE (Pimcore 10 - Plugin Broker)
pimcore.plugin.datasheet = Class.create(pimcore.plugin.admin, {
    getClassName: function () {
        return "pimcore.plugin.datasheet";
    },
    initialize: function () {
        pimcore.plugin.broker.registerPlugin(this);
    },
    postOpenObject: function (object, type) {
        if (object.data.general.o_className === 'Product') {
            // Add toolbar button
        }
    }
});

// AFTER (Pimcore 11+ - Event Listeners)
(function() {
    'use strict';
    document.addEventListener(pimcore.events.postOpenObject, (e) => {
        let object = e.detail.object;
        if (object.data.general.className === 'Product') {  // o_className -> className
            object.toolbar.add({
                text: t('Generate PDF'),
                iconCls: 'pimcore_icon_pdf',
                handler: function () {
                    Ext.Ajax.request({
                        url: '/api/generate-pdf/' + object.id,
                        success: function (response) {
                            pimcore.helpers.showNotification(
                                t("success"), t("PDF generated"), "success"
                            );
                        }
                    });
                }
            });
            pimcore.layout.refresh();
        }
    });
})();

Additional JavaScript changes:

Old (Pimcore 10)New (Pimcore 11+)Notes
ts()t()Translation function
pimcore.helpers.addCsrfTokenToUrl()Remove entirelyCSRF handled differently
o_className in JSclassNameMatches PHP change
Class.create(...)Still supported, but prefer ES6 classesFuture-proofing
/admin/tags/*May change to /pimcore-admin/tags/*Check your admin routes

1.7 ExtJS 7 Changes (Pimcore 12)

Pimcore 12 uses ExtJS 7.x. Key breaking changes for custom admin UI code:

// ExtJS 6 (Pimcore 10/11)
var store = new Ext.data.Store({
    proxy: {
        type: 'ajax',
        reader: {
            type: 'json',
            root: 'data'  // DEPRECATED in ExtJS 7
        }
    }
});

// ExtJS 7 (Pimcore 12)
var store = new Ext.data.Store({
    proxy: {
        type: 'ajax',
        reader: {
            type: 'json',
            rootProperty: 'data'  // Use rootProperty
        }
    }
});

Verify all custom grid operators, admin plugins, and data importer extensions for ExtJS 7 compatibility. Common issues:

  • root property renamed to rootProperty in store readers
  • Some grid column configs changed
  • Prototype patching (Ext.override) may break if internal class structure changed
  • Check all iconCls references still exist in Pimcore 12's icon set

1.8 Pimcore Studio UI (React)

Pimcore 12 introduces the new Studio UI built with React, running alongside the classic ExtJS admin. This is not a replacement yet but an addition:

  • Studio UI handles some new features (Generic Data Index UI, some asset management)
  • Classic Admin UI (ExtJS) remains the primary interface for most operations
  • Custom ExtJS plugins continue to work in the classic admin
  • New extension points for Studio UI use React components
  • Long-term direction is toward React, but full migration is not required for P12

If you have custom admin UI extensions, they will continue to work in the classic admin. No immediate migration to React is needed. But if you're building new admin functionality, consider building it for Studio UI instead. We wrote a detailed walkthrough on building a production-grade Pimcore 12 bundle with Studio UI integration that covers the full process from bundle skeleton to React component registration.

1.9 Remove SensioFrameworkExtraBundle

This bundle is deprecated and removed in Symfony 6. Replace all annotations:

// Before (Sensio)
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;

/** @Template("default/default.html.twig") */
public function defaultAction()
{
    return [];
}

// After (Symfony 6+)
public function defaultAction(): Response
{
    return $this->render('default/default.html.twig', []);
}

1.10 Symfony Route Annotations to Attributes

All 15+ controller files need this change:

// Before (Symfony 5.4 annotations)
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/{_locale}/{slug}_p{id}",
 *     name="product_detail",
 *     methods={"GET"},
 *     requirements={"slug"="[\w\s\-\.]+", "id"="[\w\. ]+"}
 * )
 */
public function __invoke(Request $request, string $id): Response

// After (Symfony 6.4+ attributes)
use Symfony\Component\Routing\Attribute\Route;

#[Route(
    path: '/{_locale}/{slug}_p{id}',
    name: 'product_detail',
    methods: ['GET'],
    requirements: ['slug' => '[\w\s\-\.]+', 'id' => '[\w\. ]+']
)]
public function __invoke(Request $request, string $id): Response

Update config/packages/routing.yaml:

# Change type from 'annotation' to 'attribute'
controllers:
    resource:
        path: ../src/Controller/
        namespace: App\Controller
    type: attribute

1.11 Symfony Serializer Changes (Symfony 7.x)

If you have custom Normalizers (common for search indexing, API serialization), Symfony 7.x requires a new method:

// Before (Symfony 5.4/6.x)
class ProductNormalizer implements NormalizerInterface
{
    public function normalize(mixed $object, string $format = null, array $context = []): array
    {
        // ...
    }

    public function supportsNormalization(mixed $data, string $format = null): bool
    {
        return $data instanceof Product;
    }
}

// After (Symfony 7.x)
class ProductNormalizer implements NormalizerInterface
{
    public function normalize(mixed $object, ?string $format = null, array $context = []): array
    {
        // ...
    }

    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
    {
        return $data instanceof Product;
    }

    // NEW: Required in Symfony 7.x
    public function getSupportedTypes(?string $format): array
    {
        return [
            Product::class => true,
        ];
    }
}

If you have 10+ normalizers (typical for MeiliSearch or OpenSearch indexing), this is a repetitive but critical change. Missing getSupportedTypes() causes a fatal error on Symfony 7.x.

1.12 Request::get() Deprecation

Request::get() is deprecated in Symfony 6.x. Use specific accessor methods:

// Deprecated
$value = $request->get('param');

// Use specific methods
$value = $request->query->get('param');       // GET parameters
$value = $request->request->get('param');     // POST parameters
$value = $request->attributes->get('param');  // Route parameters
$value = $request->headers->get('param');     // Headers
$value = $request->query->getBoolean('flag'); // Boolean GET parameter

1.13 Install Compatibility Bridge

composer require --no-update pimcore/compatibility-bridge-v10
composer require --no-update symfony/dotenv

Phase 2: Upgrade to Pimcore 11

2.1 Composer Update

{
  "require": {
    "pimcore/pimcore": "^11.0",
    "pimcore/admin-ui-classic-bundle": "^1.0"
  }
}
composer remove --no-update sensio/framework-extra-bundle
COMPOSER_MEMORY_LIMIT=-1 composer update -W

Expect dependency resolution to struggle with third-party bundles. Contact bundle vendors for P11-compatible versions or find alternatives.

2.2 Register the Admin Bundle

// src/Kernel.php
public function registerBundlesToCollection(BundleCollection $collection): void
{
    $collection->addBundle(new \Pimcore\Bundle\AdminBundle\PimcoreAdminBundle(), 60);
}

2.3 Install Extracted Bundles

Many core features are now separate bundles. Install only what you actually use:

composer require \
  pimcore/static-routes-bundle \
  pimcore/custom-reports-bundle \
  pimcore/application-logger-bundle \
  pimcore/seo-bundle \
  pimcore/simple-backend-search-bundle \
  pimcore/glossary-bundle \
  pimcore/tinymce-bundle \
  pimcore/xliff-bundle \
  pimcore/word-export-bundle

Register each in config/bundles.php.

2.4 Update Route Configuration

# config/routes.yaml
_pimcore:
    resource: "@PimcoreCoreBundle/config/routing.yaml"  # was: Resources/config/routing.yml

# config/routes/dev/routes.yaml
_pimcore:
    resource: "@PimcoreCoreBundle/config/routing_dev.yaml"  # was: Resources/config/routing_dev.yml

2.5 Messenger Transport Configuration

Pimcore 11 introduces new async message transports:

framework:
    messenger:
        transports:
            # Your existing transports...

            # NEW for Pimcore 11+
            pimcore_scheduled_tasks:
                dsn: "%messenger.dsn%/pimcore_scheduled_tasks"
                retry_strategy:
                    max_retries: 3
                    delay: 1000
                    multiplier: 2
            pimcore_image_optimize:
                dsn: "%messenger.dsn%/pimcore_image_optimize"
            pimcore_asset_update:
                dsn: "%messenger.dsn%/pimcore_asset_update"
            pimcore_search_backend_message:
                dsn: "%messenger.dsn%/pimcore_search_backend_message"

If using RabbitMQ (AMQP), each transport creates a separate queue. If using Doctrine transport, each creates a separate table partition. Plan worker deployment accordingly.

2.6 Run Migrations and Rebuild

bin/console doctrine:migrations:migrate
bin/console pimcore:cache:clear
bin/console cache:clear
rm -rf var/cache/*

Test everything at this checkpoint before proceeding.

If you're also redesigning workflows during this upgrade, read our guide on Pimcore enterprise workflow design for the architectural patterns.

Phase 3: Upgrade to Pimcore 12

3.1 License and Product Registration

Pimcore 12 introduces two mandatory changes:

License: POCL (Pimcore Open Core License) replaces GPLv3. Community Edition with Admin UI Classic Bundle requires an ExtJS license (approximately EUR 1,480).

EditionPrice
Community (POCL)Free (revenue under EUR 5M)
ProfessionalEUR 8,400/year
Enterprise Self-HostedEUR 25,200/year
Enterprise PaaSFrom USD 39,900/year

Product Registration: Mandatory. Three environment variables required:

VariablePurpose
PIMCORE_ENCRYPTION_SECRETDefuse encryption key, generated once per installation
PIMCORE_PRODUCT_KEYRegistration key from license.pimcore.com
PIMCORE_ENTERPRISE_TOKENComposer auth token for private packages

Registration steps:

# 1. Generate encryption secret (once per installation, NEVER change after)
vendor/bin/generate-defuse-key

# 2. Calculate registration hash
php -r "echo hash_hmac('sha256', 'your-instance-id', 'your-encryption-secret');"

# 3. Register at license.pimcore.com with the hash
# URL: https://license.pimcore.com/register?instance_identifier=ID&instance_hash=HASH
# 4. Receive product key, set as PIMCORE_PRODUCT_KEY env var
# config/config.yaml
pimcore:
    encryption:
        secret: '%env(PIMCORE_ENCRYPTION_SECRET)%'
    product_registration:
        product_key: '%env(PIMCORE_PRODUCT_KEY)%'
        instance_identifier: 'your-instance-id'

The validation is completely offline. It runs at every Symfony container build (cache clear, app start). It verifies the product key's ECDSA signature against a public key shipped with Pimcore. No internet required. If validation fails, InvalidConfigurationException is thrown and the app refuses to start.

Critical: Never change the encryption secret after initial setup. All encrypted data in the database depends on it. Treat it like a database password. Each separate installation (staging, production) can share the same key if they share the same instance identifier.

3.2 Composer Update

composer require -W pimcore/pimcore:^12.0

3.3 Security Configuration

security:
    # REMOVE (default in Symfony 6.4+):
    # enable_authenticator_manager: true

    firewalls:
        pimcore_admin_webdav:
            pattern: ^/asset/webdav  # was: ^/admin/asset/webdav

    access_control:
        - { path: ^/asset/webdav, roles: ROLE_PIMCORE_USER }

3.4 WYSIWYG Editor

# Option A: TinyMCE (Professional/Enterprise)
composer require pimcore/tinymce-bundle

# Option B: Quill (free, Community Edition)
composer require pimcore/quill-bundle

3.5 Doctrine DBAL 4 Final Changes

Beyond method replacements from Phase 1, update the Doctrine config:

# config/packages/doctrine.yaml
doctrine:
    dbal:
        connections:
            default:
                driver: "pdo_mysql"
                server_version: "mariadb-10.11"  # Be specific
                charset: utf8mb4
                default_table_options:
                    charset: utf8mb4
                    collate: utf8mb4_unicode_520_ci  # P12 default

Redis Configuration Deep Dive

Redis serves four critical functions in a production Pimcore 12 deployment. Getting the configuration wrong causes silent failures that are hard to diagnose.

1. Application Cache (Tag-Aware)

# config/packages/cache.yaml
framework:
    cache:
        # Pimcore 10 (OLD)
        # pools:
        #     pimcore.cache.pool:
        #         tags: true                                    # REMOVE
        #         adapter: pimcore.cache.adapter.redis_tag_aware  # OLD adapter name

        # Pimcore 11+/12 (NEW)
        pools:
            pimcore.cache.pool:
                public: true
                default_lifetime: 31536000  # 1 year
                adapter: cache.adapter.redis_tag_aware   # Changed adapter name
                provider: 'redis://%env(REDIS_HOST)%:%env(REDIS_PORT)%'

The adapter name change from pimcore.cache.adapter.redis_tag_aware to cache.adapter.redis_tag_aware is silent. The old name doesn't throw an error. Caching just silently falls back to filesystem, and you wonder why your multi-pod deployment has stale data everywhere.

Without Redis caching, every page render hits the database for navigation trees, site settings, class definitions, translations, and more. On a multi-pod Kubernetes deployment, each pod maintains its own filesystem cache with no invalidation coordination. Redis makes cache invalidation consistent across all pods.

2. Session Storage

# config/packages/framework.yaml
framework:
    session:
        handler_id: '%env(REDIS_SESSION_DSN)%'
        # Example: redis://redis:6379/1

For multi-pod deployments, sessions must be shared. Without Redis sessions, users get logged out when their request hits a different pod (round-robin load balancing). This is the most visible symptom of a missing Redis configuration.

3. Full Page Cache

Pimcore's full page cache stores rendered HTML output for anonymous users. In Pimcore 12, it can use Redis as its backend:

# config/packages/pimcore.yaml
pimcore:
    full_page_cache:
        enabled: true
        lifetime: 7200  # 2 hours
        exclude_patterns:
            - '/admin'
            - '/api'
        exclude_cookie: 'pimcore_admin_sid'

The full page cache dramatically reduces database load for public-facing pages. A page that takes 200ms to render from the database serves in under 5ms from cache. On high-traffic sites, this is the difference between needing 2 web pods and needing 10.

Cache invalidation happens automatically when editors publish content. But be careful with personalized content: any page that varies by user context (login state, locale, A/B test variant) must be excluded from full page caching or use ESI fragments.

4. Messenger Transport (Optional)

framework:
    messenger:
        transports:
            pimcore_core:
                dsn: 'redis://%env(REDIS_HOST)%:%env(REDIS_PORT)%/messages'

Redis Streams can replace RabbitMQ as the message transport. Simpler to operate (one fewer service), but lacks dead letter queues, message priorities, and exchange routing. For installations with complex worker topologies, RabbitMQ remains better. For simpler setups, Redis Streams works fine.

Redis Sizing

Pimcore ScaleRedis MemoryNotes
Small (< 10K objects)256 MBSingle instance
Medium (10K-100K objects)1-2 GBSingle instance, persistence enabled
Large (100K+ objects)4-8 GBConsider Redis Sentinel or Cluster

Monitor used_memory and evicted_keys. If keys are being evicted, your cache is too small and you're losing performance.

The Collation Migration (The Hardest Part)

This is the section that saves the most time. The collation migration is the most dangerous and least documented part of the entire upgrade.

Why It Breaks

Pimcore 12 migrations create new tables and alter existing columns with utf8mb4_unicode_520_ci collation. Your existing database from Pimcore 10 uses whatever the MySQL server default was. On Azure MySQL 8.0, that's utf8mb4_0900_ai_ci. On older setups, it might be utf8mb4_general_ci.

The result: mixed collations within the same table and across tables. When Pimcore runs UNION queries across objects, assets, and documents tables:

-- This is what Pimcore's getRelationData() runs
SELECT r.dest_id, r.type, o.className as subtype, concat(o.path, o.`key`) as `path`
  FROM objects o, object_relations_X r WHERE ...
UNION
SELECT r.dest_id, r.type, a.type as subtype, concat(a.path, a.filename) as `path`
  FROM assets a, object_relations_X r WHERE ...
UNION
SELECT r.dest_id, r.type, d.type as subtype, concat(d.path, d.`key`) as `path`
  FROM documents d, object_relations_X r WHERE ...

If objects.className is utf8mb4_unicode_520_ci (from P12 migration) but assets.type is utf8mb4_0900_ai_ci (from Azure default), MySQL throws:

SQLSTATE[HY000]: General error: 1267 Illegal mix of collations for operation 'UNION'

This error appears when opening any DataObject in the admin UI. The system is effectively broken.

Actual Distribution You'll Find

On a database migrated from P10 through P11 to P12 on Azure MySQL 8.0:

CollationTablesColumnsSource
utf8mb4_0900_ai_ci~580~5,700Azure MySQL 8.0 default
utf8mb4_unicode_ci~87~155Old bundle tables
utf8mb4_unicode_520_ci~6~994P12 migration-created
utf8mb3_general_ci~4~31Very old legacy tables
utf8mb4_general_ci~1~3Specific bundle tables
# Check your current distribution
bin/console doctrine:query:sql "SELECT TABLE_COLLATION, COUNT(*) AS cnt FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_COLLATION IS NOT NULL GROUP BY TABLE_COLLATION ORDER BY cnt DESC"

# Column-level check (this is where the real problem hides)
bin/console doctrine:query:sql "SELECT COLLATION_NAME, COUNT(*) AS cnt FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND COLLATION_NAME IS NOT NULL GROUP BY COLLATION_NAME ORDER BY cnt DESC"

Five Special Cases That Break Simple ALTER TABLE

1. Path-indexed tables (assets, documents, objects, http_error_log): Composite indexes on path + key/filename exceed the 3072-byte InnoDB index limit after collation conversion (utf8mb4 = 4 bytes/char, 255 chars x 4 = 1020 bytes per column, composite = over limit).

-- Drop indexes, convert, recreate with prefix lengths
DROP INDEX fullpath ON objects;
DROP INDEX type_path_classId ON objects;
ALTER TABLE objects CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci;
CREATE INDEX fullpath ON objects (path(255), `key`(255));
CREATE INDEX type_path_classId ON objects (type, path(255), classId);

2. Classification store tables: FK constraint between object_classificationstore_data_* and object_classificationstore_groups_*. The FK must be dropped before conversion and recreated after. The column name may be id (P12) or o_id (P10/11) depending on migration state.

3. Translation tables (translations_admin, translations_messages): Collation change can expose case-insensitive duplicates. If you have both "MyKey" and "mykey" as separate translation keys (valid under case-sensitive collation), converting to case-insensitive collation causes a unique constraint violation. We found ~1,600 duplicates in a single staging database. Duplicates must be removed before conversion.

4. Assets table: Must recreate the fullpath index as non-unique. Case-different filenames can exist (e.g., EEVA.tif and Eeva.tif are legitimate distinct files). A unique index would reject the second one after collation change.

5. Large tables: Tables with millions of rows (localized query tables, for example, can have 30+ tables per class with one per locale) take significant time to ALTER. On a database with 680 tables, the full conversion takes ~18 minutes.

Running the Migration

Run during a maintenance window with web pods scaled down:

# Scale down to avoid lock contention on translation tables
kubectl scale deployment pimcore pimcore-frontend -n <namespace> --replicas=0
kubectl get pods -n <namespace> -l app=pimcore -w  # Wait for termination

After running the automated migration script (which handles all five special cases):

# Rebuild class definitions (restores FK constraints)
PIMCORE_CLASS_DEFINITION_WRITABLE=1 bin/console pimcore:deployment:classes-rebuild -c

# Verify only one collation remains
bin/console doctrine:query:sql "SELECT TABLE_COLLATION, COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_COLLATION IS NOT NULL GROUP BY TABLE_COLLATION"

# Scale back up
kubectl scale deployment pimcore -n <namespace> --replicas=1
kubectl scale deployment pimcore-frontend -n <namespace> --replicas=3

Quick Reference: Collation Errors

ErrorCauseFix
Illegal mix of collations for operation 'UNION'Mixed collations across tables in relation queriesConvert all tables to utf8mb4_unicode_520_ci
Specified key was too long; max key length is 3072 bytesComposite path+key index exceeds limitDrop index, convert, recreate with prefix lengths
Referencing column and referenced column are incompatibleFK between tables with different collationsDrop FK, convert both tables, rebuild classes
Duplicate entry for key 'PRIMARY'Collation change makes case-different keys identicalRemove duplicates before converting
Lock wait timeout exceededApp holds locks on translation tablesScale down pods before ALTER

Database Optimization After Migration

After collation migration, run OPTIMIZE on all tables to defragment storage and rebuild indexes:

# Core tables
bin/console doctrine:query:sql "OPTIMIZE TABLE assets, documents, objects, versions, translations_admin, translations_messages, search_backend_data, properties, dependencies"

# Check for high fragmentation
bin/console doctrine:query:sql "SELECT TABLE_NAME, TABLE_ROWS, ROUND(DATA_LENGTH/1024/1024,2) AS data_mb, ROUND(DATA_FREE/1024/1024,2) AS free_mb, ROUND(DATA_FREE/(DATA_LENGTH+1)*100,1) AS frag_pct FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND DATA_FREE > DATA_LENGTH * 0.1 AND DATA_LENGTH > 1048576 ORDER BY DATA_FREE DESC"

MySQL SSL Configuration

For managed MySQL services (Azure, AWS RDS) with require_secure_transport=ON:

# config/packages/prod/doctrine.yaml
doctrine:
    dbal:
        connections:
            default:
                options:
                    !php/const PDO::MYSQL_ATTR_SSL_CA: true
                    !php/const PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT: false

MYSQL_ATTR_SSL_CA: true enables SSL without specifying a certificate path. The server provides its certificate and the client trusts it. MYSQL_ATTR_SSL_VERIFY_SERVER_CERT: false skips hostname verification (safe for internal networking).

For how we handle database migrations in our broader data engineering practice, that page covers our methodology.

OpenSearch Integration

Pimcore 12 introduces the Generic Data Index bundle, which uses OpenSearch (or Elasticsearch) for backend search and data indexing.

Index Setup (Must Happen Before Migrations)

# Create empty indices manually (required before migrations run)
curl -X PUT "http://opensearch-cluster:9200/pimcore_document" -H 'Content-Type: application/json' -d '{}'
curl -X PUT "http://opensearch-cluster:9200/pimcore_asset" -H 'Content-Type: application/json' -d '{}'

# DO NOT manually create Portal Engine statistics indices
# They auto-create with time-based naming: {name}__{year}_{month}
# Manual creation conflicts with the alias pattern

This is critical because some P12 migrations trigger Document->save(), which fires the DocumentIndexUpdateSubscriber event. If the index doesn't exist: index_not_found_exception followed by cascading SAVEPOINT errors that corrupt the migration state.

After migrations complete:

# Create indices for all DataObject classes
php -d memory_limit=2G bin/console generic-data-index:deployment:reindex --no-interaction

# Available commands
bin/console list generic-data-index
# generic-data-index:deployment:reindex    Creates/updates indices for classDefinitions
# generic-data-index:reindex               Triggers native reindexing
# generic-data-index:update:index          Updates index/mapping

OpenSearch Cluster Sizing

Installation ScaleNodesMemory/NodeStorage/NodeJava Heap
Small (< 10K objects)14 GB50 GB SSD2 GB
Medium (10K-100K)28 GB100 GB SSD4 GB
Large (100K+)3 (master-eligible)16 GB200 GB SSD8 GB

If replacing Elasticsearch 7.x, OpenSearch is a drop-in replacement. The API is compatible. Only the client library and configuration paths change. For how we design search architecture more broadly, see our ecommerce platforms guide.

OpenSearch Security

For internal cluster communication (pods in the same Kubernetes namespace), you can disable the security plugin:

# opensearch-cluster.yaml
additionalConfig:
    plugins.security.disabled: "true"

For external access or multi-tenant clusters, configure proper authentication.

Flysystem and Remote Storage Bugs

If you use remote asset storage (Azure Blob, AWS S3, Google Cloud Storage), Pimcore 12 has two bugs you need to know about.

Bug 1: getDimensions() Downloads Every Image on Every Page Render

Affects: Pimcore 10.6+, 11.x, 12.x with remote Flysystem storage

Every thumbnail()->html() call triggers getDimensions(), which calls readDimensionsFromFile() before trying getEstimatedDimensions(). On remote storage, each file read costs 50-100ms of network I/O.

Measured impact: A page with ~80 image references took 5,735ms (79 remote storage calls at ~65ms each). After fixing the method order, the same page rendered in 170ms with zero remote calls.

Root cause in ImageThumbnailTrait::getDimensions():

// Current order (WRONG for remote storage)
// Step 1: Check DB thumbnail cache (fast, but often a miss)
// Step 2: readDimensionsFromFile()  β†’ DOWNLOADS FILE FROM CLOUD (~65ms)
// Step 3: getEstimatedDimensions() β†’ NEVER REACHED (step 2 always succeeds)

// Fixed order
if (empty($dimensions) && $config && $asset instanceof Image) {
    $dimensions = $config->getEstimatedDimensions($asset);  // ~0ms, pure math
}
if (empty($dimensions) && $this->exists()) {
    $dimensions = $this->readDimensionsFromFile();  // 50-100ms, last resort only
}

getEstimatedDimensions() calculates dimensions from the original image dimensions (stored in the database) and the thumbnail transformation config. Pure math, zero I/O. In production, 95%+ of images have stored dimensions. This fix eliminates virtually all remote storage I/O during page rendering.

Bug 2: Folder Preview Thumbnails Stuck on Loading Spinner

Affects: admin-ui-classic-bundle 2.2.x, 2.3.x

After uploading images, the Preview tab shows a loading spinner that never resolves. The browser caches the placeholder GIF permanently because the URL uses modificationDate as cache buster (never changes), and no async thumbnail generation is dispatched for folderPreview origin (unlike treeNode which does dispatch it).

Fix: Add Cache-Control: no-store to the placeholder response and dispatch AssetPreviewImageMessage.

Kubernetes Deployment

Pod Architecture

PodPurposeReplicas
pimcore-webNginx + PHP-FPM, admin + frontend2-4
pimcore-workerSymfony Messenger consumers1-3
pimcore-opsMaintenance CLI pod (migrations, rebuilds)1
redisCache, sessions, optional transport1+
mysqlDatabase (or managed service)1+
opensearchSearch indices2-3
rabbitmqMessage broker (if not using Redis)1-3

Secrets Management

apiVersion: v1
kind: Secret
metadata:
  name: pimcore-secrets
type: Opaque
data:
  PIMCORE_ENCRYPTION_SECRET: <base64>
  PIMCORE_PRODUCT_KEY: <base64>
  PIMCORE_ENTERPRISE_TOKEN: <base64>
  DATABASE_HOST: <base64>
  DATABASE_NAME: <base64>
  DATABASE_USER: <base64>
  DATABASE_PASSWORD: <base64>
  REDIS_HOST: <base64>
  REDIS_PORT: <base64>
  APP_SECRET: <base64>

Use Sealed Secrets (Bitnami) or external secret operators for production. Never commit plain secrets to Git.

Every init container (create-assets, create-classes, db-migrations, clear-cache) must have ALL secretRefs in its envFrom list. Missing secrets cause silent Environment variable not found errors.

Worker Deployment

spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: worker
          command: ["php", "bin/console", "messenger:consume",
            "pimcore_core", "pimcore_maintenance", "pimcore_scheduled_tasks",
            "pimcore_image_optimize", "pimcore_asset_update",
            "pimcore_search_backend_message",
            "--time-limit=3600", "--memory-limit=512M"]

--time-limit and --memory-limit ensure workers restart periodically, preventing PHP memory leaks. Kubernetes restarts the pod when the process exits gracefully.

Deployment Order

1.  Deploy Kubernetes resources
2.  Shell into ops pod
3.  Install missing bundles (PimcoreAdminBundle, GenericExecutionEngine, etc.)
4.  Create OpenSearch indices manually (pimcore_document, pimcore_asset)
5.  Run OpenSearch DataObject reindex
6.  Run database migrations (with PIMCORE_CLASS_DEFINITION_WRITABLE=1)
7.  Run collation migration (maintenance window, web pods scaled down)
8.  Run database optimization (OPTIMIZE TABLE)
9.  Post-deploy commands (classes-rebuild, reindex, assets:install, cache:clear)
10. Verify: open DataObject in admin, check assets, check frontend

Our cloud services cover Kubernetes deployment and operations for Pimcore and other platforms.

Event System Architecture in Pimcore 12

Understanding Pimcore's event system is critical for both the upgrade and for building extensions.

PHP Events (Backend)

Pimcore uses Symfony's EventDispatcher. Key events for DataObjects:

use Pimcore\Event\DataObjectEvents;

// Available events
DataObjectEvents::PRE_ADD      // Before first save
DataObjectEvents::POST_ADD     // After first save
DataObjectEvents::PRE_UPDATE   // Before every save
DataObjectEvents::POST_UPDATE  // After every save
DataObjectEvents::PRE_DELETE   // Before deletion
DataObjectEvents::POST_DELETE  // After deletion

Creating an event subscriber in Pimcore 12:

use Pimcore\Event\Model\DataObjectEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class ProductEventSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            DataObjectEvents::POST_UPDATE => 'onPostUpdate',
        ];
    }

    public function onPostUpdate(DataObjectEvent $event): void
    {
        $object = $event->getObject();
        if (!$object instanceof Product) {
            return;
        }
        // Index to search, generate assets, etc.
    }
}

JavaScript Events (Admin UI)

Pimcore 12 uses a different event subscription pattern than P10:

// Pimcore 12 admin events
document.addEventListener(pimcore.events.preOpenObject, (e) => {
    // Before object editor opens
});

document.addEventListener(pimcore.events.postOpenObject, (e) => {
    // After object editor opens, e.detail.object available
});

document.addEventListener(pimcore.events.preSaveObject, (e) => {
    // Before save, can cancel with e.preventDefault()
});

document.addEventListener(pimcore.events.postSaveObject, (e) => {
    // After save completes
});

Extending Pimcore 12

Custom bundles in Pimcore 12 follow Symfony bundle conventions:

// src/CustomBundle/CustomBundle.php
namespace App\CustomBundle;

use Pimcore\Extension\Bundle\AbstractPimcoreBundle;

class CustomBundle extends AbstractPimcoreBundle
{
    public function getJsPaths(): array
    {
        return [
            '/bundles/custom/js/startup.js',
        ];
    }

    public function getCssPaths(): array
    {
        return [
            '/bundles/custom/css/admin.css',
        ];
    }
}

Register custom admin JS and CSS in the bundle config:

# config/packages/pimcore_admin.yaml
pimcore_admin:
    assets:
        js:
            - '/bundles/custom/js/startup.js'
            - '/bundles/custom/js/grid-operators.js'
        css:
            - '/bundles/custom/css/admin.css'

Post-Upgrade Verification

Command Sequence

PIMCORE_CLASS_DEFINITION_WRITABLE=1 php -d memory_limit=2G bin/console pimcore:deployment:classes-rebuild -c
php -d memory_limit=2G bin/console generic-data-index:deployment:reindex --no-interaction
bin/console assets:install public
php -d memory_limit=2G bin/console pimcore:search-backend-reindex
bin/console messenger:setup-transports
bin/console cache:clear
bin/console cache:warmup

Functional Test Matrix

AreaWhat to TestHow to Verify
Admin loginLogin, 2FA, logoutManual in browser
DataObjectsCreate, edit, save, publish, delete, versionsOpen any object, edit a field, save
AssetsUpload, thumbnails, metadata, downloadUpload an image, check thumbnail generation
DocumentsEdit pages, areabricks, save, publishEdit a page with areabricks
RelationsObject relations, asset relationsOpen object with relations, verify UNION query works
SearchBackend search, OpenSearch indexingSearch for an object in the admin toolbar
MessengerQueue processing, all transportsCheck bin/console messenger:stats
CacheRedis connectivity, invalidationPublish content, verify change visible on other pod
Full page cacheAnonymous page caching, invalidationLoad page, check response headers for cache hit
ERP integrationImport processing, field mappingRun a test import
PermissionsRBAC, workflow permissionsLogin as restricted user, verify access
Custom JSGrid operators, admin pluginsOpen grid view, check custom columns work

Performance Benchmarks

MetricAcceptableWarningCritical
Admin page load< 2s2-5s> 5s
DataObject open< 3s3-8s> 8s
Asset upload (10MB)< 5s5-15s> 15s
Search response< 500ms500ms-2s> 2s
Frontend page (cached)< 50ms50-200ms> 200ms
Frontend page (uncached)< 500ms500ms-2s> 2s
Thumbnail render (remote storage)< 200ms200ms-1s> 1s (check getDimensions bug)

Common Pitfalls

  1. Skipping Pimcore 11. You cannot jump from 10 to 12. The intermediate version handles critical schema changes, bundle extraction, and prefix removal.

  2. Running migrations without OpenSearch indices. Some migrations trigger event subscribers that query OpenSearch. index_not_found_exception cascades into corrupted SAVEPOINT state. Create indices first.

  3. Not fixing collations. Mixed collations cause UNION query failures when opening any DataObject. This breaks the admin UI silently until an editor opens a product.

  4. Losing the encryption secret. If lost after deployment, all encrypted data in the database becomes unrecoverable. This is not recoverable. Treat it like the master database password.

  5. Silent Redis cache fallback. The adapter name change from pimcore.cache.adapter.redis_tag_aware to cache.adapter.redis_tag_aware doesn't throw errors. Caching silently falls back to filesystem. Multi-pod deployments break.

  6. Not updating Messenger transports. Missing P11 transports cause scheduled tasks, asset processing, and search indexing to silently stop working.

  7. Running collation migration with web pods up. Translation table conversion deadlocks under concurrent access. Scale down first.

  8. Manually creating Portal Engine statistics indices. The auto-creation uses time-based index names with aliases. Manual creation conflicts with the alias pattern.

  9. Testing on empty database. Schema migration on an empty database hides 90% of issues. Use a full production data clone.

  10. Ignoring the getDimensions() bug on cloud storage. Every uncached page render is 3-7 seconds slower than necessary until this is patched. The fix is a two-line reorder.

  11. Missing getSupportedTypes() on normalizers. Symfony 7.x requires this method. Missing it causes a fatal error, not a deprecation warning.

  12. Not updating Request::get() calls. Deprecated but still works in Symfony 6.x. Will break in 7.x.

  13. Forgetting PIMCORE_CLASS_DEFINITION_WRITABLE=1. Migrations and class rebuilds silently fail without this env var. The error message is clear, but it's easy to forget.

  14. ExtJS 7 root vs rootProperty. Custom store readers using root: 'data' silently fail to parse responses in ExtJS 7.

Key Takeaways

  • Three phases, no shortcuts. Preparation (fix all deprecated code), Pimcore 11 (framework upgrade + bundle extraction), Pimcore 12 (registration, DBAL 4, collation, event system). Each phase is a checkpoint.

  • Collation migration is the hardest part. Five types of special tables need custom handling. Budget a maintenance window. Use an automated script. Verify at both table and column level.

  • Product registration is mandatory and offline. No internet needed for validation. But the encryption secret is irrecoverable if lost.

  • Redis is not optional in multi-pod deployments. Cache consistency, session sharing, full page cache, and optionally message transport all depend on it. The adapter name change is a silent breaking change.

  • OpenSearch indices must exist before migrations. Create document and asset indices manually, then run data object reindex. The order matters.

  • The event system migration is mechanical but critical. Plugin Broker to Event Listeners, o_className to className, @Route to #[Route], Request::get() to specific accessors. Rector can automate 80% of it.

  • Remote storage exposes Pimcore bugs. The getDimensions() ordering bug adds seconds to every page render. The folder preview cache bug makes thumbnails permanently stuck. Both have simple fixes.

  • Test on a production data clone. Every time. An empty database upgrade takes 20 minutes and succeeds trivially. Real issues appear with 70K assets, 680 tables with mixed collations, and 50K+ data objects.

This is exactly the kind of software engineering work our team handles regularly. If you're planning a Pimcore upgrade, our consulting team has done this for multiple enterprise installations across the DACH region. We also offer it as part of our broader services.

Ready to start your Pimcore upgrade? Talk to our team or request a quote.

Topics covered

Pimcore upgradePimcore 12 migrationPimcore 11 upgradeSymfony 7 migrationPHP 8.3 upgradeDoctrine DBAL 4Pimcore KubernetesPimcore OpenSearchPimcore RedisPimcore full page cacheExtJS 7Pimcore Studio UIPimcore event system

Ready to build production AI systems?

Our team specializes in building production-ready AI systems. Let's discuss how we can help transform your enterprise with cutting-edge technology.

Start a conversation