Upgrading to Commerce 4

Commerce 4 brings the power of element types to customers and addresses and incorporates new Craft 4 features.

Address and Customer models have gone away, replaced by Address (opens new window) and User (opens new window) elements. Thanks to the now-integrated commerceguys/addressing (opens new window) library, address data is now more pleasant to work with no matter what part of the planet you’re on.

If you’re upgrading from Commerce 2, see the Changes in Commerce 3 and upgrade to the latest Commerce 3 version before upgrading to Commerce 4.

# Preparing for the Upgrade

Before you begin, make sure that:

  • you’ve reviewed the changes in Commerce 4 in the changelog (opens new window) and further down this page
  • you’re running the latest version of Commerce 3.4.x
  • you’ve made sure there are no deprecation warnings from Commerce 3 that need fixing
  • you’ve checked the Payment Gateways section below and made sure any gateway plugins are ready before the upgrade
  • your database and files are backed up in case everything goes horribly wrong

Once you’ve completed these steps, you’re ready continue with the upgrade process.

# Performing the Upgrade

  1. Upgrade Craft CMS, Craft Commerce, and any other plugins, per the Craft 4 upgrade instructions. (Your composer.json should require "craftcms/commerce": "^4.0.0-beta.1".)
  2. In your terminal, run php craft commerce/upgrade and follow the interactive prompts.
  3. Go to SettingsUsersAddress Fields and drag the “Full Name”, “Organization”, and “Organization Tax ID” fields into the address field layout, so they remain editable within customers’ address books.

Once you’re running the latest version of Craft Commerce, you’ll need to update your templates and any custom code relevant to the topics detailed below.

The commerce/upgrade command must be run interactively. It will prompt you to designate or create fields and then migrate content to them.

You’ll need to run it again in production where you can only designate target fields and migrate content—unless you temporarily disable allowAdminChanges to create fields on the fly in that environment, in which case you’d need to pull your production database down locally afterward and run project-config/write.

# Order Numbers

Any time you pass a reference to an order/cart to a Commerce API, it will be consistently referenced as a number—meaning orderNumber is now number in a few places (opens new window).

# Customer → User Transition

In Commerce 4, a customer is always represented by a User (opens new window) element regardless of an order’s status.

This means that whenever you see the word “customer” in Commerce 4, it’s something that relates to a user element. (This is possible thanks to Craft 4’s new support for inactive users, which are those that don’t have an account.)

The Order::getUser() (opens new window) method has been deprecated, and you should use getCustomer() (opens new window) instead. You can also designate the customer for an order using Order::setCustomer() (opens new window) or by directly setting the Order::$customerId (opens new window) property—just make sure the user you’ve referenced already exists.

Calling the Order::setEmail() (opens new window) method works slightly differently now behind the scenes to ensure a user with the supplied email address exists. (If not, Commerce will create one for you.) In other words, an order can exist without an email address, but as soon as it has an email address it also has a customer.

# Countries and States

Commerce 4 replaces manually-managed countries and states with Craft’s Addresses (opens new window) service, which provides a full repository of countries and subdivisions (states, provinces, etc.).

Because this repository isn’t editable, Commerce 4 has moved away from custom countries and states to a new concept called “Store Markets”—which is a more flexible way of defining where the store operates. You can navigate to these settings via CommerceStore SettingsStore:

Screenshot of the Store Markets settings, with an Order Address Condition rule builder and Country List autosuggest field

  • Order Address Condition provides a condition builder for limiting what addresses should be allowed for orders.
  • Country List is an autosuggest field for choosing the countries that should be available for customers to select, in the order they’re saved in the field.

Enabled countries from Commerce 3 are migrated to the Country List field.

You can fetch that list of available countries via the new Store (opens new window) service:

{# Craft 3 #}
{% set countries = craft.commerce.countries.allEnabledCountriesAsList %}

{# Craft 4 #}
{% set countries = craft.commerce.getStore().store.getCountriesList() %}

You can get the entire list of countries, and not just those you’ve chosen, with craft.getAddresses().countryRepository.getAll().

States can no longer be enabled or disabled for selection in dropdown lists, but you can use the new Order Address Condition to limit them instead. This example is configured to only allow orders from Western Australia:

The Order Address Condition condition builder field, configured with , , , and .

While Commerce has removed support for managing custom countries and states, the commerce/upgrade command prompts you to map the custom countries to real country codes, and copies the state abbreviation (if a custom state was created) or the stateName entered by the customer to the administrativeArea field on relevant addresses and zones.

Please review your tax and shipping zones. We encourage you to use standardized countries and administrative areas (states) for your zones in the future.

# Address Management

Commerce-specific Address models are now Craft Address (opens new window) elements.

This will almost certainly require changes to your front-end templates, though it comes with several benefits:

  • better address formatting defaults
  • easier address format customization
  • custom address fields can be managed in field layouts—so no more need for custom1, custom2, etc.

If store managers had been editing user addresses directly from their profile pages in the control panel, you’ll want to expose address details in the Users field layout via SettingsUsersUser Fields.

# Order Addresses

Each address can only have a single owner, whether that’s an order or a user.

An order’s addresses (both estimated and normal billing + shipping) belong solely to that order. If a customer designates one of their saved addresses for an order’s shipping or billing, the address will be cloned to that order with references to the original address element stored in order.sourceBillingAddressId and order.sourceShippingAddressId.

Addresses are no longer automatically added to a customer’s address book when an order is completed, and a user must be logged in to be able to save an address to their address book. makePrimaryBillingAddress and makePrimaryShippingAddress will not have any effect for a user that’s not logged in.

# User Addresses

You can use User::getAddresses() (opens new window) to fetch any user’s addresses, including the currently-logged-in user:

{% set userAddresses = currentUser.getAddresses() %}

If you’d like to save a new address to a user’s address book, you must provide an ownerId:


$address = new \craft\elements\Address();
// Designate the logged-in user as the owner of this address
$address->ownerId = Craft::$app->getUser()->getIdentity()->id;


# Store Address

The Commerce store address is now an Address (opens new window) element available via the store service:

{# Commerce 3 #}
{% set storeAddress = craft.commerce.addresses.storeLocationAddress %}

{# Commerce 4 #}
{% set storeAddress = craft.commerce.getStore().getStore().getLocationAddress() %}

# Custom Address Fields and Formatting

The concept of address lines has gone away along with DefineAddressLinesEvent (opens new window). Use Craft’s Addresses::formatAddress() (opens new window) instead.

# Address Template Changes

The change in address format means you’ll need to update some references in your templates.

businessName and businessTaxId are now organization and organizationTaxId:

{# Commerce 3 #}
{# @var model craft\commerce\models\Address #}
{{ input('text', modelName ~ '[businessName]', model.businessName ?? '') }}
{{ input('text', modelName ~ '[businessTaxId]', model.businessTaxId ?? '') }}

{# Commerce 4 #}
{# @var address craft\elements\Address #}
{{ input('text', 'organization', address.organization ?? '') }}
{{ input('text', 'organizationTaxId', address.organizationTaxId ?? '') }}

stateId and stateValue references can be replaced with administrativeArea. It expects a two-letter code if the state/province is in the list of subdivisions for the current country, or an arbitrary string for countries that don’t.

{# Commerce 3 #}
{% set states = craft.commerce.states.allEnabledStatesAsListGroupedByCountryId %}
{% set options = (countryId and states[countryId] is defined ? states[countryId] : []) %}

{% tag 'select' with { name: modelName ~ '[stateValue]' } %}
  {% for key, option in options %}
    {# @var option \craft\commerce\models\State #}
    {% set optionValue = (stateId ?: '') %}
    {{ tag('option', {
      value: key,
      selected: key == optionValue,
      text: option
    }) }}
  {% endfor %}
{% endtag %}

{# Commerce 4 #}
{% set administrativeAreas = craft.commerce.getAdministrativeAreasListByCountryCode() %}

{% tag 'select' with { name: 'administrativeArea' } %}
  {% for key, option in administrativeAreas %}
    {# @var address \craft\elements\Address #}
    {% set selectedValue = address.administrativeArea ?? '' %}
    {{ tag('option', {
      value: key,
      selected: key == selectedValue,
      text: option
    }) }}
  {% endfor %}
{% endtag %}

city is now locality:

{# Commerce 3 #}
{# @var model craft\commerce\models\Address #}
{{ input('text', modelName ~ '[city]', model.city ?? '') }}

{# Commerce 4 #}
{# @var address craft\elements\Address #}
{{ input('text', 'locality', address.locality ?? '') }}

zipCode is now postalCode:

{# Commerce 3 #}
{# @var model craft\commerce\models\Address #}
{{ input('text', modelName ~ '[zipCode]', model.zipCode ?? '') }}

{# Commerce 4 #}
{# @var address craft\elements\Address #}
{{ input('text', 'postalCode', address.postalCode ?? '') }}

Any custom fields can be treated just like those on any other element type. For example, if you were previously using custom1, which was part of Commerce 3’s address model, your migration would’ve made it a custom field on the Commerce 4 address element:

{# Commerce 3 #}
{{ input('text', 'shippingAddress[address1]', order.shippingAddress.address1 ?? '') }}
{{ input('text', 'shippingAddress[custom1]', order.shippingAddress.custom1 ?? '') }}

{# Commerce 4 #}
{{ input('text', 'shippingAddress[addressLine1]', order.shippingAddress.addressLine1 ?? '') }}
{{ input('text', 'shippingAddress[fields][custom1]', order.shippingAddress.custom1 ?? '') }}

# Front-End Form Requests and Responses

Check out the example templates—they’re compatible with Commerce 4!

# Saving an Address

If you’re providing a way for customers to save their addresses on the front end, you’ll need to make a few adjustments:

  • Address field names will need to be updated, where any custom field names should follow the fields[myFieldName] format used by other element types.
  • You must specify the user’s ID in an ownerId field for the address you’re saving.
  • If you’re saving an existing address, as opposed to a new one, you’ll need to reference it via the addressId param instead of id.
  • The form action should be users/save-address rather than commerce/customer-addresses/save.
  • The (optional) makePrimaryShippingAddress and makePrimaryBillingAddress params are now isPrimaryShipping and isPrimaryBilling.

The new controller actions are only available for logged-in users; guests are no longer allowed to maintain address books.

# Deleting an Address

If you’re allowing customers to delete their addresses on the front end…

  • The form action should be users/delete-address rather than commerce/customer-addresses/delete.
  • You must specify the address to be deleted via an addressId param instead of id.

The new controller actions are only available for logged-in users; guests are no longer allowed to maintain address books.

# Payment Forms

Gateway payment forms are now namespaced with paymentForm and the gateway’s handle, to prevent conflicts between cart/order fields and those required by the gateway.

If you were displaying the payment form on the final checkout step, for example, you would need to make the following change:

{# Commerce 3 #}
{{ cart.gateway.getPaymentFormHtml(params)|raw }}

{# Commerce 4 #}
{% namespace cart.gateway.handle|commercePaymentFormNamespace %}
  {{ cart.gateway.getPaymentFormHtml(params)|raw }}
{% endnamespace %}

This makes it possible to display multiple payment gateways’ form fields inside the same <form> tag, where the gatewayId param still determines which form data should be used.

# Payment Sources Responses

Ajax responses from commerce/payment-sources/* no longer return the payment form error using the paymentForm key. Use paymentFormErrors to get the payment form errors instead.

# Config Settings

# PDF Settings

The orderPdfFilenameFormat and orderPdfPath settings have been removed. Create a default order PDF instead.

# Gateway Settings

Support for commerce-gateways.php has been removed. We recommend migrating any gateway-specific setting overrides to environment variables.

Commerce 3

// config/commerce-gateways.php
return [
    'myStripeGateway' => [
        'apiKey' => getenv('STRIPE_API_KEY'),

Commerce 4

# .env

Screenshot of Stripe gateway settings in the control panel cropped to emphasize the Secret API Key field containing a $STRIPE_API_KEY environment variable placeholder

# Twig Filters

We removed the json_encode_filtered Twig filter. Use json_encode instead.

# Events

The Order::EVENT_AFTER_REMOVE_LINE_ITEM (opens new window) string has been renamed from afterRemoveLineItemToOrder to afterRemoveLineItemFromOrder.

# Controller Actions

  • The cartUpdatedNotice param is no longer accepted for commerce/cart/* requests. Use a hashed successMessage param instead.
  • The commerce/orders/purchasable-search action was removed. Use commerce/orders/purchasables-table instead.
  • The customer-orders/get-orders action was removed. Use {{ currentUser.getOrders() }} in Twig templates or the Element API (opens new window) to provide your own controller endpoint.

# Elements

One element method was deprecated in Commerce 4:

Some element methods have been removed in Commerce 4:

# Element Actions

These Commerce-specific element actions have been removed and rely on Craft’s:

# Models

# Changed

# Removed

Old What to do instead
ShippingAddressZone::getStatesNames() (opens new window) getDescription() (opens new window)
Discount::$code (opens new window) $codes[0] (opens new window)
Discount::getDiscountUserGroups() (opens new window) Discount user groups were migrated to the customer condition rule.
Discount::getUserGroupIds() (opens new window) Discount user groups were migrated to the customer condition rule.
Discount::setUserGroupIds() (opens new window) Discount user groups were migrated to the customer condition rule.
OrderHistory::$customerId (opens new window) $userId (opens new window)
OrderHistory::getCustomer() (opens new window) getUser() (opens new window)
Settings::$showCustomerInfoTab (opens new window) $showEditUserCommerceTab (opens new window)
ShippingAddressZone::getCountries() (opens new window) getDescription() (opens new window)
ShippingAddressZone::getCountriesNames() (opens new window) getDescription() (opens new window)
ShippingAddressZone::getCountryIds() (opens new window) getDescription() (opens new window)
ShippingAddressZone::getStateIds() (opens new window) getDescription() (opens new window)
ShippingAddressZone::getStates() (opens new window) getDescription() (opens new window)
ShippingAddressZone::getStatesNames() (opens new window) getDescription() (opens new window)
ShippingAddressZone::isCountryBased (opens new window) Not applicable; zones can be country- and state-based simultaneously.
States (opens new window) craft.app.getAddresses().subdivisionRepository.getAll(['US'])
TaxAddressZone::getCountries() (opens new window) getDescription() (opens new window)
TaxAddressZone::getCountriesNames() (opens new window) getDescription() (opens new window)
TaxAddressZone::getCountryIds() (opens new window) getDescription() (opens new window)
TaxAddressZone::getStateIds() (opens new window) getDescription() (opens new window)
TaxAddressZone::getStates() (opens new window) getDescription() (opens new window)
TaxAddressZone::getStatesNames() (opens new window) getDescription() (opens new window)
TaxAddressZone::isCountryBased (opens new window) Not applicable; zones can be country- and state-based simultaneously.

# Services

In Commerce 4, ShippingMethods::getAvailableShippingMethods() (opens new window) has been renamed to getMatchingShippingMethods() (opens new window) to better represent the method.

# Changed

A few methods have had changes to their arguments:

# Deprecated

Several methods have been deprecated:

# Controllers

Several controllers have been removed entirely in Commerce 4:

A few controller methods have been removed as well:

# User Permissions

Some permissions have changed in Commerce 4:

  • commerce-manageProducts has been replaced by commerce-editProductType:<uid>, with nested permissions:
    • commerce-createProducts:<uid>
    • commerce-deleteProducts:<uid>
  • commerce-managePromotions has new, more granular nested permissions:
    • commerce-editSales
    • commerce-createSales
    • commerce-deleteSales
    • commerce-editDiscounts
    • commerce-createDiscounts
    • commerce-deleteDiscounts
  • commerce-manageCustomers has been replaced by Craft’s standard user management permissions.

# Payment Gateways

There are gateway-specific changes to be aware of in Commerce 4 in addition to the removed support for commerce-gateways.php.

# Stripe

The “Charge” gateway has been removed. Use the “Payment Intents” gateway instead.

# Cart Cookies

The customer’s current cart number is now stored in a new cart cookie rather than in the session. This allows for a guest cart to persist even after a browser is closed. Current cart sessions are automatically migrated to the cookie when the customer visits the the front end.

The cart cookie and its default one-year expiry can be modified via config/app.php.

This only requires attention if you have a custom plugin or module that manually modifies the current cart number in the session.