Customers, Multistore and i18n

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

A PrestaShop store is rarely just a catalog: it has people who buy (customers), rules that treat some of them differently (groups), sometimes more than one storefront on the same installation (multistore), several languages and currencies, and static content such as delivery terms or a roast calendar (CMS pages). All of this is data and configuration that module and theme code has to respect, because almost every query in the core is filtered by customer group, shop, language or currency. This page explains the model behind each of those topics and shows how to work with it from the back office, PHP, the CQRS command bus and the Webservice. It was written against PrestaShop 9.2.0 (released 2026-09-30; Symfony 6.4.46, PHP 8.5.12 and Hummingbird 2.1.2 in the official Docker image) and checked on 2026-10-27. The data model is the same in 8.x; the places where 9.x differs are named in the text.

The running example is Pocket Coffee. The page creates a Coffee Club customer group with a 10% reduction, enrols customers into it with a small module, runs the shop as two storefronts (a Spanish shop in euros and a second shop whose default currency is the dollar), shows prices formatted for three locales, and publishes a "Roast calendar" CMS page in English and Spanish. Everything was executed in disposable PrestaShop 9.2.0 Docker shops: the customer, group, Webservice and currency examples ran in the shared test shop or in a second, throw-away installation, and the multistore examples ran only in the throw-away installation because turning multistore on changes the whole shop. The section What was run and what was not lists the exact limits. Related pages: Catalog and Pricing for specific prices and cart rules, Hooks for the hook mechanism used below, and Themes: Hummingbird and Classic for per-shop themes.

Customers

The customer record

A customer is a row of ps_customer (class Customer, an ObjectModel). The columns that matter in day-to-day work are these.

Column Meaning Notes

id_customer

Primary key.

id_shop_group, id_shop

The shop where the account was created.

With share_customer enabled on the shop group, the account is visible in every shop of the group.

id_default_group

The customer’s default group.

Drives the group reduction and the price display method, see Customer groups.

id_lang

Language of the customer, used for e-mails.

Nullable.

firstname, lastname, email

Identity.

email is unique per shop, or per shop group when customers are shared.

passwd

Password hash.

Hashed with bcrypt; the front office also stores last_passwd_gen.

id_gender, birthday

Social title and date of birth.

Optional, controlled by the registration form settings.

newsletter, optin, newsletter_date_add, ip_registration_newsletter

Newsletter and partner offers consent.

The psgdpr, ps_emailsubscription and marketing modules read them.

company, siret, ape, website

B2B identity.

Shown only when the B2B mode is enabled in Customer Settings.

outstanding_allow_amount, max_payment_days, id_risk, show_public_prices

B2B payment terms and risk level.

Used by the "Improved B2B" flag introduced in 9.2.

is_guest

Guest account, created by guest checkout, without a password the customer knows.

A guest can later be turned into a registered customer.

active, deleted

Account enabled, account soft-deleted.

Deleting a customer in the back office sets deleted=1 unless the full deletion method is chosen.

note

Private note for the merchant.

secure_key

Per-customer key used in URLs and payment return pages.

Read-only through the Webservice.

reset_password_token, reset_password_validity

Password recovery.

The default test shop contains two customers created by the installer and its bundled modules: the demo customer pub@prestashop.com and an anonymous placeholder customer anonymous@psgdpr.com created by the psgdpr module, which replaces the personal data of customers who ask to be forgotten.

Registration, guests and required fields

The front office has three ways to create a customer, and they all end up in CustomerPersister (classes/form/CustomerPersister.php):

  • Registration page (/registration), the form of the customer_form object. The form fields come from CustomerFormatter and can be extended by modules with the additionalCustomerFormFields hook and validated with validateCustomerFormFields.

  • Checkout, personal information step. The same form, optionally with a password. If PS_GUEST_CHECKOUT_ENABLED is 1 (it is 1 after a fresh install) the visitor can leave the password empty; the account is then created with is_guest=1 and placed in the Guest group.

  • Back office, Customers > Customers > Add new customer, which runs AddCustomerCommand.

A guest can become a registered customer afterwards: the front office offers "Create an account" on the order confirmation page, and the back office has the TransformGuestToCustomerCommand. The Customer Settings page (Shop Parameters > Customer Settings, route admin_customer_preferences at /configure/shop/customer-preferences/) holds the switches that change this flow.

Configuration key Value after install Effect

PS_GUEST_CHECKOUT_ENABLED

1

Allow checkout without creating an account.

PS_CUSTOMER_CREATION_EMAIL

1

Send the "account created" e-mail.

PS_CUSTOMER_OPTIN

1

Show the partner offers (opt-in) checkbox.

PS_PASSWD_TIME_FRONT

360

Minutes a customer must wait before asking for another password reset e-mail.

PS_UNIDENTIFIED_GROUP, PS_GUEST_GROUP, PS_CUSTOMER_GROUP

1, 2, 3

Group ids of visitors, guests and registered customers.

Required fields of the customer form are set in the back office (Customer Settings) through SetRequiredFieldsForCustomerCommand and read with GetRequiredFieldsForCustomer. They are stored in the ps_required_field table, which ObjectModel reads when it validates an object (the code path is getFieldsRequiredDatabase() in ObjectModel), so the rule applies to customers created from PHP and from the Webservice as well, not only to the front form. This was read from the source, not run.

Hooks around customers

Hook Type When it fires

additionalCustomerFormFields

action

The registration form is built; a module returns extra FormField objects.

validateCustomerFormFields

action

The form is submitted; a module validates the fields it added.

actionSubmitAccountBefore

action

Before the account is created in RegistrationController or the checkout step.

actionCustomerAccountAdd

action

The account was created from the front office. Parameter newCustomer is the Customer.

actionCustomerAccountUpdate

action

The customer edited the account in the front office.

actionAuthenticationBefore, actionAuthentication

action

Before and after a successful login.

actionCustomerLogoutBefore, actionCustomerLogoutAfter

action

Logout.

actionCustomerAddGroups, actionCustomerBeforeUpdateGroup

action

Group membership changes in Customer.

actionObjectCustomerAddAfter, actionObjectCustomerUpdateAfter, actionObjectCustomerDeleteAfter

action

Generic ObjectModel hooks: they also fire for customers created in the back office, from the Webservice and from PHP.

actionCustomerAccountAdd only fires for accounts created through CustomerPersister. A module that must react to every new customer, whoever creates it, uses the actionObjectCustomerAddAfter hook instead. The module in Enrolling customers in the Coffee Club uses the front-office hook on purpose, because the club is a sign-up perk.

Creating a customer with the CQRS command bus

Since 1.7.7 the back office creates customers by dispatching AddCustomerCommand on the command bus. Code that needs the same behaviour (validation, the "account created" e-mail rules, group handling) can dispatch the same command. In PrestaShop 9 the bus is PrestaShopBundle\CommandBus\MessengerCommandBus, built on Symfony Messenger.

<?php

use PrestaShop\Decimal\DecimalNumber;
use PrestaShop\PrestaShop\Core\Domain\Customer\Command\AddCustomerCommand;
use PrestaShop\PrestaShop\Core\Domain\Customer\Group\Command\AddCustomerGroupCommand;
use PrestaShop\PrestaShop\Core\Domain\Customer\Group\Query\GetCustomerGroupForEditing;

// $bus is the 'prestashop.core.command_bus' service (injected in a module service or controller)
$groupId = $bus->handle(new AddCustomerGroupCommand(
    [1 => 'Coffee Club', 2 => 'Club del Café'],   // localized names, indexed by language id
    new DecimalNumber('10'),                         // reduction in percent
    false,                                           // false: prices shown tax included
    true,                                            // show prices
    [1]                                              // shop ids
));

$customerId = $bus->handle(new AddCustomerCommand(
    'Ada', 'Lovelace', 'ada@example.test', 'a-long-random-password',
    3,                                  // default group id (Customer)
    [3, $groupId->getValue()],          // group ids the customer belongs to
    1                                   // shop id
));

$group = $bus->handle(new GetCustomerGroupForEditing($groupId->getValue()));
echo $group->getReduction();            // 10

This sequence was run in a PrestaShop 9.2.0 container through a small script that boots AdminKernel, using a prepared language context (the console and plain PHP scripts do not set one up, see the pitfalls table). It returned group id 5 and customer id 5, the group read back as reduction 10 with the names in both languages, and the customer belonged to groups 3 and 5. The constructor signatures printed by reflection are the ones in the listing: AddCustomerCommand takes firstName, lastName, email, password, defaultGroupId, groupIds, shopId and the optional genderId, isEnabled, isPartnerOffersSubscribed, birthday, isGuest; AddCustomerGroupCommand takes localizedNames, reductionPercent, displayPriceTaxExcluded, showPrice and shopIds. Passwords given to the command are hashed by the handler.

Customer groups

The built-in groups

A fresh install creates three groups. Their ids are stored in configuration so modules do not hard-code them.

Id Name Configuration key Who is in it

1

Visitor

PS_UNIDENTIFIED_GROUP

Anyone who is not logged in. Never stored in ps_customer_group.

2

Guest

PS_GUEST_GROUP

Customers created by guest checkout (is_guest=1).

3

Customer

PS_CUSTOMER_GROUP

Registered customers. New accounts get it as default group.

The Webservice lists them with their translated names, for example Visitor/Visitante, Guest/Invitado and Customer/Cliente in the test shop that has English and Spanish installed.

What a group controls

A group is a row of ps_group plus ps_group_lang (the name, one row per language) and ps_group_shop (the shops where the group exists). The row itself holds only three settings:

Field Type Meaning

reduction

decimal(5,2)

A percentage taken off every price shown to a customer whose default group this is.

price_display_method

tinyint

0 shows prices tax included, 1 tax excluded (constants Group::PRICE_DISPLAY_METHOD_TAX_INCL and _TAX_EXCL).

show_prices

tinyint

0 hides prices (catalog mode for the group).

Everything else that depends on a group lives in other tables and reads the customer’s membership in ps_customer_group:

Customers, groups and the features that read the group: ps_customer has a default group and many memberships in ps_customer_group; the default group feeds the price reduction and tax display, while membership feeds category visibility, carriers, payment modules, cart rules and specific prices
  • ps_category_group: which groups can browse a category (set in the category form).

  • ps_carrier_group: which groups can choose a carrier.

  • ps_module_group: which groups see a payment module (Payment > Preferences > Group restrictions).

  • ps_cart_rule_group: cart rules that apply only to some groups.

  • ps_specific_price.id_group: a price that applies only to one group, or 0 for all.

  • ps_group_reduction: a reduction per group and per category (see below).

How the reduction is computed

The price code in Product::priceCalculation() applies the group reduction last, after the specific price. It first asks GroupReduction::getValueForProduct() for a reduction defined for the product’s category and the customer’s default group. If there is one, that value is used; otherwise the global reduction of the group is used. A category reduction therefore replaces the global one for those products, it does not add to it.

The rule was exercised on the demo product 1 (price 23.90, with a built-in 20% specific price, so the unit price is 19.12 tax excluded) for a customer in a second installation:

Situation Group reduction Price (tax excluded)

Default group Customer (3), also a member of the Coffee Club (group 4)

0.00

19.12

Default group switched to the Coffee Club (reduction 10)

10.00

17.208

Coffee Club default, plus a 20% reduction for the product’s category

category 20%

15.296

Default group restored to Customer

0.00

19.12

Membership alone does not give the global reduction. A customer who is a member of the Coffee Club but keeps Customer as default group pays the full price; Group::getReduction($idCustomer) returned 0.00 in that state. This is the most common surprise when a module "adds the customer to the group" and the discount does not appear: either also change the default group, or give the club its benefit through a cart rule or a specific price restricted to the group, both of which look at the membership.

Prices are cached per request: after changing a group, call Product::flushPriceCache(), Group::clearCachedValues() and GroupReduction::resetStaticCache() in scripts and tests, or the old value is returned.

Creating the Coffee Club through the Webservice

The legacy Webservice also exposes groups and customers. The key used below is read from an environment variable and was created in the back office or with WebserviceKey, see Webservice API.

cat > group.xml <<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<prestashop xmlns:xlink="http://www.w3.org/1999/xlink">
  <group>
    <reduction>10</reduction>
    <price_display_method>0</price_display_method>
    <show_prices>1</show_prices>
    <name>
      <language id="1">Coffee Club</language>
      <language id="2">Club del Café</language>
    </name>
  </group>
</prestashop>
XML

curl -s -u "${PS_WS_KEY}:" -X POST --data-binary @group.xml http://localhost:8080/api/groups

The request returned 201 Created and the new group with id 4 (the test copy of the group was named with a PCFG prefix so that it could be found and removed afterwards). Creating a customer needs the associations block to choose the groups, and the default group is id_default_group:

<prestashop xmlns:xlink="http://www.w3.org/1999/xlink">
  <customer>
    <id_default_group>4</id_default_group>
    <id_lang>2</id_lang>
    <passwd>a-long-random-password</passwd>
    <lastname>Lovelace</lastname>
    <firstname>Ada</firstname>
    <email>ada@example.test</email>
    <active>1</active>
    <associations>
      <groups>
        <group><id>3</id></group>
        <group><id>4</id></group>
      </groups>
    </associations>
  </customer>
</prestashop>

The POST returned 201 Created (the test copy used pcfg-ada@example.test) with customer id 3, id_default_group 4 and both group associations. The passwd field was sent in clear text and came back as a bcrypt hash: the Webservice hashes it (setWsPasswd), even though the synopsis schema labels the field isHashedPassword. Reading back with a filter and JSON output:

curl -s -g -u "${PS_WS_KEY}:" \
  'http://localhost:8080/api/customers?display=[id,firstname,email]&filter[email]=[ada@example.test]&output_format=JSON'
# {"customers":[{"id":3,"firstname":"Ada","email":"ada@example.test"}]}

Enrolling customers in the Coffee Club

The Coffee Club perk is "members get 10% off beans". This module creates the group when it is installed and adds every customer who ticks the newsletter box while registering. It hooks actionCustomerAccountAdd because the club is a sign-up perk; swap the hook for actionObjectCustomerAddAfter to enrol customers created elsewhere as well.

<?php

declare(strict_types=1);

if (!defined('_PS_VERSION_')) {
    exit;
}

class Pcfgclub extends Module
{
    private const CONFIG_GROUP_ID = 'PCFGCLUB_GROUP_ID';

    public function __construct()
    {
        $this->name = 'pcfgclub';
        $this->tab = 'pricing_promotion';
        $this->version = '1.0.0';
        $this->author = 'Pocket Coffee';
        $this->need_instance = 0;
        $this->ps_versions_compliancy = ['min' => '9.0.0', 'max' => '9.99.99'];
        $this->bootstrap = true;

        parent::__construct();

        $this->displayName = $this->trans('Coffee Club enrolment', [], 'Modules.Pcfgclub.Admin');
        $this->description = $this->trans('Adds new subscribers to the Coffee Club customer group.', [], 'Modules.Pcfgclub.Admin');
    }

    public function isUsingNewTranslationSystem(): bool
    {
        return true;
    }

    public function install(): bool
    {
        return parent::install()
            && $this->createClubGroup()
            && $this->registerHook('actionCustomerAccountAdd');
    }

    public function uninstall(): bool
    {
        $groupId = (int) Configuration::get(self::CONFIG_GROUP_ID);
        if ($groupId > 0) {
            (new Group($groupId))->delete();
        }

        return Configuration::deleteByName(self::CONFIG_GROUP_ID) && parent::uninstall();
    }

    private function createClubGroup(): bool
    {
        $group = new Group();
        $group->reduction = 10;
        $group->price_display_method = 0; // 0 = tax included, 1 = tax excluded
        $group->show_prices = true;
        foreach (Language::getIDs(false) as $idLang) {
            $group->name[$idLang] = 'Coffee Club';
        }

        return $group->add() && Configuration::updateValue(self::CONFIG_GROUP_ID, (int) $group->id);
    }

    /**
     * @param array{newCustomer: Customer} $params
     */
    public function hookActionCustomerAccountAdd(array $params): void
    {
        $customer = $params['newCustomer'] ?? null;
        $groupId = (int) Configuration::get(self::CONFIG_GROUP_ID);

        if (!$customer instanceof Customer || $groupId <= 0 || !$customer->newsletter) {
            return;
        }

        $customer->addGroups([$groupId]);
    }
}

The module was installed with bin/console prestashop:module install pcfgclub (the test copy named the group PCFG Coffee Club), php -l reported no syntax errors, and PHP CS Fixer 3.95.27 with the PrestaShop coding standard reported nothing for the class. A real registration was then submitted to the front office (POST /registration with the form fields firstname, lastname, email, password, newsletter=1, customer_privacy=1, psgdpr=1, submitCreate=1): the HTTP answer was a redirect to the home page and the database showed the new customer in groups 3 and 4 with id_default_group 3. The group exists, the membership is there, and the reduction still needs the default group or a group-restricted cart rule, as explained above. Translation domains follow Modules.Pcfgclub.Admin; see Module Translations.

Multistore

The model

Multistore lets one installation serve several storefronts that share a catalog and a back office. There are three levels:

  • the installation, which holds global configuration (the "all shops" context);

  • shop groups (ps_shop_group), each with three sharing flags: share_customer, share_order and share_stock;

  • shops (ps_shop), each belonging to one group, with a root category (id_category), a theme_name and one or more URLs in ps_shop_url (domain, domain_ssl, physical_uri, virtual_uri, main, active).

Multistore model: shop groups contain shops; each shop has URLs, a theme, a default language and currency; configuration values resolve from the shop row, then the shop group row, then the global row

The feature is off after install: Shop::isFeatureActive() is false and the database has one group ("Default") and one shop. It is switched on in the back office (Shop Parameters > General > Enable multistore), by the configuration key PS_MULTISHOP_FEATURE_ACTIVE, or in code by the multistore feature adapter prestashop.adapter.feature.multistore (enable(), disable(), isActive() for "enabled" and isUsed() for "enabled and at least two active shops"). Once enabled, Advanced Parameters > Multistore appears, and a context selector at the top of every back office page chooses whether you are editing all shops, one group or one shop.

Context Constant Value stored for it

Single shop

Shop::CONTEXT_SHOP (1)

A row with id_shop and id_shop_group.

Shop group

Shop::CONTEXT_GROUP (2)

A row with id_shop_group and id_shop NULL.

All shops

Shop::CONTEXT_ALL (4)

A row with both NULL.

Creating a second shop

The following script was run in a throw-away PrestaShop 9.2.0 installation. It enables the feature, creates a shop group, a shop with its URL and copies the catalog data from shop 1. The back office does the same thing when you submit Advanced Parameters > Multistore > Add a new shop and tick the data to import.

<?php

require '/var/www/html/config/config.inc.php';

Configuration::updateGlobalValue('PS_MULTISHOP_FEATURE_ACTIVE', 1);

$group = new ShopGroup();
$group->name = 'Pocket Coffee EU';
$group->share_customer = 0;
$group->share_order = 0;
$group->share_stock = 0;
$group->active = 1;
$group->add();

$shop = new Shop();
$shop->name = 'Pocket Coffee Spain';
$shop->id_shop_group = $group->id;
$shop->id_category = 2;                // root category of the shop (Home)
$shop->theme_name = 'hummingbird';
$shop->active = 1;
$shop->add();

$url = new ShopUrl();
$url->id_shop = $shop->id;
$url->domain = 'shop-es.example.test';
$url->domain_ssl = 'shop-es.example.test';
$url->physical_uri = '/';
$url->virtual_uri = '';
$url->main = 1;
$url->active = 1;
$url->add();

// copyShopData() reads the "categoryBox" request value, as the back office form does
$_POST['categoryBox'] = [2];
$shop->copyShopData(1, [
    'lang' => true, 'currency' => true, 'country' => true, 'zone' => true, 'group' => true,
    'tax_rules_group' => true, 'carrier' => true, 'cms' => true, 'contact' => true,
    'module' => true, 'hook_module' => true, 'image' => true, 'product' => true,
    'product_attribute' => true, 'stock_available' => true, 'attribute' => true,
    'attribute_group' => true, 'feature' => true, 'manufacturer' => true, 'supplier' => true,
]);

In the test installation the second shop used the domain 127.0.0.1:8091 next to localhost:8091 for shop 1, which is a convenient way to have two hostnames on one machine. In production, each shop needs its own domain or a virtual_uri (a path such as /es/), and the web server must answer for the domain. A first attempt that imported only a subset of the tables ended in a fatal TypeError on the home page of the new shop (a product price was null in the price formatter). The import list above is the one that produced a working second storefront, including product_attribute, stock_available, attribute, attribute_group and image; the exact missing row was not isolated. When you create shops from code, import every table the pages read, or rebuild the *_shop associations yourself. A call to copyShopData() outside the back office also needs the categoryBox request value, otherwise it fails with an error on a null value.

Configuration values per context

Configuration::get() and Configuration::updateValue() take optional shop group and shop ids. The documentation advises against passing explicit ids and to rely on the current context instead, except for updateGlobalValue(), which writes the "all shops" row. Two details of the signature updateValue($key, $values, $html = false, $idShopGroup = null, $idShop = null) are worth knowing, and both were checked by running them:

  • updateGlobalValue() is updateValue($key, $values, $html, 0, 0).

  • A null shop id is replaced by the context shop, not by "no shop". Passing only a group id while the context is a single shop wrote a row for that shop and group. To write a group-level value pass 0 as the shop id.

The script below writes three values for the same key and reads them back in each context.

<?php

Configuration::updateGlobalValue('PCFG_GREETING', 'Welcome to Pocket Coffee');             // all shops
Configuration::updateValue('PCFG_GREETING', 'Pocket Coffee EU welcome', false, 2, 0);       // group 2
Configuration::updateValue('PCFG_GREETING', 'Hola desde la tienda de España', false, 2, 2); // group 2, shop 2

Shop::setContext(Shop::CONTEXT_ALL);
echo Configuration::get('PCFG_GREETING');   // Welcome to Pocket Coffee
Shop::setContext(Shop::CONTEXT_GROUP, 2);
echo Configuration::get('PCFG_GREETING');   // Pocket Coffee EU welcome
Shop::setContext(Shop::CONTEXT_SHOP, 1);
echo Configuration::get('PCFG_GREETING');   // Welcome to Pocket Coffee  (shop 1 has no own row: falls back)
Shop::setContext(Shop::CONTEXT_SHOP, 2);
echo Configuration::get('PCFG_GREETING');   // Hola desde la tienda de España

The rows in ps_configuration were (NULL, NULL), (group 2, NULL) and (group 2, shop 2), and Shop::getContextListShopID() returned both shops in the "all" context and only shop 2 in the context of group 2. The lookup order, from the most specific context to the most general one, is shown in the diagram. Setting a value in a parent context does not overwrite values already stored for its children.

flowchart TD A["Configuration::get('PCFG_GREETING') in shop context 2"] --> B{"Row with id_shop = 2?"} B -- yes --> R["Return the shop value"] B -- no --> C{"Row with id_shop_group = 2 and id_shop NULL?"} C -- yes --> R2["Return the group value"] C -- no --> D{"Row with both NULL?"} D -- yes --> R3["Return the global value"] D -- no --> E["Return the default (false)"]

In Symfony code the same lookup goes through the legacy configuration adapter prestashop.adapter.legacy.configuration with a ShopConstraint value object (new ShopConstraint($shopId, $shopGroupId)), and settings forms become multistore-aware by implementing getParent() with MultistoreConfigurationType and giving each field a multistore_configuration_key option. The shop context adapter prestashop.adapter.shop.context answers isSingleShopContext(), isShopGroupContext(), isAllContext(), getContextShopID() and getContextListShopID().

What differs per shop

In the test installation the two shops were given different settings and the home pages were fetched from each hostname:

Setting Shop 1 (localhost:8091) Shop 2 (127.0.0.1:8091)

ps_shop.theme_name

hummingbird

pcfgcoffee, a child theme

PS_LANG_DEFAULT

1 (English)

2 (Spanish)

PS_CURRENCY_DEFAULT

1 (EUR)

2 (USD)

<html lang> and <body> class

en-US, lang-en currency-eur

es-ES, lang-es currency-usd

Stylesheets

hummingbird/assets/css/theme.css

the same file plus the child theme’s pocket-coffee.css

Price in the product list

€23.90

23,90 $

The last row is an important detail: changing the default currency of a shop re-labels the amounts, it does not convert them. The product price column holds 23.90 in the shop’s default currency, so shop 2 showed 23,90 $ for a product that costs 23.90 euros in shop 1. Converting amounts is the job of the conversion_rate of the secondary currencies, which applies to a currency that is not the default one. When a store changes its default currency, the catalog prices must be re-imported or converted by the merchant.

Customer sharing was tested with the same two shops. A customer created in shop 1 was looked up with Customer::customerExists($email, true, true) in each shop context:

Shop group setting Found in shop 1 Found in shop 2

share_customer=0, shops in different groups

yes

no, the e-mail can register again

share_customer=1, both shops in the same group

yes

yes

share_order and share_stock follow the same rule for order history and for stock levels. They cannot be changed once orders or stock exist without consequences, so decide them before the shops go live.

Writing multistore-aware SQL

Tables that hold shop data come in pairs, for example product and product_shop, and language tables carry an id_shop column when texts differ per shop (cms_lang has id_shop). The Shop class generates the joins and restrictions:

<?php

// A table that exists in every shop: join its *_shop association table
$sql = 'SELECT wp.`id_product`
        FROM `' . _DB_PREFIX_ . 'wishlist_product` wp
        LEFT JOIN `' . _DB_PREFIX_ . 'product` p ON p.`id_product` = wp.`id_product`
        ' . Shop::addSqlAssociation('product', 'p');

// A table that exists only in some shops: restrict by shop (or by group with $share = true)
$sql = 'SELECT date_add, COUNT(`date_add`) AS total FROM `' . _DB_PREFIX_ . 'connections` WHERE 1 ' . Shop::addSqlRestriction();

// A language table that differs per shop
$sql = '... LEFT JOIN `' . _DB_PREFIX_ . 'product_lang` pl ON pl.`id_product` = wp.`id_product`' . Shop::addSqlRestrictionOnLang('pl');

Module settings stored with Configuration are multistore-ready by default. Files uploaded per shop use the -g<groupId> and -s<shopId> filename suffixes, as the documentation describes with the advertising module example.

Webservice and Admin API in multistore

With multistore active the Webservice accepts id_shop and id_group_shop query parameters, and a key is valid only for the shops it is associated with (ps_webservice_account_shop). In the test installation a key associated with shop 1 only got {"errors":[{"code":132,"message":"No permission for this key on this shop"}]} for id_shop=2 and for id_group_shop=2; after the association was added, GET /api/products?id_shop=2 returned the products, /api/shops listed both shops with their theme_name, and /api/shop_urls listed both URLs. An id_shop that does not exist produced a redirect to the shop home page rather than a Webservice error. The configurations resource lists the rows of all contexts together (the global row and the shop row of PS_LANG_DEFAULT appeared in one list). Details are in Webservice API.

The Admin API handles multistore differently: every request must carry exactly one of shopId, shopGroupId, shopIds or allShops, otherwise it returns 400 Bad Request, and the support is gated behind the feature flag admin_api_multistore, which is disabled by default in 9.2.0. That behaviour is from the documentation and was not run.

Languages

The language record

A language is a row of ps_lang with an iso_code (es), a language_code (es-es), a locale (es-ES), the date formats date_format_lite and date_format_full, an is_rtl flag and an active flag. The association table ps_lang_shop says which shops use it. The locale is what the CLDR-based formatting and the Symfony translator use; the language_code is what appears in URLs and in the lang attribute of older code paths.

In the test shop after the Spanish pack was added:

id_lang iso_code locale language_code active date_format_lite

1

en

en-US

en-us

1

m/d/Y

2

es

es-ES

es-es

1

d/m/Y

Languages are managed in International > Localization > Languages. Adding a language downloads the language pack (translation catalogs for the core, modules and the default theme) from the PrestaShop translation service and creates the multilingual rows (*_lang tables) for existing content, copying the default-language texts into the new language until someone translates them. The same operation exists as the AddLanguageCommand (name, ISO code, IETF tag, date formats, flag and "no picture" image paths, RTL flag, active flag, shop association) and as the legacy Language::downloadAndInstallLanguagePack($isoCode, $version, $params, $installModuleTranslations). The legacy call needs a booted Symfony kernel: in a plain PHP script it created the es row and the *_lang rows (19 product translations) and then died on a missing container (Call to a member function get() on null), so run it with $GLOBALS['kernel'] set or from the back office.

Multilingual data

Content that exists in several languages uses a *_lang table keyed by (id, id_lang) or (id, id_lang, id_shop). In an ObjectModel the fields flagged 'lang' => true are arrays indexed by language id. The Webservice returns them as an array of {id, value} objects, or as a single value when the request is limited to one language (?language=2), and accepts <language id="2"> children on write. The same structure appears in the Pocket Coffee group example above.

Front-office URLs carry the language when more than one language is active and friendly URLs are on: the Spanish roast calendar page is /es/content/6-calendario-de-tueste, the English one /content/6-roast-calendar. The default language has no prefix. In a multistore installation a shop can have a different default language (PS_LANG_DEFAULT per shop), as the table in Multistore shows.

Locale-aware formatting

Number, date and currency formatting comes from the CLDR data bundled with the core and is exposed through locale objects. The repository service is prestashop.core.localization.locale.repository. This output comes from the test shop:

<?php

$repo = $container->get('prestashop.core.localization.locale.repository');
foreach (['en-US', 'es-ES', 'de-DE', 'fr-FR'] as $code) {
    $locale = $repo->getLocale($code);
    echo $code, ' ', $locale->formatPrice(1234.5, 'EUR'), ' ', $locale->formatPrice(29.9, 'USD'), ' ', $locale->formatNumber(1234.5), PHP_EOL;
}
Locale formatPrice(1234.5, 'EUR') formatPrice(29.9, 'USD') formatNumber(1234.5)

en-US

€1,234.50

$29.90

1,234.5

es-ES

1.234,50 €

29,90 $

1.234,5

de-DE

1.234,50 €

29,90 $

1.234,5

fr-FR

1 234,50 €

29,90 $

1 234,5

Templates never format prices by hand: Smarty and Twig use the price formatter and the $currency and $language context. Themes translate their own wordings with the {l} function, covered in Themes: Hummingbird and Classic and Module Translations.

Currencies

The currency record

A currency is a row of ps_currency (iso_code, numeric_iso_code, precision, conversion_rate, active, deleted, unofficial, modified), a localized name, symbol and pattern per language in ps_currency_lang, and the shops where it is available in ps_currency_shop (which carries its own conversion_rate). The test shop had EUR (rate 1.000000, the default) and USD (rate 1.117294, fetched at install time).

Concept Behaviour

Default currency (PS_CURRENCY_DEFAULT)

The currency in which catalog prices are stored. The conversion rate of the default currency is 1.

Secondary currency

price_in_currency = price_in_default * conversion_rate. The customer picks it with the currency selector.

Official currency

Created from a CLDR ISO code: name, symbol, numeric code and precision come from the bundled data (AddCurrencyCommand).

Unofficial currency

A merchant-defined currency such as a loyalty-point unit (AddUnofficialCurrencyCommand), with its own name, symbol and precision.

Automatic exchange rates

A cron or the "Update exchange rates" action refreshes official rates (RefreshExchangeRatesCommand, ToggleExchangeRateAutomatizationCommand).

Per-module and per-country restrictions

ps_module_currency restricts payment modules by currency; ps_country ties a default currency to a country.

Adding GBP and converting 10 euros ran like this in the throw-away installation:

<?php

use PrestaShop\PrestaShop\Core\Domain\Currency\Command\AddCurrencyCommand;

$currencyId = $bus->handle(new AddCurrencyCommand('GBP', 0.86, true));   // iso code, exchange rate, enabled
$currency = new Currency($currencyId->getValue());

echo $currency->numeric_iso_code;                               // 826
echo $currency->precision;                                       // 2
echo Tools::convertPrice(10, $currencyId->getValue());         // 8.6   (from the default currency)
echo Tools::convertPrice(8.6, $currencyId->getValue(), false); // 10    (back to the default currency)
echo $repo->getLocale('en-GB')->formatPrice(8.6, 'GBP');        // £8.60

The numeric code and precision were filled from the bundled CLDR data without any extra input, and the currency was then removed with DeleteCurrencyCommand. The other domain commands are EditCurrencyCommand, EditUnofficialCurrencyCommand, ToggleCurrencyStatusCommand, BulkToggleCurrenciesStatusCommand, BulkDeleteCurrenciesCommand, and the queries GetCurrencyForEditing, GetCurrencyExchangeRate and GetReferenceCurrency. The back office manages currencies in International > Localization > Currencies (route admin_currencies_index), and the Webservice exposes them as the currencies resource.

CMS pages

Pages and categories

CMS pages are the static content of the store: delivery terms, legal notice, "About us". A page is a row of ps_cms (id_cms_category, position, active, indexation) with texts in ps_cms_lang (meta_title, head_seo_title, meta_description, content, link_rewrite per language and shop), and it belongs to a category in ps_cms_category (a tree, with ps_cms_category_lang). Association with shops is in ps_cms_shop. The installer creates five pages: Delivery, Legal Notice, Terms and conditions of use, About us and Secure payment.

The back office manages them in Design > Pages (route admin_cms_pages_index). The themes render them with templates/cms/page.tpl and templates/cms/category.tpl, and the sitemap page and the footer link list modules read the same tables. The Webservice exposes them as content_management_system.

Creating a page from code

This script ran against the throw-away installation with English and Spanish installed. It creates a category and the Pocket Coffee "Roast calendar" page and prints the URLs.

<?php

use PrestaShop\PrestaShop\Core\Domain\CmsPage\Command\AddCmsPageCommand;
use PrestaShop\PrestaShop\Core\Domain\CmsPageCategory\Command\AddCmsPageCategoryCommand;

$categoryCommand = new AddCmsPageCategoryCommand(
    [1 => 'Pocket Coffee guides', 2 => 'Guías de Pocket Coffee'],        // names
    [1 => 'pocket-coffee-guides', 2 => 'guias-pocket-coffee'],           // friendly URLs
    1,                                                                    // parent category id (Home)
    true                                                                  // displayed
);
$categoryCommand->setLocalisedDescription([1 => '', 2 => '']);          // required by the handler
$categoryCommand->setLocalisedMetaTitle([1 => '', 2 => '']);
$categoryCommand->setLocalisedMetaDescription([1 => '', 2 => '']);
$categoryCommand->setShopAssociation([1]);
$categoryId = $bus->handle($categoryCommand);

$pageId = $bus->handle(new AddCmsPageCommand(
    $categoryId->getValue(),
    [1 => 'Roast calendar', 2 => 'Calendario de tueste'],                                        // titles
    [1 => 'Roast calendar | Pocket Coffee', 2 => 'Calendario de tueste | Pocket Coffee'],        // meta titles
    [1 => 'When we roast each origin.', 2 => 'Cuándo tostamos cada origen.'],                    // meta descriptions
    [1 => 'roast-calendar', 2 => 'calendario-de-tueste'],                                         // friendly URLs
    [1 => '<p>Ethiopia on Mondays, Colombia on Wednesdays, Brazil on Fridays.</p>',
     2 => '<p>Etiopía los lunes, Colombia los miércoles, Brasil los viernes.</p>'],               // content
    true,                                                                                         // indexed by search engines
    true,                                                                                         // displayed
    [1]                                                                                           // shop ids
));
echo $link->getCMSLink($pageId->getValue(), null, null, 2, 1);

The page got id 6 (the installer creates 1 to 5) and the two URLs printed were http://localhost:8091/content/6-roast-calendar and http://localhost:8091/es/content/6-calendario-de-tueste. Fetching them returned HTTP 200, the titles Roast calendar | Pocket Coffee and Calendario de tueste | Pocket Coffee, the body class layout-full-width page-cms cms-id-6 and the translated content. With multistore active, ps_cms_lang held one row per language and shop (four rows for the page) but ps_cms_shop contained only shop 1, the shop given in the command: the page is associated with shop 1 only.

AddCmsPageCategoryCommand is the one command in this page whose constructor is not enough: the handler asserts that the description, meta title and meta description arrays are present, and without the three setters it throws a TypeError on a null argument. The page-level listing and form hooks are actionCmsPageGridDefinitionModifier, actionCmsPageGridQueryBuilderModifier, actionCmsPageGridDataModifier, actionCmsPageGridFilterFormModifier and actionCmsPageGridPresenterModifier, with the same set for categories.

What was run and what was not

  • Run in the shared PrestaShop 9.2.0 test shop: Webservice group and customer creation with XML, the read filters, languages and JSON output, the custom roast_batches resource used on the Webservice page, php -l and PHP CS Fixer on the modules.

  • Run in a second, disposable 9.2.0 installation: the CQRS group and customer commands, the front-office registration and the pcfgclub hook, the group reduction calculations, the currency commands and locale formatting, the CMS commands and URLs, the complete multistore sequence (feature switch, group, shop, URL, data copy, configuration contexts, per-shop theme, language and currency, customer sharing, Webservice with id_shop), and the theme tests mentioned in the Themes page.

  • Not run: the Admin API in multistore mode and the admin_api_multistore flag, the Improved B2B flag, automatic exchange-rate refresh, a shop on a real second domain with HTTPS, the language pack download for languages other than Spanish, Ask AI and the One Page Checkout interaction with customer forms.

  • Behaviour taken from the code, not from a guide: the global group reduction follows only the default group of the customer, and a category reduction replaces it. The numbers in the reduction table come from running the price code.

Pitfalls

Pitfall What happens and what to do

Adding a customer to a group does not apply the group reduction.

The global reduction reads id_default_group. Change the default group, or restrict a cart rule or a specific price to the group.

Group reduction and category reduction are added in the mind but not in the code.

A category reduction replaces the global group reduction for products of that category.

Price changes are not visible in a script.

Product::flushPriceCache(), Group::clearCachedValues() and GroupReduction::resetStaticCache() clear the in-process caches.

getPriceStatic() throws "If no employee is assigned in the context, cart ID must be provided".

In a CLI script set Context::getContext()->employee or pass a cart id.

Configuration::updateValue($key, $v, false, $groupId) writes a shop row.

The null shop id is replaced by the context shop. Use 0 as the shop id for a group-level value.

Console commands and scripts fail with "Cannot build Language context".

Commands that do not prepare the language context fail when they reach code that needs it; prestashop:theme:enable failed in 9.2.0 when the activation had to change a module’s state. Call ContextBuilderPreparer::prepareLanguageId() in scripts, or toggle the module first with prestashop:module.

A new shop shows a fatal error on the home page.

Required *_shop association rows are missing. Import the product, attribute and stock tables when creating the shop.

Changing the default currency "changes the price".

It re-labels the stored amounts. Convert the catalog separately.

A customer cannot register in the second shop with the same e-mail.

share_customer=1 on the group makes the e-mail unique across its shops.

AddCmsPageCategoryCommand throws a TypeError.

Call the three setLocalised... setters before dispatching.

A password is sent to the Webservice in clear text.

The server hashes the value, but the request carries it as is: use HTTPS only.