One Page Checkout for module developers

PrestaShop 9.2 introduces a new native One Page Checkout module: ps_onepagecheckout.

This page explains how the module works, how it integrates with the checkout process, and what module developers need to know to ensure compatibility with stores using One Page Checkout. It covers the checkout architecture, the available extension points, and the changes that may affect payment, carrier, checkout customization, and other modules interacting with the checkout flow.

The guidance on this page applies to any module that interacts with the checkout, not only payment modules. If you build payment or carrier modules, jump straight to Payment modules compatibility or Carrier modules compatibility.

What is ps_onepagecheckout?

ps_onepagecheckout is the native One Page Checkout module introduced in PrestaShop 9.2. When enabled, it replaces the standard multi-step checkout with a single-page checkout experience that combines customer information, delivery options, and payment methods into a unified flow.

The module is included with PrestaShop 9.2. While installed by default, the One Page Checkout experience is not enabled automatically. Merchants must enable and configure it before it becomes active on the front office.

If your module interacts with the checkout process, displays content during checkout, or modifies checkout behavior, you should review the compatibility considerations described on this page.

Module architecture

The module follows a clean, handler-based architecture. Here is an overview of the key layers.

Checkout process

The core of the module is how it builds and injects a custom CheckoutProcess into Core’s order flow.

  • OnePageCheckoutProcessProvider: implements Core’s CheckoutProcessProviderInterface. This is the object the module returns from the hook. It exposes isEnabled() and buildCheckoutProcess(CheckoutSession $session, TranslatorComponent $translator): CheckoutProcess.
  • OnePageCheckoutProcessBuilder: creates the OnePageCheckoutProcess instance, injecting a single unified step (CheckoutOnePageStep) instead of the native multi-step flow.
  • OnePageCheckoutProcess: extends Core’s CheckoutProcess and adds isOnePageCheckoutEnabled() to reflect the module’s own availability check, keeping the is_one_page_checkout_enabled template variable consistent.
  • OnePageCheckoutAvailability: the single source of truth for whether OPC is currently active. It reads the PS_ONE_PAGE_CHECKOUT_ENABLED configuration key (exposed on the module as Ps_Onepagecheckout::CONFIG_ONE_PAGE_CHECKOUT_ENABLED).

The classes live under the module’s src/Checkout/ directory and use the PrestaShop\Module\PsOnePageCheckout\Checkout namespace.

AJAX endpoints

Each checkout interaction is handled by a dedicated front controller under controllers/front/: guest initialization, address forms and persistence, country states, carrier listing and selection, payment listing and selection, cart totals, gift wrapping, draft persistence, and final submission. They all extend the abstract base class Ps_OnepagecheckoutAbstractOpcJsonFrontController and return structured JSON responses.

The set of endpoints grows with the module, so treat the controllers/front/ directory as the authoritative list rather than hardcoding endpoint names. The front-end never hardcodes these URLs either: the module exposes them at runtime through window.ps_onepagecheckout.urls (and to Smarty as {$opc_urls}).

Form layer

  • OnePageCheckoutFormFactory: single factory that wires the checkout form and its data persister. Any code that needs the OPC form goes through this factory.
  • OnePageCheckoutForm: main form object handling address, customer, and delivery data binding.
  • OnePageCheckoutFormatter: prepares all template variables for the checkout view.
  • BackOfficeConfigurationForm: handles the back-office configuration rendering and submission (Twig-based, targeting PrestaShop 9.2).

Front-end

JavaScript is bundled with Webpack and organized around feature boundaries:

  • opc-guest-init.js: guest account creation flow
  • opc-address.js / opc-address-modal.js: address form management
  • opc-carrier-list.js / opc-carrier-select.js: shipping selection
  • opc-payment-list.js / opc-payment-select.js: payment selection
  • opc-submit.js: final submission and validation feedback
  • events.js: the JS event contract (see below)

The module also injects a runtime configuration block into the page from hookActionFrontControllerSetMedia, using Media::addJsDef() to expose all AJAX URLs and feature flags as a window.ps_onepagecheckout object. The front-end reads it through a small accessor (views/js/runtime/opc-runtime.js). This avoids hardcoded URLs in JavaScript and keeps the JS/PHP contract explicit.

The new hook: actionCheckoutBuildProcess

This is the hook that lets ps_onepagecheckout override the native multi-step checkout process.

Purpose

actionCheckoutBuildProcess is dispatched by Core before the native checkout process is built. A module implementing this hook returns an object implementing CheckoutProcessProviderInterface. If exactly one enabled provider is returned across all modules, Core uses its checkout process instead of the default multi-step flow.

The provider contract

A module does not return a CheckoutProcess directly from the hook. It returns a provider implementing this interface:

// src/Adapter/Order/Checkout/CheckoutProcessProviderInterface.php

namespace PrestaShop\PrestaShop\Adapter\Order\Checkout;

use CheckoutProcess;
use CheckoutSession;
use PrestaShopBundle\Translation\TranslatorComponent;

interface CheckoutProcessProviderInterface
{
    /**
     * Indicates whether the module checkout can be used.
     */
    public function isEnabled(): bool;

    /**
     * Builds the checkout process for the current customer session.
     */
    public function buildCheckoutProcess(
        CheckoutSession $session,
        TranslatorComponent $translator
    ): CheckoutProcess;
}

The CheckoutSession and TranslatorComponent are handed to your provider’s buildCheckoutProcess() method by Core, not passed as hook parameters. The hook itself is invoked with an empty parameter array.

How Core uses it

The resolution logic lives in CheckoutProcessProviderResolver (src/Adapter/Order/Checkout/CheckoutProcessProviderResolver.php).

Here is the actual code:

// src/Adapter/Order/Checkout/CheckoutProcessProviderResolver.php

public function resolve(CheckoutSession $session, TranslatorComponent $translator): ?CheckoutProcess
{
    $provider = $this->resolveProvider();
    if ($provider === null) {
        return null; // No single valid provider → use native checkout
    }

    $checkoutProcess = $provider->buildCheckoutProcess($session, $translator);

    return $checkoutProcess instanceof CheckoutProcess ? $checkoutProcess : null;
}

public function resolveProvider(): ?CheckoutProcessProviderInterface
{
    $providers = $this->getValidProviders();

    // Exactly one enabled provider must be registered, otherwise fall back.
    if (count($providers) !== 1) {
        return null;
    }

    return reset($providers);
}

protected function getValidProviders(): array
{
    if ($this->cachedProviders !== null) {
        return $this->cachedProviders;
    }

    $providers = [];
    // true → collect output from every module, keyed by module name.
    $hookOutput = Hook::exec('actionCheckoutBuildProcess', [], null, true);

    if (!is_array($hookOutput)) {
        return $this->cachedProviders = $providers;
    }

    foreach ($hookOutput as $moduleName => $provider) {
        if (!$provider instanceof CheckoutProcessProviderInterface || !$provider->isEnabled()) {
            continue;
        }

        $providers[$moduleName] = $provider;
    }

    return $this->cachedProviders = $providers;
}

Key design decisions:

  1. All modules are asked, exactly one enabled provider wins. The hook is broadcast to every module. The resolver keeps only providers that are valid CheckoutProcessProviderInterface instances and report isEnabled() === true. If the count is not exactly one, Core falls back to native checkout.
  2. No registration config key. A provider becomes active simply by returning an enabled provider from the hook: there is no configuration key to write on install. Conflicts between two enabled providers are resolved by falling back to native checkout, not by a “first wins” or “configured module” rule.
  3. Safe fallback by default. If the hook output isn’t an array, contains no valid enabled provider, or the provider’s buildCheckoutProcess() returns something other than a CheckoutProcess instance, Core uses its own native checkout. There is no risk of a broken checkout from a module returning an unexpected value.

Where it is dispatched

OrderController::buildCheckoutProcess() calls the resolver before constructing the native process:

// controllers/front/OrderController.php

protected function buildCheckoutProcess(CheckoutSession $session, $translator)
{
    /** @var CheckoutProcessProviderResolver $checkoutProcessProviderResolver */
    $checkoutProcessProviderResolver = $this->get(CheckoutProcessProviderResolver::class);
    $resolvedCheckoutProcess = $checkoutProcessProviderResolver->resolve($session, $translator);
    if ($resolvedCheckoutProcess instanceof CheckoutProcess) {
        return $resolvedCheckoutProcess; // Module-provided process wins
    }

    // …otherwise build the native multi-step CheckoutProcess
}

How ps_onepagecheckout implements it

// ps_onepagecheckout.php

public function install()
{
    return $this->installInParent()
        && $this->installOnePageCheckoutConfiguration()
        && $this->registerHook('actionCheckoutBuildProcess')
        && $this->registerHook('actionFrontControllerSetMedia')
        // … other hooks
    ;
}

public function hookActionCheckoutBuildProcess(array $params = []): CheckoutProcessProviderInterface
{
    return new OnePageCheckoutProcessProvider($this->context, $this);
}

On install, the module registers the hook (and sets its PS_ONE_PAGE_CHECKOUT_ENABLED configuration). On uninstall, it clears that configuration. The provider it returns reports isEnabled() based on that same configuration key, so disabling OPC transparently restores native checkout.

Reacting to checkout updates

One Page Checkout dynamically updates parts of the checkout page as the customer interacts with it. Depending on the action performed, sections such as carriers, payment methods, addresses, or the cart summary may be refreshed over AJAX without a full page reload.

This affects any module that injects JavaScript widgets, buttons, hosted fields, iframes, or custom event listeners into those sections: make sure your code can be re-initialized after an update, not only on the initial page load.

All updates are announced through the PrestaShop JavaScript event bus, so you react to them with prestashop.on(...). The complete, authoritative list of events is defined in views/js/events.js (OPC_EVENTS). The payment and carrier sections below show the events most relevant to each.

Payment modules compatibility

Payment modules integrate with OPC through the standard Core paymentOptions hook: there is nothing OPC-specific you need to do to appear in the payment list. OPC gathers options through Core’s PaymentOptionsFinder, so any module that already returns valid PaymentOption objects shows up automatically. See the Payment modules documentation for how to build payment options.

There are two integration styles, depending on how your payment is submitted.

Form-based payment options

If your PaymentOption provides a form (or action and inputs), OPC renders it inside a #pay-with-{id}-form wrapper and submits it for you when the customer clicks Place Order. OPC first persists the checkout data (customer, address, delivery method) via its own AJAX submission, then submits your payment form. No extra code is required on your side: this is the default path.

Self-submitting (binary) payment options

Payment modules that drive submission themselves, such as smart buttons, hosted fields, or redirect/iframe flows that set binary = true, are handled by the module itself. OPC does not try to submit an inner form for these: your module owns the submit and redirect.

Because OPC still needs the checkout data (customer, selected address, and delivery method) persisted before your payment flow takes over, the module exposes a JavaScript entry point:

window.ps_onepagecheckout.submitBeforePayment();

Call it from your payment flow before submitting or redirecting. It:

  • validates the checkout preconditions (a payment method is selected, the form is valid),
  • persists the customer, address, and selected delivery method through OPC’s submit endpoint,
  • returns a Promise: resolve before continuing, and handle rejection (it rejects with "OPC form not found." if the OPC form isn’t on the page),
  • does not place the order or redirect: that remains your module’s responsibility.

Call it once per order attempt.

OPC guards against concurrent submissions internally (an in-flight flag), so a second call made while the first is still running returns early without persisting anything. Trigger it from a single submit handler rather than from multiple event paths.

// Inside your payment module's submit handler (e.g. smart-button click)
try {
  await window.ps_onepagecheckout.submitBeforePayment();
  // OPC has now saved customer / address / delivery method.
  // Proceed with your own payment submission or redirect.
  startMyPaymentFlow();
} catch (error) {
  // OPC form unavailable or preconditions not met: abort the payment.
}

Re-initializing after a payment refresh

OPC re-renders the payment methods section over AJAX, for example after a carrier or address change. If your payment option mounts widgets, hosted fields, or smart buttons, re-mount them whenever the section is refreshed by listening to opcPaymentMethodsUpdated:

prestashop.on('opcPaymentMethodsUpdated', () => {
  remountMyPaymentWidgets();
});

Carrier modules compatibility

Carrier modules integrate with OPC through the standard Core carrier mechanism: there is nothing OPC-specific you need to do for your carrier to appear in the delivery options. See the Carrier modules documentation for how to build a carrier module.

OPC loads and refreshes the carrier list over AJAX (via its carriers controller) when the selected delivery address changes, without a full page reload. If your carrier module injects UI into the delivery section, such as extra fields, pickup point pickers, or maps, re-initialize it after each refresh.

The most relevant carrier events are:

  • opcCarriersLoading: the carrier list is being fetched.
  • opcCarriersUpdated: the carrier list has been re-rendered (the server response includes the rendered HTML, updated cart totals, and the delivery address ID).
  • opcCarrierSelected: the customer selected a carrier (the event carries the selected delivery option key).
  • opcCarriersFailed: the carrier list failed to load.

For example, re-mount your carrier UI whenever the list is refreshed:

prestashop.on('opcCarriersUpdated', () => {
  remountMyCarrierWidgets();
});

JavaScript event: opcFinalSubmitStarted

If your module needs to react just before the final checkout submission, you can listen to the opcFinalSubmitStarted event emitted by ps_onepagecheckout.

The event is emitted through PrestaShop’s JavaScript event bus (prestashop.emit and prestashop.on), not as a native DOM CustomEvent:

// Emitted by ps_onepagecheckout
prestashop.emit('opcFinalSubmitStarted');

To subscribe to the event:

prestashop.on('opcFinalSubmitStarted', () => {
  // react just before the final checkout form is submitted
});

Keep the following in mind:

  • The event is available only when One Page Checkout is enabled and the ps_onepagecheckout assets are loaded.
  • The event must be consumed through the PrestaShop event bus, not through document.addEventListener().
  • No payload is provided with the event.

The complete list of events emitted by the module is defined in views/js/events.js (OPC_EVENTS).

If your module extends or injects into the OPC flow

If your module adds steps, modifies templates, or injects data into the OPC checkout:

  • Check whether you were relying on Core classes or hooks that changed in 9.2 (much of the OPC-specific logic now lives in ps_onepagecheckout).
  • The PS_ONE_PAGE_CHECKOUT_ENABLED configuration key is still defined and read by Core (via OnePageCheckoutAvailabilityChecker / OnePageCheckoutSettings), but the ps_onepagecheckout module now provisions it on install/uninstall and toggles it on enable/disable. Treat the module as the writer, and Core and the module as the readers.

If your module uses actionFrontControllerSetMedia or actionFrontControllerSetVariables

Verify that your assets load after ps_onepagecheckout registers its scripts, or declare a dependency.

If your module reads is_one_page_checkout_enabled in templates

This template variable is still available in the checkout template. It reflects the module’s availability check (which in turn reads PS_ONE_PAGE_CHECKOUT_ENABLED). No action needed: the variable works the same way as before.

New behavior of the checkout process

Area Previous behavior 9.2 behavior
OPC flow Driven largely from Core (OrderController) Provided by the ps_onepagecheckout module
Custom checkout injection No standard hook, overrides required actionCheckoutBuildProcess hook
Hook return value N/A Module returns a CheckoutProcessProviderInterface, not a CheckoutProcess directly
Provider selection No standard mechanism Resolver picks the single enabled provider, otherwise multiple or none means native fallback
PS_ONE_PAGE_CHECKOUT_ENABLED Provisioned by Core Provisioned by ps_onepagecheckout on install/uninstall, still read by Core
Checkout fallback Native checkout always available Native checkout used unless exactly one enabled provider is registered

Compatibility

These changes ship in PrestaShop 9.2.0 and ps_onepagecheckout 0.4.0. The module declares compliance with PrestaShop >= 9.2.0.

Modules targeting older PrestaShop versions are not affected. The actionCheckoutBuildProcess hook and CheckoutProcessProviderInterface do not exist in versions prior to 9.2, so guard your hook registration and any instanceof and type references accordingly if your module supports a version range.

See also

Resources