Knockout in Magento

Why Magento's checkout is rendered in the browser by Knockout instead of in a phtml, and where PHP still fits.

Syntaxpublic function getConfig(): array // Magento\Checkout\Model\ConfigProviderInterface

Knockout is a small JavaScript library that keeps a view model and the page in sync. A value wrapped in ko.observable() notifies every binding that reads it when the value changes, so the markup updates without DOM code of your own. Magento bundles Knockout and adds a layer on top of it: the UI component framework. A uiComponent is a JavaScript class with a name, a template, and a position in a tree that a client-side registry holds by name.

Why checkout is built this way

Checkout is one URL, checkout/index/index, whose contents change constantly. Totals recalculate, shipping methods arrive from the server, the payment list reacts to the address. A .phtml renders once, on the server, so it cannot do that. Magento's checkout template therefore renders almost nothing: a container element, a JSON description of the component tree, and a configuration object called window.checkoutConfig. Knockout renders everything else in the browser. The checkout block is declared cacheable="false", so the full page cache never serves the page.

Where PHP still fits

PHP's job is to fill that JSON. A config provider, a class implementing Magento\Checkout\Model\ConfigProviderInterface, adds keys to window.checkoutConfig. A layout processor changes the component tree. Those two are the entry points.

What trips people up

Both are registered in di.xml, which is cached and compiled. After adding one, run bin/magento cache:flush, and bin/magento setup:di:compile in production mode. Until then the class exists and is never called.

Example

Example · php
<?php
// app/code/Vendor/DeliveryNote/Model/CheckoutConfigProvider.php
declare(strict_types=1);

namespace Vendor\DeliveryNote\Model;

use Magento\Checkout\Model\ConfigProviderInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;

class CheckoutConfigProvider implements ConfigProviderInterface
{
    private const XML_PATH_ENABLED = 'delivery_note/general/enabled';

    public function __construct(
        private readonly ScopeConfigInterface $scopeConfig
    ) {
    }

    // Longhand constructor, identical to the promoted property above:
    //
    // private ScopeConfigInterface $scopeConfig;
    //
    // public function __construct(ScopeConfigInterface $scopeConfig)
    // {
    //     $this->scopeConfig = $scopeConfig;
    // }

    public function getConfig(): array
    {
        return [
            'deliveryNote' => [
                'enabled' => $this->scopeConfig->isSetFlag(
                    self::XML_PATH_ENABLED,
                    ScopeInterface::SCOPE_STORE
                ),
                'maxLength' => 255,
            ],
        ];
    }
}

When to use it

  • Expose an admin store-config flag to the checkout JavaScript.
  • Decide whether a piece of checkout work belongs in a phtml or in a uiComponent.
  • Work out why a new value is missing from window.checkoutConfig.
  • Explain to a reviewer why the checkout page is excluded from the full page cache.

More examples

Registering the provider in di.xml

CompositeConfigProvider takes an array of ConfigProviderInterface instances in its constructor and merges their getConfig() results. Adding an item to that array is the whole registration. The file goes in etc/frontend/ because checkout only exists in the frontend area. Run bin/magento cache:flush, then reload checkout and type window.checkoutConfig.deliveryNote in the browser console to confirm the keys arrived.

Example · xml
<!-- app/code/Vendor/DeliveryNote/etc/frontend/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Checkout\Model\CompositeConfigProvider">
        <arguments>
            <argument name="configProviders" xsi:type="array">
                <item name="vendor_delivery_note" xsi:type="object">
                    Vendor\DeliveryNote\Model\CheckoutConfigProvider
                </item>
            </argument>
        </arguments>
    </type>
</config>

A component reading those keys

window.checkoutConfig is already in the page before any RequireJS module runs, so reading it needs no request and no callback. The `|| {}` guard matters: if the provider is not registered yet, or di is still cached, the key is absent and `config.enabled` would throw on undefined. Reach for this whenever a component needs a value that PHP knows at page render time.

Example · javascript
// app/code/Vendor/DeliveryNote/view/frontend/web/js/view/note-hint.js
define(['uiComponent'], function (Component) {
    'use strict';

    var config = window.checkoutConfig.deliveryNote || {};

    return Component.extend({
        defaults: {
            template: 'Vendor_DeliveryNote/note-hint'
        },

        isEnabled: config.enabled === true,
        maxLength: config.maxLength || 0
    });
});

Observable shorthand and longhand

observe() wraps the named property in a ko.observable, exactly as the commented line does; it only differs in detail, using ko.observableArray when the current value is an array and skipping a property that is already a function. It does not register imports, exports or links: those come from the component's own imports/exports/links config, which initialize() wires up via initLinks() either way. Either form makes `note` a function: read it as this.note(), write it as this.note('text'). A plain string property renders once and never updates, which is the most common reason a template looks frozen.

Example · javascript
// app/code/Vendor/DeliveryNote/view/frontend/web/js/view/note-counter.js
define(['ko', 'uiComponent'], function (ko, Component) {
    'use strict';

    return Component.extend({
        defaults: {
            template: 'Vendor_DeliveryNote/note-counter',
            note: ''
        },

        initialize: function () {
            this._super();

            this.observe('note');
            // Longhand for the same wrapping:
            // this.note = ko.observable(this.note);

            return this;
        },

        remaining: function () {
            return 255 - this.note().length;
        }
    });
});

Discussion

  • Be the first to comment on this lesson.