Pimcore Upgrade 10 to 12: The Real Migration Path

php dev.to

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:

Component Pimcore 10.6 Pimcore 11 Pimcore 12
PHP 8.0+ 8.1+ 8.3+
Symfony 5.4 6.2+ 6.4 / 7.x
Doctrine DBAL 2.x / 3.x 3.x 4.x
Admin UI Built-in (ExtJS 6) Separate bundle (ExtJS 6/7) Separate bundle (ExtJS 7) + Studio UI (React)
License GPLv3 GPLv3 POCL (Pimcore Open Core License)
Core bundles Monolithic Extracted Extracted
Search backend Built-in Simple Backend Search Bundle Generic Data Index + OpenSearch
WYSIWYG TinyMCE built-in TinyMCE bundle TinyMCE or Quill bundle
Product registration None None Required (offline validation)
Event system (JS) Plugin Broker Event Listeners Event Listeners + Studio UI events
Collation utf8mb4_general_ci utf8mb4_general_ci utf8mb4_unicode_520_ci (required)
Cache Pimcore adapter Symfony adapter Symfony adapter (config change)
Full page cache Built-in Built-in Separate bundle option
Asset storage Local / Flysystem Local / Flysystem Local / 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      │
└─────────────────┘     └─────────────────┘     └─────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 DELETE Manual SQL
$db->updateWhere() $db->executeStatement() with UPDATE Manual SQL
$db->quote($val, $type) $db->quote($val) Strings only in DBAL 4, no type param
Pimcore\Db\ConnectionInterface Doctrine\DBAL\Connection Type hint change in DI
Pimcore\Db\Connection Doctrine\DBAL\Connection Class 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"
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

Update your services.yaml if using explicit dependency injection:

services:
    App\Export\PriceExporter:
        arguments:
            $connection: '@doctrine.dbal.default_connection'
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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_id id (or oo_id in object store tables) Primary key
o_creationDate creationDate Timestamp
o_modificationDate modificationDate Timestamp
o_path path Tree path
o_key key Object key/slug
o_published published Publication flag
o_parentId parentId Parent reference
o_type type Object type
o_className className Class name
o_classId classId Class ID
o_userOwner userOwner Creator user ID
o_userModification userModification Last modifier user ID
o_versionCount versionCount Version 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"
Enter fullscreen mode Exit fullscreen mode

Example listing condition migration:

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

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

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
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode
# Preview changes
vendor/bin/rector process --dry-run

# Apply changes
vendor/bin/rector process
Enter fullscreen mode Exit fullscreen mode

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)) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

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();
        }
    });
})();
Enter fullscreen mode Exit fullscreen mode

Additional JavaScript changes:

Old (Pimcore 10) New (Pimcore 11+) Notes
ts() t() Translation function
pimcore.helpers.addCsrfTokenToUrl() Remove entirely CSRF handled differently
o_className in JS className Matches PHP change
Class.create(...) Still supported, but prefer ES6 classes Future-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
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

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', []);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Update config/packages/routing.yaml:

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

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,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

1.13 Install Compatibility Bridge

composer require --no-update pimcore/compatibility-bridge-v10
composer require --no-update symfony/dotenv
Enter fullscreen mode Exit fullscreen mode

Phase 2: Upgrade to Pimcore 11

2.1 Composer Update

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

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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/*
Enter fullscreen mode Exit fullscreen mode

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).

Edition Price
Community (POCL) Free (revenue under EUR 5M)
Professional EUR 8,400/year
Enterprise Self-Hosted EUR 25,200/year
Enterprise PaaS From USD 39,900/year

Product Registration: Mandatory. Three environment variables required:

Variable Purpose
PIMCORE_ENCRYPTION_SECRET Defuse encryption key, generated once per installation
PIMCORE_PRODUCT_KEY Registration key from license.pimcore.com
PIMCORE_ENTERPRISE_TOKEN Composer 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
Enter fullscreen mode Exit fullscreen mode
# config/config.yaml
pimcore:
    encryption:
        secret: '%env(PIMCORE_ENCRYPTION_SECRET)%'
    product_registration:
        product_key: '%env(PIMCORE_PRODUCT_KEY)%'
        instance_identifier: 'your-instance-id'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)%'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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 Scale Redis Memory Notes
Small (< 10K objects) 256 MB Single instance
Medium (10K-100K objects) 1-2 GB Single instance, persistence enabled
Large (100K+ objects) 4-8 GB Consider 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 ...
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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:

Collation Tables Columns Source
utf8mb4_0900_ai_ci ~580 ~5,700 Azure MySQL 8.0 default
utf8mb4_unicode_ci ~87 ~155 Old bundle tables
utf8mb4_unicode_520_ci ~6 ~994 P12 migration-created
utf8mb3_general_ci ~4 ~31 Very old legacy tables
utf8mb4_general_ci ~1 ~3 Specific 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"
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Quick Reference: Collation Errors

Error Cause Fix
Illegal mix of collations for operation 'UNION' Mixed collations across tables in relation queries Convert all tables to utf8mb4_unicode_520_ci
Specified key was too long; max key length is 3072 bytes Composite path+key index exceeds limit Drop index, convert, recreate with prefix lengths
Referencing column and referenced column are incompatible FK between tables with different collations Drop FK, convert both tables, rebuild classes
Duplicate entry for key 'PRIMARY' Collation change makes case-different keys identical Remove duplicates before converting
Lock wait timeout exceeded App holds locks on translation tables Scale 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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

OpenSearch Cluster Sizing

Installation Scale Nodes Memory/Node Storage/Node Java Heap
Small (< 10K objects) 1 4 GB 50 GB SSD 2 GB
Medium (10K-100K) 2 8 GB 100 GB SSD 4 GB
Large (100K+) 3 (master-eligible) 16 GB 200 GB SSD 8 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"
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

Pod Purpose Replicas
pimcore-web Nginx + PHP-FPM, admin + frontend 2-4
pimcore-worker Symfony Messenger consumers 1-3
pimcore-ops Maintenance CLI pod (migrations, rebuilds) 1
redis Cache, sessions, optional transport 1+
mysql Database (or managed service) 1+
opensearch Search indices 2-3
rabbitmq Message 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>
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

--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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
    }
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Functional Test Matrix

Area What to Test How to Verify
Admin login Login, 2FA, logout Manual in browser
DataObjects Create, edit, save, publish, delete, versions Open any object, edit a field, save
Assets Upload, thumbnails, metadata, download Upload an image, check thumbnail generation
Documents Edit pages, areabricks, save, publish Edit a page with areabricks
Relations Object relations, asset relations Open object with relations, verify UNION query works
Search Backend search, OpenSearch indexing Search for an object in the admin toolbar
Messenger Queue processing, all transports Check bin/console messenger:stats
Cache Redis connectivity, invalidation Publish content, verify change visible on other pod
Full page cache Anonymous page caching, invalidation Load page, check response headers for cache hit
ERP integration Import processing, field mapping Run a test import
Permissions RBAC, workflow permissions Login as restricted user, verify access
Custom JS Grid operators, admin plugins Open grid view, check custom columns work

Performance Benchmarks

Metric Acceptable Warning Critical
Admin page load < 2s 2-5s > 5s
DataObject open < 3s 3-8s > 8s
Asset upload (10MB) < 5s 5-15s > 15s
Search response < 500ms 500ms-2s > 2s
Frontend page (cached) < 50ms 50-200ms > 200ms
Frontend page (uncached) < 500ms 500ms-2s > 2s
Thumbnail render (remote storage) < 200ms 200ms-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. Full guide: https://oronts.com/en/guides/pimcore-upgrade-10-to-12

Source: dev.to

arrow_back Back to Tutorials