Variant Price Surcharge: Technical Details
Technical details of Variant Price Surcharge for Sylius: price calculation flow, decorated calculators, data model with per-channel overrides, saving in the form, plugin structure and routes.
Variant Price Surcharge: Technical Details
Price calculation flow
graph TD
A["calculate() / calculateOriginal()"] --> B["Own price via the Sylius calculator"]
B --> C{"Global switch on?"}
C -->|No| Z["Own price"]
C -->|Yes| D{"Surcharge stored and not base?"}
D -->|No| Z
D -->|Yes| H1{"Enabled, channel value before global value?"}
H1 -->|No| Z
H1 -->|Yes| E{"Product has a base variant?"}
E -->|No| Z
E -->|Yes| F["Base variant price via the Sylius calculator"]
F --> G["(base price + fixed surcharge) x percentage / 100"]
G --> H["Round, minimum 0"]
- The variant's own price is always looked up first, even if it is discarded afterwards.
- The channel comes from
$context['channel']. For surcharge enabled, percentage and fixed surcharge, the calculator takes each field from that channel's override, otherwise from the global values, otherwise 100 or 0 respectively. - The lookups of the switch, the surcharge and the base variant run inside a
tryblock. Any exception there results in the own price and a log entry at levelerror. The price lookup of the base variant is deliberately outside that block so that missing channel pricing stays visible as an error. - The calculation uses floating-point numbers and is rounded to whole units with PHP
round(). Negative results are set to 0. calculateLowestPriceBeforeDiscount()passes through the value of the decorated calculator.- When the global switch is off, the surcharge lookup is skipped entirely.
- The repository loads the overrides together with the surcharge in one query, so the price calculation triggers no additional queries per variant.
- Without a stored settings row the switch counts as on. The row is never written on read, only when the settings page is saved.
Decorated calculators
| Service | Applies to | Decorator |
|---|---|---|
sylius.calculator.product_variant_price |
Sylius 2.2 and 2.3, cart and order | SurchargeAwareProductVariantPriceCalculator |
sylius.calculator.product_variant_catalog_price |
from Sylius 2.3, product page, listings, variant selection, API | SurchargeAwareCatalogPricesCalculator, registered by the compiler pass DecorateCatalogPricesCalculatorPass |
Data model
| Table | Column | Type | Note |
|---|---|---|---|
mmd_product_variant_price_surcharge |
id |
INT | Primary key |
product_variant_id |
INT | Unique, foreign key to sylius_product_variant, ON DELETE CASCADE |
|
is_base |
Boolean | Base variant | |
is_enabled |
Boolean | Surcharge enabled | |
percentage |
Decimal(10,2), nullable | Default 100.00 | |
fixed_surcharge |
INT, nullable | Smallest currency unit, default 0 | |
mmd_product_variant_price_surcharge_channel |
id |
INT | Primary key |
surcharge_id |
INT | Foreign key to mmd_product_variant_price_surcharge, ON DELETE CASCADE |
|
channel_code |
VARCHAR(255) | Channel code, no foreign key to sylius_channel |
|
is_enabled |
Boolean, nullable | NULL means inherit the global value |
|
percentage |
Decimal(10,2), nullable | NULL means inherit the global value |
|
fixed_surcharge |
INT, nullable | NULL means inherit the global value |
|
mmd_variant_price_surcharge_settings |
id |
INT | Always 1 |
active |
Boolean | Global switch, default on |
The combination of surcharge_id and channel_code is unique (index mmd_surcharge_channel_idx). The surcharge is attached to the variant through a foreign key. The plugin does not modify the Sylius ProductVariant class.
Three migrations create the tables: Version20260905080038 (surcharges), Version20260905124014 (settings) and Version20261015135354 (per-channel overrides).
Saving in the variant form
ProductVariantTypeExtension extends the form type ProductVariantType with an unmapped sub-form. On load it fetches the variant's stored row or creates an empty object. The form always shows one entry per Sylius channel, even if no row exists for it yet. On submit:
- For a base variant, it removes all per-channel overrides.
- It also removes entries where all three fields are set to inherit.
- It does not persist the row if Base variant and Apply surcharge are off and no override remains.
- A row that is already stored is otherwise updated with the new values even when the fields are switched off.
Plugin structure
Excerpt of the main directories:
src/
Calculator/ SurchargeAwareProductVariantPriceCalculator, SurchargeAwareCatalogPricesCalculator
Command/ SetupCommand
Controller/ Admin/VariantPriceSurchargeSettingsController
DependencyInjection/ Extension, Configuration, Compiler/DecorateCatalogPricesCalculatorPass
Entity/ ProductVariantPriceSurcharge, Channel/ProductVariantPriceSurchargeChannel, Settings/VariantPriceSurchargeSettings
Form/ Extension/ProductVariantTypeExtension, Type/...
Menu/ AdminMenuListener, MenuGroup
Migrations/
Repository/
Twig/ PluginVersionExtension
config/
doctrine/ Mapping of the surcharge entity
grids/ Overview grid
routes/ admin.yaml, shop.yaml (no routes)
services/
twig_hooks/ Form section and settings page
templates/admin/
translations/ messages and flashes, de and en
Routes
| Route | Path | Purpose |
|---|---|---|
mmd_variant_price_surcharge_admin_settings |
/<admin path>/variant-price-surcharge/settings |
Settings page, GET and POST |
| Resource routes of the overview | /<admin path>/variant-price-surcharges |
List and delete |
<admin path> is the parameter sylius_admin.path_name, admin by default. Both routes sit behind the Sylius admin firewall.
Further notes
- The settings page shows the installed plugin version below its title, determined through Composer. Without a tag, the branch with the short commit hash appears.
- The plugin sends no data to external services and processes no personal data.
- Translations are in
translations/for German and English.