registered cleanly with Hyvä Checkout
An additional checkout step, for example a gift wrapping selection or an age verification, cannot simply be bolted onto Magento 2 with a template override. With Hyvä Checkout and Magewire, a custom checkout step becomes a properly registered, server side validated component, without KnockoutJS and without fragile jsLayout merges.
Table of Contents
- 1. Why a custom checkout step becomes necessary
- 2. Checkout architecture: Luma KnockoutJS versus Hyvä Checkout
- 3. Defining a custom checkout step as a module
- 4. Registering the checkout step in the step pipeline
- 5. Connecting the Alpine.js template and Magewire state
- 6. Server side validation before the step transition
- 7. Ordering, visibility and conditional skipping
- 8. Persisting custom data on the quote
- 9. Checkout step approaches compared
- 10. Summary
- 11. FAQ
1. Why a custom checkout step becomes necessary
The native Magento checkout consists of two hard wired steps: shipping and payment. As soon as a project needs more, for example a delivery time slot selection, an agreement to special terms, or an age verification for certain product categories, this rigid two step model is no longer enough. An additional checkout step is then not a cosmetic change but a structural extension of the order process that has to be hooked cleanly into the existing step pipeline.
In a classic Luma setup, that means digging deep into the KnockoutJS component structure of the checkout, manipulating jsLayout arrays through a LayoutProcessor, and registering custom Knockout templates. For a Hyvä project that, by project convention, explicitly avoids KnockoutJS and UI components, that is not a viable path. This is exactly where Hyvä Checkout comes in: a new checkout step is defined there as a PHP class with a Magewire component and an Alpine.js template, with no Knockout dependency at all.
This article covers the full path from idea to a production ready checkout step: the architectural differences between Luma and Hyvä Checkout, the module structure for a custom step, registration in the step pipeline, the Alpine.js template, server side validation, conditional visibility, and persisting custom data on the quote. The focus throughout is on Magento 2.4.8-p4 with PHP 8.4 and constructor property promotion.
2. Checkout architecture: Luma KnockoutJS versus Hyvä Checkout
The native Magento checkout under checkout_index_index.xml renders a single page application made of KnockoutJS components, whose structure is described through a nested jsLayout array. Every checkout step is a node in that tree, with its own Knockout template, its own view model, and its own visibility logic driven by Magento_Checkout/js/model/step-navigator. Adding a new step means writing a LayoutProcessorInterface plugin that extends the jsLayout array at runtime, plus registering a RequireJS module that loads as a Knockout component.
Hyvä Checkout replaces this entire architecture with Magewire, a server rendered component layer modeled after Livewire, combined with Alpine.js for purely client side interactions such as accordions or toggle states. A checkout step in this model is no longer a Knockout component but a PHP class that holds state, runs validation, and renders a .phtml template with Alpine directives. The round trip between frontend and backend runs through Magewire requests, not through the classic checkout REST endpoints.
For projects with the rule of no KnockoutJS, no jQuery, no UI components, this is the only consistent way to build an additional checkout step without violating the Hyvä principles. The following sections build consistently on this Magewire model and show how a custom step is structured as a Mironsoft module.
3. Defining a custom checkout step as a module
A custom checkout step starts with a PHP class that implements the step contract of Hyvä Checkout. This class provides a unique code, a sort position, a visibility condition, and a reference to the associated Magewire component that renders the actual content. Important for maintainability: the step class itself contains no template logic, it only describes metadata, while the Magewire component handles state and interaction.
In the example project this is a checkout step for a delivery time slot: the customer chooses between several available time slots before moving on to payment. The class implements getCode(), getSortOrder() and isVisible(), where isVisible() checks, for example, whether the shipping method even supports time slots at all.
<?php
declare(strict_types=1);
namespace Mironsoft\CheckoutDeliverySlot\Model\Step;
use Hyva\Checkout\Model\CheckoutStepInterface;
use Magento\Checkout\Model\Session as CheckoutSession;
use Magento\Quote\Model\Quote;
/**
* Custom checkout step that lets the customer pick a delivery time slot
* before proceeding to the payment step.
*/
class DeliverySlotStep implements CheckoutStepInterface
{
private const STEP_CODE = 'delivery-slot';
private const SORT_ORDER = 15;
/**
* @param CheckoutSession $checkoutSession
*/
public function __construct(
private readonly CheckoutSession $checkoutSession
) {
}
/**
* Unique identifier used by the step pool and by Magewire wire keys.
*
* @return string
*/
public function getCode(): string
{
return self::STEP_CODE;
}
/**
* Position between shipping (10) and payment (20) in the step pipeline.
*
* @return int
*/
public function getSortOrder(): int
{
return self::SORT_ORDER;
}
/**
* Only show this checkout step when the selected shipping method
* actually supports delivery time slots.
*
* @return bool
*/
public function isVisible(): bool
{
$quote = $this->checkoutSession->getQuote();
$shippingMethod = (string) $quote->getShippingAddress()->getShippingMethod();
return str_starts_with($shippingMethod, 'timeslotcarrier_');
}
}
This separation between step metadata and Magewire component pays off especially in testing: isVisible() can be tested in isolation with a prepared quote object, without any Alpine.js template or Magewire round trip being involved. A custom checkout step stays understandable this way even as complexity grows.
4. Registering the checkout step in the step pipeline
After the step class comes registration through di.xml. Hyvä Checkout manages the order of all steps through a step pool configuration, into which custom modules enter their checkout step as a named argument. This registration is purely declarative and requires no change to core classes, which makes upgrades unproblematic, since no preference and no core override is created.
Alongside registering the step itself, the associated Magewire component must also be made known so that it is loaded when the checkout page is requested and hooked into the component tree. Both registrations belong together in the same di.xml, so a reviewer immediately sees which step renders which component when reading the module.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Hyva\Checkout\Model\CheckoutStepPool">
<arguments>
<argument name="steps" xsi:type="array">
<item name="delivery_slot" xsi:type="object">Mironsoft\CheckoutDeliverySlot\Model\Step\DeliverySlotStep</item>
</argument>
</arguments>
</type>
<type name="Hyva\Checkout\Model\Magewire\ComponentPool">
<arguments>
<argument name="components" xsi:type="array">
<item name="delivery_slot" xsi:type="string">Mironsoft\CheckoutDeliverySlot\Model\Magewire\DeliverySlotSelector</item>
</argument>
</arguments>
</type>
</config>
A common mistake when registering a custom checkout step: the sort position collides with an already existing step, which makes the order in the frontend non deterministic. It is worth listing all registered sort positions once, for example with a small CLI command that reads out the step pool configuration, before a new step gets its position assigned.
5. Connecting the Alpine.js template and Magewire state
The actual user interface of the checkout step lives in the .phtml template of the Magewire component. Unlike a classic Alpine component without Magewire, x-data here only holds purely client side UI state, for example which accordion is currently expanded. The actual business data, meaning the selected time slot, is synchronized with the PHP backend through Magewire bindings and validated there before the checkout advances to the next step.
This split is intentional: Alpine.js handles pure presentation logic without a server round trip, Magewire handles everything related to validation, persistence, or business logic. For a checkout step with several selectable time slots, that means: clicking a slot triggers a Magewire call that checks server side whether the slot is still available, and only then updates the state in the frontend.
<div x-data="{ expanded: true }" class="border border-slate-200 rounded-xl p-4">
<button type="button" @click="expanded = !expanded" class="flex items-center justify-between w-full font-semibold">
<span>Choose a delivery time slot</span>
<svg x-show="!expanded" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div x-show="expanded" class="mt-4 space-y-2">
<template x-for="slot in $wire.entangle('availableSlots')" :key="slot.id">
<label class="flex items-center gap-3 p-3 rounded-lg border cursor-pointer"
:class="$wire.entangle('selectedSlotId').get() === slot.id ? 'border-orange-500 bg-orange-50' : 'border-slate-200'">
<input type="radio" name="delivery_slot"
@change="$wire.selectSlot(slot.id)"
:checked="$wire.entangle('selectedSlotId').get() === slot.id">
<span x-text="slot.label"></span>
</label>
</template>
</div>
</div>
The selectSlot() method is implemented as a PHP method on the Magewire component itself, checks the availability of the slot there against the current capacity of the shipping carrier, and on success updates the internal state of the component. The actual checkout step stays consistent this way, even when two customers want to pick the same scarce time slot at the same time.
6. Server side validation before the step transition
A checkout step without server side validation is just a pretty interface. Before Hyvä Checkout allows the transition to the next step, the step pipeline calls a validation method on the associated Magewire component. Only once this method returns no error is the internal progress updated and does the customer see the next step. This validation runs entirely server side and therefore cannot be bypassed by disabled JavaScript or a manipulated client request.
For the delivery time slot checkout step, that means concretely: before the customer is allowed to move on to payment, the component checks again whether the selected slot still exists and whether it is still assigned to the same shipping method the customer chose in the previous step. If anything changes between selection and confirmation, for example because the slot got booked out by another customer, validation fails with a clear error message instead of allowing an inconsistent order.
<?php
declare(strict_types=1);
namespace Mironsoft\CheckoutDeliverySlot\Model\Magewire;
use Hyva\Checkout\Model\Magewire\Component\EvaluationResultFactory;
use Hyva\Checkout\Model\Magewire\Component\EvaluationResultInterface;
use Magento\Checkout\Model\Session as CheckoutSession;
use Mironsoft\CheckoutDeliverySlot\Api\SlotAvailabilityCheckerInterface;
/**
* Server side validation executed before the checkout step pipeline
* advances from the delivery slot step to the payment step.
*/
class DeliverySlotValidator
{
/**
* @param CheckoutSession $checkoutSession
* @param SlotAvailabilityCheckerInterface $availabilityChecker
* @param EvaluationResultFactory $resultFactory
*/
public function __construct(
private readonly CheckoutSession $checkoutSession,
private readonly SlotAvailabilityCheckerInterface $availabilityChecker,
private readonly EvaluationResultFactory $resultFactory
) {
}
/**
* Re-check slot availability right before the step transition is allowed.
*
* @param int $selectedSlotId
* @return EvaluationResultInterface
*/
public function evaluate(int $selectedSlotId): EvaluationResultInterface
{
$quote = $this->checkoutSession->getQuote();
if (!$this->availabilityChecker->isAvailable($selectedSlotId, (int) $quote->getId())) {
return $this->resultFactory->createError(
'The selected delivery time slot has just been booked out. Please choose another slot.'
);
}
return $this->resultFactory->createSuccess();
}
}
It is important that this validation lives exclusively in the Magewire component and is not additionally duplicated in the Alpine template. A client side pre check is entirely legitimate as a pure convenience feature, but it never replaces the server side check that is the only thing actually deciding on progress in the checkout step.
7. Ordering, visibility and conditional skipping
Not every customer should see every checkout step. The isVisible() method from the step class decides at runtime whether a step even appears in the pipeline. For the delivery time slot step, that means: customers who chose a shipping method without time slots skip the step entirely, without the pipeline needing to model special cases in the frontend for that.
The sort position additionally controls where between the native shipping and payment steps a custom checkout step gets inserted. With several custom steps from different modules, a fixed convention pays off, for example spacing sort positions in steps of ten between the native positions, so that a further step can later be inserted without shifting existing positions. Without this convention, every new extension potentially shifts the order of all other steps.
For more complex conditions, for example a checkout step that should only appear for certain customer groups or only when certain product categories are in the cart, it is advisable to extract the visibility logic into its own testable service instead of nesting it directly inside isVisible(). The step class then only calls this service, staying slim and readable itself.
#!/usr/bin/env bash
# List all registered checkout step sort positions before adding a new one,
# to avoid accidental collisions between modules
set -euo pipefail
bin/magento dev:di:info Hyva\\Checkout\\Model\\CheckoutStepPool \
| grep -A1 "steps" \
| sort -k2 -n
echo "Verify the new sort order does not collide with an existing step."
8. Persisting custom data on the quote
For the selection from a custom checkout step to reach the payment page and later the order, the data must be persisted on the quote. The cleanest method for that is extension attributes on Magento\Quote\Api\Data\CartExtensionInterface, declared through extension_attributes.xml and backed by a custom db_schema.xml table with a foreign key on quote. That way the selected time slot stays available throughout the rest of the order process, including being carried over into the final order when the order is placed.
A frequently overlooked point: quote extension attributes are not automatically carried over during the transition to the order. That requires a plugin on Magento\Quote\Model\QuoteManagement::submit() that explicitly copies the custom data from the quote to the order. Without this plugin, the checkout loses the information gathered in the custom checkout step at exactly the moment it becomes relevant for fulfillment and shipping.
9. Checkout step approaches compared
Depending on the project setup and target architecture, there are different ways of implementing an additional checkout step. For Hyvä projects the choice is almost always clear in practice, the following table nevertheless shows the trade offs of the alternatives.
| Approach | Technology | Hyvä compatibility | Recommendation |
|---|---|---|---|
| LayoutProcessor + jsLayout | KnockoutJS, RequireJS | Violates the no-Knockout rule | Only for a pure Luma fallback |
| Hyvä Checkout step | PHP, Magewire, Alpine.js | Fully compatible | Standard approach for this project |
| Standalone page before checkout | Controller, CMS block | Compatible, but not a real step | Only for very simple intermediate steps |
| Modal instead of step | Alpine.js, no server round trip needed | Compatible | For purely confirmatory add-ons |
The decision between a real checkout step and a simple modal depends on whether server side validation with its own progress state is needed. If the extension only needs a confirmation without persistence, an Alpine modal inside an existing step is enough, without touching the step pipeline at all.
Mironsoft
Magento 2 checkout development with Hyvä Checkout and Magewire
An additional checkout step for your order process?
We design and implement custom checkout steps in Magento 2 with Hyvä Checkout, from step registration through Alpine.js templates to server side validation and data persistence on the quote.
Step design
Planning visibility, ordering and data model for new checkout steps
Magewire components
Cleanly separating server side validation and Alpine.js interaction
Quote & order
Carrying extension attributes cleanly from the quote into the order
10. Summary
Custom checkout steps in Magento 2 cannot sensibly be built through the classic KnockoutJS architecture in a Hyvä project, because that contradicts the theme's core principles. Hyvä Checkout replaces this path with Magewire components with Alpine.js templates: a step class provides code, ordering and visibility, a Magewire component holds state and validation, an Alpine template handles pure presentation logic. Registration runs entirely through di.xml, with no core override.
The decisive quality factor for every custom checkout step is server side validation immediately before the transition to the next step, combined with a clean transfer of the gathered data from the quote into the final order. Whoever implements these two points consistently gets a checkout step that fits seamlessly into the native step pipeline and stays consistent even under concurrent orders.
Custom checkout steps in Magento 2, the essentials at a glance
Architecture
Hyvä Checkout replaces KnockoutJS steps with Magewire components using Alpine.js templates, no UI components.
Registration
Register step and component exclusively through di.xml in the step pool, no preference needed.
Validation
Server side check in the Magewire component decides on the step transition, never the Alpine template alone.
Data persistence
Extension attributes on the quote plus an explicit plugin for carrying data into the order on submit.