A common way to set up a multi-store Magento installation is to have the webserver (Apache or Nginx) specify the store run code and run type based on a virtual host. Vhost matching is done on a domain and port basis, and therefore this works fine if you're running different scopes on different (sub) domains.

To run different scopes per subdirectory (or URL slug), the situation is slightly more complex. If you try to run different scopes for example.com/en and example.com/au, it can become hard to manage this through the webserver; Nginx doesn't let you set variables in a location block and managing a map becomes messy (see footnote for updated info regarding using an Nginx map). Apache also has its own issues with setting variables on a directory block (or rather, it's not really meant to be used that way).

To work around this issue, most solutions suggest copying the index.php and .htaccess file and putting it in a subdirectory of your project. (A variation of this is where you symlink the app, lib, var and vendor directories.) Along with some modifications to the two files, this solution works fine, and is well documented:

You can also use the "Add Store Code to Urls" configuration that comes with Magento out of the box. However, this setting is extremely limited; it only works with store view scopes. It also uses the store view code as the URL slug, which doesn't work if you want URLs such as shop.example.com/en, but your store view code is b2c_en. (In such cases, the URL would need to be shop.example.com/b2c_en.)

Going back to the subdirectory/symlink workaround, the suggested solutions become messier if you have "multiple dimensions" of scopes. I.e. say that I'm managing a business that does both B2C and B2B in multiple countries. I could end up with a URL structure that would require to look like this:

  • example.com/en (b2c_en scope)
  • example.com/au (b2c_au scope)
  • wholesale.example.com/en (b2b_en scope)
  • wholesale.example.com/au (b2b_au scope)
  • etc.

Using an internal rewrite

What if, without touching the web server, the PHP application could determine the correct run code from the URL, internally rewrite the request to remove the language code, and then forward the request to Magento and its front-controller as if the request came through directly? This would give us the flexibility and scale to manage complex multi-store set-ups in an elegant way.

It turns out that this is quite straightforward to do!

First we need to intercept our request and rewrite it before Magento bootstraps itself. This can be done through two ways:

  • By using the the php.ini setting auto_prepend_file, or
  • by adding our "interceptor" file to the Composer autoloader

I suggest going with the Composer autoloader method as it's easier to manage within most project.

A simple example, running a store scope from a subdirectory

Firstly, add your interceptor file (in this case app/etc/stores.php) to the files autoload section of composer.json:

{
    [...]
    "autoload": {
        "files": [
            "app/etc/stores.php",
            [...]
        ]
    }
}

Then, in app/etc/stores.php, you can set the appropriate run code and run type. The request also needs to be rewritten for the Magento front controller not to confuse the language code as a frontname.

A very simple example follows below:

<?php declare(strict_types=1);

$requestUri = $_SERVER['REQUEST_URI'];

// Determine the store view code from the URL
// (Assuming the format is "example.com/en/catalog/...")
$paths = explode('/', ltrim($requestUri, '/'));
$pathsFiltered = array_filter($paths);
$languageCode = reset($pathsFiltered);

// Place check for valid language code here. Ensure we're not accidentally
// using a frontcode (in the case of the URL being example.com/catalog/...)

// Set run code and run type
$_SERVER[\Magento\Store\Model\StoreManager::PARAM_RUN_CODE] = $languageCode;
$_SERVER[\Magento\Store\Model\StoreManager::PARAM_RUN_TYPE] = 'store';

// Add initial, untouched URL here (we will need it later, as we shall see)
$_SERVER['REQUEST_URI_INITIAL'] = $_SERVER['REQUEST_URI'] ?? null;

// Create our new request URI
// You can also use preg_replace if you want a stricter match
$newRequestUri = str_replace('/' . $languageCode, '', $requestUri);
$_SERVER['REQUEST_URI'] = $newRequestUri;

One issue remains — in the rendered HTML, Magento will now output all links without including the language code. For the purposes of generating links, we need to tell the application to use the original URL, and not the rewritten one. That's where the "REQUEST_URI_INITIAL" server variable comes in.

In your module's frontend/di.xml, add a plugin for Magento\Framework\Url.

In the plugin, create a method that intercepts getCurrentUrl, like so:

/**
 * Get original URL, before it was rewritten and stripped of the store code
 *
 * @param \Magento\Framework\Url $subject
 * @param string $result
 * @return string|string[]
 */
public function afterGetCurrentUrl($subject, $result)
{
    $target = $this->request->getServer('REQUEST_URI_INITIAL');
    if ($target) {
        $find = $this->request->getServer('REQUEST_URI');
        return str_replace($find, $target, $result);
    }
    return $result;
}

As you can see, we're simply replacing the REQUEST_URI variable with our REQUEST_URI_INITIAL variable in the return value.

At this point, the foundations are in place to run your website's multiple scopes from a subdirectory, without actually having to create directories and symlinks.

While above is the simplest example, it gives us tremendous flexibility to dynamically determine the scope run code.

A more complex example; using both subdomain and directory to determine scope

In our case, we needed "multiple scope dimensions"; the run code had to be determined by both the subdomain and subdirectory. Using the internal rewrite method, this is very achivable.

Our "interceptor" file looks something like this:

<?php declare(strict_types=1);

$store = new \Company\Multistore\Model\Store(
    \Company\Multistore\Model\Config::STORE_CODE_MAP,
    $_SERVER['HTTP_HOST'] ?? null,
    $_SERVER['REQUEST_URI'] ?? null,
    'au'
);

$_SERVER[\Magento\Store\Model\StoreManager::PARAM_RUN_CODE] = $store->getRunCode();
$_SERVER[\Magento\Store\Model\StoreManager::PARAM_RUN_TYPE] = $store->getRunType();
$_SERVER[\Company\Multistore\Model\Store::REQUEST_URI_INITIAL] = $_SERVER['REQUEST_URI'] ?? null;
$_SERVER['REQUEST_URI'] = $store->getRequestUri();

You can see that the logic to determine run code, run type and rewrite URI is abstracted to a separate class.

A constant named STORE_CODE_MAP is being passed to the constructor of that class. The constant looks something like follows:

/**
 * @var array
 *
 * The format is:
 *
 * subdomain => [
 *      directory => store_view_code
 * ]
 *
 * So `store.example.com/en` matches store view with code `b2c_en`.
 */
public const STORE_CODE_MAP = [
    'store' => [
        'en' => self::B2C_EN,
        'au' => self::B2C_AU,
        'nz' => self::B2C_NZ,
    ],
    'wholesale' => [
        'en' => self::B2B_EN
        'au' => self::B2B_AU
    ],
    'distributor' => [
        'nz' => self::B2D_NZ
    ]
];

While the custom-developed module used in above example is too large to post here, if there's enough interest I can look at open-sourcing it.

This has been running in production safely for quite some time.

Known issues

There are however some gotchas.

  • The Magento setting "Auto redirect to base URL" doesn't work
  • The ForgotPasswordPost controller will sometimes redirect the customer to the wrong store, and not show a message. It looks like this is a bug that happens due to a combination of a core Magento issue and the URL rewrite. Although it looks like this is fixed in 2.4.1+, it's something to look out for when testing.

Conclusion

We've seen that we can run multiple scopes through both subdomains and subdirectories by using an application-level rewrite.

Because scope determination is done on an application level, this approach also works great with Warden, Docker and really any combination of CI/CD and infrastructure.

As a sidenote, I think this demonstrates Magento's flexibility and strength, and its open architecture — in a world where most ecommerce SaaS platforms don't even support multiple scopes.

What approach do you use to run Magento in multi-store mode?


Update

Regarding the use of a map in Nginx, Sergii Shymko pointed out that it is still possible to use an Nginx map for both domain and subdirectory pattern matching.

It's possible to implement the dynamic store resolution from URL path via Nginx map by using a RegEx capturing group to hold the parsed store code. Complex substitution expressions, such as "b2c_$store", are supported since Nginx 1.11.2, whereas simple ones like "$store" will work even on earlier versions.

map $host$request_uri $MAGE_RUN_CODE {
    default b2c_en;
    ~^example\.com/(?<store>.*?)/ "b2c_$store";
    ~^wholesale\.example\.com/(?<store>.*?)/ "b2b_$store";
}

The RegEx can strictly list all known store codes, for instance: (?<store>en|au|nz)


Update #2

If you run MFTF, make sure to include the country code in your base URL in .env, otherwise any URL assertions will fail.

Also, Magento CLI commands are run through a proxy web script located at dev/tests/acceptance/utils/command.php. Requests will now 404 as the script cannot be located when it includes the country code, so we need to include a dot-segment to MAGENTO_CLI_COMMAND_PATH. (And yes, apparently dot-segments work in URLs the way you would expect them to for directory structures.)

Our .env file will need to look something like this:

MAGENTO_BASE_URL=https://shop.company.test/en/
MAGENTO_CLI_COMMAND_PATH=../dev/tests/acceptance/utils/command.php

[...]

magento2 multi-store scopes