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.
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 β
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
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 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"
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_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"
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 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
}
}
});
Verify all custom grid operators, admin plugins, and data importer extensions for ExtJS 7 compatibility. Common issues:
rootproperty renamed torootPropertyin store readers- Some grid column configs changed
- Prototype patching (
Ext.override) may break if internal class structure changed - Check all
iconClsreferences 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).
| 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
# 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 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 ...
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:
| 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"
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
| 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"
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 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"
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
| 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>
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
| 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
-
Skipping Pimcore 11. You cannot jump from 10 to 12. The intermediate version handles critical schema changes, bundle extraction, and prefix removal.
-
Running migrations without OpenSearch indices. Some migrations trigger event subscribers that query OpenSearch.
index_not_found_exceptioncascades into corrupted SAVEPOINT state. Create indices first. -
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.
-
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.
-
Silent Redis cache fallback. The adapter name change from
pimcore.cache.adapter.redis_tag_awaretocache.adapter.redis_tag_awaredoesn't throw errors. Caching silently falls back to filesystem. Multi-pod deployments break. -
Not updating Messenger transports. Missing P11 transports cause scheduled tasks, asset processing, and search indexing to silently stop working.
-
Running collation migration with web pods up. Translation table conversion deadlocks under concurrent access. Scale down first.
-
Manually creating Portal Engine statistics indices. The auto-creation uses time-based index names with aliases. Manual creation conflicts with the alias pattern.
-
Testing on empty database. Schema migration on an empty database hides 90% of issues. Use a full production data clone.
-
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.
-
Missing
getSupportedTypes()on normalizers. Symfony 7.x requires this method. Missing it causes a fatal error, not a deprecation warning. -
Not updating
Request::get()calls. Deprecated but still works in Symfony 6.x. Will break in 7.x. -
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. -
ExtJS 7
rootvsrootProperty. Custom store readers usingroot: '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_classNametoclassName,@Routeto#[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
Related Guides
Enterprise Guide to Agentic AI Systems
Technical guide to agentic AI systems in enterprise environments. Learn the architecture, capabilities, and applications of autonomous AI agents.
Read guideAgentic Commerce: How to Let AI Agents Buy Things Safely
How to design governed AI agent-initiated commerce. Policy engines, HITL approval gates, HMAC receipts, idempotency, tenant scoping, and the full Agentic Checkout Protocol.
Read guideThe 9 Places Your AI System Leaks Data (and How to Seal Each One)
A systematic map of every place data leaks in AI systems. Prompts, embeddings, logs, tool calls, agent memory, error messages, cache, fine-tuning data, and agent handoffs.
Read guideReady 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