Modifying a Primary Key in Code

Modifying a MODX object's primary key in code.

By Bob Ray  |  December 16, 2025  |  10 min read
Modifying a Primary Key in Code

The Problem

I ran into this recently when working on the long-overdue new version of MyComponent, a development environment for creating MODX extras. MyComponent lets you specify the key and value of lexicon strings when you use them in PHP, HTML, or JavaScript code. It looks like this in PHP: $modx->lexicon('some_key~~string to display'). MyComponent parses this code. It writes $_lang['some_key'] = 'string to display' in the appropriate lexicon file, then it removes '~~string to display' part from the snippet code, leaving just $modx->lexicon('some_key'). It does something similar for lexicon tags in resources or chunks, and lexicon stings in JavaScript code. It also handles them in a variety of other places like Settings and some description fields. I ran into the problem when I wanted to do the same thing for menu items in the MODX Manager, where both the text (name) and description fields can be lexicon keys.

My method worked for menu fields in the transport.menus.php file, but what about the leftover ~~string to display in the database for that menu item? If that isn't removed, it will appear again in the transport file when MyComponent's ExportObjects is run. Then the lexicon strings won't work in the installed package because they won't match the key that MODX looks for (which will still contain the "~~" string).

It's easy enough to get the menu item from the database and replace the part that needs removing:

$menuObject = $modx->getObject('modMenu', array('namespace' => $namespace);
$text = $menuObject->get('text');
$pattern = '/~~.*([\'\"][\),])/';
$replace = '$1';
$text = preg_replace($pattern, $replace, $text);
$menuObject->set('text', $text);
$success = $menuObject->save();

The problem is that this code never changed anything, even though $success was always true. When tracing the code in the debugger, I noticed that the text field is the primary key (pk) of the modMenu object. When MODX tried to save my menu object, the SQL generated basically said "update the text field where the text field equals the new text value." Since there is no menu object with that key, the save failed every time.

The problem was that I was trying to modify the primary key of the menu object.

Since simply saving the object didn't work, I consulted Google. Google's AI is sometimes surprisingly good about MODX operations, and at other times it gives bad advice. The first thing it said was that I shouldn't do it because of the possibility of disastrous side effects due to related objects and ACL entries — good advice in general.

That advice didn't really apply to my use case, though (AIs are bad at edge cases). The only possible related objects are parents and children of the menu item. My menu item had no children and the parent wouldn't change. The ACL issue was also not a problem because access to menu items is controlled by the menu object's permission field rather than by ACL entries.

I'm fond of the Google AI since it recently referred to me as "prominent MODX developer, Bob Ray," but I still wasn't ready to follow the advice above.

The AI went on to describe how to do it if you were determined to ignore its advice. It said to create a new object with the changed fields and the unchanged fields, adjust all the related objects, then delete the original object.

This made complete sense, but at about this point, I realized that MODX does this itself when you change the text field of a menu in the Manager (which works). I wondered how it was done. I looked at the modMenu class file to see if there was special handling in its save() method, but it just calls parent::save(), so that was no help.

The Solution

Finally, it occurred to me that MODX must be calling a processor, since it didn't handle this in the modMenu object code. After a short search, I found the system/menu/update processor, which contains this comment in its beforeSet() method:

// Setup to allow PK change

Eureka! (sort of).

The processor code matches the AI advice in creating a new object, adjusting the children (if any), and removing the old object.

I tried extracting the original menu object's fields with toArray(), modifying the text field, then sending the array of fields as the second argument to the processor (our old friend, the $scriptProperties array):

$fields = $chunk->toArray();
$modx->runProcessor('system/menu/update', $fields);

That didn't work any better than my original code (i.e., not at all), so I again consulted the processor code. The first two fields it looks for in the $scriptProperties array are in these lines:

$oldName = $this->getProperty('previous_text');
$newName = $this->getProperty('text');

I modified my code to send the new value of the text field in the 'text' member of the array and I added a new field, previous_text which contained the old value.

This still didn't work, but while tracing the code in the debugger, I saw that it was issuing an error message: action not specified. I was already sending the correct action (in this case home) in the action field of my array, but I could see another added field: action_id in the processor. I tried adding that field with 'action_id' => 'home'. Finally, my perseverance paid off. Everything worked as it should. I have to confess that I don't see the point of the action_id field, since it's only used to set the value of the action field (which already holds that value).

The final code is in the LexiconHelper class of MyComponent, but if I posted that, it would make your head spin. It's in a deeply nested series of loops that processes multiple namespaces, multiple menus, multiple widgets and multiple fields. Inside that series of loops, it handles menu objects, checking their text and description fields (the only menu fields where lexicon keys would live). Note that the update processor only needs to be called if the text field has changed, but it simplifies the code to call it every time.

The Code

The pattern is a regular expression. It's very simple because we're just dealing with a field value rather than a complex string. All this code does is remove the ~~string to display bits (if any) and save the result to the DB:


function updateMenuObjects ($namespace) { $pattern = '/~~.+$/'; $replace = ''; $menuObjects = $modx->getCollection('modMenu' => array('namespace' => $namespace)); foreach ($menuObjects as $object) { $dirty = false; $fieldsToChange = array('text','description'); foreach($fieldsToChange as $fieldToChange) { $original = $object->get($fieldToChange); $t = preg_replace($pattern, $replace, $original); if ($original !== $t) { // Field has changed $dirty = true; $newFields = $object->toArray(); $newFields['action_id'] = $object->get('action'); $isRename = true; $newFields['previous_text'] = $original; $newFields[$fieldToChange] = $t; } } if ($dirty) { $response = $modx->runProcessor('system/menu/update', $newFields); } } }

For each menu object, we call preg_replace() to replace the '~~' and everything after it with an empty string. If that doesn't change anything, we do nothing. If it does, me mark the object as dirty. If it ends up dirty, because either field has changed (or both have), we set all the fields needed by the processor and then call the processor with $modx->runProcessor().

Other Cases

Unfortunately, interesting as it is, this method has limited uses. It won't work with Settings of any kind, or with Contexts. Their key fields are read-only in the Manager, and there is no code in their update processor to handle a changed primary key field. I suspect that this is because the code to correct all the related objects would be so complex. I haven't looked to make sure, but I suspect that deleting and recreating them in the Manager might cause problems with related objects. I could be wrong. I'd recommend thinking carefully about context names (keys) and System Setting keys, so you don't have to change them later.

It isn't necessary to use the method above for Dashboards or Widgets, because those objects have an ID field, so you can change their names at will and just save them.

Of course in normal operations, you can just change the Menu text in the Manager and click on save. I may have just hit on the only possible use case for this technique. I seriously considered just telling MyComponent users to clear the ~~string to display bits manually in the Manager. MyComponent does it automatically in the transport files, so they would never end up in the finished project. But changing them manually is just the kind of task that MyComponent is supposed to save you from, and as you've probably guessed, I find it hard to resist a challenge.

For the Curious

In case you're wondering about the complex code I mentioned earlier in this article, here's the nested code from MyComponent's LexiconHelper class:

public function updateObjects($updateObjects, $ns = array()) {
    /* The $updateObjects array looks like this:
        $updateObjects = array(
            'menus' => true,
            'widgets' => true,
        );

        $ns is an array of namespaces. The incoming argument is only used
        in unit tests, in normal operation, it's empty and the namespace
        array is taken from the project's config file.
    */

    $pattern = '/~~.+$/';
    $replace = '';

    if (empty($ns)) {
        $ns = $this->modx->getOption('namespaces', $this->helpers->props,
            array());
    }
    foreach ($updateObjects as $key => $value) {
        foreach ($ns as $nsName => $nsValue) {
            if (($key == 'menus') && $value) {
                $menuObjects = $this->modx->getCollection('modMenu', array('namespace' => $nsName));
                /* Fix squiggles in 'text & 'description' fields */
                foreach ($menuObjects as $object) {
                    $dirty = false;
                    $isRename = false;
                    $fieldsToChange = array('text', 'description');

                    foreach ($fieldsToChange as $fieldToChange) {
                        $original = $object->get($fieldToChange);
                        $t = preg_replace($pattern, $replace, $original);
                        if ($original !== $t) { // Field has changed
                            $dirty = true;
                            if ($fieldToChange === 'text') {
                                $newFields = $object->toArray();
                                $newFields['action_id'] = $object->get('action');
                                $isRename = true;
                                $newFields['previous_text'] = $original;
                                $newFields[$fieldToChange] = $t;
                            } else {
                                if ($isRename) {
                                    $newFields[$fieldToChange] = $t;
                                } else {
                                    $object->set($fieldToChange, $t);
                                }
                            }
                        }
                    }
                    if ($dirty) {
                        /* We only need to run processor if 'text' field (pk) has changed */
                        if ($isRename) {
                            $response = $this->modx->runProcessor('system/menu/update', $newFields);
                        } else {
                            $success = $object->save();
                        }
                    }
                }
            }
            if ($key == 'widgets' && $value) {
                $widgetObjects = $this->modx->getCollection('modDashboardWidget', array('namespace' => $nsName));

                /* Fix squiggles in 'name' & 'description'  fields */
                foreach ($widgetObjects as $object) {
                    $fields = array('name', 'description');
                    $dirty = false;
                    foreach ($fields as $field) {
                        $original = $object->get($field);
                        $t = preg_replace($pattern, $replace, $original);

                        if ($original !== $t) {
                            $object->set($field, $t);
                            $dirty = true;
                        }
                    }
                    if ($dirty) {
                        $success = $object->save();
                    }
                }
            }
        }
    }
}

It's complex because it's potentially handling multiple namespaces, multiple menus and widgets, and multiple fields to check (which may, or may not contain '~~' strings). For Menus, it's convoluted because it has to handle four possible conditions: 1) The text field changes and the description field doesn't, 2) the reverse of that, 3) both fields change, 4) no fields change.

It's further complicated by the fact that the processor only needs to be called if the 'text' field of a menu has changed. I could have simplified it by calling the processor for all cases (as I did in the shorter example above), necessary or not, but the processor is significantly slower than just setting the fields and calling save().

There may be a way to make it clearer, but the alternatives I considered didn't really make it any less confusing. Also, it took quite a while to get it working, and I'm not sure I could have done it without being able to step through the code in the debugger. Once it passed all the unit tests, I had trouble coming up with the energy to rewrite it.


Bob Ray is the author of the MODX: The Official Guide and dozens of MODX Extras including QuickEmail, NewsPublisher, SiteCheck, GoRevo, Personalize, EZfaq, MyComponent and many more. His website is Bob’s Guides. It not only includes a plethora of MODX tutorials but there are some really great bread recipes there, as well.