Order Edit Draft Session
When a back-office user opens the order edit page, the bundle keeps the original order untouched until the user explicitly saves. Every change — adding, modifying, or removing line items, editing header fields — is accumulated in a draft copy of the order rather than applied to the original row. When the user saves, the draft is applied to the original order and then removed. If the user navigates away without saving, the draft is cleaned up on the next scheduled run.
This design allows safe, incremental editing of large orders without introducing partial or inconsistent states. The feature is built on the DraftSession component.
Architecture Overview
The following diagram shows the main nodes and their relations.
Route Layer
Page routes — optional {orderDraftSessionUuid?} path segment:
oro_order_create create new order
oro_order_create_for_customer create for a specific customer
oro_order_create_for_customer_user create for a specific customer user
oro_order_update edit existing order
oro_order_suborder_update edit sub-order
oro_order_reorder create order as copy of existing
BeginDraftSessionOnRequestListener (component) generates UUID, redirects if absent on GET
AJAX entry points — UUID required:
oro_order_entry_point partial-submit handler for OrderType
oro_suborder_entry_point partial-submit handler for SubOrderType
Line item AJAX routes — UUID required:
oro_order_line_item_draft_create
oro_order_line_item_draft_update
oro_order_line_item_draft_delete
oro_order_line_item_draft_mass_update
oro_order_line_item_draft_calculate_taxes_and_discounts
All routes above share:
SetDraftSessionUuidToRequestContextListener stores UUID in RequestContext
EnableEntityDraftsOnRequestListener (component) disables ORM draft filter
KeepDraftSessionUuidOnRouteGenerateListener auto-appends UUID to generated URLs
Controller Layer
OrderController creates the form with draft_session_sync=true
dispatches OrderEvent on every render
DraftSessionControllerTrait asserts draft exists and access is granted
Form Layer
OrderDraftSyncExtension form extension (OrderType + SubOrderType)
PRE_SET_DATA → loads order from draft (if exists)
POST_SET_DATA → creates initial draft (if none exists yet)
SyncOrderToOrderDraftOnOrderEventListener saves order state to draft on OrderEvent
Line Item Controllers
OrderLineItemDraftCreateController create a new line item draft
OrderLineItemDraftUpdateController update an existing line item draft
OrderLineItemDraftDeleteController soft-delete a line item draft
OrderLineItemDraftMassUpdateController load update forms for multiple items
OrderLineItemDraftCalculateTaxesAndDiscountsController taxes/discounts preview
Save Layer
UpdateHandlerFacade / OrderFormHandler persists the original order
DeleteOrderDraftOnAfterEntityFlushListener removes the draft after flush
Business Logic
OrderDraftManager facade wrapping EntityDraftManager
Synchronizers (tagged oro_order.draft_session.synchronizer):
OrderDraftSynchronizer order header + line item application
OrderLineItemDraftSynchronizer line item scalar fields
OrderAddressAwareOrderDraftSynchronizer billing and shipping addresses
OrderDiscountAwareOrderDraftSynchronizer order discounts
PaymentTermAwareOrderDraftSynchronizer payment term
ExtendedFieldsAwareEntityDraftSynchronizer extended fields (Order + OrderLineItem)
RecalculateTotalsOrderDraftSynchronizer recalculate totals (lowest priority)
Factories (tagged oro_order.draft_session.factory):
OrderDraftFactory instantiates Order drafts
OrderLineItemDraftFactory instantiates OrderLineItem drafts
Event Listeners:
ClearDraftSourceOnOrderDraftPersistEventListener clears draft source refs for new entities
before flush; restores after flush
OrderDraftAwareTotalCalculateListener applies draft state before total calculation
Relations summary:
The draft session UUID travels through every request as a route parameter and is stored in the router
RequestContextby the component’sSetDraftSessionUuidToRequestContextListener. All bundle code that needs the UUID reads it from the context viaOrderDraftManager.OrderControllercreates the order form withdraft_session_sync=true, which activatesOrderDraftSyncExtension. The extension silently replaces the order’s field values with draft state on every page load and creates the draft when none exists.SyncOrderToOrderDraftOnOrderEventListenermakes everyOrderEventdispatch (which happens on each partial submit — address lookup, price recalculation, etc.) persist the current order state back to the draft.Line item changes do not go through the order form at all. Dedicated AJAX controllers handle create, update, and delete of individual
OrderLineItemdraft rows without touching the order.On final save,
UpdateHandlerFacadeflushes the original order (which already contains the draft state applied byOrderDraftSyncExtensiononPRE_SET_DATA), and thenDeleteOrderDraftOnAfterEntityFlushListenerremoves the draft.ClearDraftSourceOnOrderDraftPersistEventListenerlistens onEntityDraftPersistBeforeEventandEntityDraftPersistAfterEvent. When a new (unsaved) order is being drafted, the draft source reference must be cleared before the draft flush to avoid a Doctrine “non-persisted association” error; the listener restores it afterwards.OrderDraftAwareTotalCalculateListenerlistens onTotalCalculateBeforeEventand callsloadFromEntityDraftbefore totals are computed, ensuring the calculation uses draft state rather than the persisted order state.
Entities
\Oro\Bundle\OrderBundle\Entity\OrderImplements
EntityDraftAwareInterface. Carries the three draft fields required by the component:draftSessionUuid(ties this copy to a session),draftSource(points to the original order), anddrafts(the collection of draft copies for the original).\Oro\Bundle\OrderBundle\Entity\OrderLineItemImplements
EntityDraftSoftDeleteAwareInterface. Carries the same three draft fields plus a booleandraftDeleteflag. When the flag is set, the synchronizer removes the corresponding original line item when the draft session is saved, without deleting it from the database until that point.
Request Lifecycle
Route and UUID Injection
Six page routes accept an optional {orderDraftSessionUuid?} path segment:
oro_order_create, oro_order_create_for_customer, oro_order_create_for_customer_user,
oro_order_update, oro_order_suborder_update, and oro_order_reorder. The component’s
BeginDraftSessionOnRequestListener detects when this segment is absent on a safe (GET) request
and redirects the browser to the same URL with a freshly generated UUID appended.
The two AJAX entry point routes — oro_order_entry_point and oro_suborder_entry_point — and
the five line item draft routes always require the UUID; they do not generate it.
SetDraftSessionUuidToRequestContextListener stores the UUID in the router RequestContext
for all routes, so it is available to all services without needing to pass it through arguments.
EnableEntityDraftsOnRequestListener disables the ORM draft filter for all applicable routes
so that draft rows are visible alongside original rows. KeepDraftSessionUuidOnRouteGenerateListener
automatically injects the UUID into any URL generated for the same set of routes so that internal
links and redirects carry the session forward without manual intervention.
OrderController and DraftSessionControllerTrait
\Oro\Bundle\OrderBundle\Controller\OrderController
Purpose: Entry point for the order edit page. Responsible for building the order form and
dispatching OrderEvent on each render.
Principles:
The form is always created with the
draft_session_syncoption set totrue. This is what activatesOrderDraftSyncExtensionfor this particular form instance.After building the form, the controller dispatches
\Oro\Bundle\OrderBundle\Event\OrderEventviaEventDispatcher. Listeners on this event — includingSyncOrderToOrderDraftOnOrderEventListener— perform draft-related work in response.The controller delegates the actual form handling and persistence to
UpdateHandlerFacadeandOrderFormHandler. It does not interact with the draft infrastructure directly.
\Oro\Bundle\OrderBundle\Controller\DraftSession\DraftSessionControllerTrait
Purpose: Shared security guard for all line item draft controllers.
Principles:
Before any line item operation, every draft controller asserts that a draft exists for the order and that the current user holds the
oro_order_updateACL permission on the draft entity. This is a deliberate extra check: the draft entity is a separate database row from the original, so the standard ACL vote on the original order is not sufficient.If the draft is not found, or if the user lacks permission, the controller throws a 404 or 403 response before any form or data logic runs.
Form Initialization (OrderDraftSyncExtension)
\Oro\Bundle\OrderBundle\Form\Extension\OrderDraftSyncExtension
Purpose: Silently replace the order’s field values with draft state before the form is rendered, and ensure a draft always exists for the session before line item AJAX calls begin.
Principles:
The extension is only active when
draft_session_sync=trueis set on the form options and a draft session UUID is present in theRequestContext. When either condition is not met, the extension does nothing. This makes it safe to attach toOrderTypeandSubOrderTypeunconditionally.On
FormEvents::PRE_SET_DATA(priority 100), the extension callsOrderDraftManager::loadFromEntityDraft($order). If a draft exists for the current session, the order entity’s fields are overwritten in memory with the draft’s state before the form binds its data. The user therefore always sees the most recent draft state, never the last database state.On
FormEvents::POST_SET_DATA(priority -100), the extension checks whether a draft already exists. If not — which is the case on the very first GET request for this order and session — it saves the current order state to a new draft immediately. This guarantees a draft row is present before any line item AJAX request arrives.
Things to consider:
The PRE_SET_DATA load triggers the synchronizer chain, which dispatches
EntityFromDraftSyncBeforeEventandEntityFromDraftSyncEvent. Custom listeners on those events run as part of the page load, not just during save operations.The POST_SET_DATA save goes through
EntityDraftPersister, which temporarily isolates non-draft unit-of-work entries during the flush (see DraftSession component — Unit of Work Isolators). Non-draft changes pending in the entity manager are suppressed during this flush and restored immediately afterwards — they are not lost.
Syncing Order Changes to the Draft (SyncOrderToOrderDraftOnOrderEventListener)
\Oro\Bundle\OrderBundle\EventListener\DraftSession\SyncOrderToOrderDraftOnOrderEventListener
Purpose: Persist the current order state to the draft whenever a partial submit changes order-header fields (customer, payment term, addresses, currency, pricing).
Principles:
The listener handles
\Oro\Bundle\OrderBundle\Event\OrderEvent, which is dispatched byOrderControlleron every response — both GET (initial load) and POST (partial or full submit).It only acts when three conditions are all true: the form was submitted,
draft_session_syncis enabled on the form, and a draft already exists for the order.When conditions are met, it calls
OrderDraftManager::saveToEntityDraft($order)to persist the current in-memory order state into the draft row.
Things to consider:
EntityDraftPersistertemporarily suppresses non-draft unit-of-work entries during the draft flush and restores them immediately afterwards. The listener itself is stateless across requests, so this isolation has no observable side effects on it.This listener only handles the order entity. Line item changes are not routed through
OrderEvent— they go through dedicated AJAX controllers.
Line Item Management (Draft Controllers)
Individual line item operations are handled by five dedicated AJAX controllers. All share the following characteristics:
Every route requires the
{orderDraftSessionUuid}parameter.Every controller uses
DraftSessionControllerTraitto assert the draft exists and is accessible.Every controller calls
OrderDraftManager::loadFromEntityDraft($order)first to read the current draft state of the order before acting on any of its line items.
OrderLineItemDraftCreateController(oro_order_line_item_draft_create)Renders and processes the form for creating a new line item draft. On valid submission, saves the new line item to the draft and triggers a datagrid refresh.
OrderLineItemDraftUpdateController(oro_order_line_item_draft_update)Renders and processes the form for modifying an existing line item draft. On valid submission, saves the updated state to the draft and triggers a datagrid refresh. On initial load, also fetches and passes taxes and discounts for display.
OrderLineItemDraftDeleteController(oro_order_line_item_draft_delete)Schedules the line item for deletion via
EntityManager::remove(), then saves it to the draft. This sets thedraftDeleteflag on the draft row; the original line item is not deleted until the order is finally saved. The approach defers the deletion so that saving the order is still reversible by discarding the session.OrderLineItemDraftMassUpdateController(oro_order_line_item_draft_mass_update)Returns rendered update forms for a comma-separated list of line item IDs in a single response. Used to populate the editing UI for multiple rows without one round-trip per line item.
OrderLineItemDraftCalculateTaxesAndDiscountsController(oro_order_line_item_draft_calculate_taxes_and_discounts)Accepts a submitted line item form, applies promotions to a transient order snapshot, and returns the taxes and discounts breakdown as rendered HTML. Nothing is persisted.
Final Save (DeleteOrderDraftOnAfterEntityFlushListener)
\Oro\Bundle\OrderBundle\EventListener\DraftSession\DeleteOrderDraftOnAfterEntityFlushListener
Purpose: Remove the draft entity after the original order has been flushed to the database.
Principles:
The listener reacts to
AfterFormProcessEvent, whichUpdateHandlerFacadedispatches after a successful flush.At the point this listener runs, the form’s
PRE_SET_DATApass has already applied the draft state to the original order entity (viaOrderDraftSyncExtension), so flushing the original order effectively persists all draft changes. The draft entity is now redundant and is removed by callingOrderDraftManager::deleteEntityDraft($order).
Key Components
OrderDraftManager
\Oro\Bundle\OrderBundle\DraftSession\Manager\OrderDraftManager
Purpose: Unified entry point for all draft-session operations within the Order bundle. Wraps
the component-level EntityDraftManager and reads the current draft session UUID from the
RequestContext automatically when none is supplied.
Principles:
Provides five operations: check whether a draft exists, find a draft, load entity state from a draft, save entity state to a draft, and delete a draft.
The load operation is in-memory and read-only. The save and delete operations flush to the database using the unit-of-work isolation and listener isolation strategy described in the DraftSession component.
Things to consider:
saveToEntityDrafttemporarily suppresses non-draft unit-of-work entries during the draft flush. Pending non-draft changes are not lost — they are restored immediately after the flush.Synchronizers for
OrderandOrderLineItemuseEntityDraftSyncReferenceResolverto ensure related entities (customer, currency, product, etc.) are managed by the entity manager before being assigned to the draft. Custom synchronizers for order-related entities should do the same for any relation fields they copy.
Synchronizers
Seven synchronizers, each tagged with oro_order.draft_session.synchronizer, collectively handle
all fields. The chain pattern means each synchronizer owns a single concern and any number of
synchronizers can be added without modifying existing ones.
OrderDraftSynchronizerCopies order header fields. When applying draft changes to the original order (sync-from-draft), it also processes line item changes: creates new original line items for each new draft line item, applies soft-deletes (draftDelete flag), and updates existing ones. This is the synchronizer that physically moves line item draft state into the real order on save.
OrderLineItemDraftSynchronizerCopies all scalar fields of a single
OrderLineItem, including kit item line items and kit item extended fields.OrderAddressAwareOrderDraftSynchronizerCopies billing and shipping address associations.
OrderDiscountAwareOrderDraftSynchronizerCopies the order’s discount collection.
PaymentTermAwareOrderDraftSynchronizerCopies the payment term association.
ExtendedFieldsAwareEntityDraftSynchronizerRegistered once for
Orderand once forOrderLineItem. Automatically copies all form-enabled custom extended fields. See Excluding Extended Fields for how to opt out specific fields.RecalculateTotalsOrderDraftSynchronizerRecalculates order totals after all other synchronizers have finished. Tagged with the lowest priority to guarantee it runs last.
Twig Integration
The bundle registers three Twig functions via OrderDraftExtension.
oro_order_draft_session_uuid()Returns the current draft session UUID string, or
nullwhen the request is not inside a draft session. Use it to detect whether the draft editing mode is active and to conditionally render draft-specific controls or construct draft-aware URLs.{% set draftSessionUuid = oro_order_draft_session_uuid() %} {% if draftSessionUuid %} {# render draft-aware datagrid or action buttons #} {% endif %}
oro_order_get_order_or_draft_id(order)Returns the ID of the draft entity when inside a draft session, or the ID of the original order when outside one. Use it wherever a template needs a single stable ID that represents the entity currently being edited, regardless of whether a draft is active.
<form action="{{ path('oro_order_update', {id: oro_order_get_order_or_draft_id(order)}) }}">
oro_order_get_order_draft_id(order)Returns the ID of the draft entity for the given order if a draft currently exists for the active session, or
nullotherwise. Differs fromoro_order_get_order_or_draft_idin that it always returnsnulloutside a draft session rather than falling back to the original order ID.{% set draftId = oro_order_get_order_draft_id(order) %} {% if draftId %} {# act on the draft row directly #} {% endif %}
Draft Cleanup
Abandoned drafts — sessions left without saving — are cleaned up automatically by the
oro:cron:draft-session:cleanup:order cron command (scheduled at midnight daily). The command
sends a message to the message queue; the EntityDraftsCleanupProcessor then batch-deletes all
draft Order and OrderLineItem rows whose updatedAt timestamp is older than the
configured threshold (default: 7 days).
To trigger cleanup manually or override the lifetime:
# Use the default lifetime of 7 days
php bin/console oro:cron:draft-session:cleanup:order
# Specify a custom lifetime
php bin/console oro:cron:draft-session:cleanup:order --draft-lifetime=30
Customization
Adding a Custom Field Synchronizer
To synchronize an additional field (for example, a field introduced by a third-party bundle),
implement \Oro\Component\DraftSession\Synchronizer\EntityDraftSynchronizerInterface and tag
the service with oro_order.draft_session.synchronizer.
If the field is a relation, inject \Oro\Component\DraftSession\Doctrine\EntityDraftSyncReferenceResolver
and use it to ensure the related entity is managed by the entity manager before assigning it to
the draft:
namespace Acme\Bundle\OrderBundle\DraftSession;
use Oro\Bundle\OrderBundle\Entity\Order;
use Oro\Component\DraftSession\Doctrine\EntityDraftSyncReferenceResolver;
use Oro\Component\DraftSession\Entity\EntityDraftAwareInterface;
use Oro\Component\DraftSession\Synchronizer\EntityDraftSynchronizerInterface;
class WarehouseOrderDraftSynchronizer implements EntityDraftSynchronizerInterface
{
public function __construct(
private readonly EntityDraftSyncReferenceResolver $referenceResolver,
) {
}
public function supports(string $entityClass): bool
{
return $entityClass === Order::class;
}
public function synchronizeFromDraft(
EntityDraftAwareInterface $draft,
EntityDraftAwareInterface $entity
): void {
assert($draft instanceof Order);
assert($entity instanceof Order);
$entity->setWarehouse($this->referenceResolver->getReference($draft->getWarehouse()));
}
public function synchronizeToDraft(
EntityDraftAwareInterface $entity,
EntityDraftAwareInterface $draft
): void {
assert($entity instanceof Order);
assert($draft instanceof Order);
$draft->setWarehouse($this->referenceResolver->getReference($entity->getWarehouse()));
}
}
Listening to Draft Lifecycle Events
The component dispatches lifecycle events at every key point of the draft workflow. For the full reference see DraftSession Component — Events. The most useful ones for order customization are:
EntityDraftCreatedEventFired after a factory instantiates a new draft. Use it to set computed defaults or copy non-mapped state without modifying the factory itself.
EntityFromDraftSyncBeforeEventFired before synchronizers apply draft state to the original entity. Use it when the synchronizer needs associations on the draft to be initialized before the bulk field copy begins — register a listener here to eagerly load the required relations.
EntityFromDraftSyncEventFired after synchronizers finish applying draft state. Use it to react to the final synchronized state of the entity — for example, to trigger downstream recalculations.
EntityToDraftSyncBeforeEventFired before synchronizers copy entity state to the draft. Use it to capture or prepare entity state before the bulk field copy begins.
EntityToDraftSyncEventFired after synchronizers finish copying entity state to the draft. Use it to react to the final synchronized state of the draft.
EntityDraftPersistBeforeEvent/EntityDraftPersistAfterEventFired immediately before and after the draft is flushed. The Order bundle uses these events in
ClearDraftSourceOnOrderDraftPersistEventListenerto temporarily clear draft source references on new (unsaved) entities before the flush — to avoid a Doctrine “non-persisted association” error — and to restore them immediately afterwards.
Example — performing additional initialization on a newly created order draft:
namespace Acme\Bundle\OrderBundle\EventListener\DraftSession;
use Oro\Bundle\OrderBundle\Entity\Order;
use Oro\Component\DraftSession\Event\EntityDraftCreatedEvent;
class SetDefaultsOnOrderDraftCreatedListener
{
public function onEntityDraftCreated(EntityDraftCreatedEvent $event): void
{
$draft = $event->getDraft();
if (!$draft instanceof Order) {
return;
}
// Perform any post-creation initialization on the draft.
$draft->setCustomerNotes('Draft in progress');
}
}
acme_order.event_listener.set_defaults_on_order_draft_created:
class: Acme\Bundle\OrderBundle\EventListener\DraftSession\SetDefaultsOnOrderDraftCreatedListener
tags:
- { name: kernel.event_listener, event: Oro\Component\DraftSession\Event\EntityDraftCreatedEvent, method: onEntityDraftCreated }
Excluding Extended Fields from Automatic Synchronization
ExtendedFieldsAwareEntityDraftSynchronizer automatically copies all form-enabled custom extended
fields. If a dedicated synchronizer already handles a specific field, exclude it from the automatic
sync to prevent duplicate or conflicting writes.
Use \Oro\Bundle\OrderBundle\DependencyInjection\Compiler\ExcludeExtendedFieldFromDraftSyncPass
in the bundle’s build() method:
namespace Acme\Bundle\OrderBundle;
use Oro\Bundle\OrderBundle\DependencyInjection\Compiler\ExcludeExtendedFieldFromDraftSyncPass;
use Oro\Bundle\OrderBundle\Entity\Order;
use Oro\Bundle\OrderBundle\Entity\OrderLineItem;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class AcmeOrderBundle extends Bundle
{
public function build(ContainerBuilder $container): void
{
parent::build($container);
$container->addCompilerPass(
new ExcludeExtendedFieldFromDraftSyncPass([
[Order::class, 'warehouse'],
[OrderLineItem::class, 'warehouse'],
])
);
}
}