Changing one field in the Magento checkout through layout XML looks simple, until the field lives under every payment method. A layout processor is a PHP class that edits jsLayout right before it's serialized for the browser, and it's the right tool when XML can't reach.
To see where it fits, you first need to know how the checkout page gets rendered.
Overview of the JavaScript initialization process for Magento’s checkout
Magento’s checkout is a “one page” checkout because a single server-rendered page (the checkout_index_index route) loads once, and the shipping and payment steps then render client-side in Knockout.js without any page reloads. That first page is still a regular server-side route rendered with layout XML. We can confirm this by opening up Magento Checkout’s routes.xml file:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
<router id="standard">
<route id="checkout" frontName="checkout">
<module name="Magento_Checkout" />
</route>
</router>
</config>And confirm the frontName="checkout", which will control all URLs starting with /checkout, including Magento’s checkout.
Since we know the /checkout route loads layout XML files with the naming convention of checkout_index_index.xml, the appropriate layout file within this Checkout module is applied to this route:
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" layout="checkout" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="content">
<block class="Magento\Checkout\Block\Onepage" name="checkout.root" template="Magento_Checkout::onepage.phtml" cacheable="false">
<arguments>
<argument name="jsLayout" xsi:type="array">
...You’ll notice this layout XML file references the main content container, adding a sole <block> to this container named checkout.root. This block loads the onepage.phtml template file, which then initializes the JavaScript process with the script tag:
<?php
/** @var $block \Magento\Checkout\Block\Onepage */
/** @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer */
?>
<div id="checkout" data-bind="scope:'checkout'" class="checkout-container">
...
<script type="text/x-magento-init">
{
"#checkout": {
"Magento_Ui/js/core/app": <?= /* @noEscape */ $block->getJsLayout() ?>
}
}
</script>
...This magical little piece of code is what hands off the remainder of the checkout rendering process to JavaScript.
Not sure how a block differs from a view model? See Block class vs. view model.
Where does jsLayout come from?
In this PHTML template above, the $block variable is type-hinted to the Onepage class:
/** @var $block \Magento\Checkout\Block\Onepage */This tells us that $block->getJsLayout() references \Magento\Checkout\Block\Onepage::getJsLayout(). When we inspect this function, we can see where this magic takes place:
...
/**
* @inheritdoc
*/
public function getJsLayout()
{
foreach ($this->layoutProcessors as $processor) {
$this->jsLayout = $processor->process($this->jsLayout);
}
return $this->serializer->serialize($this->jsLayout);
}
...Here is where we finally see a reference to something called a “layout processor”. This code loops over all layout processors in order, and each one receives the array the previous one returned. Only after the last processor runs does the array get serialized to JSON.
Layout processor: an example
There is a default layout processor located in the Magento Checkout module. If we look in its process() function, we’ll notice it carrying out some extra tasks relating to address attributes, shipping & billing fields:
...
/**
* Process js Layout of block
*
* @param array $jsLayout
* @return array
*/
public function process($jsLayout)
{
$attributesToConvert = [
'prefix' => [$this->options, 'getNamePrefixOptions'],
'suffix' => [$this->options, 'getNameSuffixOptions'],
];
$elements = $this->getAddressAttributes();
$elements = $this->convertElementsToSelect($elements, $attributesToConvert);
// The following code is a workaround for custom address attributes
if (isset(
$jsLayout['components']['checkout']['children']['steps']['children']['billing-step']['children']['payment']
['children']
)) {
$jsLayout['components']['checkout']['children']['steps']['children']['billing-step']['children']
['payment']['children'] = $this->processPaymentChildrenComponents(
$jsLayout['components']['checkout']['children']['steps']['children']['billing-step']['children']
['payment']['children'],
$elements
);
}
...
return $jsLayout;
}
...The $jsLayout variable is passed in as an argument, modified, and then returned as value to the function call of getJsLayout().
In much the same way this jsLayout was modified using a core built-in layout processor, we can create our own layout processor to modify just about any part of the jsLayout.
Why not modify layout components with XML?
If you are familiar with UI Components, you will be familiar with the role that layout XML plays to inject values & properties into JavaScript components.
As a quick summary, values defined within a UI Component’s config array with XML:
...
<item name="myCustomUiComponent" xsi:type="array">
<item name="component" xsi:type="string">uiComponent</item>
<item name="config" xsi:type="array">
<!-- Setting a value for "myKey"... -->
<item name="myKey" xsi:type="string">myValue</item>
</item>
</item>
......can be accessed as a property of the related UI Component:
define([
"uiComponent"
], function (
Component
) {
"use strict";
return Component.extend({
initialize: function () {
this._super();
// "myKey" is now accessible within the UI component:
console.log(this.myKey); // Outputs "myValue"
return this;
}
});
});The values within this layout XML can of course be overridden within custom modules, following Magento’s standard XML fallback merging process. This overriding process is one of the great strengths of Magento.
In almost all scenarios, you will want to define properties and value overrides just like this with XML.
In specific circumstances, this may not be possible though, as a large amount of XML will come with the increased risk of introducing either human error or reduced code maintainability. A great example of this scenario is when dealing with fields that are dynamically created during the checkout, such as those related to payment methods. Payment methods can be toggled on or off from Magento’s admin or added by third-party modules, and core's own layout processor builds a billing address form for each one at runtime. Those forms don't exist in any XML file, so there's nothing for your XML to reference.
It's also possible you may have unique client requirements in which you'll need to be able to easily duplicate or move blocks of components to other areas of the jsLayout, and this would be an extremely time-consuming process to write or handle with XML.
For these scenarios, you will almost always wish to create your own custom layout processors to process these dynamic situations.
Layout processor vs plugin on getJsLayout()
The other approach you'll see in the wild is a plugin on Magento\Checkout\Block\Onepage::getJsLayout(). It works, but look at what that method returns: a JSON string, not an array. An afterGetJsLayout() plugin has to decode the JSON, walk the array, then encode it again, on every checkout page load.
Some developers plugin Magento\Checkout\Block\Checkout\LayoutProcessor::process() instead, which does hand you the array. But now your change is tied to one specific core class, and it runs before the other processors in the list (TotalsProcessor, DirectoryDataProcessor, and any third-party ones), so anything they add later is out of your reach.
A layout processor is the extension point Magento built for exactly this job. It gets the array, it runs in a known position in the list, and it's just another item injected with di.xml.
Implementing your own custom layout processor
The checkout is the most layered part of Magento's frontend, and that's the hard part done.
Creating your own custom layout processor is actually extremely simple. Your first step is to make Magento aware of your layout processor by adding it as a value to the $layoutProcessors array of the Onepage class.
This can easily be done by using argument substitution with a di.xml file:
<?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="Magento\Checkout\Block\Onepage">
<arguments>
<argument name="layoutProcessors" xsi:type="array">
<item name="foo_bar_updateaddresssortorder" xsi:type="object">Foo\Bar\Block\Checkout\LayoutProcessor\UpdateAddressSortOrder</item>
</argument>
</arguments>
</type>
</config>Since you depend on the Magento_Checkout module now, you’ll also want to add that module as a dependency to your module. This matters for more than tidiness: di.xml arrays are merged in module load order, so the <sequence> is what places your processor after core's addressFormAttributes processor. That's the processor that builds the billing address forms, so if yours ran first, there would be nothing to update.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Foo_Bar">
<sequence>
<module name="Magento_Checkout"/>
</sequence>
</module>
</config>Then, you can create a new class which implements the LayoutProcessorInterface, to apply any update you wish with PHP.
Within your custom layout processor, you have access to the full jsLayout array which will be rendered in the checkout.
In this layout processor, we’re just updating the sort order of the city, region_id and postcode billing address fields. This code will override any values defined with XML, and update the related address fields' sortOrder value.
Where those billing forms live depends on the Display Billing Address On setting (Stores > Configuration > Sales > Checkout > Checkout Options). Set to "Payment Method", core creates one <code>-form under payments-list for every payment method. Set to "Payment Page", there's a single shared billing-address-form under afterMethods. This processor handles both:
<?php
declare(strict_types=1);
namespace Foo\Bar\Block\Checkout\LayoutProcessor;
use Magento\Checkout\Block\Checkout\LayoutProcessorInterface;
class UpdateAddressSortOrder implements LayoutProcessorInterface
{
private const SORT_ORDER = [
'city' => '72',
'region_id' => '74',
'postcode' => '76',
];
public function process($jsLayout): array
{
if (!isset($jsLayout['components']['checkout']['children']['steps']['children']
['billing-step']['children']['payment']['children'])) {
return $jsLayout;
}
$payment = &$jsLayout['components']['checkout']['children']['steps']['children']
['billing-step']['children']['payment']['children'];
// "Display Billing Address On: Payment Method" -- one form per payment method
if (isset($payment['payments-list']['children'])) {
foreach ($payment['payments-list']['children'] as &$paymentMethod) {
if (!isset($paymentMethod['children']['form-fields']['children'])) {
continue;
}
$this->updateSortOrder($paymentMethod['children']['form-fields']['children']);
}
unset($paymentMethod);
}
// "Display Billing Address On: Payment Page" -- one shared form
if (isset($payment['afterMethods']['children']['billing-address-form']['children']['form-fields']['children'])) {
$this->updateSortOrder(
$payment['afterMethods']['children']['billing-address-form']['children']['form-fields']['children']
);
}
unset($payment);
return $jsLayout;
}
private function updateSortOrder(array &$fields): void
{
foreach (self::SORT_ORDER as $field => $sortOrder) {
if (isset($fields[$field])) {
$fields[$field]['sortOrder'] = $sortOrder;
}
}
}
}A few details in there are worth calling out. The isset() checks run before taking any reference, because &$array['a']['b'] silently creates those keys (set to null) when they don't exist. And the unset($paymentMethod) after the foreach matters too: without it, $paymentMethod stays a reference to the last payment method, and any later reuse of that variable name quietly overwrites it.
Also note this only reorders billing address forms. The shipping address fields live in a different branch entirely, under shipping-step > shippingAddress > shipping-address-fieldset, and you'd target that path the same way.
The same functionality would be extremely hard to write with XML, because we are iterating through every payment method and applying updates to each of the children form fields. Creating this same functionality with XML would lead to lots of redundant code, making it hard to diagnose & debug in future updates.
Conclusion
Reach for XML first. When the change repeats across dynamic children like payment methods, write a layout processor.
If you're overriding checkout Knockout templates in the same module, read up on file extension conventions in RequireJS config before you write the map. And once your checkout components start sharing data, local vs. external JavaScript state is the next thing to get straight. The Customize the Magento 2 Checkout course goes through layout processors step by step.
If you've found a checkout change that neither XML nor a layout processor could handle cleanly, I'd like to hear what it was.



