Skip to content

docs(extending-modx): match plugins, snippets, REST, and lexicons to 3.x - #676

Open
Ibochkarev wants to merge 5 commits into
modxorg:3.xfrom
Ibochkarev:docs/extending-modx-i18n
Open

Ibochkarev wants to merge 5 commits into
modxorg:3.xfrom
Ibochkarev:docs/extending-modx-i18n

Conversation

@Ibochkarev

@Ibochkarev Ibochkarev commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #662, plus a fact check of the developer pages against MODX 3.x commit 313aabf8478ac2770c1090f42acdf34022291712 (22 October 2026).

Summary

English and Russian pages now describe the same runtime.

Lexicons

The placeholder example is [[!%key? &userinput=amazing]]. Without the ? the parser keeps the value inside the entry name.

Property When omitted
topic default
language the request language (cultureKey), not the column default en
namespace core. A lexicon outside the core namespace has to set it

A coordinate that includes a namespace is loaded once. A bare topic name is loaded again on the next call.

A lexicon row's language column defaults to en. The create processor does not set createdon. editedon is set when the entry is created in the manager and updated when the row is written.

getLanguageTopics() returns the topics for the page. The controller loads action, then that list, then trash. The base method returns an empty array. addLexiconTopic() adds one more topic after that.

connectors/lang.js.php reads topic, splits on commas, and loads each coordinate with modLexicon::load(). The response replaces MODx.lang.

A System Setting label is setting_<key>, and the description is setting_<key>_desc. The Settings grid loads both topics of the setting's namespace.

Plugins, snippets, and the manager

The OnPageNotFound sample only redirects. $modx->resource is not loaded on that event. Mail is created with getService('mail', \MODX\Revolution\Mail\modPHPMailer::class). The constants are on MODX\Revolution\Mail\modMail.

Inside a snippet, $this is the snippet object and $modx is in scope. A core snippet is written to cache/includes/elements/modx/revolution/modsnippet/{id}.include.cache.php. The directory follows the class name.

A manager controller class is not the file name. Namespace quip and action home load QuiphomeManagerController. Action some/action loads QuipsomeActionManagerController.

There is no per-component connector.php. A component processor is a subclass of MODX\Revolution\Processors\Processor, or runProcessor() is given processors_path. The namespace query parameter selects a manager page.

An empty session_cookie_path is replaced with base_url, then with MODX_BASE_URL if base_url is empty too.

In the Russian manager the site menu is «Содержимое». The Russian section index no longer links three items at the section root.

REST

A missing verb method returns status 405 and the message Unsupported HTTP method get. If the method runs and never calls success(), failure(), or collection(), the service sends the controller response as it stands. There is no second 405.

trimParameters does not trim the parsed body or the query string. Trim strings in the controller. See modxcms/revolution#17114

Parsing and custom users

OnParseDocument fires on every parser pass. A plugin changes the content by writing $modx->documentOutput. A change to the content argument is discarded. See modxcms/revolution#16992

An extended user is registered with a MODX\Revolution\modExtensionPackage row. Leave path empty so MODX uses the namespace directory and appends model/. table_prefix on this page is ext_. The system setting extension_packages is still read and marked deprecated. In that JSON the path already ends in model/.

Existing users keep class_key MODX\Revolution\modUser until it is changed. The phone relation on the sample schema is many, so the sample calls getMany('Phones').

Affected versions

3.x

Not checked

  • Spanish and Dutch copies of these pages are not in the repository.
  • xPDO sources are not in the tree, so getLoader(), schema extends, hydration by class_key, and getOption() on a plugin object were not checked.
  • Child pages under custom-users/, creating-components/, core-model/, and the individual system-event pages were not part of this pass. The system-event index still shows the label OnBeforeResourceGroupRemove.
  • Samples the pages themselves mark as 2.x were not compared with a 2.x tree.

@Ibochkarev
Ibochkarev requested a review from Mark-H October 20, 2026 14:27
@Mark-H

Mark-H commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

Where does this concept of manually including /connectors/lang.js.php to load extra lexicon topics from? That's the biggest issue still present. That's how core works yes but not how people extending modx should be loading their lexicons.

@Ibochkarev

Copy link
Copy Markdown
Collaborator Author

You are right, and the page no longer tells the reader to call the connector.

Where the concept comes from. connectors/lang.js.php?topic=… is the manager's own mechanism: the header of the manager page asks the connector for the topics of the page (manager/templates/default/header.tpl:23 — ?ctx=mgr&topic=topmenu,file,resource,{$_lang_topics}), and the connector reads $_REQUEST['topic'], splits it by commas and loads every coordinate with modLexicon::load(). The response replaces MODx.lang.

How a Component does it. The page declares the topics and the manager loads them:

  • a controller names them in getLanguageTopics() — core/src/Revolution/modManagerController.php:533; the core controllers override it (controllers/default/context/index.class.php:70 returns ['context']);
  • a page that needs a topic beyond those of its controller adds it with addLexiconTopic() — modManagerController.php:1027.

Both load the topic and put it into _lang_topics, which the header hands over to the connector. The order works out: prepareLanguage() runs at the start of render(), and process() runs before the header is rendered (modManagerController.php:162,180,203).

The page now says that a Component does not call the connector itself — the manager does. The Russian page follows.

@Jako Jako left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added a comment about loading the lexicons with the lang.js.php

```

A page declares the topics it needs, and the manager loads them into `MODx.lang`. A controller names them in `getLanguageTopics()`:
A page declares the topics it needs, and the manager loads them into `MODx.lang`. To add the topics of an own component, name them in `getLanguageTopics()` of a controller of the component — it extends the list of the lexicons loaded for the page:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To add multiple lexicons, the parameter of getLanguageTopics can contain an array of topics instead of a string.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the example returns a list now, and the page says that the method returns the list of the topics of the page:

public function getLanguageTopics()
{
    return ['mycomponent:default', 'mycomponent:settings'];
}

The code behind it: modManagerController::prepareLanguage() takes the return value of getLanguageTopics(), loads every topic of the list with modLexicon->load() and hands the list over to the header of the page through _lang_topics. The core controllers use the list the same way — for example return ['user','access','policy','context','setting']; in the controller of a user group.

One detail worth knowing, since the wording can be read both ways: every element of the list has to be a string with one topic. A nested array would not work — modLexicon::load() skips everything that is not a string, and implode(',', $languageTopics) would flatten it with a notice.

The Russian page follows.

… lexicon coordinates

The review of modxorg#662 left four remarks on the page, all of them about
the code.

**The syntax of the tag.** The example of a placeholder was written as
`[[!%userinput=`amazing`]]`, which is not a valid tag: the parser splits
the tag on the first `?`, so without it the value stays part of the
name. The old example of the page had the same problem in the
namespace property, where the name was written as `namespace\_name` —
the backslash is not an escape in the syntax of a tag. The example is
now `[[!%key? &userinput=`amazing`]]`.

**The optional properties.** The `topic` and the `language` can be
left out — the topic falls back to `default`, the language to the one
of the request. The `namespace` falls back to `core` as well, so it has
to be given for a lexicon outside the core namespace; without it the
tag looks in the core only.

**The topics of the JavaScript connector.** The page states that
`connectors/lang.js.php` takes a comma-separated `topic` parameter.
The connector reads `$_REQUEST['topic']`, splits it by commas and
loads every coordinate with `modLexicon::load()`; the templates of the
core ask for the topics the same way
(`manager/templates/default/header.tpl` requests
`?ctx=mgr&topic=topmenu,file,resource,…`). The page now says so.

**The lexicons of the settings.** The label of a setting is the key
`setting_<key>`, and its description is `setting_<key>_desc`. The
core keeps its labels in the `setting` topic, a Component outside the
core namespace keeps them in the `default` topic — that is the topic
the manager writes a translated label into
(`modSystemSetting::updateTranslation()` picks the topic by the
namespace of the setting). The Settings grid loads both topics of the
namespace, so a key with the `setting_` prefix is found in either of
them.

The example of `modLexicon::load()` is rewritten as well: every
argument is a coordinate, there is no separate parameter for the
namespace.

The `description` of the frontmatter of both locales is quoted, and the
stray quote at its end is gone.
… call the connector

The page told the reader to load extra lexicon topics in the browser by
calling `connectors/lang.js.php` with a `topic` parameter. That is how
the core does it, but it is not how a Component does it: the connector
is the manager's own mechanism, and the manager calls it for the topics
of the page.

A page declares the topics it needs and the manager loads them:

- a controller names them in `getLanguageTopics()`
  (`core/src/Revolution/modManagerController.php:533`, the core
  controllers override it);
- a page that needs a topic beyond those of its controller adds it with
  `addLexiconTopic()` (`modManagerController.php:1027`).

Both put the topic into `_lang_topics`, which the header template hands
over to the connector (`manager/templates/default/header.tpl:23`), and
the response replaces `MODx.lang`.

The Russian page follows.
…ts own topics

The page told that a controller names its topics in `getLanguageTopics()`,
but did not say that this is the way a Component adds the topics of its
own namespace, and it did not say why the connector is not the way to
add topics while the page runs.

It is now stated in both places:

- to add the topics of an own component, name them in
  `getLanguageTopics()` of a controller of the component — it extends
  the list of the lexicons loaded for the page;
- the connector does not extend the lexicon of a page that is already
  running. It answers with the topics of its own request — `core:default`
  and the ones asked for — and the response replaces `MODx.lang`, so the
  topics have to be declared before the page is rendered.

The Russian page follows.
…s() takes a list

The method returns the list of the topics of the page, so a component
names all of its topics at once. The example now returns two topics and
the page says so.

The code behind it: `modManagerController::prepareLanguage()` takes the
return value of `getLanguageTopics()`, loads every topic of the list
with `modLexicon::load()` and hands the list over to the header of the
page through `_lang_topics`. The core controllers use the list the same
way — `return ['user','access','policy','context','setting'];`.

The Russian page follows.
@Ibochkarev
Ibochkarev force-pushed the docs/extending-modx-i18n branch from 94705f1 to 2523405 Compare October 23, 2026 09:02
Samples for mail, snippets, REST, and user models described classes and paths the core does not use.
@Ibochkarev Ibochkarev changed the title docs(extending-modx/internationalization): fix the tag syntax and the lexicon coordinates docs(extending-modx): match plugins, snippets, REST, and lexicons to 3.x Oct 23, 2026
@Ibochkarev
Ibochkarev requested a review from Jako October 23, 2026 10:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants