How does translation scope work in Magento 2?

Does this mean there is no such thing as a module scope anymore?

Yes

If so, what happens if two different modules define different translations for one string?

Magento 2 load translation from all modules and then load from current (based on current controller). In most cases this resolve problem.

I'm thinking of ambiguous words like "state" that will be translated different depending on the context. How does Magento 2 deal with these?

Looks like is no big issue there. In any case Magento can add inline context like State (Address), State (order)


Here's what I've found, not sure if it will answer all of your questions though.

So the translation is something that's happening in the \Magento\Framework\Phrase class (an object of this class is being returned in the __ function).

If you check \Magento\Framework\Phrase\README.md, you can get interesting information regarding this class:

Class \Magento\Framework\Phrase calls renderer to make the translation of the text. Phrase provides RendererInterface and a few renderers to support different kinds of needs of translation of the text. Here are list of renderers in this library:

  • Placeholder render - it replaces placeholders with parameters for substitution. It is the default render if none is set for the Phrase.
  • Translate render - it is a base renderer that implements text translations.
  • Inline render - it adds inline translate part to text translation and returns the strings by a template.
  • Composite render - it can have several renderers, calls each renderer for processing the text. Array of renderer class names pass into composite render constructor as a parameter.

So the translation is initiated via the _initTranslate method of \Magento\Framework\App\Area.php, here a renderer is set on the Phrase class:

protected function _initTranslate()
{
    $this->_translator->loadData(null, false);

    \Magento\Framework\Phrase::setRenderer(
        $this->_objectManager->get('Magento\Framework\Phrase\RendererInterface')
    );

    return $this;
}

Looking at \Magento\Translation\etc\di.xml we can see the following preference is set:

<preference for="Magento\Framework\Phrase\RendererInterface" type="Magento\Framework\Phrase\Renderer\Composite" />

And later in the same file we find the declaration:

<type name="Magento\Framework\Phrase\Renderer\Composite">
    <arguments>
        <argument name="renderers" xsi:type="array">
            <item name="translation" xsi:type="object">Magento\Framework\Phrase\Renderer\Translate</item>
            <item name="placeholder" xsi:type="object">Magento\Framework\Phrase\Renderer\Placeholder</item>
            <item name="inline" xsi:type="object">Magento\Framework\Phrase\Renderer\Inline</item>
        </argument>
    </arguments>
</type>

So looking at the render function of this Composite class we can see that it renders the source using each argument items render function:

public function render(array $source, array $arguments = [])
{
    $result = $source;
    foreach ($this->_renderers as $render) {
        $result[] = $render->render($result, $arguments);
    }
    return end($result);
}

When we check \Magento\Framework\Phrase\Renderer\Translate.php (the first argument of the Composite class) we can see that the render function uses the TranslateInterface class (corresponds to $this->_translator in the code below) to get the corresponding translation:

public function render(array $source, array $arguments)
{
    $text = end($source);

    try {
        $data = $this->translator->getData();
    } catch (\Exception $e) {
        $this->logger->critical($e->getMessage());
        throw $e;
    }

    return array_key_exists($text, $data) ? $data[$text] : $text;
}

Remember the _initTranslate method mentionned above ? Right before setting the \Magento\Framework\Phrase renderer we have the following code:

$this->_translator->loadData(null, false);

Where $this->_translator is a \Magento\Framework\TranslateInterface

Under app/etc/di.xml we can find the following preference:

<preference for="Magento\Framework\TranslateInterface" type="Magento\Framework\Translate" />

Finally, we can find how each module translation is loaded in the loadData method of \Magento\Framework\Translate class:

public function loadData($area = null, $forceReload = false)
{
    $this->setConfig(
        ['area' => isset($area) ? $area : $this->_appState->getAreaCode()]
    );

    if (!$forceReload) {
        $this->_data = $this->_loadCache();
        if ($this->_data !== false) {
            return $this;
        }
    }
    $this->_data = [];

    $this->_loadModuleTranslation();
    $this->_loadThemeTranslation();
    $this->_loadPackTranslation();
    $this->_loadDbTranslation();

    $this->_saveCache();

    return $this;
}

Then in _loadModuleTranslation we get the current module and get the corresponding translations:

protected function _loadModuleTranslation()
{
    $currentModule = $this->getControllerModuleName();
    $allModulesExceptCurrent = array_diff($this->_moduleList->getNames(), [$currentModule]);

    $this->loadModuleTranslationByModulesList($allModulesExceptCurrent);
    $this->loadModuleTranslationByModulesList([$currentModule]);
    return $this;
}

There's probably more to say about it but I reckon that summarizes (even if my post is super long) the translation process in Magento 2.