Important

You are browsing documentation for version 6.1 of OroCommerce, supported until 2029. Read the documentation for the latest LTS version to get up-to-date information.

See our Release Process documentation for more information on the currently supported and upcoming releases.

Common Checkout Customization Methods 

This document covers the most common checkout workflow customizations. You can customize the checkout process in two ways:

  • Modify the configuration and logic of an existing checkout.

  • Create a new checkout workflow with a custom name, then base all customizations on that name.

The examples below assume we have extended a multistep checkout workflow and named the new workflow acme_demo_checkout.

src/Acme/Bundle/DemoBundle/Resources/config/oro/workflows.yml 
imports:
    -
        resource: '@OroCheckoutBundle/Resources/config/oro/workflows/b2b_flow_checkout.yml'
        workflow: b2b_flow_checkout
        as: acme_demo_checkout
        replace: []

Transfer Custom Data from Shopping List to Order with Checkout 

The most common checkout change is adding new attributes and displaying them on the checkout form. As an example, we add the external_po_number field to the Shopping List and transfer it to the Order:

  1. Add an extendable field external_po_number to the Shopping List, Checkout and Order entities with migration.

    src/Acme/Bundle/DemoBundle/Migrations/Schema/v1_13/AddExternalPoNumberColumn.php 
    <?php
    
    namespace Acme\Bundle\DemoBundle\Migrations\Schema\v1_13;
    
    use Doctrine\DBAL\Schema\Schema;
    use Oro\Bundle\EntityBundle\EntityConfig\DatagridScope;
    use Oro\Bundle\EntityExtendBundle\EntityConfig\ExtendScope;
    use Oro\Bundle\MigrationBundle\Migration\Migration;
    use Oro\Bundle\MigrationBundle\Migration\QueryBag;
    
    class AddExternalPoNumberColumn implements Migration
    {
        public function up(Schema $schema, QueryBag $queries)
        {
            $this->addExternalPoNumber($schema, 'oro_shopping_list');
            $this->addExternalPoNumber($schema, 'oro_checkout');
            $this->addExternalPoNumber($schema, 'oro_order');
        }
    
        private function addExternalPoNumber(Schema $schema, string $tableName): void
        {
            $table = $schema->getTable($tableName);
            $table->addColumn(
                'external_po_number',
                'string',
                [
                    'notnull' => false,
                    'length' => 255,
                    'oro_options' => [
                        'extend' => [
                            'is_extend' => true,
                            'owner' => ExtendScope::OWNER_CUSTOM
                        ],
                        'entity' => ['label' => 'External Po Number'],
                        'datagrid' => ['is_visible' => DatagridScope::IS_VISIBLE_TRUE]
                    ]
                ]
            );
        }
    }
    
  2. Define the storage for external_po_number during the Checkout. Add an extendable field external_po_number to the Checkout Entity and expose it as an attribute in the Checkout workflow:

    src/Acme/Bundle/DemoBundle/Resources/config/oro/workflows.yml 
    workflows:
        acme_demo_checkout:
            attributes:
                # Extends the list of attributes of the b2b_flow_checkout.attributes
                external_po_number:
                    property_path: checkout.external_po_number
    

Alternative

You can define a checkout workflow attribute and store the data in the Workflow Data:

src/Acme/Bundle/DemoBundle/Resources/config/oro/workflows.yml 
workflows:
    acme_demo_checkout:
        attributes:
            # Extends the list of attributes of the b2b_flow_checkout.attributes
            external_po_number:
                type: string
  1. Transfer the external_po_number value from the Shopping List to the Checkout.

    To transfer the data from the source object to the checkout at checkout start, decorate the Oro\Bundle\CheckoutBundle\Workflow\ActionGroup\StartShoppingListCheckout action group, which is responsible for the start logic:

    src/Acme/Bundle/DemoBundle/Workflow/ActionGroup/StartShoppingListCheckout.php 
    <?php
    
    namespace Acme\Bundle\DemoBundle\Workflow\ActionGroup;
    
    use Oro\Bundle\CheckoutBundle\Workflow\ActionGroup\StartShoppingListCheckoutInterface;
    use Oro\Bundle\ShoppingListBundle\Entity\ShoppingList;
    
    /**
     * Transfer external_po_number from shopping list to the checkout on checkout creation.
     */
    class StartShoppingListCheckout implements StartShoppingListCheckoutInterface
    {
        public function __construct(
            private StartShoppingListCheckoutInterface $innerAction
        ) {
        }
    
        public function execute(
            ShoppingList $shoppingList,
            bool $forceStartCheckout = false,
            bool $showErrors = false,
            bool $validateOnStartCheckout = true,
            bool $allowManualSourceRemove = true,
            bool $removeSource = true,
            bool $clearSource = false
        ): array {
            $result = $this->innerAction->execute(
                $shoppingList,
                $forceStartCheckout,
                $showErrors,
                $validateOnStartCheckout,
                $allowManualSourceRemove,
                $removeSource,
                $clearSource
            );
    
            $result['checkout']->setExternalPoNumber($shoppingList->getExternalPoNumber());
    
            return $result;
        }
    }
    

    Make sure you register the service.

    src/Acme/Bundle/DemoBundle/Resources/config/services.yml 
    services:
        acme_demo.action_group.start_from_shopping_list:
            class: Acme\Bundle\DemoBundle\Workflow\ActionGroup\StartShoppingListCheckout
            decorates: oro_checkout.action_group.start_from_shopping_list
            arguments:
                - '@.inner'
            tags:
                - { name: 'oro_action_group_service' }
    

Alternative

To transfer the data from the source object to the checkout at checkout start, you can decorate or replace the transition service, or add an event listener on the oro_workflow.start event.

  1. Modify the place_order transition form to include the new attribute.

    1. Add the attribute to the transition form fields

    src/Acme/Bundle/DemoBundle/Resources/config/oro/workflows.yml 
    workflows:
        acme_demo_checkout:
            transitions:
                place_order:
                    form_options:
                        # Extends the list of attribute_fields
                        attribute_fields:
                            external_po_number: ~
    
    1. Render the new attribute. For more information, see documentation on Layouts.

  2. Transfer external_po_number from the Checkout to the Order during Order placement by adding an event listener to the extendable_action.finish_checkout event.

    src/Acme/Bundle/DemoBundle/Workflow/EventListener/FinishCheckoutEventListener.php 
    <?php
    
    namespace Acme\Bundle\DemoBundle\Workflow\EventListener;
    
    use Oro\Component\Action\Event\ExtendableActionEvent;
    
    /**
     * Transfer external_po_number from checkout to order on checkout finish.
     */
    class FinishCheckoutEventListener
    {
        public function onFinishCheckout(ExtendableActionEvent $event): void
        {
            $data = $event->getData();
            if (!$data) {
                return;
            }
    
            $checkout = $data->offsetGet('checkout');
            $order = $data->offsetGet('order');
    
            $order->setExternalPoNumber($checkout->getExternalPoNumber());
        }
    }
    
    src/Acme/Bundle/DemoBundle/Resources/config/services.yml 
    services:
        Acme\Bundle\DemoBundle\Workflow\EventListener\FinishCheckoutEventListener:
            tags:
                - { name: kernel.event_listener, event: extendable_action.finish_checkout, method: onFinishCheckout }
    

Add Intermediate Step to Existing Multistep Checkout 

Another frequently implemented Checkout workflow customization is adding a new step to gather additional information.

Note

For simplicity, less important allowed transitions (such as back_to_*) are not listed. Be sure to include them in your customization.

To illustrate such customization, consider a case where only a customer user with manager permissions can place an Order if external_po_number starts with the EXT- prefix.

This scenario covers the following aspects:

  • Adding an intermediate step to the existing multistep checkout

  • Modifying/extending the transition logic with service-based transitions

  • Implementing the ability to direct users to different destinations based on a specific condition

  • Adding and checking a new ACL permission

  1. Define a new workflow with additional step manager_approval. To reach this step, modify the configuration of the place_order transition by adding the conditional_steps_to option and rewriting the transition_service.

  2. After this change, if the external_po_number field starts with the EXT- prefix, buyers without the acme_demo_checkout_approve ACL permission cannot proceed with the checkout and are redirected to the manager_approval step. Only users with manager permissions can complete orders in this workflow. Managers can also place such orders directly from the Order Review step without restrictions.

    src/Acme/Bundle/DemoBundle/Resources/config/oro/workflows.yml 
    imports:
        -
            resource: '@OroCheckoutBundle/Resources/config/oro/workflows/b2b_flow_checkout.yml'
            workflow: b2b_flow_checkout
            as: acme_demo_checkout
            replace: []
    
    workflows:
        acme_demo_checkout:
            defaults:
                active: false
    
            attributes:
                # Extends the list of attributes of the b2b_flow_checkout.attributes
                external_po_number:
                    property_path: checkout.external_po_number
    
            steps:
                manager_approval:
                    order: 80
                    allowed_transitions:
                        - place_order
                        - finish_checkout
                        # A set of additional transitions are here
    
                order_created:
                    # Overrides b2b_flow_checkout.steps.order_created.order
                    order: 90
    
            transitions:
                place_order:
                    # Overrides b2b_flow_checkout.transitions.place_order.transition_service
                    transition_service: 'acme_demo.workflow.transition.place_order'
                    # Adds conditional_step_to
                    conditional_steps_to:
                        manager_approval:
                            conditions:
                                '@and':
                                    - '@start_with': [$external_po_number, 'EXT-']
                                    - '@not':
                                          - '@acl_granted': 'acme_demo_checkout_approve'
    
  3. Define the ACL permission.

    src/Acme/Bundle/DemoBundle/Resources/config/oro/acls.yml 
    acls:
        acme_demo_checkout_approve:
            label: acme.demo.security.permission.checkout_approve
            type: action
            group_name: "commerce"
            category: "checkout"
    
  4. Change the implementation of the Place Order transition to avoid creating an order when it is now allowed.

    src/Acme/Bundle/DemoBundle/Workflow/Transition/PlaceOrder.php 
    <?php
    
    namespace Acme\Bundle\DemoBundle\Workflow\Transition;
    
    use Doctrine\Common\Collections\Collection;
    use Oro\Bundle\ActionBundle\Model\ActionExecutor;
    use Oro\Bundle\CheckoutBundle\Entity\Checkout;
    use Oro\Bundle\WorkflowBundle\Entity\WorkflowItem;
    use Oro\Bundle\WorkflowBundle\Model\TransitionServiceInterface;
    use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
    
    /**
     * PlaceOrder transition customization for Demo Checkout.
     */
    class PlaceOrder implements TransitionServiceInterface
    {
        public function __construct(
            private TransitionServiceInterface $baseTransition,
            private AuthorizationCheckerInterface $authorizationChecker,
            private ActionExecutor $actionExecutor
        ) {
        }
    
        public function isPreConditionAllowed(WorkflowItem $workflowItem, Collection $errors = null): bool
        {
            if ($workflowItem->getCurrentStep()?->getName() === 'manager_approval'
                && !$this->authorizationChecker->isGranted('acme_demo_checkout_approve')
            ) {
                $errors?->add(['message' => 'Pending approval']);
    
                return false;
            }
    
            return $this->baseTransition->isPreConditionAllowed($workflowItem, $errors);
        }
    
        public function isConditionAllowed(WorkflowItem $workflowItem, Collection $errors = null): bool
        {
            return $this->baseTransition->isConditionAllowed($workflowItem, $errors);
        }
    
        public function execute(WorkflowItem $workflowItem): void
        {
            /** @var Checkout $checkout */
            $checkout = $workflowItem->getEntity();
            // Do not execute place order transition logic if conditions are not met.
            if (str_starts_with($checkout->getExternalPoNumber(), 'EXT-')
                && !$this->authorizationChecker->isGranted('acme_demo_checkout_approve')
            ) {
                $this->actionExecutor->executeAction(
                    'flash_message',
                    [
                        'message' => 'This checkout requires manager approval.',
                        'type' => 'warning'
                    ]
                );
    
                return;
            }
    
            $this->baseTransition->execute($workflowItem);
        }
    }
    
    src/Acme/Bundle/DemoBundle/Resources/config/services.yml 
    services:
        acme_demo.workflow.transition.place_order:
            class: Acme\Bundle\DemoBundle\Workflow\Transition\PlaceOrder
            arguments:
                - '@oro_checkout.workflow.b2b_flow_checkout.transition.place_order'
                - '@security.authorization_checker'
                - '@oro_action.action_executor'
            tags:
                - { name: 'oro_workflow.transition_service' }
    

Block Checkout Transition Availability or Execution 

To limit the availability and execution of a transition, use workflow guard events, such as oro_workflow.pre_announce, oro_workflow.announce, oro_workflow.pre_guard and oro_workflow.guard. The pre_announce and pre_guard events run before any transition logic, while the announce and guard events run immediately after. The *announce events limit transition availability, and the *guard events limit execution.

The example below illustrates a scenario where customer users belonging to the Guest customer group are not allowed to place orders if the total amount is less than 100 USD. Here, the limit should apply only to acme_demo_checkout.

src/Acme/Bundle/DemoBundle/Workflow/EventListener/DisallowCheapOrdersForGuestsEventListener.php 
<?php

namespace Acme\Bundle\DemoBundle\Workflow\EventListener;

use Oro\Bundle\CheckoutBundle\DataProvider\Manager\CheckoutLineItemsManager;
use Oro\Bundle\CheckoutBundle\Entity\Checkout;
use Oro\Bundle\ConfigBundle\Config\ConfigManager;
use Oro\Bundle\CustomerBundle\Provider\CustomerUserRelationsProvider;
use Oro\Bundle\OrderBundle\Entity\Order;
use Oro\Bundle\PricingBundle\SubtotalProcessor\TotalProcessorProvider;
use Oro\Bundle\WorkflowBundle\Event\Transition\PreAnnounceEvent;

/**
 * Disallow order placement for guests if total is less than 100 USD
 */
class DisallowCheapOrdersForGuestsEventListener
{
    private const TOTAL_LIMIT = 100.0;

    public function __construct(
        private CustomerUserRelationsProvider $customerUserRelationsProvider,
        private ConfigManager $configManager,
        private TotalProcessorProvider $totalsProvider,
        private CheckoutLineItemsManager $checkoutLineItemsManager
    ) {
    }

    public function onPreAnnounce(PreAnnounceEvent $event): void
    {
        // Nothing to do, already denied
        if (!$event->isAllowed()) {
            return;
        }

        $workflowItem = $event->getWorkflowItem();
        /** @var Checkout $checkout */
        $checkout = $workflowItem->getEntity();
        if (!$checkout instanceof Checkout) {
            return;
        }

        // Do not apply the limit for non-anonymous customer group
        $customerUserGroup = $this->customerUserRelationsProvider->getCustomerGroup($checkout->getCustomerUser());
        if ($customerUserGroup?->getId() != $this->configManager->get('oro_customer.anonymous_customer_group')) {
            return;
        }

        if ($checkout->getCurrency() !== 'USD') {
            return;
        }

        if ($this->getOrderTotalForCheckout($checkout) < self::TOTAL_LIMIT) {
            $event->getErrors()?->add([
                'message' => 'Cannot proceed to checkout because total amount is less than 100 USD'
            ]);

            $event->setAllowed(false);
        }
    }

    private function getOrderTotalForCheckout(Checkout $checkout): float
    {
        $orderLineItems = $this->checkoutLineItemsManager->getData($checkout);
        $order = new Order();
        $order->setLineItems($orderLineItems);

        return $this->totalsProvider->enableRecalculation()->getTotal($order)->getAmount();
    }
}
src/Acme/Bundle/DemoBundle/Resources/config/services.yml 
services:
    Acme\Bundle\DemoBundle\Workflow\EventListener\DisallowCheapOrdersForGuestsEventListener:
        arguments:
            - '@oro_customer.provider.customer_user_relations_provider'
            - '@oro_config.manager'
            - '@oro_pricing.subtotal_processor.total_processor_provider'
            - '@oro_checkout.data_provider.manager.checkout_line_items'
        tags:
            - { name: kernel.event_listener, event: oro_workflow.acme_demo_checkout.pre_announce, method: onPreAnnounce }

Import Workflow Configuration Conditionally 

The workflow bundle provides different ways to organize workflow configuration. You can split it into separate parts and combine them using the imports directive.

Note

Consider following the advice below when organizing the checkout workflow configuration:

  • For complex workflows, use imports to separate different parts of the configuration, such as steps and transitions.

  • For simple workflows with a limited number of changes, keep all configurations in one place.

While developing a workflow, you may need to switch to a new implementation of transition logic, for example when migrating to service-based transitions. To do this while keeping the option to easily revert to the old implementation, import different versions of the transition configuration with an import_condition expression. You can also use this feature to load workflow configuration only when a specific 3rd party package is available.

src/Acme/Bundle/DemoBundle/Resources/config/oro/workflows.yml 
 imports:
     # BC. Load workflows with definition-based transitions
     # when acme_demo.use_transition_services DI parameter is not present or set to false.
     -
         resource: 'workflows/checkout.yml'
         import_condition: "parameter_or_null('acme_demo.use_transition_services') !== true"

     # NEW. Load workflows with transition as a service implementation
     # when acme_demo.use_transition_services DI parameter is present and set to true
     -
         resource: 'workflows/checkout_with_services.yml'
         import_condition: "parameter_or_null('acme_demo.use_transition_services') === true"

Choose Storage for Additional Checkout Data 

When working with checkouts, you have three storage options for additional data: Checkout Entity, Workflow Data, and Workflow Result.

The Checkout Entity suits any data useful across the entire checkout workflow, or any logic that may use the Checkout entity outside the workflow. This method requires an entity migration and running the update process. For non-extend fields, it requires a DB schema update and may require downtime.

Alternatively, you can store data in the WorkflowData by configuring a workflow attribute. This storage is easier to set up and only requires reloading the workflow definition. It is a good option when the data is needed in the checkout workflow itself or is specific to that workflow. For instance, if an additional checkout workflow starts for a customer group that requires approval, the approval information is specific to that checkout with approval workflow and belongs in the WorkflowData.

A third place to store workflow data at runtime is the Workflow Result. In YAML-based checkouts, it stores variable values for a transition. Use it to transfer non-persistent data in the WorkflowItem across the various logic parts that have access to the WorkflowItem.

Warning

The data stored in the Workflow Result is not persisted and is only available during the execution of the workflow.

Access the WorkflowItem by the Given Workflow Entity 

As the examples above show, sometimes only the workflow entity is available. When the data is stored in the WorkflowItem, first retrieve it from the workflow entity using the oro_workflow.manager service. For example, to work with data stored in the WorkflowItem, modify the FinishCheckoutEventListener as follows:

src/Acme/Bundle/DemoBundle/Workflow/EventListener/Alternatives/FinishCheckoutEventListener.php 
<?php

namespace Acme\Bundle\DemoBundle\Workflow\EventListener\Alternatives;

use Oro\Bundle\WorkflowBundle\Model\WorkflowManager;
use Oro\Component\Action\Event\ExtendableActionEvent;

/**
 * Transfer external_po_number from WorkflowItem to order on checkout finish.
 */
class FinishCheckoutEventListener
{
    public function __construct(
        private WorkflowManager $workflowManager
    ) {
    }

    public function onFinishCheckout(ExtendableActionEvent $event): void
    {
        $data = $event->getData();
        if (!$data) {
            return;
        }

        $checkout = $data->offsetGet('checkout');
        $order = $data->offsetGet('order');

        $workflowItem = $this->workflowManager->getWorkflowItem($checkout, 'acme_demo_checkout');
        $externalPoNumber = $workflowItem?->offsetGet('external_po_number');

        $order->setExternalPoNumber($externalPoNumber);
    }
}

Related Articles