DraftSession Component
The Oro\Component\DraftSession component provides the infrastructure for session-based entity draft editing. It is designed for workflows where a back-office user needs to make several incremental changes to a persistent entity and apply all of them at once — without affecting the stored data until an explicit save.
Key concepts:
A draft session is identified by a UUID that is present in the current request route and in the router
RequestContext.A draft entity is a temporary copy of a persistent entity. It shares the same table as the original and is distinguished by a non-null
draftSessionUuidcolumn.The DraftSession ORM filter automatically excludes draft entities from all regular Doctrine queries while the filter is enabled.
When the user saves, the draft state is synchronized back to the original entity and then the draft is deleted.
Architecture Overview
The component is organized into seven layers. The diagram below shows the main nodes and their relations.
Request Layer
BeginDraftSessionOnRequestListener generates a UUID and redirects if absent from the route
SetDraftSessionUuidToRequestContextListener stores UUID in the router RequestContext
KeepDraftSessionUuidOnRouteGenerateListener propagates UUID into every generated URL
Entity Layer
DraftSessionAwareInterface lightweight contract: draftSessionUuid only
↑ all entities related to a draft-aware entity must implement this at minimum
EntityDraftAwareInterface extends above: adds draftSource and drafts collection
EntityDraftSoftDeleteAwareInterface extends EntityDraftAwareInterface with a draftDelete flag
ORM Layer
DraftSessionOrmFilter appends draftSessionUuid IS NULL to all draft-aware queries
DraftSessionOrmFilterManager controlled enable / disable / check of the filter
Provider Layer
DraftSessionUuidProvider reads the draft session UUID from the router RequestContext
EntityDraftRepositoryChain routes draft lookups to the first matching repository
└─► EntityDraftRepositoryInterface one implementation per entity class
├─ GenericEntityDraftRepository finds draft by entity ID + session UUID
└─ ExclusiveEntityDraftRepository finds draft by session UUID only (no source entity)
Operations Facade
EntityDraftManager main entry point for application code
│
├─► EntityDraftLoader apply draft state to an entity (read-only)
│ ├─ uses EntityDraftRepositoryInterface
│ └─ uses EntityDraftSynchronizerInterface
│
├─► EntityDraftPersister persist entity state to a draft
│ ├─ uses EntityDraftRepositoryInterface
│ ├─ uses EntityDraftFactoryInterface (create path: no draft exists yet)
│ ├─ uses EntityDraftSynchronizerInterface (update path: draft already exists)
│ ├─ uses DoctrineListenersIsolator
│ └─ uses DraftEntitiesEntityManagerIsolator
│
└─► EntityDraftRemover delete a draft
├─ uses EntityDraftRepositoryInterface
├─ uses DoctrineListenersIsolator
└─ uses DraftEntitiesEntityManagerIsolator
Synchronization / Creation Layer
EntityDraftSynchronizerChain runs all matching EntityDraftSynchronizerInterface implementations
└─► EntityDraftSynchronizerInterface one implementation per entity class or field concern
└─ ExtendedFieldsAwareEntityDraftSynchronizer reusable implementation for extended fields
EntityDraftFactoryChain delegates to first matching EntityDraftFactoryInterface implementation
└─► EntityDraftFactoryInterface one implementation per entity class
EntityDraftSyncReferenceResolver resolves managed entity references inside synchronizers
Isolator Layer
DoctrineListenersIsolator disables all Doctrine event listeners except a whitelist
DraftEntitiesEntityManagerIsolator flushes only draft entities by isolating non-draft UoW entries
NonDraftEntitiesEntityManagerIsolator flushes only non-draft entities by isolating draft UoW entries
└─► DraftEntitiesUnitOfWorkIsolator extracts/restores draft scheduling entries in the UoW
└─► NonDraftEntitiesUnitOfWorkIsolator extracts/restores non-draft scheduling entries in the UoW
└─ UnitOfWorkSnapshot immutable snapshot of extracted UoW state
Cleanup Layer
EntityDraftsCleanupProcessor (MQ) receives lifetime config, delegates to strategy
└─► EntityDraftsCleanupStrategyInterface
├─ GenericEntityDraftsCleanupStrategy single entity class
└─ CompositeEntityDraftsCleanupStrategy multiple entity classes
Relations summary:
EntityDraftManageris the single facade that application and bundle code should call. It resolves the draft session UUID viaDraftSessionUuidProvider(which reads from the routerRequestContext) and delegates the actual work toEntityDraftLoader,EntityDraftPersister, andEntityDraftRemover.All three collaborators receive an
EntityDraftRepositoryInterfacefor draft lookups. In the typical wire-up, aEntityDraftRepositoryChainis injected, routing each lookup to the first matching per-entity implementation.EntityDraftPersisterisolates Doctrine event listeners viaDoctrineListenersIsolatorand usesDraftEntitiesEntityManagerIsolatorto flush only draft-related changes. Non-draft changes that are pending in the unit of work are temporarily extracted, the draft-only flush runs, and the non-draft entries are restored — no entity manager clear takes place. The persister usesEntityDraftFactoryInterfaceto create a new draft row when none exists, orEntityDraftSynchronizerInterfaceto refresh an existing one.EntityDraftLoaderusesEntityDraftSynchronizerInterfaceto copy draft fields back onto the original entity in memory. Nothing is persisted.EntityDraftRemoverisolates Doctrine event listeners the same way as the persister, usesDraftEntitiesEntityManagerIsolatorfor the flush, and dispatchesEntityDraftDeleteBeforeEventbefore flushing the removal.Both
EntityDraftFactoryChainandEntityDraftSynchronizerChaindispatch events before and after completing their work, making them the primary extension points alongside tagged service registration.The
DraftSessionOrmFilteris the transparency guarantee: all draft rows are invisible to the rest of the application unless the filter is explicitly disabled for a specific route viaEnableEntityDraftsOnRequestListeneror programmatically viaDraftSessionOrmFilterManager.When a synchronizer copies a relation field from the source entity to the draft, the referenced entity may not be tracked by the entity manager in the current context. Synchronizer implementations must use
EntityDraftSyncReferenceResolverto obtain a fresh managed reference.
Events dispatched (Oro\Component\DraftSession\Event namespace):
EntityDraftCreatedEvent— after a factory creates a draft.EntityFromDraftSyncBeforeEvent— before synchronizers apply draft state to the original entity.EntityFromDraftSyncEvent— after synchronizers finish applying draft state to the original entity.EntityToDraftSyncBeforeEvent— before synchronizers copy entity state to the draft.EntityToDraftSyncEvent— after synchronizers finish copying entity state to the draft.EntityDraftPersistBeforeEvent/EntityDraftPersistAfterEvent— before and after the draft is flushed.EntityDraftDeleteBeforeEvent— after the draft is scheduled for removal, before the flush.
Entity Interfaces
The Component defines three interfaces that determine at what level an entity participates in a draft session.
The related-entity requirement. Every entity that is created, updated, or deleted as part of an EntityDraftAwareInterface entity’s draft session must implement DraftSessionAwareInterface at minimum. Two component mechanisms depend on this contract:
DraftSessionOrmFilterdetermines whether to append thedraftSessionUuid IS NULLconstraint by checking whether the entity’sClassMetadatadeclares a mappeddraftSessionUuidfield. An entity without this mapped field is invisible to the filter, so its draft rows will appear in normal application queries and break the transparency guarantee.The UoW isolators (
DraftEntitiesUnitOfWorkIsolatorandNonDraftEntitiesUnitOfWorkIsolator) classify every UoW scheduling entry as a draft or a non-draft row by testing$entity instanceof DraftSessionAwareInterface && $entity->getDraftSessionUuid() !== null. A related entity that does not implement the interface cannot be identified as a draft row and will not be correctly isolated during the partial-flush strategy — it may be flushed at the wrong time or suppressed unintentionally.
To satisfy both mechanisms an entity must implement DraftSessionAwareInterface and use DraftSessionAwareTrait (or provide an equivalent ORM mapping) so that the draft_session_uuid column exists in the entity’s table.
Choose the interface based on the entity’s role in the draft session:
Root draft entity — the entity the user edits through the draft form — must implement
EntityDraftAwareInterface. It requires a self-referencingdraftSourceassociation and adraftsone-to-many collection declared with concrete Doctrine ORM annotations directly in the entity class.Collection member with deferred deletion — a child entity that can be removed in the draft state and must only be physically deleted when the user saves — must implement
EntityDraftSoftDeleteAwareInterface.Simple related entity — any other entity that participates in the draft session by carrying the same UUID as its parent, without needing its own source reference or drafts collection — need only implement
DraftSessionAwareInterface.
\Oro\Component\DraftSession\Entity\DraftSessionAwareInterfaceA lightweight contract for entities that only need to carry a draft session UUID. Requires a single Doctrine-mapped field (
draftSessionUuid) and is the minimum interface that both the ORM filter and the isolator-layer UoW inspection code use to identify draft rows. Entities that only participate in a session by UUID — without needing a source reference or a draft copies collection — should implement this interface rather than the heavierEntityDraftAwareInterface. This interface is intentionally separate from the platform’s\Oro\Bundle\DraftBundle\Entity\DraftableInterface, which serves a different purpose (project-based publish workflow versus session-based edit drafts).\Oro\Component\DraftSession\Entity\EntityDraftAwareInterfaceExtends
DraftSessionAwareInterfacewith the full draft lifecycle contract. Must be implemented by the root draft entity and by any collection member that also needs its own source reference. Adds a self-referencing association to the original entity the draft was created from and a collection of all draft copies that exist for the entity. The$draftSource(ManyToOneself-reference) and$drafts(OneToManycollection,mappedBy: 'draftSource') properties cannot be mapped generically in a trait — each entity class must declare them with concrete Doctrine ORM annotations and initialize$draftsto anew ArrayCollection()in the constructor.\Oro\Component\DraftSession\Entity\EntityDraftSoftDeleteAwareInterfaceExtends
EntityDraftAwareInterfacewith a boolean deletion flag (isDraftDelete()/setDraftDelete()). When the flag is set on a collection member, the corresponding original entity is removed when the draft session is saved rather than immediately. Use this interface for child collection members where deletion must be deferred until the parent form is committed.
Reusable Traits
The Component ships three traits that provide complete, Doctrine-mapped implementations of the corresponding interfaces.
\Oro\Component\DraftSession\Entity\DraftSessionAwareTraitMaps the
draft_session_uuidORM column (nullableGUID) and implementsgetDraftSessionUuid()andsetDraftSessionUuid(). Use this trait for entities that implementDraftSessionAwareInterfaceonly (simple related entities).\Oro\Component\DraftSession\Entity\EntityDraftAwareTraitExtends
DraftSessionAwareTraitand implementsgetDraftSource(),setDraftSource(),getDrafts(),addDraft(), andremoveDraft(). The trait does not declare or map the$draftSourceand$draftsproperties — each entity class using the trait must add these two properties with#[ORM\ManyToOne]and#[ORM\OneToMany]annotations respectively, and the constructor must initialize$draftsto anew ArrayCollection().\Oro\Component\DraftSession\Entity\EntityDraftSoftDeleteAwareTraitMaps the
draft_deleteboolean ORM column and implementsisDraftDelete()andsetDraftDelete(). Combine withEntityDraftAwareTraitfor entities that implementEntityDraftSoftDeleteAwareInterface.
The following example shows the three-level pattern as used in the Order bundle:
// Root draft entity — full lifecycle: source reference and drafts collection.
class Order implements EntityDraftAwareInterface
{
use EntityDraftAwareTrait; // provides draftSessionUuid field + source/drafts helpers
// $draftSource and $drafts must be declared with concrete ORM annotations.
#[ORM\ManyToOne(targetEntity: Order::class, inversedBy: 'drafts')]
#[ORM\JoinColumn(name: 'draft_source_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
protected ?self $draftSource = null;
#[ORM\OneToMany(mappedBy: 'draftSource', targetEntity: Order::class, fetch: 'EXTRA_LAZY')]
protected ?Collection $drafts = null;
public function __construct()
{
$this->drafts = new ArrayCollection();
}
}
// Collection member with deferred deletion — supports the soft-delete flag.
class OrderLineItem implements EntityDraftSoftDeleteAwareInterface
{
use EntityDraftAwareTrait; // draftSessionUuid + source/drafts helpers
use EntityDraftSoftDeleteAwareTrait; // draft_delete column
// $draftSource and $drafts must also be declared with ORM annotations here.
#[ORM\ManyToOne(targetEntity: OrderLineItem::class, inversedBy: 'drafts')]
#[ORM\JoinColumn(name: 'draft_source_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
protected ?self $draftSource = null;
#[ORM\OneToMany(mappedBy: 'draftSource', targetEntity: OrderLineItem::class, fetch: 'EXTRA_LAZY')]
protected ?Collection $drafts = null;
}
// Simple related entity — carries the UUID so the ORM filter and UoW isolators work.
class OrderDiscount implements DraftSessionAwareInterface
{
use DraftSessionAwareTrait; // draft_session_uuid column only
}
EntityDraftManager
\Oro\Component\DraftSession\Manager\EntityDraftManager is the main component-level facade for draft-session operations. Bundle-level services (such as OrderDraftManager in the Order bundle) delegate to this service rather than calling the lower-level collaborators directly.
The manager provides five operations: checking whether a draft exists for an entity, finding a draft entity, loading entity state from a draft (delegates to EntityDraftLoader), saving entity state to a draft (delegates to EntityDraftPersister), and deleting a draft (delegates to EntityDraftRemover). All operations accept an optional explicit draft session UUID; when omitted, the UUID is resolved from the router RequestContext via DraftSessionUuidProvider.
Draft Repository
The repository layer is responsible for looking up whether a draft exists and for fetching the draft entity for a given source entity and draft session UUID. All three internal collaborators (EntityDraftLoader, EntityDraftPersister, EntityDraftRemover) use the repository to locate drafts rather than querying entities directly.
\Oro\Component\DraftSession\Provider\EntityDraftRepositoryInterfaceThe contract all repository implementations must satisfy. One implementation is required per entity class. The repository must be able to both check for the existence of a draft and return it. This is the main extension point for customizing how drafts are composed when fetched — for example, loading specific relations eagerly alongside the draft in a single query.
\Oro\Component\DraftSession\Provider\EntityDraftRepositoryChainAggregates all tagged
EntityDraftRepositoryInterfaceimplementations and routes each lookup to the first one that declares support for the given entity class.\Oro\Component\DraftSession\Provider\GenericEntityDraftRepositoryA reusable implementation that looks up a draft by matching either the entity ID or the draft ID against
draftSourceand the provided draft session UUID. Suitable for most entity classes where the draft always has a source entity.\Oro\Component\DraftSession\Provider\ExclusiveEntityDraftRepositoryA simpler implementation that looks up a draft by draft session UUID alone, without involving the source entity identity. Use this for entity classes where there is always at most one draft per session and no source entity association is needed (for example, a root-level entity where a single draft per session is guaranteed).
To register a custom repository, implement EntityDraftRepositoryInterface, tag the service with the repository tag defined by the consuming bundle, and ensure the supports() method returns true only for the intended entity class.
Request Lifecycle Listeners
The following listeners wire the draft session UUID into the Symfony request lifecycle:
BeginDraftSessionOnRequestListenerOn safe (read-only) HTTP requests to a configured list of routes, checks whether a draft session UUID is already present in the route. If it is absent, the listener generates a new UUID and redirects the browser to the same URL with the UUID appended. This guarantees every draft-aware page load has a session UUID before any form or controller logic runs.
SetDraftSessionUuidToRequestContextListenerOn every request, reads the current draft session UUID (if any) from the request and stores it in the router
RequestContext. This makes the UUID accessible to URL generators without passing it explicitly.KeepDraftSessionUuidOnRouteGenerateListenerWhen a configured route is being generated, copies the UUID from the
RequestContextinto the generated URL parameters. This ensures that links generated inside a draft session page automatically carry the same UUID.
ORM Filter
\Oro\Component\DraftSession\Doctrine\DraftSessionOrmFilterA Doctrine SQL filter that appends a
draftSessionUuid IS NULLcondition to every query involving a draft-aware entity. When enabled (the default), all draft rows are invisible to regular application queries, so no existing code needs to be aware of the draft table rows.\Oro\Component\DraftSession\Manager\DraftSessionOrmFilterManagerProvides controlled enable/disable/check access to the ORM filter. Use it whenever draft rows must be temporarily visible — for example, inside cleanup strategies or dedicated draft controllers that need to read or write draft rows directly.
\Oro\Component\DraftSession\EventListener\EnableEntityDraftsOnRequestListenerDisables the ORM filter for a configurable list of routes on
kernel.requestand re-enables it onkernel.terminate. Register this listener for any route that legitimately needs direct access to draft rows.
Internal Collaborators
The collaborator services described here are not intended to be called directly by most application code — use EntityDraftManager instead. They are documented to clarify the principles that govern each operation and what to keep in mind when reacting to the events they dispatch.
EntityDraftLoader
Purpose: Bring a regular entity’s in-memory state in line with the draft that exists for the current draft session, without touching the database.
Principles:
The loader is flexible about its input: it accepts both a regular entity and a draft entity, resolving the appropriate source and target internally.
When given a draft with a persisted source, it synchronizes fields from the draft into the original entity and returns it.
When given a draft that has no source (an entity being created for the first time), it instantiates a new entity and synchronizes the draft into it.
When given a regular entity, it looks up its draft and synchronizes if found; if no draft exists, the entity is returned unchanged.
The operation is purely in-memory and read-only from a persistence perspective — nothing is flushed.
Things to consider:
Synchronization triggers
EntityFromDraftSyncBeforeEventandEntityFromDraftSyncEvent, so all listeners registered for those events will run during a load operation.If a synchronizer reads associations on the draft entity and those associations are not yet initialized, they must be eagerly loaded before synchronization begins. The best place to do this is in a listener on
EntityFromDraftSyncBeforeEvent.
EntityDraftPersister
Purpose: Persist an entity’s current state to its draft within the draft session, creating a new draft when none exists or updating the existing one.
Principles:
The persister captures whether the entity is scheduled for deletion in the unit of work before doing anything else — this information is needed to propagate the soft-delete flag to the draft.
All Doctrine event listeners are disabled during the persist/flush cycle via
DoctrineListenersIsolator, preventing application-level side effects (re-indexing, audit logging, total recalculation) from running against drafts. An explicit whitelist allows specific listeners to remain active when required.Listeners are always re-enabled in a
finallyblock, even if an exception occurs.The persister uses
DraftEntitiesEntityManagerIsolatorto flush only draft-related rows. Non-draft entity changes that are pending in the unit of work are temporarily extracted from the UoW scheduling queues, the draft-only flush runs, and the non-draft entries are restored.The persister dispatches
EntityDraftPersistBeforeEventjust before flushing andEntityDraftPersistAfterEventimmediately after.
Things to consider:
Non-draft entity changes pending in the unit of work are temporarily suppressed during the draft flush and restored afterwards — they are not lost, but they are also not persisted as a side effect of the draft save.
If your bundle’s infrastructure listeners must run during draft persistence (for example, a custom audit listener), add them to the
DoctrineListenersIsolatorwhitelist in the service configuration.Ensure relation fields are managed inside synchronizers. When a synchronizer copies a relation field from the source entity to the draft, the referenced entity must be tracked by the entity manager. If it was fetched in a different context or is in a detached state, use
EntityDraftSyncReferenceResolver::getReference()to obtain a fresh managed proxy reference. For enum fields usegetEnumReference(). Failing to do this will cause a Doctrine “detached entity” error on flush.
EntityDraftRemover
Purpose: Remove the persisted draft entity for a given entity within a draft session.
Principles:
If no draft exists for the entity, the operation is a no-op.
Doctrine event listeners are disabled during removal for the same reason as in the persister — to avoid triggering side effects on draft rows.
EntityDraftDeleteBeforeEventis dispatched after the draft is scheduled for removal but before the flush, allowing listeners to react while the draft is still in the unit of work.Listeners are always re-enabled in a
finallyblock.
DoctrineListenersIsolator
\Oro\Component\DraftSession\Isolator\DoctrineListenersIsolator is the shared mechanism used by both EntityDraftPersister and EntityDraftRemover to isolate draft persistence from the application’s Doctrine listener infrastructure. It disables all ORM event listeners on the relevant entity manager except those in an explicit whitelist, and restores them afterwards. The whitelist is configured per-bundle in the service definition and allows critical infrastructure listeners to remain active during draft operations.
Unit of Work Isolators
The following isolator classes implement the partial-flush strategy used by EntityDraftPersister and EntityDraftRemover. They ensure that a draft-only flush never accidentally persists non-draft entity changes that are pending in the same unit of work, and vice versa.
\Oro\Component\DraftSession\Isolator\DraftEntitiesEntityManagerIsolatorThe high-level entry point used by the persister and remover. Its
flushDraftEntities()method collects all draft entities that are scheduled for insertion or update, temporarily extracts all non-draft scheduling entries from the unit of work viaNonDraftEntitiesUnitOfWorkIsolator, callsEntityManager::flush(), and then merges the non-draft entries back.\Oro\Component\DraftSession\Isolator\NonDraftEntitiesEntityManagerIsolatorThe inverse operation. Its
flushNonDraftEntities()method temporarily extracts all draft scheduling entries from the unit of work viaDraftEntitiesUnitOfWorkIsolator, callsEntityManager::flush(), and then merges the draft entries back. Use this when you need to flush the regular entity changes while a draft is also pending in the unit of work.\Oro\Component\DraftSession\Isolator\DraftEntitiesUnitOfWorkIsolatorLow-level helper that extracts draft entity scheduling entries (insertions, updates, deletions, collection changes) from the Doctrine unit of work and marks draft entities as read-only so that Doctrine skips changeset computation for them during a non-draft flush. Returns a
UnitOfWorkSnapshotthat can be used to restore the extracted state.\Oro\Component\DraftSession\Isolator\NonDraftEntitiesUnitOfWorkIsolatorLow-level helper that extracts non-draft entity scheduling entries from the unit of work, leaving only draft-related changes pending. Returns a
UnitOfWorkSnapshotfor later restoration. Used internally byDraftEntitiesEntityManagerIsolator.\Oro\Component\DraftSession\Isolator\UnitOfWorkSnapshotAn immutable value object that holds the extracted UoW state (scheduling queues and read-only flags) captured by one of the UoW isolators. Passed back to the corresponding restore method to replay the extracted state after the isolated flush completes.
Draft Factory
A draft factory is responsible for instantiating a new draft entity and populating its initial state from the source. One factory implementation is needed per entity class that participates in a draft session.
\Oro\Component\DraftSession\Factory\EntityDraftFactoryInterfaceImplement this interface for each entity class. The factory receives the source entity and the draft session UUID, creates a fresh draft instance, and returns it. The factory is the right place for entity-specific initialization logic that cannot be expressed as a generic synchronizer.
\Oro\Component\DraftSession\Factory\EntityDraftFactoryChainAggregates all tagged factory implementations and delegates to the first one that declares support for the given entity class. After the factory returns the draft, the chain dispatches
EntityDraftCreatedEvent, giving listeners a chance to perform post-creation work without modifying the factory itself.
To register a custom factory, tag the service with the factory tag defined by the consuming bundle (for example, oro_order.draft_session.factory for the Order bundle).
Draft Synchronizer
A draft synchronizer is responsible for copying fields between an entity and its draft in both directions. Multiple synchronizers can be registered for the same entity class; the chain calls all matching ones, which allows different concerns (addresses, discounts, extended fields) to be separated into focused implementations.
\Oro\Component\DraftSession\Synchronizer\EntityDraftSynchronizerInterfaceImplement this interface for each entity class or for a specific subset of its fields. Two directions must be supported: copying from the original entity into the draft (used when creating or refreshing a draft), and copying from the draft back to the original entity (used when loading or saving).
\Oro\Component\DraftSession\Synchronizer\EntityDraftSynchronizerChainAggregates all tagged synchronizer implementations and runs every one that supports the given entity class. When synchronizing from a draft, the chain dispatches
EntityFromDraftSyncBeforeEventbefore any synchronizers run andEntityFromDraftSyncEventafter all have finished. When synchronizing to a draft, it dispatchesEntityToDraftSyncBeforeEventbefore any synchronizers run andEntityToDraftSyncEventafter all have finished.\Oro\Component\DraftSession\Synchronizer\ExtendedFieldsAwareEntityDraftSynchronizerA reusable synchronizer that automatically copies all form-enabled custom extended fields between an entity and its draft. It covers scalar values, single relations, and collection-type relations. Register one instance per entity class that has extended fields. Fields that are handled by a dedicated synchronizer should be excluded from automatic sync — see Excluding Extended Fields.
\Oro\Component\DraftSession\Doctrine\EntityDraftSyncReferenceResolverA utility service for use inside synchronizer implementations. When a synchronizer copies a relation field from the source entity onto the draft, the referenced entity must be tracked by the entity manager. If the entity is not currently tracked (for example, it was fetched in a different context or scope), a Doctrine “detached entity” error will occur on flush.
EntityDraftSyncReferenceResolverresolves this:getReference()returns the entity as-is if the entity manager already tracks it, or obtains a lightweight Doctrine proxy reference by ID otherwise. For enum fields,getEnumReference()performs the same resolution accepting either anEnumOptionInterfaceobject or a plain string identifier. Inject this service into any synchronizer that copies relation fields.
Events
All events are located in the Oro\Component\DraftSession\Event namespace. They are the primary extension point for reacting to draft lifecycle moments without modifying the core infrastructure.
EntityDraftCreatedEventDispatched by
EntityDraftFactoryChainimmediately after a new draft is created. Use this event to perform additional initialization on the draft that is too specific for a general synchronizer — for example, setting computed defaults or copying non-mapped state.EntityFromDraftSyncBeforeEventDispatched by
EntityDraftSynchronizerChainbefore any synchronizers run when applying draft changes to the original entity. Use this event when you need to capture or modify entity state before the bulk field copy begins, or to eagerly initialize associations on the draft entity that the synchronizer will read.EntityFromDraftSyncEventDispatched by
EntityDraftSynchronizerChainafter all synchronizers finish applying draft changes to the original entity. Use this event to react to the final synchronized state of the entity.EntityToDraftSyncBeforeEventDispatched by
EntityDraftSynchronizerChainbefore any synchronizers run when copying entity state to the draft. Use this event to capture or prepare entity state before the bulk field copy begins.EntityToDraftSyncEventDispatched by
EntityDraftSynchronizerChainafter all synchronizers finish copying entity state to the draft. Use this event to react to the final synchronized state of the draft.EntityDraftPersistBeforeEventDispatched by
EntityDraftPersisterimmediately before the draft is flushed to the database. Carries references to the draft and, optionally, the original entity.EntityDraftPersistAfterEventDispatched by
EntityDraftPersisterimmediately after the draft is flushed. Same payload asEntityDraftPersistBeforeEvent.EntityDraftDeleteBeforeEventDispatched by
EntityDraftRemoverafter the draft is scheduled for removal but before the flush. Use this event to react to or prevent the imminent deletion while the draft is still in the unit of work.
Example — react to a draft being saved back to the original entity:
namespace Acme\Bundle\OrderBundle\EventListener\DraftSession;
use Oro\Bundle\OrderBundle\Entity\Order;
use Oro\Component\DraftSession\Event\EntityFromDraftSyncEvent;
class OnOrderDraftSavedListener
{
public function onEntityFromDraftSync(EntityFromDraftSyncEvent $event): void
{
if ($event->getEntityClass() !== Order::class) {
return;
}
/** @var Order $order */
$order = $event->getTarget();
// React to changes applied from the draft.
}
}
Draft Cleanup
Abandoned draft entities (those whose session was never saved) are removed by the cleanup infrastructure.
\Oro\Component\DraftSession\Cleanup\EntityDraftsCleanupStrategyInterfaceDefines the contract for cleaning up stale draft entities. An implementation receives a threshold date and a batch size, removes all draft rows last updated before the threshold in batches, and returns the total number of removed rows.
\Oro\Component\DraftSession\Cleanup\GenericEntityDraftsCleanupStrategyA concrete, reusable implementation for any entity class. It temporarily disables the ORM filter before querying for stale drafts so that draft rows are visible, then re-enables it afterwards. Configure one instance per entity class that may accumulate abandoned drafts.
\Oro\Component\DraftSession\Cleanup\CompositeEntityDraftsCleanupStrategyAggregates multiple cleanup strategy instances and sums their return values. Use this when a single draft session spans more than one entity class (for example,
OrderandOrderLineItem), so that all related draft rows are cleaned up in one operation.\Oro\Component\DraftSession\Async\EntityDraftsCleanupProcessorA message queue processor that receives a draft lifetime in days, calculates the corresponding threshold date, and delegates to the configured cleanup strategy. Triggered by a bundle-level cron command or on-demand message.
Extending the Component
Adding a Draft Factory for a New Entity
Implement EntityDraftFactoryInterface and tag the service. The following example shows a factory for a hypothetical Acme\Bundle\FooBundle\Entity\FooEntity:
namespace Acme\Bundle\FooBundle\DraftSession\Factory;
use Acme\Bundle\FooBundle\Entity\FooEntity;
use Oro\Component\DraftSession\Entity\EntityDraftAwareInterface;
use Oro\Component\DraftSession\Factory\EntityDraftFactoryInterface;
use Oro\Component\DraftSession\Synchronizer\EntityDraftSynchronizerInterface;
class FooEntityDraftFactory implements EntityDraftFactoryInterface
{
public function __construct(
private readonly EntityDraftSynchronizerInterface $entityDraftSynchronizer,
) {
}
public function supports(string $entityClass): bool
{
return $entityClass === FooEntity::class;
}
public function createDraft(EntityDraftAwareInterface $entity, string $draftSessionUuid): FooEntity
{
assert($entity instanceof FooEntity);
$draft = new FooEntity();
$draft->setDraftSessionUuid($draftSessionUuid);
$draft->setDraftSource($entity->getId() ? $entity : null);
$this->entityDraftSynchronizer->synchronizeToDraft($entity, $draft);
return $draft;
}
}
Adding a Draft Synchronizer for a New Entity
Implement EntityDraftSynchronizerInterface and tag the service:
namespace Acme\Bundle\FooBundle\DraftSession;
use Acme\Bundle\FooBundle\Entity\FooEntity;
use Oro\Component\DraftSession\Entity\EntityDraftAwareInterface;
use Oro\Component\DraftSession\Synchronizer\EntityDraftSynchronizerInterface;
class FooEntityDraftSynchronizer implements EntityDraftSynchronizerInterface
{
public function supports(string $entityClass): bool
{
return $entityClass === FooEntity::class;
}
public function synchronizeFromDraft(
EntityDraftAwareInterface $draft,
EntityDraftAwareInterface $entity
): void {
assert($draft instanceof FooEntity);
assert($entity instanceof FooEntity);
$entity->setTitle($draft->getTitle());
$entity->setDescription($draft->getDescription());
}
public function synchronizeToDraft(
EntityDraftAwareInterface $entity,
EntityDraftAwareInterface $draft
): void {
assert($entity instanceof FooEntity);
assert($draft instanceof FooEntity);
$draft->setTitle($entity->getTitle());
$draft->setDescription($entity->getDescription());
}
}
Excluding Extended Fields from Automatic Synchronization
ExtendedFieldsAwareEntityDraftSynchronizer automatically syncs all form-enabled custom extended fields. If a specific field requires special handling in a dedicated synchronizer, exclude it from the automatic sync by calling addExcludedField() on the EntityDraftExtendedFieldsProvider service for the given entity class at container build time.
The Order bundle provides \Oro\Bundle\OrderBundle\DependencyInjection\Compiler\ExcludeExtendedFieldFromDraftSyncPass as a convenience compiler pass for this purpose. See Order Edit Draft Session — Excluding Extended Fields for a usage example.