Light API 0.4.0 is a public beta: a minor release such as 0.5.0 may still break. What that means for your module
Light
Developer guideRecipes

A settings page from your system.xml

A Light settings page drawn from your own system.xml, with only the everyday fields in simple mode.

Light draws a settings page from your module's own system.xml: no controller, no template. In simple mode it shows the few fields you name, in plain words; the rest stays in the stock section.

When to use it

  • Your module has settings a merchant changes in daily work: whether a feature is on, a sender address, a title on the storefront.
  • Name only the everyday fields in simple, one by one. Everything else stays behind the Advanced section at the end of the page (Built for Light, rule 4).
  • Fields on core settings pages are not supported. Make your own page and, when it belongs in daily selling, point a Settings card at it.

How the page is drawn and saved

Steps

All items go in etc/adminhtml/di.xml, except the emails of step 6, which go in etc/di.xml.

1. The page

A Page\Pool array item (pool settings_pages) names the stock section and the fields simple mode shows. The pattern-B example's page, written by the scaffold:

docs/magerex-light/examples/acme-module-hello/src/etc/adminhtml/di.xml
<type name="Mrx\Settings\Model\Page\Pool">

The pilot names every field one by one; there is no wildcard, so a new field in system.xml never shows up in simple mode by accident:

app/code/Disrex/RequestAnAccountLight/etc/adminhtml/di.xml
<item name="seo_title" xsi:type="string">seo/meta_title</item>
<item name="seo_description" xsi:type="string">seo/meta_description</item>

advanced lists the settings the merchant should be able to read on the page but only changes in the advanced view. They show as read-only rows in the Advanced section (step 7):

app/code/Disrex/RequestAnAccountLight/etc/adminhtml/di.xml
<item name="advanced" xsi:type="array">
    <item name="registration_url" xsi:type="string">general/registration_url</item>
    <item name="success_url" xsi:type="string">general/success_url</item>
    <item name="terms_url" xsi:type="string">general/terms_and_conditions_url</item>
    <item name="form_fields" xsi:type="string">form_fields/enabled</item>
</item>

labels and notes replace the stock labels and comments with warm words, keyed by <group>/<field>:

app/code/Disrex/RequestAnAccountLight/etc/adminhtml/di.xml
<item name="general/enabled" xsi:type="string" translate="true">Accept account requests</item>

The keys of a page item:

Prop

Type

The page is reached at light/settings/app/page/<code>, from the app's card and from Settings > Apps.

2. Send the stock section to the page

A ConfigSections item (pool config_sections) sends the stock section adminhtml/system_config/edit/section/<section> to your page in simple mode.

3. Store-view fields

A field with showInStore in system.xml gets one input per store view under the main input, so the merchant sets a Dutch and a German text on the same page. An empty store-view input removes the store-view value. Channels and store views has the details.

4. Validate across fields

A validator on the page item names a class that implements SettingsValidatorInterface. It gets every posted value and returns the errors per field, before anything is saved:

app/code/Mrx/Settings/Api/SettingsValidatorInterface.php
public function validate(array $values, ?int $websiteId): array;

The validate classes of system.xml fields run too; the doctor warns with validate_class about one Light can't run.

5. A Settings card, only for daily selling settings

The page is always reached from the app's card and from Settings > Apps. A card on the Settings index (pool settings_cards) is optional: one per module, only for settings the merchant changes while selling. A card with only page gets its URL from the page, takes module like any item, and its label and description are translated. The Settings index groups cards in sections (pool settings_groups: store, selling, legal, users, advanced).

6. Customer emails

A module that sends emails adds rows to Settings > Customer emails (pool emails), in the global etc/di.xml: the texts the merchant saves are put into the mail wherever it renders, in checkout, cron and the REST API as well as in the admin. Returns adds its rows with the RMA resource and module, so they show only to admins who may manage returns, and only while RMA is on:

app/code/Mrx/Returns/etc/di.xml
<item name="locked" xsi:type="boolean">true</item>
<item name="resource" xsi:type="string">MageOS_RMA::config</item>
<item name="module" xsi:type="string">MageOS_RMA</item>

locked keeps an email the shop must send switched on. Without resource a row needs the Customer emails page's resource; a row the admin may not change shows read-only and is skipped on save. note explains what the merchant can change in the template:

app/code/Mrx/Settings/etc/di.xml
<item name="note" xsi:type="string" translate="true">The subject and the text at the top of the email. The canceled items below it are filled in for each order.</item>

7. The Advanced section

The page ends with the Advanced section: "Advanced" and "Settings you rarely need. You can only change them in the advanced view." on the left; on the right a card with the rows of advanced, the line "N more settings are in the advanced view." and the button "Edit these settings in the advanced view". The button opens the stock section, and the banner of the advanced view leads back to your page: "Back to" followed by the label of the first card in the settings index that shows the page, such as "Back to Returns". You declare nothing to get it: without advanced the card holds the line and the button, and a page that leaves no setting out has no section.

  • Each entry is a group/field of the section, one by one, like simple. The label is the field's labels entry, else its system.xml label.
  • The value reads as the admin would read it: the option label of a select or multiselect, Yes or No for a switch, ****** for a secret (an obscure or password field, or one whose backend_model is Encrypted), the stored text and units as stored (whitespace collapsed, cut at 120 characters), "Not set" when empty. The line under the rows counts only the settings the rows don't show.
  • A field that is already in simple, isn't in system.xml, holds no value (a note), stores a list (ArraySerialized) or comes after the sixth row is left out, and the doctor says why.

A page with its own template (a PHP class page) renders the same section and passes its own rows, as Settings > Returns does:

app/code/Mrx/Returns/view/adminhtml/templates/settings/returns.phtml
<?= /* @noEscape */ $block->renderPartial('Mrx_Settings::advanced-section.phtml', [
    'rows' => $viewModel->getAdvanced($scope),
    'url' => $viewModel->getAdvancedUrl($scope->getChannelId()),
]) ?>

AdvancedSection::getUrl() gives the URL of a stock section, and getNote() the line with the count. Pages that aren't settings pages keep the AdvancedHint block for a single "Only available in the advanced view" line, as the kit page does:

app/code/Mrx/Light/view/adminhtml/layout/light_kit_index.xml
<block class="Mrx\Light\Block\AdvancedHint" name="mrx.kit.advanced.hint" as="advanced_hint">

A page written as a PHP class still works for a page system.xml can't describe: its controller extends Mrx\Settings\Controller\Adminhtml\Settings\AbstractSettingsPage and sets PAGE_CODE, it is served at light/settings/<code>, its Page\Pool item is a class that implements Mrx\Settings\Api\PageInterface, and the page saves through light/settings/save with that code. A page that stores a cleaned-up value ("nl 1234.56.789.b01" saved as "NL123456789B01") also implements Mrx\Settings\Api\NormalizesInputInterface, so the form shows what was saved. A page whose save changes more than its own fields (channel notes, a name in the header) implements Mrx\Settings\Api\ReloadsAfterSaveInterface, and the form reloads after saving. Content under any Light settings page goes in the container mrx.settings.after (handle mrx_settings_page).

What a save cleans

Light refreshes the stored config after every save; it no longer cleans the whole config cache type. Return full_page only when this save changed something every storefront page shows (getCacheTypes() is called after save() on the same object, so you can decide per save): it empties the whole page cache, Varnish included. For output on some pages, clean their tags instead (clean_cache_by_tags, as Magento's own entity saves do). Return config if your module caches data derived from config under the config cache type. A schema page names the same types in cache_types.

Tracking codes are in every page's head, so Light's Tracking page decides like this:

app/code/Mrx/Settings/Model/Page/Tracking.php
        $changed = $this->scopedConfig->save($values, $channelId, $data);
        $this->reload = $channelId !== null && $changed !== [];
        // Every storefront page carries the codes in its head.
        $this->cacheTypes = $changed !== [] ? ['full_page', 'block_html'] : [];

Country rules

A country module tells Business details and Shipping how its country writes the business registration number, the tax ID and the postcode. It adds one item to the pool country_rules of Mrx\Settings\Model\Country\CountryRules, keyed by the ISO country code, in etc/di.xml (global area). The Netherlands item of Light for the Netherlands (Mrx_CountryNl) is the example:

packages/module-country-nl/etc/di.xml
<item name="NL" xsi:type="array">
    <item name="business_id" xsi:type="array">
        <item name="label" xsi:type="string" translate="true">KvK number</item>
        <item name="placeholder" xsi:type="string">12345678</item>
        <item name="pattern" xsi:type="string">^\d{8}$</item>
        <item name="message" xsi:type="string" translate="true">A KvK number has 8 digits.</item>
        <item name="numeric" xsi:type="boolean">true</item>
        <item name="invoice_labels" xsi:type="array">
            <item name="en" xsi:type="string">Chamber of Commerce</item>
            <item name="nl" xsi:type="string">KvK-nummer</item>
            <item name="de" xsi:type="string">KvK-Nummer</item>
        </item>
    </item>
    <item name="tax_id" xsi:type="array">
        <item name="label" xsi:type="string" translate="true">VAT number</item>
        <item name="placeholder" xsi:type="string">NL123456789B01</item>
        <item name="pattern" xsi:type="string">^NL\d{9}B\d{2}$</item>
        <item name="message" xsi:type="string" translate="true">Enter a Dutch VAT number like NL123456789B01.</item>
        <item name="invoice_labels" xsi:type="array">
            <item name="en" xsi:type="string">VAT number</item>
            <item name="nl" xsi:type="string">Btw-nummer</item>
            <item name="de" xsi:type="string">USt-IdNr.</item>
        </item>
    </item>
    <item name="postcode" xsi:type="array">
        <item name="match" xsi:type="string">^(\d{4})\s*([A-Za-z]{2})$</item>
        <item name="format" xsi:type="string">$1 $2</item>
        <item name="pattern" xsi:type="string">^[1-9]\d{3} [A-Z]{2}$</item>
        <item name="message" xsi:type="string" translate="true">Enter a Dutch postcode like 1013 AB.</item>
    </item>
</item>
  • business_id and tax_id take label, placeholder (the example), pattern and message (shown when the value doesn't match), numeric (keep digits only) and invoice_labels (the label on the invoice address that Business details writes for Magento's own PDFs and on the legal page variables, by language en, nl or de). Light's own email and PDF footer (Mrx_Documents) doesn't read invoice_labels: it prints the group's label, translated through your module's CSVs in the language of the store view.
  • postcode takes match and format ($1 $2 rewrites what match captured, in capitals), pattern and message.
  • Leave out the keys a group doesn't need. A pattern the PCRE engine refuses counts as no check.
  • A country without an item gets core's labels "Tax ID" and "Business registration number", no example and no check. In Magento's EU country list the tax ID also gets the check every EU VAT number passes: two letters, then up to 13 letters or digits.
  • Write texts in English with translate="true" and add the rows to your module's CSV.

Country wording

Core's English names no country. A sentence that a country words differently has one neutral English phrase in core, and the country module gives the shop's own wording back through its CSVs. Take "Printed on invoices and refunds, together with your tax ID and business registration number." Core's Dutch row is neutral. Mrx_CountryNl ships the Dutch wording of the Netherlands under the same English key:

packages/module-country-nl/i18n/nl_NL.csv
"Printed on invoices and refunds, together with your tax ID and business registration number.","Staat op facturen en creditfacturen, samen met je btw- en KvK-nummer."

An English admin on a Dutch shop reads the old English from the pack's en_US.csv:

packages/module-country-nl/i18n/en_US.csv
"Printed on invoices and refunds, together with your tax ID and business registration number.","Printed on invoices and refunds, together with your VAT and KvK numbers."
  • Magento merges module CSVs in module order, and a later module's row wins. The pack lists every core module whose rows it overrides in its <sequence> (Ship it as a pack). Without that line the core row wins and the pack's wording doesn't show.
  • A tier of CSVs (core, or one pack) holds one translation per phrase. An override by a pack in the same locale is not a conflict; two modules of the same tier that disagree are, and the consistency test reports it.
  • Installed language packages load after every module CSV, so they win over both. community-engineering/language-nl_nl translates Tax as "BTW", for example. Before you give your module a new English phrase, check that no installed package translates it (php tests/i18n/dictionary.php <locale> --packs prints what they translate). If one does, pick a phrase only your module uses. A phrase you can't change, such as one from a module you don't own, goes into the pool prefer_phrases with the module whose row should win (Troubleshooting).
  • A row never renames a phrase that Magento prints too. A pack row "Tax","VAT" would change the storefront cart as well. The one exception is British spelling in an en_GB.csv ("Color","Colour").

Cards on the Taxes, Business details and Payments pages

Four containers take cards from other modules: mrx.settings.taxes.preset (above the Taxes form, for a country's ready-made tax setup), mrx.settings.taxes.cross_border (cards on sales abroad), mrx.settings.business.registration (Business details, below the tax ID and registration number) and mrx.settings.payments.providers (in the Online payments band, after the provider cards and outside the Payments form). They hold display cards. The page saves only its own fields, so a card that needs input posts to its own controller and sets aclResource. On Taxes, a button with data-mrx-tax-action="<url>" posts to that URL and reloads the page with the answer's message; Mrx_Settings/js/taxes wires it.

Wording on core cards

Core cards take their country wording from block arguments, so a country module changes them with a referenceBlock in its layout. This is tier 2: the arguments work, but a release may rename them (Versioning).

BlockArguments
mrx.settings.taxes.osscountry (the other four arguments apply only while this is the shop's home country), tax_name ("Dutch VAT"), oss_url, oss_label, oss_authority
mrx.settings.paymentsiban_placeholder, the example in the IBAN field
mrx.settings.payments.providers.nonetitle and text, the banner shown while no payment provider is installed

When the shop's home country isn't country, the OSS card ignores its other arguments (tax_name, oss_url, oss_label and oss_authority): it calls the tax "%1 VAT" with the country's name and links to the EU's One Stop Shop page.

Light for the Netherlands sets the Dutch values in its own light_settings_taxes.xml, so a shop without the pack shows the neutral wording:

packages/module-country-nl/view/adminhtml/layout/light_settings_taxes.xml
<referenceBlock name="mrx.settings.taxes.oss">
    <arguments>
        <argument name="country" xsi:type="string">NL</argument>
        <argument name="tax_name" xsi:type="string" translate="true">Dutch VAT</argument>

Payment providers

Settings > Payments has two bands. "Online payments" holds a card per payment provider module, then the providers Light only lists ("Other online payments"). "Manual payment methods" holds bank transfer, cash on delivery and pay in store. Your bridge module for a payment extension registers the provider in the pool payment_providers, an argument providers of Mrx\Settings\Model\Payments\ProviderDetector, in etc/adminhtml/di.xml. The item's key is the provider code.

Settings > Payments: the Pay. and Mollie cards under Online payments, the manual payment methods below

KeyMeaning
labelthe provider's name, as the merchant knows it
prefixthe start of every payment method code the provider registers; the only required key
own_sectiontrue when your module sets the provider up on this page; Light then leaves it out of "Other online payments"
statea Mrx\Settings\Api\PaymentProviderStateInterface: while isOffered() is false, Light counts the provider's methods as off. A provider that can be connected per channel implements ChannelPaymentProviderStateInterface (since 0.3.0) instead
carda Mrx\Settings\Api\PaymentProviderCardInterface: with own_section, Light draws the provider's card
logoa view file id (Vendor_Module::images/logo.png), shown 20px high
descriptionone line shown while the provider isn't connected; use translate="true"
dashboard_urlthe provider's own dashboard, in the card's More actions
config_sectionthe stock section with the provider's payment methods, for "Manage payment methods"
advanced_sectionthe stock section with the provider's other settings, for the Advanced panel's button
resourcethe ACL resource to connect the provider and switch its methods, checked next to Magento_Payment::payment
sort_orderthe order of the cards and of the names on Home's payments step
modulethe module that must be enabled for the item to count

Mrx_PaymentsMollie registers Mollie like this. The item names a card, so Light draws Mollie's card and Mollie's forms go inside it:

packages/module-payments-mollie/etc/adminhtml/di.xml
<item name="mollie" xsi:type="array">
    <item name="label" xsi:type="string">Mollie</item>
    <item name="prefix" xsi:type="string">mollie_</item>
    <item name="own_section" xsi:type="boolean">true</item>
    <item name="state" xsi:type="object">Mrx\PaymentsMollie\Model\Mollie\ProviderState\Proxy</item>
    <item name="card" xsi:type="object">Mrx\PaymentsMollie\Model\Mollie\ProviderCard\Proxy</item>
    <item name="logo" xsi:type="string">Mollie_Payment::images/mollie_logo_configuration_tab.png</item>
    <item name="description" xsi:type="string" translate="true">Cards, iDEAL, Bancontact, PayPal, Klarna and more through your Mollie account.</item>
    <item name="dashboard_url" xsi:type="string">https://my.mollie.com/dashboard</item>
    <item name="config_section" xsi:type="string">mollie_payment_methods</item>
    <item name="advanced_section" xsi:type="string">mollie_general</item>
    <item name="resource" xsi:type="string">Mollie_Payment::config</item>
    <item name="module" xsi:type="string">Mollie_Payment</item>
    <item name="sort_order" xsi:type="number">20</item>
</item>

Pay. (Mrx_PaymentsPaynl) is a provider without a master switch: no single Pay. setting turns it off while its credentials stay. Its state class answers isOffered() on its own, true while the credentials are complete and a checkout method is on, in every channel. Until then Light counts Pay.'s methods as off:

packages/module-payments-paynl/Model/Paynl/ProviderState.php
public function isOffered(): bool
{
    return $this->everyChannel(fn (?int $channelId): bool => $this->connection->isOffered($channelId));
}

isOffered() answers for the whole shop: true only while the provider is offered in every channel. When a merchant can connect or switch on your provider for one channel only, implement Mrx\Settings\Api\ChannelPaymentProviderStateInterface (since 0.3.0) instead. It extends PaymentProviderStateInterface with one method:

app/code/Mrx/Settings/Api/ChannelPaymentProviderStateInterface.php
public function isOfferedIn(?int $channelId): bool;

With a channel (website) id, answer for that channel; with null, give the same answer as isOffered(). Settings > Payments asks it when it saves one channel, so "Keep at least one payment method on" lets a channel that pays through your provider switch its manual methods off, and refuses one where your provider is off. Under All channels it still asks isOffered(). A state that implements only PaymentProviderStateInterface keeps working: it is asked isOffered() for every channel. Mollie and Pay. implement the channel interface; Pay.'s answers through the connection of that channel:

packages/module-payments-paynl/Model/Paynl/ProviderState.php
public function isOfferedIn(?int $channelId): bool
{
    return $channelId === null ? $this->isOffered() : $this->connection->isOffered($channelId);
}

Register the state as a \Proxy, as above. After you add the interface to a state class, delete its generated proxy (generated/code/<Vendor>/<Module>/.../ProviderState/Proxy.php): an older proxy doesn't forward isOfferedIn(), so the call fails, is logged and counts as not offered.

Before your module lets a merchant switch off its last method, or disconnect, ask Mrx\Settings\Api\PaymentMethodsInterface whether customers can still pay another way:

app/code/Mrx/Settings/Api/PaymentMethodsInterface.php
public function hasOtherCheckoutMethod(string $exceptPrefix, ?int $channelId): bool;

Pass your own method prefix. A null channel asks every channel and is true only when each has another method. A provider whose switch a channel can override asks channel by channel. "Free" doesn't count as another way to pay, and neither does a method that only charges what a customer saved before: a stored card of Magento_Vault or a PayPal billing agreement. Magento ships PayPal's billing agreement switched on, also in a shop without a PayPal account, so without this rule every shop with Magento_Paypal would pass the check.

A bridge adds no setup step. Home's "Accept payments" (the key payments of setup_steps) belongs to core: it names the providers of payment_providers in their sort_order through a description_provider, and it is done once a payment method works at checkout.

Light owns the card: header, status badge, account line, test-mode warning, method strip and three panels. Your module supplies its data and two forms. Each method answers for a channel ($channelId, a website id) or, for null, all channels, from stored config or your module's own cache, so the page never waits for the provider's API:

app/code/Mrx/Settings/Api/PaymentProviderCardInterface.php
    public const STATUS_NOT_CONNECTED = 'not_connected';
    public const STATUS_OFF = 'off';
    public const STATUS_TEST = 'test';
    public const STATUS_NO_METHODS = 'no_methods';
    public const STATUS_LIVE = 'live';

    /**
     * One of the STATUS_ constants, for a channel (its website) or, for null, all channels.
     */
    public function getStatus(?int $channelId): string;

    /**
     * The account the shop is connected to, as the merchant knows it (a profile or sales location); '' while not
     * connected.
     */
    public function getAccountName(?int $channelId): string;

    /**
     * The methods the merchant can switch in Light, in the provider's order.
     *
     * @return list<array{code: string, title: string, active: bool, logo: string}> logo: an absolute URL or ''
     */
    public function getMethods(?int $channelId): array;
  • getStatus() returns one value. When several are true, return the first of not connected, off at checkout, test mode, no methods on, live. Light maps it to the badge: "Not connected", "Off at checkout", "Test mode" (warning), "No methods on" (warning) or "Live" (success). Any other value, or a call that throws, shows "Status unknown"; Light logs the exception and the rest of the page renders.
  • Pay.'s ProviderCard is the worked example. It reads stored config and an answer from Pay. that it keeps for a quarter of an hour, so the page never waits for Pay.'s API, and maps Pay.'s own status onto the constants:
packages/module-payments-paynl/Model/Paynl/ProviderCard.php
return match ($this->connection->getStatus($channelId)) {
    Connection::STATUS_LIVE => self::STATUS_LIVE,
    Connection::STATUS_TEST => self::STATUS_TEST,
    Connection::STATUS_NO_METHODS => self::STATUS_NO_METHODS,
    default => self::STATUS_NOT_CONNECTED,
};
  • getAdvancedRows() returns up to six read-only rows (label, value, already formatted) for the Advanced panel.
  • In test mode the card shows "Test mode is on. Orders placed now aren't really paid, so don't ship them." with a button that opens your Connection panel and focuses the element you mark data-mrx-provider-test-mode.

The two panels are your own templates, as children of the band block:

app/code/Mrx/Settings/view/adminhtml/layout/light_settings_payments.xml
            <block class="Mrx\Settings\Block\Settings" name="mrx.settings.payments.online" template="Mrx_Settings::page/payments/online.phtml" after="mrx.settings.scope" before="mrx.settings.payments">

In your module's light_settings_payments.xml, add blocks under <referenceBlock name="mrx.settings.payments.online"> with as="<code>_methods" (the method switches) and as="<code>_connection" (keys, test mode, Connect or Disconnect). A panel shows only while its block has output, and only to an admin with the item's resource.

Pay.'s layout file adds both panels, each with its own template and the @api view models:

packages/module-payments-paynl/view/adminhtml/layout/light_settings_payments.xml
<referenceBlock name="mrx.settings.payments.online">
    <block class="Magento\Backend\Block\Template" name="mrx.payments.paynl.connection" as="paynl_connection" template="Mrx_PaymentsPaynl::settings/connection.phtml">
        <arguments>
            <argument name="view_model" xsi:type="object">Mrx\PaymentsPaynl\ViewModel\PaynlSection</argument>
            <argument name="icons" xsi:type="object">Mrx\Light\ViewModel\Icons</argument>
        </arguments>
    </block>
    <block class="Magento\Backend\Block\Template" name="mrx.payments.paynl.methods" as="paynl_methods" template="Mrx_PaymentsPaynl::settings/methods.phtml">
        <arguments>
            <argument name="view_model" xsi:type="object">Mrx\PaymentsPaynl\ViewModel\PaynlSection</argument>
            <argument name="icons" xsi:type="object">Mrx\Light\ViewModel\Icons</argument>
        </arguments>
    </block>
</referenceBlock>
  • A panel is your <form>, and it posts to your own settings page. Save only the keys the post carries: a missing key stays unchanged, so each panel saves on its own.
  • Attach the save bar (Mrx_Settings/js/settings-form) to the methods form only. save-bar keeps one bar per form, so give the connection form its own buttons ("Save connection").
  • The link #payments-<code>-connect opens your card's Connection panel, for a Home step or a palette entry.
  • Draw fields with the @api view model Mrx\Settings\ViewModel\Fields (layout argument fields), not with Mrx\Settings\Block\Settings. field(), select(), multiselect() and toggle() give the markup of core's pages. With the option path, a control shows its channel note ("Changed for …" with the reset link, the lock note, or "… uses its own value") and is disabled while the setting can't differ per channel. Under it, each store view with a value of its own from the advanced view gets "Changed for Deutsch in the advanced view" with "Remove the Deutsch value", which the save of light/settings/save carries out (Store-view values from the advanced view). Leave path off a control whose text your page keeps per store view itself, or its translations show as such values. icon() renders a Light icon.

A pack that charges shipping its own way

Settings > Shipping and pickup charges shipping through zones, stored as JSON on the flat rate carrier. A pack whose carrier calculates rates another way (a rate table, a carrier's live prices) tells core so through one config path, mrx_settings/shipping/rates_from. It holds zones (the default) or the code of the carrier that charges shipping instead, for all channels or per website.

ValueWhat core does
zones, flatrate or emptyAs always: the zones card shows, and a save for all channels stores flat rate as on
a carrier code, while carriers/<code>/active is on in that scope and carriers/<code>/model is setThe zones card folds to a note ("Another delivery method charges shipping now. Your zones are kept."), posts no zones and leaves them stored; a save for all channels no longer switches the flat rate back on

Core checks the carrier's model because a removed module takes its config.xml default with it, while its saved active row stays behind; a stale value then changes nothing. The page's other checks work as before: when another carrier is on, a save passes the "add a shipping rate" check, so your active carrier counts as a rate.

Your pack offers the choice and writes three values in one save() of Mrx\Settings\Api\ScopedConfigWriterInterface, for all channels (null) or one website: mrx_settings/shipping/rates_from set to your carrier code, your carrier's active set to '1' and carriers/flatrate/active set to '0'. To switch back, write zones, carriers/flatrate/active '1' and your carrier's active '0'. Write the flags as '1' and '0': for all channels the writer stores a value that differs from the stored one, and for a website it stores any value that differs from what the website reads now, also one equal to the all-channels value. To let a website follow the all-channels choice again, write null for the three paths. Core never writes the path itself, and the zones survive every switch. Tell merchants to switch back to zones before they remove your pack: without your card nothing offers the choice, and the zones stay folded while your carrier is still installed and on.

The rate table pack mrx/module-shipping-matrixrate (Install) works this way for MatrixRate. Its block mrx.ratetable.choice sits in content of light_settings_shipping before mrx.settings.shipping, outside the Shipping form, with its own request: picking the other choice confirms, writes the three values and reloads. Its Rate table page lives at its own route, light/ratetable/index, with a page item ratetable in settings_pages only for the save; no other pool entry points at that code, because light/settings/ratetable doesn't exist.

Settings > Shipping and pickup with the rate table pack: "How do you charge shipping?" with Rate table chosen and the channel's rates summed up

The same choice card on a phone

The pack's Rate table page: Charge by, and the rates as cards with their destinations, postcodes, steps and Free from

The Rate table page on a phone: Charge by as a select, the rates stacked, the Advanced part at the end

Settings > Legal pages drafts a text per legal page type and language. Two pools in etc/adminhtml/di.xml describe them. legal_page_types (an argument types of Mrx\Settings\Model\LegalPages\LegalPageManager) lists the kinds of legal page, with label, description, sort_order and languages, which limits a type to shops that sell in one of those languages. Core registers the types every shop needs; Mrx_CountryNl adds the German withdrawal instructions:

packages/module-country-nl/etc/adminhtml/di.xml
<item name="withdrawal" xsi:type="array">
    <item name="label" xsi:type="string" translate="true">Withdrawal instructions</item>
    <item name="description" xsi:type="string" translate="true">The official German model text (Widerrufsbelehrung) with the model withdrawal form. Required when you sell to Germany.</item>
    <item name="sort_order" xsi:type="number">25</item>
    <item name="languages" xsi:type="array">
        <item name="de" xsi:type="string">de</item>
    </item>
</item>

legal_page_templates (an argument templates of Mrx\Settings\Model\LegalPages\Templates) holds the drafts, by type and then language. Each is an HTML file in a view folder, with {{name}}-style placeholders filled from the business details:

packages/module-country-nl/etc/adminhtml/di.xml
<item name="de" xsi:type="array">
    <item name="title" xsi:type="string">Rückgabe und Rücksendung</item>
    <item name="identifier" xsi:type="string">rueckgabe-und-ruecksendung</item>
    <item name="file" xsi:type="string">Mrx_CountryNl::legal_page_templates/de/returns.html</item>
    <item name="placeholder" xsi:type="string">[bitte ergänzen</item>
    <item name="variables" xsi:type="object">Mrx\CountryNl\Model\LegalPages\GermanVariables</item>
</item>
  • file is Module_Name::path under the module's view/adminhtml. placeholder is the text that marks a part the merchant still fills in.
  • variables names a class that implements Mrx\Settings\Api\LegalPageVariablesInterface, for placeholders that language alone needs, such as the register line of a German legal notice. A variables class that throws is logged, and the draft renders without its values.
  • The item's parent key is the legal page type. A country module adds a type or a language to an existing type; the pools take a null item to remove one.

Ship it as a pack

A country or a payment provider ships as its own module outside core, so a shop that doesn't need it never loads it. Mrx_CountryNl ("Light for the Netherlands", in packages/module-country-nl) is the example: every Dutch fact on this page sits in its files, and no core file names it. A pack is a Light module with three things to get right.

The package declares the API range it was built for (Versioning):

packages/module-country-nl/composer.json
"extra": {
    "mrx-light-api": "^0.4"
}

registration.php carries the range guard, so the pack doesn't register with a Light that doesn't satisfy it:

packages/module-country-nl/registration.php
    ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Mrx_CountryNl', __DIR__);
}

module.xml sequences every core module whose pools the pack fills, so DI merges its items after core's:

packages/module-country-nl/etc/module.xml
<module name="Magento_Tax"/>
<module name="Magento_User"/>
<module name="Mrx_Light"/>
<module name="Mrx_Settings"/>
<module name="Mrx_Catalog"/>
<module name="Mrx_Home"/>
<module name="Mrx_Content"/>
<module name="Mrx_Orders"/>
<module name="Mrx_Discounts"/>

The pack names only @api types and catalogued pools. The doctor and the compatibility gate treat it like any module: run bin/magento mrx:light:doctor --module=Mrx_CountryNl. Where a pack changes a core item by key or sets a core block's arguments, that is tier 2 (Changing Light for one shop), so the pack ships in the same release as core and gets retested with every minor.

A pack for another country follows the same shape: its items in country_rules, carriers, legal_page_types, legal_page_templates and page_templates, its wording through referenceBlock. Ask for a knownPacks line in the release that ships it, so the doctor rule country_pack (Troubleshooting) warns a shop based in that country that runs without the pack:

app/code/Mrx/Light/etc/di.xml
<item name="NL" xsi:type="array">
    <item name="module" xsi:type="string">Mrx_CountryNl</item>
    <item name="package" xsi:type="string">mrx/module-country-nl</item>
</item>

What the admin sees

The pilot's settings page in simple mode: the everyday fields, with warm labels and notes

The end of the page: the Advanced section with read-only rows and the button to the advanced view

  • Simple mode. The page lists the named fields with their labels and notes, "Applies to" for store-view fields, the store-view inputs and the Advanced section. The stock section URL opens this page.
  • Advanced mode. The stock section, unchanged.

ACL

The page asks for the section's own ACL resource from system.xml, and each email row for its resource.

Check it

  • bin/magento mrx:light:doctor --module=<your module>: schema_field reports a field in simple, advanced, labels or notes that the section doesn't have, an advanced entry that is in simple or can't show as a row, and unknown_reference with kind section a section that doesn't exist.
  • tests/playwright/settings-schema.spec.ts is the test to copy.

Pitfalls

  • A field that simple names but system.xml hides with showInDefault="0" shows nothing at default scope.
  • Settings pages are keyed by code in admin_user bookmarks and URLs: never rename a page code (Versioning).

Last updated on

On this page