Dynamically Customizing Line Item Prices

Craft Commerce provides a number of ways to customize prices for your customers:

  • Manage discrete base and promotional pricing for every variant;
  • Generate catalog pricing from complex product and customer criteria;
  • Promote products via sales (only available in projects upgraded from Commerce 4.x or earlier);
  • Offer discounts via coupon codes or based on cart contents;

In addition to built-in pricing features, you have complete programmatic control over the final price a customer sees (and pays), via Commerce’s events system.

In earlier versions of Commerce, much of what we’ll discuss was possible with adjusters. We no longer recommend applying arbitrary price changes via adjustments, as they are not interpreted consistently across gateways and other third-party accounting and shipping tools.

Refreshing Line Items #

Any time a cart or order is recalculated, Commerce emits a pair of events before and after its line items are “refreshed” from their purchasables:

Changes made to the cart in either event happen prior to Commerce applying any adjustments.

Let’s look at an example that gives repeat customers a discount:

use craft\commerce\elements\Order;
use craft\commerce\events\OrderLineItemsRefreshEvent;
use yii\base\Event;

Event::on(
    Order::class,
    Order::EVENT_AFTER_LINE_ITEMS_REFRESHED,
    function(OrderLineItemsRefreshEvent $event) {
        $order = $event->sender;
        $lineItems = $event->lineItems;
        $customer = $order->getCustomer();

        // Does this cart belong to a guest? There may be nothing to do:
        if (!$customer || !$customer->getIsCredentialed()) {
            return;
        }

        // Ok—do they have any past orders?
        $hasCompletedOrders = Order::find()
            ->customer($customer)
            ->isCompleted(true)
            ->exists();

        if (!$hasCompletedOrders) {
            return;
        }

        foreach ($lineItems as $lineItem) {
            // Reduce the base price by 10%, or use the sale price if it's already lower:
            $lineItem->promotionalPrice = min($lineItem->price * 0.9, $lineItem->salePrice);
        }

        // Set the line items back onto the event:
        $event->lineItems = $lineItems;
    }
);

Commerce returns the lesser of price and promotionalPrice via salePrice, but we are setting the reduced price back on promotionalPrice to ensure the original price remains visible to the customer.

This change is “stable,” in that it won’t apply multiple times. Commerce will always restore the line item’s price and promotionalPrice (and therefore the computed salePrice) when it is refreshed from the purchasable, so we will only ever act on the original price.

Custom Line Items #

If your pricing represents an additional fee, charge, or add-on, consider using a custom line item. Store managers can add custom line items to any order via the control panel—or you can add them programmatically via the same EVENT_BEFORE_LINE_ITEMS_REFRESHED event:

use craft\commerce\Plugin as Commerce;
use craft\commerce\elements\Order;
use craft\commerce\elements\Variant;
use craft\commerce\enums\LineItemType;
use craft\commerce\events\OrderLineItemsRefreshEvent;
use yii\base\Event;
use Illuminate\Support\Collection;

Event::on(
    Order::class,
    Order::EVENT_BEFORE_LINE_ITEMS_REFRESHED,
    function(OrderLineItemsRefreshEvent $event) {
        $order = $event->sender;

        // Get existing line items, and *remove* the add-on, if it’s already present:
        $lineItems = Collection::make($event->lineItems)
            ->reject(function($li) {
              return $li->getSku() === '__CUSTOM-ADDON-ENGRAVING';
            });

        $eligibleItems = $lineItems->filter(function($li) {
            // Only variants are eligible:
            if (!$li->getPurchasable() instanceof Variant) {
                return false;
            }

            // Does it belong to a product of the type we’re looking for?
            if (!$li->getPurchasable()->getProduct()->getType()->handle === 'timepieces') {
                return;
            }

            // Did they provide a message in the line item options when adding it to their cart?
            return $li->options['engravingMessage'] ?? null;
        });

        if (count($eligibleItems)) {
            // Ok, we know the add-on is required by at least one item!
            $taxCatId = Commerce::getInstance()->getTaxCategories()->getDefaultTaxCategory()->id;
            $shippingCatId = Commerce::getInstance()->getShippingCategories()->getDefaultShippingCategory($order->storeId)->id;

            // Create the custom line item:
            $addOn = Commerce::getInstance()->getLineItems()->create($order, [
                'description' => Craft::t('site', 'Custom engraving charge'),
                // Make sure the SKU matches what we looked for, above!
                'sku' => '__CUSTOM-ADDON-ENGRAVING',
                // Default quantity is 1, but we're making it agree with the number of "engravable" items:
                'qty' => count($eligibleItems),
                'price' => 9.99,
                'promotionalPrice' => 9.99,
                // A tax and shipping category must be provided explicitly so Commerce knows how to handle the additional charge:
                'taxCategoryId' => $taxCatId,
                'shippingCategoryId' => $shippingCatId,
            ], LineItemType::Custom);

            $lineItems->push($addOn);
        }

        // Assign items back onto the event:
        $event->lineItems = $lineItems->all();
    }
);

This example adds a “custom” line item to the order that is not based on an existing purchasable. You are free to structure it however you like, so long as it meets Commerce’s requirements. An order can have any number of custom line items! Keep in mind that custom line items don’t have an associated purchasable, and can cause problems with cart and order templates that assume lineItem.purchasable is a variant object.

Always use a stable identifier (like a sku) when adding custom items so that you can programmatically remove them, later. The format in this example isn’t significant, but including a prefix can help avoid collisions with real SKUs!

If your fees are managed in Commerce as products and variants, you can construct a line item based on that purchasable using the same API:

foreach ($eligibleItems as $eligibleItem) {
    $product = $eligibleItem->getPurchasable()->getProduct();

    // Let’s assume the fee variant is associated with the “Timepiece” product:
    $fee = $product->engravingFee->one();

    // Create a line item based on the “fee” purchasable:
    $addOnItem = Commerce::getInstance()->getLineItems()->create($order, [
        'purchasableId' => $fee->id,
    ], LineItemType::Purchasable);

    $lineItems->push($addOnItem);
}

Customers can still technically “remove” a custom line item, or any line item that was programmatically added—but as soon as the order is recalculated, this process will start over. We advise adjusting your cart management UI to reflect customer capabilities, using one of these strategies:

  • Check for “custom” line items — lineItem.type == 'custom'
  • Detect relevant SKU patterns — lineItem.sku starts with '__CUSTOM'
  • Give each product type its own line item template — {{ include("_cart/line-item-types/#{lineItem.purchasable.product.type.handle}") }}

Populating Line Items #

A line item’s price and promotionalPrice can be updated as it’s being populated for the cart. In a custom module, listen to the LineItems::EVENT_POPULATE_LINE_ITEM event:

use craft\commerce\events\LineItemEvent;
use craft\commerce\services\LineItems;
use craft\commerce\models\LineItem;
use yii\base\Event;

Event::on(
    LineItems::class,
    LineItems::EVENT_POPULATE_LINE_ITEM, 
    function(LineItemEvent $event) {
        /** @var LineItem **/
        $lineItem = $event->lineItem;

        $lineItem->price = 119.99;
        $lineItem->promotionalPrice = 109.99;
    }
);

You are not obligated to overwrite both price and promotionalPrice, but it can be useful to customers to maintain and display evidence of the price they saw prior to your logic being applied in the cart. A line item’s effective salePrice will always be the lesser of the two values—but it cannot be set directly.

While this example is technically complete, setting every line item’s price to $109.99 will rarely be the desired effect! Let’s inject some additional logic to apply a discount based on the customer’s user group.

This next example is obviated by catalog pricing, available in Commerce 5.x, but we’re keeping it published for posterity!

Our goal is to give “VIP” customers (those in a special user group) a 10% discount on any products of a particular type. We can add some conditionals to that example above to accomplish this:

use craft\commerce\events\LineItemEvent;
use craft\commerce\services\LineItems;
use craft\commerce\elements\Variant;
use yii\base\Event;

Event::on(
    LineItems::class,
    LineItems::EVENT_POPULATE_LINE_ITEM, 
    function(LineItemEvent $event) {
        /** @var craft\commerce\models\LineItem $lineItem **/
        $lineItem = $event->lineItem;

        /** @var craft\commerce\base\Purchasable $purchasable **/
        $purchasable = $lineItem->getPurchasable();

        $order = $lineItem->getOrder();
        $customer = $order->getCustomer();

        // Check if the customer is in the VIP group:
        if (!$customer || !$customer->isInGroup('vip')) {=
            return;
        }

        // Make sure this is a variant and not a donation, etc:
        if (!$purchasable instanceof Variant::class) {
            return;
        }

        $product = $purchasable->getProduct();

        if ($product->getType()->handle === 'trophies') {
             // In Commerce 4.x, you could directly set the `salePrice`:
             $lineItem->salePrice = $lineItem->price * 0.9;

             // In Commerce 5.x, you must set `promotionalPrice`:
             $lineItem->promotionalPrice = $lineItem->price * 0.9;
        }
    }
);

Whenever a line item is populated from a variant, we screen out ineligible customers and purchasables, and then check the product type. When we get a match (and haven’t bailed or returned early), we assign the salePrice of the line item to 90% of the base price that Commerce copied from the variant.

Your logic here does not need to be hard-coded—you have access to the entire Craft and Commerce APIs, meaning you can load configuration and content from any source to derive pricing information. Consider how you might prompt the customer for additional line item options to create dynamically-priced, configurable products!

Note that we check against the customer associated with the order, not the “current” user. This is vital to keeping the logic consistent, regardless of who is making changes to an order (the customer, or a store manager).

Applies to Craft Commerce 5, Craft Commerce 4, and Craft Commerce 3.