Layout processors in Magento 2
Why I finally wrote this down
Checkout broke on staging with a blank payment step. PHP logs were clean. The failure lived in a mutated jsLayout tree that Knockout could not bind. I had edited a layout processor without mapping the JSON contract first, and spent an afternoon walking nested array keys instead of reading the pipeline once.
That confusion came from a B2B commerce checkout where we added custom steps and custom fields. Official docs describe pieces in isolation. They do not show how checkout_index_index.xml, layout processors, AMD view models, and Knockout templates hand off to each other. A teammate walked through the flow on a lunch-and-learn in 2021. I kept notes, then lost them in a blank payment step debug session. This post is the distilled version.
What a layout processor is
In Magento 2, a layout processor is a PHP class implementing Magento\Checkout\Block\Checkout\LayoutProcessorInterface. It receives the $jsLayout array, mutates it, and returns it. The array describes checkout UI components: steps, form fields, sortOrder, visible flags, and Knockout component bindings.
Processors are not layout XML and not view/frontend/ui_component/*.xml files. They are the last server-side edit on the checkout config tree before the browser sees JSON.
XML is good for static structure. Layout processors are the middle ground when you need dynamic field visibility, config-driven validation, or filtering fields before they reach the browser. Leave the skeleton in XML. Put conditional logic in the processor.
Where processors run in the pipeline
The path from layout handle to rendered step is longer than most teams trace on the first pass:
Layout XML merge.
checkout_index_index.xmldefines thecheckout.rootblock: classMagento\Checkout\Block\Onepage, templateonepage.phtml.Onepage block.
onepage.phtmlrenders the#checkoutcontainer and firesx-magento-initonMagento_Ui/js/core/appwith$block->getJsLayout().getJsLayout().
Onepage.phploops registered layout processors and calls eachprocess($jsLayout)indi.xmlregistration order.JSON in page. The merged array is serialized into the HTML response.
RequireJS + Knockout. AMD modules bootstrap UI components from that tree. Each step view model pulls child templates via
getRegion().
If a change never appears, check whether you edited XML when the runtime path actually goes through a processor, or the reverse. If Knockout throws nothing but a step is blank, diff jsLayout before blaming PHP.
displayArea and getRegion: how a custom step actually renders
Suppose you add a personal-info-step under components.checkout.children.steps.children in app/design/frontend/Vendor/theme/Magento_Checkout/layout/checkout_index_index.xml. The XML declares children, dependencies, and the AMD component string. That is only the wiring diagram.
The step view model (personalInfoStep.js) sets a Knockout template:
return Component.extend({
defaults: {
template: 'Acme_Checkout/personalInfo'
}
});In personalInfo.html, the step shell loops a region:
<div id="personal-info-step" class="step-content" data-role="content">
<!-- ko foreach: getRegion('personal-info-form-container') -->
<!-- ko template: getTemplate() --><!-- /ko -->
<!-- /ko -->
</div>getRegion() looks for child nodes whose displayArea matches the string you pass. It renders each matching node's template. It does not recurse into grandchildren on its own. You declare another displayArea when you need a nested loop.
Back in XML, the form container child sets the area name:
<item name="personal-info-form-container" xsi:type="array">
<item name="component" xsi:type="string">Acme_Checkout/js/view/personal-info-form</item>
<item name="displayArea" xsi:type="string">personal-info-form-container</item>
<item name="config" xsi:type="array">
<item name="template" xsi:type="string">Acme_Checkout/personal-info-form</item>
</item>
<item name="children" xsi:type="array">...</item>
</item>The form template calls getRegion('personal-info-fieldsets') inside a <fieldset>. Each field child under that area uses ui/form/field templates. A region_id select might set filterBy against a parent country_id value. The same dance repeats: XML declares the tree, templates call getRegion, Knockout renders leaf fields.
When debugging, I trace in this order: index layout XML, step JS defaults template, step HTML region name, child XML displayArea, field template. One mismatched string and the step renders empty with no PHP error.
Using a layout processor to filter fields
Not every field belongs on every store view or customer segment. You could hide fields only in JavaScript, but then the server still ships them in jsLayout. I prefer unsetting or toggling visible in a layout processor so the tree matches what Knockout should bind.
Typical pattern on a B2B checkout:
Read
scopeConfigor a small service that encodes business rules.Walk the branch for your custom step (for example
personal-info-step→ form container → fieldset → individual fields).unset()nodes you do not want, or setvisible = falsewhen siblings still reference the node.Return the full
$jsLayoutarray.
Business rules still belong in services. The processor should ask a service whether a field should show, then write the answer into jsLayout. I learned that after one processor started duplicating quote logic and became impossible to unit test.
Typical use cases I see in the wild
Checkout step shaping: reorder shipping before billing, inject a custom step, or remove a step for a store view.
Field ordering and visibility: move telephone above company, hide company for B2C, show a PO number field only when a config flag is on.
Payment and shipping presentation: filter methods, attach validation metadata, or add custom Knockout component children under a step.
Config-driven UI: read
scopeConfiginside the processor and setvisible,sortOrder, orcomponentwithout forking a whole UI component XML file.
How to find layout processors in a codebase
Start broad, then narrow to checkout:
grep -R "LayoutProcessorInterface" app/code vendor/Then open etc/frontend/di.xml in modules that grep hits. Look for processors registered on Magento\Checkout\Block\Onepage via the layoutProcessors constructor argument array, or plugins on Magento\Checkout\Block\Checkout\LayoutProcessor. Core ships a default processor; custom modules append theirs to the same pipeline.
On a custom checkout module, expect files along these lines (names illustrative):
app/code/Vendor/Acme_Checkout/Block/Checkout/CheckoutLayoutProcessor.php
app/code/Vendor/Acme_Checkout/etc/frontend/di.xml
app/design/frontend/Vendor/theme/Magento_Checkout/layout/checkout_index_index.xml
app/design/frontend/Vendor/theme/Magento_Checkout/web/js/view/personalInfoStep.js
app/design/frontend/Vendor/theme/Magento_Checkout/web/template/personalInfo.htmlWhen two modules touch the same jsLayout branch, registration order matters. The later processor wins if it overwrites the same key.
A minimal processor example
This hides the company field on the billing address form when a store config flag is off. Generic, but close to real projects:
<?php
declare(strict_types=1);
namespace Vendor\AcmeCheckout\Checkout;
use Magento\Checkout\Block\Checkout\LayoutProcessorInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
class CompanyFieldVisibilityProcessor implements LayoutProcessorInterface
{
private const XML_PATH_SHOW_COMPANY = 'acme_checkout/fields/show_company';
public function __construct(private ScopeConfigInterface $scopeConfig) {}
public function process($jsLayout)
{
$show = $this->scopeConfig->isSetFlag(
self::XML_PATH_SHOW_COMPANY,
ScopeInterface::SCOPE_STORE
);
$company = &$jsLayout['components']['checkout']['children']['steps']['children']
['billing-step']['children']['payment']['children']
['afterMethods']['children']['billing-address-form']['children']
['form-fields']['children']['company'];
if (isset($company)) {
$company['visible'] = $show;
}
return $jsLayout;
}
}Register it in app/code/Vendor/Acme_Checkout/etc/frontend/di.xml:
<type name="Magento\Checkout\Block\Onepage">
<arguments>
<argument name="layoutProcessors" xsi:type="array">
<item name="acme_company_visibility" xsi:type="object">
Vendor\AcmeCheckout\Checkout\CompanyFieldVisibilityProcessor
</item>
</argument>
</arguments>
</type>Always return $jsLayout. Use unset() only when you intend to remove a component entirely. For most field toggles, visible = false is safer because sibling components may still reference the node.
Failure modes I keep debugging
Wrong sort order. Two processors fight over sortOrder on the same step. Fields land in different positions per store view depending on module load order.
Mutating the wrong branch. jsLayout paths are deep and unforgiving. One missing children key means your change silently does nothing. Dump the merged layout before and after your processor runs in a developer environment.
displayArea mismatch. The Knockout template calls getRegion('personal-info-fieldsets') but XML sets displayArea to a different string. The step shell renders, inner form is empty, no PHP stack trace.
Cache not cleared. Layout and full-page cache can mask processor changes. Run bin/magento cache:flush while iterating locally. Include layout in the flush plan for staging deploys.
Plugin vs processor confusion. A plugin on LayoutProcessor::process wraps core behavior. A dedicated class in the layoutProcessors array is a separate pipeline participant. Mixing both without tracing order produces diffs that only show up in some environments.
JavaScript-only failures. PHP does not throw when you remove a step Knockout still expects. The page loads, then a step component fails to initialize. Treat jsLayout edits like API schema changes.
Quick review before merge
Which Knockout component consumes the node I changed?
Does every
getRegion()call in my templates have a matchingdisplayAreain XML or processor output?Can I disable my module without leaving a half-shaped step behind?
Did I verify
jsLayoutoutput on the target store view, not just default?Did layout cache get flushed in the environment where QA tested?