Technical Guide

Pimcore Workflow Design for Enterprise: The Architecture Behind 20 Editors

How to design Pimcore workflows for enterprise teams. Three-layer state separation, field ownership, event control, version management, and ERP import safety.

March 25, 202615 min readOronts Engineering Team

The Problem Nobody Talks About

Every Pimcore installation starts the same way. A few products, a content team of three, everything managed through the admin UI. Publishing means clicking a button. Life is simple.

Then the system grows. Twenty editors across multiple departments. An ERP pushing thousands of product updates daily. Background workers generating thumbnails, PDFs, and search index updates. Multiple output channels pulling products for different audiences. A search engine syncing every change.

And suddenly everything breaks in ways nobody anticipated.

We saw this firsthand on an enterprise PIM project for a B2B manufacturer in the DACH region. The system had multiple event subscribers, several async workers, an ERP integration pushing daily imports, and multiple output channels serving different business needs. The team had grown to 20+ editors, and the original architecture was crumbling under its own weight.

The root problem: Pimcore's save() is designed for human editors, not for enterprise systems with multiple automated processes competing for the same data.

This article covers how we redesigned the workflow architecture from the ground up. The patterns here apply to any Pimcore installation that outgrows the basics. For broader context on how we approach system architecture and PIM implementations, those guides provide useful background.

Why Pimcore's Default Model Breaks at Scale

Pimcore's object persistence works through a single entry point:

$product->save();

This triggers an implicit behavior chain:

save()
  -> PRE_UPDATE event fires (all subscribers, synchronous)
  -> update() persists FULL object state to published tables
  -> saveVersion() creates a version snapshot
  -> POST_UPDATE event fires (all subscribers, synchronous)
  -> Each subscriber may dispatch async messages
  -> Each message handler may call save() again
  -> Loop until supervisor or error breaks it

Every save() persists the entire object. Not the field that changed. The entire object. This design works fine for human editors making occasional changes. It fails when you have:

ProblemWhat HappensReal Impact
No concurrency controlTwo workers load the same product, each modifies one field, both save. Second save overwrites first worker's change.Generated assets revert silently after another worker runs
Event stormsOne save triggers multiple subscribers, each dispatching messages, each causing more savesA product import generates thousands of unnecessary saves
Version explosionEvery worker save creates a version nobody needsThousands of versions per product, storage consumed, history useless
No field ownershipContent edits and system writes go through the same save() pathERP import overwrites manually curated fields
No side-effect controlVersion::disable() is a global static flag, not scoped or composableDisabling versions in one worker affects all workers in the same process

Before we fixed these problems, a product import took hours and generated massive message queues. After the architecture redesign, the same import processes in minutes with controlled, predictable side effects.

The Three-Layer State Model

The core architectural decision: separate three concerns that Pimcore conflates into one.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                         β”‚
β”‚  Layer 1: OPERATIONAL WORKFLOW (human process)          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚ NEW │─▢│IN_PROGRESS│─▢│IN_REVIEW │─▢│ APPROVED β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                  β–²            β”‚               β”‚        β”‚
β”‚                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜               β”‚        β”‚
β”‚                (feedback loop)                β–Ό        β”‚
β”‚                                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚                                        β”‚ PUBLISHED  β”‚  β”‚
β”‚                                        β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                                        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                        β”‚
β”‚  Layer 2: PUBLICATION STATE (Pimcore built-in)         β”‚
β”‚  published = true  β†’ product is live on website        β”‚
β”‚  published = false β†’ not visible on any channel        β”‚
β”‚  Draft versions β†’ edits saved without publishing       β”‚
β”‚                                                        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                        β”‚
β”‚  Layer 3: CHANNEL ELIGIBILITY (computed, NOT stored)   β”‚
β”‚  isChannelReady = published=true                       β”‚
β”‚                   AND workflow=PUBLISHED                β”‚
β”‚                   AND ChannelValidator.passes()         β”‚
β”‚                   AND not archived                      β”‚
β”‚                                                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Layer 1: Operational Workflow

One workflow on the Product class using Pimcore's Symfony Workflow integration. Type: state_machine (one place at a time).

This workflow answers: Who owns the product now? What phase is it in? Who acts next?

pimcore:
    workflows:
        product_lifecycle:
            enabled: true
            label: 'Product Lifecycle'
            type: 'state_machine'
            supports:
                - 'Pimcore\Model\DataObject\Product'
            initial_markings:
                - 'new'
            marking_store:
                type: state_table

            places:
                new:
                    label: 'New'
                    color: '#90CAF9'
                    permissions:
                        - save: true
                          publish: false
                in_progress:
                    label: 'In Progress'
                    color: '#FFE082'
                    permissions:
                        - save: true
                          publish: false
                in_review:
                    label: 'In Review'
                    color: '#FFAB91'
                approved:
                    label: 'Approved'
                    color: '#A5D6A7'
                published:
                    label: 'Published'
                    color: '#4CAF50'
                archived:
                    label: 'Archived'
                    color: '#BDBDBD'

The key distinction most teams miss: there is no REJECTED state. Rejection is a transition back to IN_PROGRESS with a mandatory comment stored in Notes & Events. This keeps the workflow clean and the review process as a loop, not a dead end.

Layer 2: Publication State

This is NOT a workflow. This uses Pimcore's existing mechanisms:

  • published = true controls website visibility
  • Pimcore versioning creates snapshots on every save
  • Draft versions let editors work without affecting the live site
  • Publishing promotes a specific version to be the live one

This gives Git-like behavior:

Git ConceptPimcore Equivalent
main branchCurrently published version
Feature branchDraft version (saved without publishing)
Merge to mainPublish action (promotes draft to live)
Commit historyVersion history in Versions tab

When an editor saves without publishing, only a draft version is created. The published tables stay unchanged. The live website never sees half-finished work.

Layer 3: Channel Eligibility (Computed, Not Stored)

This is the decision that eliminated an entire class of bugs. Channel readiness (whether a product is eligible for a specific output channel like a marketplace feed, export pipeline, or partner API) is NOT a stored field on the product. It is computed from current truth.

class ProductOutputEligibility
{
    public function isChannelReady(Product $product, string $channel): bool
    {
        return $product->isPublished()
            && $this->workflowState->getCurrentState($product) === 'published'
            && !$this->workflowState->isArchived($product)
            && $this->channelValidator->isValid($product, $channel);
    }

    public function getBlockingErrors(Product $product, string $channel): array
    {
        $errors = [];
        if (!$product->isPublished()) {
            $errors[] = 'Product must be published';
        }
        // ... check workflow state, archived
        return array_merge($errors, $this->channelValidator->getErrors($product, $channel));
    }
}

Why computed is better than a stored status field:

Stored FieldComputed Result
Can become stale (says READY but required image was deleted)Always reflects current truth
Needs manual reset after changesNo reset needed, computed on demand
Must sync with workflow stateNo sync required
Creates duplicate truthSingle source of truth: the actual data
Requires migration for new rulesJust update the validator logic

The old system used hacks where products had to be unpublished to stage them for specific channels. This broke website visibility every time. The computed approach kills that pattern completely.

For more on how we think about data engineering pipelines and channel-specific output, that service page covers our broader approach.

Field Ownership: Who Can Write What

The second critical architecture decision: not all fields are equal. Some belong to editors, some to machines, some to both with rules.

pimtx:
    field_ownership:
        Product:
            editor_owned:
                - name
                - description
                - shortDescription
                - images
            system_owned:
                - thumbnail
                - searchIndex
                - checksum
                - lastSyncTimestamp
            shared:
                - categories
                - price
                - availability
                - status
DomainOwnerExamplesMutation Path
Editor-ownedAdmin usersname, description, imagesPimcore native save
System-ownedWorkers and integrationsthumbnails, search index, checksumsTransaction layer
SharedBoth, with conflict policycategories, prices, availabilityTransaction layer with conflict strategy

Field ownership determines:

  • Which fields trigger change detection for which operations
  • Which conflict resolution strategies apply
  • Whether partial update is safe for a given field
  • What shows in the business vs technical audit view

Without field ownership, an ERP import can overwrite manually curated content. With it, the import only touches the fields it owns, and shared fields use a configured conflict strategy (retry, skip, fail, or merge-if-safe).

This pattern is central to how we built PimTx, our open-source Pimcore transaction and concurrency layer. The consulting team has helped multiple enterprise Pimcore clients implement this exact pattern.

Event Subscriber Control: Stopping the Cascade

The most dangerous pattern in Pimcore enterprise systems is the save cascade. One save triggers 13 event subscribers. Each dispatches async messages. Each message handler loads the product, modifies a field, and saves. Each save triggers 13 subscribers again.

The EventSubscriberSupervisor is a process-scoped singleton that controls which subscribers are active:

// Disable all app-level event subscribers before worker save
$this->eventSubscriberSupervisor->disableAll();

try {
    $product->setQrCode($generatedAsset);
    $product->save();
} finally {
    $this->eventSubscriberSupervisor->enableAll();
}

The order of operations matters. Getting it wrong creates the exact loop you're trying to prevent:

// WRONG: events re-enabled BEFORE save
$this->eventSubscriberSupervisor->disableAll();
try {
    // do work...
} finally {
    $this->eventSubscriberSupervisor->enableAll(); // re-enabled here
}
$product->save(); // save fires events, loop starts

// CORRECT: save happens INSIDE the disabled scope
$this->eventSubscriberSupervisor->disableAll();
try {
    $product->setQrCode($generatedAsset);
    $product->save(); // save here, events suppressed
} finally {
    $this->eventSubscriberSupervisor->enableAll();
}

This is process-scoped. It only disables subscribers within the same PHP process. Other worker pods have their own supervisor instances. But since the save doesn't dispatch new messages (subscribers are disabled), no new messages reach other workers.

Version Management: Preventing the Explosion

In the original system, every worker save created a version. With 6 workers processing every product change, a single human edit generated 6+ versions nobody needed. Over time, products accumulated thousands of versions, consuming storage and making the version history useless for actual auditing.

The solution: a scoped version guard that suppresses unnecessary versions during system operations.

// PimTx scoped version guard (reference-counted)
$versionGuard = $this->versionGuardFactory->create();
$versionGuard->suppress();

try {
    // Multiple system operations, no versions created
    $product->setThumbnail($thumbnailAsset);
    $product->save();

    $product->setChecksum($newChecksum);
    $product->save();
} finally {
    $versionGuard->restore();
}

Unlike Pimcore's global Version::disable(), this guard is reference-counted and scoped. Nested operations work correctly. One guard restoring doesn't accidentally re-enable versions for a parent operation that still needs them suppressed.

ApproachScopeNestingSafety
Version::disable() (Pimcore)Global staticBroken (any enable() re-enables for everyone)Dangerous in concurrent workers
Scoped Version Guard (PimTx)Per-operation, reference-countedCorrect (restore only when all guards released)Safe for concurrent workers

The result: editor saves create versions (audit trail preserved). Worker saves create operation log entries instead (full observability without version bloat). You can see exactly what each worker did, when, and to which fields, without scrolling through 4,000 useless version entries.

ERP Import Safety

The most politically sensitive part of any PIM architecture: what happens when the ERP pushes data.

In the original system, ERP imports overwrote everything. Manually curated descriptions, carefully set categories, editorial status. All gone after a nightly import.

The architecture solution combines field ownership with import-specific rules:

# ERP import field mapping
# PROTECTED fields (not in mapping, import cannot touch them):
#   - description (editor-owned)
#   - shortDescription (editor-owned)
#   - images (editor-owned)
#   - workflow state (process-controlled)

# IMPORTED fields (ERP-owned):
#   - erpId (resolver key)
#   - sku
#   - dimensions
#   - weight
#   - ean

# SHARED fields (import updates, but change detection applies):
#   - categories (merge strategy)
#   - price (overwrite with notification)
#   - availability (overwrite with change-impact check)

When an ERP import changes a business-critical field on a published product, the change-impact detection automatically flags it for review. The import doesn't need to know about editorial workflows. The field ownership system handles it.

Critical safeguards:

  1. Channel eligibility recalculates automatically (computed, not stored)
  2. Workflow state is never reset by imports
  3. Editor-owned fields are not in the import mapping
  4. Shared fields use conflict strategies with notification
  5. The EventSubscriberSupervisor prevents import-triggered cascades

What Happens When a Live Product Gets Edited

This is the most critical user-facing flow. An editor needs to update a live product without breaking the website or any output channel.

1. Product is in PUBLISHED state, published=true, live on website
2. Editor opens product, modifies description
3. Editor clicks "Save" (not "Publish")
4. Pimcore creates a DRAFT VERSION only
5. Published tables stay unchanged
6. Website continues serving the current published version
7. Change-impact detection runs:
   - If business-critical fields changed: relevant teams notified
   - If only internal/SEO fields changed: no notification
8. Editor continues working on the draft
9. When ready: submit for review (IN_REVIEW)
10. Reviewer approves: transition to APPROVED
11. Release manager publishes: draft promoted to live
12. Workflow returns to PUBLISHED
13. Channel eligibility recalculates automatically

The website never sees half-finished work. No output channel gets broken by work-in-progress changes. Everything is version-controlled, auditable, and reversible.

This is exactly the kind of custom software architecture we build for enterprise clients. If you need help planning a workflow redesign, request a quote or talk to our consulting team.

Change-Impact Detection

Not all changes are equal. An editor fixing a typo in the SEO text shouldn't trigger a channel review. An editor replacing the main product image should.

The change detection system classifies changes by impact:

class ChangeImpactClassifier
{
    private const BUSINESS_CRITICAL_FIELDS = [
        'description',
        'shortDescription',
        'images',
        'technicalAttributes', // Classification Store
        'price',
        'availability',
        'categories',
    ];

    private const NON_IMPACTFUL_FIELDS = [
        'seoText',
        'searchKeywords',
        'internalTags',
        'lastSyncTimestamp',
    ];

    public function classify(Product $product, array $changedFields): ChangeImpact
    {
        $critical = array_intersect($changedFields, self::BUSINESS_CRITICAL_FIELDS);

        if (count($critical) > 0) {
            return ChangeImpact::BUSINESS_CRITICAL;
        }

        $nonImpactful = array_intersect($changedFields, self::NON_IMPACTFUL_FIELDS);
        if (count($nonImpactful) === count($changedFields)) {
            return ChangeImpact::NON_IMPACTFUL;
        }

        return ChangeImpact::STANDARD;
    }
}

Implementation: a PRE_UPDATE subscriber loads the previous version data, compares field by field against current state, classifies changes by business impact, and stores the change summary as a Note on the object.

Validation Rules for Channel Readiness

Since channel eligibility is computed, the validator defines what "ready" means per channel:

class ChannelValidator
{
    public function isValid(Product $product, string $channel): bool
    {
        return count($this->getErrors($product, $channel)) === 0;
    }

    public function getErrors(Product $product, string $channel): array
    {
        $errors = [];

        // Common requirements for all channels
        if (!$product->getMainImage()) {
            $errors[] = 'At least one product image required';
        }

        if (empty($product->getDescription())) {
            $errors[] = 'Description text required in at least one locale';
        }

        // Channel-specific requirements
        if ($channel === 'marketplace') {
            if (empty($product->getPrice())) {
                $errors[] = 'Price is required for marketplace export';
            }
            if (empty($product->getCategories())) {
                $errors[] = 'At least one category required for marketplace';
            }
        }

        return $errors;
    }
}

The UI shows exactly what blocks eligibility for each channel. No guessing, no "ask the channel manager." The product either passes validation or the system tells you why it doesn't.

For how we handle similar validation patterns in AI workflow design and AI governance, those guides cover the parallels.

The Transaction Layer (PimTx)

All of these patterns come together in PimTx, the transaction and concurrency layer we built as a Symfony bundle. Every system write becomes an explicit operation:

// Instead of raw $product->save()
$result = $this->transactionManager->execute(
    OperationContext::for($product)
        ->name('qr_code_generation')
        ->systemField('qrCode')
        ->lock(LockType::OPERATION)
        ->version(VersionPolicy::SUPPRESS_PIMCORE_VERSION)
        ->events(EventPolicy::SUPPRESS)
        ->conflict(ConflictStrategy::RETRY)
        ->maxRetries(3)
        ->idempotencyKey("qr:{$product->getId()}:{$locale}")
        ->mutate(function (Product $p) use ($qrAsset) {
            $p->setQrCode($qrAsset);
        })
);

if ($result->wasSkipped()) {
    // Idempotent: same operation already ran
    $this->logger->info('QR generation skipped', [
        'reason' => $result->skipReason,
        'product_id' => $product->getId(),
    ]);
}

What this declaration does:

  • Acquires an operation-scoped lock (prevents concurrent QR generation for the same product)
  • Checks idempotency (same operation with same key won't run twice)
  • Suppresses version creation (operation logged in PimTx audit, not Pimcore versions)
  • Suppresses event subscribers (prevents cascading saves)
  • Retries on conflict (if another worker modified the product between load and save)
  • Returns a structured result (success, skipped, conflict resolved, or failed)

The governance rule: any non-editor mutation that reaches Pimcore through unmanaged save() is considered out of policy. System code must go through the transaction layer. Without this rule, teams slowly bypass the layer and recreate the same incidents.

For more on how we observe and monitor systems like this in production, see our AI observability guide, which covers similar structured logging and tracing patterns.

Notification Architecture

Pimcore's workflow supports notifications on transitions:

EventWho Gets NotifiedChannel
Product enters IN_REVIEWReviewer rolePimcore notification + email
Product approvedProduct owner / editorPimcore notification
Business-critical change detectedRelevant channel teamsEmail with change summary
Published product edited (draft created)Responsible editorPimcore notification
Product publishedRelease managerPimcore notification
Import changes shared fieldsField ownerEmail with diff

Notifications carry context: what changed, who changed it, which fields were affected, and the business impact level. Not just "Product 12345 was updated."

Common Pitfalls

  1. Using multiple workflows. One workflow per product class. Multiple workflows create state conflicts and permission confusion. Channel readiness is handled by computed eligibility, not separate workflows.

  2. Storing channel status as a field. Any stored status field becomes stale the moment someone deletes an image or changes a required attribute. Compute eligibility from current truth.

  3. Skipping field ownership. Without field ownership, every import, worker, and editor competes for the same fields through the same save() path. Define who owns what before the first line of code.

  4. Using Version::disable() globally. This global static flag breaks when multiple operations run concurrently. Use reference-counted, scoped version guards.

  5. Letting imports touch workflow state. ERP imports should update data fields, not process state. Workflow state is controlled by human transitions and automated change detection.

  6. Triggering review on every change. Classify changes by impact. SEO text updates should not trigger business reviews. Only business-critical field changes should.

  7. Not separating editorial save from system save. The biggest source of event storms. Worker saves and editor saves need different paths with different side-effect policies.

  8. Ignoring the save loop. One uncontrolled subscriber can turn a product import into a multi-hour disaster. The EventSubscriberSupervisor is not optional.

Implementation Roadmap

Phase 1: Foundation (workflow + channel eligibility)

  • Define workflow states and transitions in YAML
  • Implement computed channel eligibility (replace stored status fields)
  • Configure workflow permissions per state
  • Basic change-impact detection (business-critical vs non-impactful)

Phase 2: Governance (field ownership + event control)

  • Define field ownership registry (editor/system/shared per field)
  • Implement EventSubscriberSupervisor
  • Add scoped version guards for worker operations
  • Configure ERP import field protection

Phase 3: Full Production (transaction layer + notifications)

  • Deploy PimTx transaction layer
  • Add idempotency for all worker operations
  • Implement notification architecture
  • Full audit logging with business and technical views
  • Regression testing across all workers and integrations

See our trust and compliance overview for how we document system guarantees for enterprise clients.

Key Takeaways

  • Separate three concerns into three layers. Workflow controls process. Published flag controls web visibility. Channel eligibility is computed from current truth. Mixing them creates hacks that break everything.

  • Field ownership is not optional at scale. Without it, every worker, import, and editor overwrites each other's work through the same save() path. Define who owns which fields before writing the first workflow transition.

  • Event subscriber control prevents cascading disasters. One uncontrolled save can trigger thousands of messages. The EventSubscriberSupervisor is the single most impactful fix for Pimcore performance at scale.

  • Compute, don't store channel readiness. Any stored status field becomes stale. Compute eligibility from the current published state, workflow state, and validation rules. No sync required, no stale data possible.

  • Version guards preserve audit trails without the explosion. Editor saves create versions. Worker saves create operation logs. Same observability, 1/100th the storage.

  • Wrap Pimcore, don't fork it. PimTx governs all system writes through an explicit operation model. It wraps $object->save() with guards. It does not replace or override Pimcore core. Upgrades stay clean.

We built PimTx as an open-source Symfony bundle for exactly these patterns. If you're running an enterprise Pimcore installation and hitting these scaling problems, our AI and data engineering services cover the full stack from architecture review to production deployment.

Ready to redesign your Pimcore workflow architecture? Talk to our team or request a quote.

Topics covered

Pimcore workflowPimcore enterpriseSymfony workflow PimcorePIM workflow designPimcore state machinePimcore versioningfield ownershipPimTx

Ready to build production AI systems?

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

Start a conversation