Make Magento 2 Discounts Readable: One Line per Cart Price Rule
On a shop I look after, a note that no customer was meant to read appeared in the cart. The text came from a rule’s description field, which the admin presents as a place for the team’s own notes.
What Magento stores, and what it does not
Magento merges every applied discount into one totals row: “Discount (…)”.
The text in brackets is built from the rules’ labels. The chain is in
RulesApplier::getRuleLabel():
the rule’s store label first, then, when a coupon applies, the rule
description, then the coupon code.
In the admin, description is a notes field. It holds whatever the team wrote down for itself: which campaign a rule belongs to, which ticket it came from. That is the text that reached the cart.
A template fix cannot produce one line per rule, because the amounts are not stored. On the order, Magento keeps three fields:
coupon_code, a single codediscount_description, a text fieldapplied_rule_ids, a list of ids
Those three are the whole record. sales_order in
Magento/Sales/etc/db_schema.xml
carries coupon_code and discount_description as varchar(255),
applied_rule_ids as varchar(128), and no column for an amount per rule.
Checked against Magento 2.4.7.
No per-rule amounts. Once the order exists, the split of the discount across the rules cannot be reconstructed. Anything that wants one line per rule has to store the amounts at the moment the order is created.
I have also seen the same text in the shipping row, “Shipping & Handling
(…)”, on an order whose only rule was free shipping. A free-shipping rule
runs through the same description code, in Validator::processShippingAmount().
I could not find the core line that puts the text into that row, so take the
shipping row as something I saw in the lab and the discount row as the path the
source shows.
The email itself is not Magento’s. That layout comes from a second module, described in the sister post, and neither module needs the other.
The benchmark was Shopify
Shopify does this better, and that is what started the module. Its checkout lists each applied discount as its own line in the order summary, including when several discounts combine (discount combinations changelog). The separate lines are my own reading of a Shopify checkout rather than a documented promise. I looked for a page in Shopify’s own documentation that states it, and did not find one.
Shopify has its own weakness here: the line carries the code the customer typed, and a merchant cannot substitute a friendly name (Shopify Community). What it shows for an automatic discount, which has no code to show, I have not been able to establish from a source worth citing.
So the target was both: one line per rule, and a label that comes from a field written for customers, including for a rule the customer never typed anything to trigger.
The label rule
| Kind | Label | Amount column |
|---|---|---|
coupon | the coupon code | -amount |
automatic | the rule’s store label, else a translated “Discount” | -amount |
free_shipping | the code or the store label, else “Free shipping” | -shipping discount, or “Free” |
gift | the coupon code of the promotion | ”Gift” when it carries no money, otherwise the amount |
gift_card | ”Gift card ••••1234”, masked | -amount |
The rule description and the rule name are never used. Labels and amounts are snapshotted when the order is placed, so a rule that is relabelled or deleted later does not change existing orders.
How the amounts survive checkout
Storage on the quote address, at collect time. After an address’s totals are
collected, the per-rule lines are resolved and stored on the quote address
(quote_address.pp_discount_lines). Resolution is one generic rule resolver
that reads core’s own per-rule discount breakdown of the address, so it covers
every simple_action including by_percent, by_fixed, cart_fixed and
buy_x_get_y, plus a free shipping resolver. Both sit in a composite, and that
composite is the extension point: an adapter adds a named resolver to it
through di.xml. A failed collect clears the stored lines.
A totals segment extension attribute. A plugin on
CartTotalRepositoryInterface adds the lines to the discount total segment as
extension_attributes.discount_lines and replaces the segment title, which
carries the rule description in core. A quote with only zero-value lines, a
gift or free shipping, gets a zero discount segment so renderers still
receive the lines. Stored lines that do not add up to the segment value are not
exposed at all, and the title stays the plain “Discount”.
Persistence at order placement. On
sales_model_service_quote_submit_success the lines are written to
pp_discount_line: order-level lines plus item-level per-rule amounts, which
are what make the credit memo split possible. A discounted order with no
attributable lines gets one generic automatic “Discount” line for the full
amount, so an order never falls back to the core label.
The contract, for anyone rendering the rows
Each line is its own totals row in the standard Magento totals blocks, with the
row code pp_discount_line_<n>, replacing the core discount row. The row
carries the data key discount_line:
discount_line = {
kind: "coupon" | "automatic" | "gift" | "free_shipping" | "gift_card",
rule_id: int|null, // null for gift_card
code: string|null, // coupon or gift card code, masked for gift_card
label: string, // resolved by the label rule, safe to show
amount: float, // order currency, positive number = discount
base_amount: float
}
Detect a discount line by that data key, not by the row code. The Mageplaza
adapter keeps Mageplaza’s own row codes and still carries discount_line with
kind gift_card. Row label and value stay populated as core expects, so a
template that ignores the data key still prints a correct plain row.
One base, small renderers
The base package knows no frontend. It owns the data, the storage, the label rule and every sales document. Storefront rendering lives in separate packages, so a shop installs only what it runs.
| Package | What it adds |
|---|---|
magento2-module-discount-lines | Data, storage, label rule, documents, configuration |
-hyva-checkout | Hyvä Checkout price summary |
-hyva-theme | Hyvä default theme cart page |
-luma | Luma cart summary and checkout sidebar |
-amasty-promo | Amasty Free Gift rules as gift lines |
-mageplaza-giftcard | Mageplaza gift cards, masked |
All six were released as 1.0.0 on 2026-11-01, MIT licensed, on GitHub and Packagist, so the Hyvä and Luma renderers both exist today.
The two Hyvä packages are not alternatives to each other. -hyva-theme renders
the cart page of the Hyvä default theme and -hyva-checkout renders the price
summary in Hyvä Checkout, so a shop running both needs both.
Loki Checkout is not built. Because the base package is frontend-agnostic, any further checkout can get a renderer, but today none exists.
Why Amasty and Mageplaza, and how to add your own
Those two adapters exist because those two extensions are in the installation I work with, not because I surveyed the market. Mageplaza happens to be the gift card extension running there.
For any other provider, write the adapter yourself. It is one class with one method:
interface DiscountLineResolverInterface
{
/**
* Resolve discount lines for a collected quote address.
*
* @return DiscountLineInterface[]
*/
public function resolve(Address $address, Quote $quote): array;
}
Then hand it to the composite under a name of your own, in di.xml:
<type name="PixelPerfect\DiscountLines\Service\Resolver\CompositeResolver">
<arguments>
<argument name="resolvers" xsi:type="array">
<item name="your_provider" xsi:type="object">
Your\Module\Service\Resolver\YourResolver
</item>
</argument>
</arguments>
</type>
An entry that does not implement the interface is skipped and logged rather than thrown, so a broken adapter degrades to the core row instead of taking the cart down.
Four variants, chosen per store view
Under Stores > Configuration > Sales > Discount Lines: A puts the coupon chip below the label, B makes the chip the label and is the default, C groups the lines with a “You save” sum, and D uses an icon and a monospace code with no chip.
Sales documents always use plain text, for example “Coupon CODE -2.25”, because Outlook and PDF renderers cannot draw a chip safely.
What the lines add up to
The lines always add up to the document’s discount total, to the cent.
Rounding differences of up to one cent per line are absorbed by the largest line. A larger remainder that belongs to no rule becomes a generic “Discount” line rather than being assigned to one, because a wrong attribution is harder to spot than a vague one. An invoice scales the stored lines to the invoice discount; a credit memo splits each rule in proportion to the refunded quantities.
Limits
- Orders placed before installation keep the merged core row. The per-rule amounts cannot be rebuilt, because Magento never stored them.
- Multishipping is not supported in 1.0.
- Across several invoices of one order, per-rule amounts can drift by one cent per rule. Every document’s discount total is exact.
- With Amasty, a gift line gets too much of the amount when an automatic rule also discounts the gift item. The sum stays correct. The fix is the Amasty condition “is promo item = No” on automatic rules.
- Existing carts show the core row until the next totals collect.
Install
composer require pixelperfectat/magento2-module-discount-lines
composer require pixelperfectat/magento2-module-discount-lines-hyva-checkout
bin/magento setup:upgrade
The second line is a renderer. Install one for every frontend you run, not one
in total: -hyva-checkout for Hyvä Checkout, -hyva-theme for the cart page of
the Hyvä default theme, -luma for Luma. A shop that runs the Hyvä theme and
Hyvä Checkout installs both. Add -amasty-promo or -mageplaza-giftcard if you
run those extensions.
Quality, in numbers
Built and tested in a clean Mage-OS 3.1.0 lab on PHP 8.5, with demo data only.
Every package runs composer check in CI: composer validate, PHP lint,
PHP_CodeSniffer, PHPStan and PHPUnit. Four of the six add Infection mutation
testing, which gates the build below an MSI of 85 or a covered-code MSI of 90.
Mutation tests are deliberately broken copies of the code, and the score is the share the test suite catches. Every figure below was read out of the public CI log of the run linked beside it, not copied from a release note.
| Package | MSI / covered-code MSI | Run |
|---|---|---|
magento2-module-discount-lines | 93 / 93 | 42242224997, 2026-11-06 |
-amasty-promo | 100 / 100 | 42242248810, 2026-11-06 |
-mageplaza-giftcard | 99 / 99 | 42242346501, 2026-11-06 |
-luma | 93 / 93 | 42242324118, 2026-11-06 |
-hyva-theme | no Infection step | runs validate, lint, cs, stan, test |
-hyva-checkout | no Infection step | runs validate, lint, cs, stan, test |
The two Hyvä renderers have no Infection step, so they carry no score. I do not count a missing score as a passing one.
Mutation testing found a real bug before release: an all-digit Mageplaza gift card code crashed the masking.
Please try to break it
These modules are new, and a lab is a narrow slice of what Magento shops throw at a cart: rule combinations, currencies, tax setups, other extensions that also touch totals.
Install them in a test environment and stack things. Several coupons at once, free shipping with a gift, a partial refund across two invoices, a currency switch between order and base. If the numbers disagree, or a line looks wrong, that is worth an issue on GitHub, including when it is only ugly rather than wrong. I read every one.
The emails are a separate question
The lines appear in the order confirmation, but the email itself is still Magento’s. A second module replaces those templates, and this post describes it. Neither module requires the other.
Questions
Why does Magento show only one discount row?
Magento merges every applied discount into the totals row "Discount (...)". On the order it keeps only `coupon_code`, `discount_description` and `applied_rule_ids`, so no per-rule amounts. After checkout the split cannot be reconstructed, which is why emails, invoices and credit memos cannot show it.
Can a cart rule description reach the customer?
Yes. `RulesApplier::getRuleLabel()` in Magento 2.4.7 takes the rule's store label first, and when a coupon applies and no store label is set it takes the rule description, which is a notes field in the admin. An internal note then appears in the discount row of the cart, of the order confirmation and of the invoice. I have also seen the same text in the shipping row, on an order whose only rule was free shipping.
How do the lines relate to Magento's own discount total?
They always sum to it, to the cent. Rounding differences of up to one cent per line are absorbed by the largest line. A larger remainder that belongs to no rule becomes a generic "Discount" line rather than being assigned to one. Lines that exceed the total, or that disagree between the order and the base currency, are dropped, and then the core row appears with the plain label "Discount".
Which frontends are supported?
Hyvä Checkout, the Hyvä default theme and Luma, each as its own package, all released 1.0.0 on 2026-11-01. The base package is frontend-agnostic, so another checkout can get a renderer, but none exists today.
Where Is Your Magento Project Right Now?
Tell me briefly what you are working on, where things stand and what has already been tried. I will be honest about whether I am the right developer for it.