
Working with plugins
A plugin on MiniShop3 events: how to get the parameters, return data, abort an operation and pass data to another plugin.
Component plugin (MODX events)
The package ships a system plugin minishop3.php on core MODX events (not to be confused with msOn*):
| MODX event | Purpose |
|---|---|
OnMODXInit | Service registration, namespace |
OnLoadWebDocument | Frontend assets, registerFrontend() |
OnDocFormSave | Resource-to-product conversion via ProductService::handleConversion() |
OnBeforeUserFormSave / OnUserSave | Sync msCustomer ↔ modUser when ms3_customer_sync_enabled |
OnUserRemove | Cleanup of related customer data |
For cart and order business logic, subscribe to events from the events hub.
Basic plugin structure
<?php
/**
* MiniShop3 plugin
* Events: msOnBeforeAddToCart, msOnAddToCart
*/
switch ($modx->event->name) {
case 'msOnBeforeAddToCart':
// Code for "before add" event
break;
case 'msOnAddToCart':
// Code for "after add" event
break;
}Getting incoming parameters
Method 1: $scriptProperties array
All event parameters are in $scriptProperties:
<?php
switch ($modx->event->name) {
case 'msOnBeforeAddToCart':
$product = $scriptProperties['msProduct'];
$count = $scriptProperties['count'];
$options = $scriptProperties['options'];
$controller = $scriptProperties['controller'];
break;
}Method 2: extract()
<?php
switch ($modx->event->name) {
case 'msOnBeforeAddToCart':
extract($scriptProperties);
// Now available: $msProduct, $count, $options, $controller
break;
}Debug: list all parameters
<?php
$modx->log(modX::LOG_LEVEL_ERROR, '[' . $modx->event->name . '] Parameters: ' . print_r(array_keys($scriptProperties), true));
$modx->log(modX::LOG_LEVEL_ERROR, '[' . $modx->event->name . '] ' . print_r($scriptProperties, true));controller parameter
Passed automatically
Every MiniShop3 controller event receives a controller parameter — the instance of the current controller (Cart, Order, Customer).
The controller gives access to MiniShop3 and MODX:
<?php
switch ($modx->event->name) {
case 'msOnBeforeAddToCart':
/** @var \MiniShop3\Controllers\Cart\Cart $controller */
$controller = $scriptProperties['controller'];
$ms3 = $controller->ms3;
$modx = $controller->modx;
// Use public controller methods for cart data
break;
}Aborting the operation
An event with the Before prefix is aborted by returning an error message:
$modx->event->output()
<?php
switch ($modx->event->name) {
case 'msOnBeforeAddToCart':
$product = $scriptProperties['msProduct'];
if ($product->get('price') <= 0) {
$modx->event->output('Product is not available for order');
return;
}
if ($scriptProperties['count'] > 10) {
$modx->event->output('Maximum 10 units per product');
return;
}
break;
}How it works
- MiniShop3's
invokeEvent()collects all plugin outputs - If any output is non-empty, the operation is aborted
- The message is returned to the user (frontend or API response)
Events that support aborting
msOnBeforeGetCartmsOnBeforeAddToCartmsOnBeforeChangeInCartmsOnBeforeChangeOptionsInCartmsOnBeforeRemoveFromCartmsOnBeforeEmptyCartmsOnBeforeAddToOrdermsOnAddToOrdermsOnBeforeRemoveFromOrdermsOnBeforeEmptyOrdermsOnEmptyOrdermsOnSubmitOrdermsOnBeforeCreateOrdermsOnBeforeChangeOrderStatus- Other events with the
Beforeprefix
Modifying data
returnedValues
To change the parameters the core continues with:
<?php
switch ($modx->event->name) {
case 'msOnBeforeAddToCart':
$values = &$modx->event->returnedValues;
if ($scriptProperties['count'] < 2) {
$values['count'] = 2;
}
$options = $scriptProperties['options'] ?? [];
$options['source'] = 'promo_landing';
$values['options'] = $options;
break;
}Take returnedValues by reference (&)
// Correct — changes apply
$values = &$modx->event->returnedValues;
$values['count'] = 5;
// Wrong — changes are lost
$values = $modx->event->returnedValues;
$values['count'] = 5;Utils::invokeEvent and EventGate
Most cart, order, cost and customer events go through MiniShop3\Utils\Utils::invokeEvent(), not raw $modx->invokeEvent(). Core clears returnedValues before the call and normalizes the response:
| Response field | Meaning |
|---|---|
success | true when no plugin returned non-empty output |
message | Plugin output strings joined with <br/> |
data | Original $scriptProperties shallow-merged with returnedValues |
values | Raw merged returnedValues from all plugins |
Shallow merge: if two plugins set returnedValues['count'], the plugin with higher priority wins (MODX runs plugins in ascending priority order; later plugins overwrite keys).
Two ways to change data
- By reference in
$scriptProperties(the older MS2 way; still works where params are passed with&). returnedValues— preferred contract since 1.11+.
<?php
switch ($modx->event->name) {
case 'msOnBeforeAddToCart':
$values = &$modx->event->returnedValues;
$values['count'] = min(99, (int) $scriptProperties['count']);
break;
}Callers read $response['data']['count'] after merge.
Abort: output, false and cancel
| Signal | Where | Effect |
|---|---|---|
$modx->event->output('text') | Utils::invokeEvent | success = false, text in message |
$modx->event->output(false) or output('cancel') | import, notifications, some raw calls | EventGate::isCancelled() → true |
A plugin's return cancels nothing
Only what a plugin passed to output() reaches the event result: modX::invokeEvent() collects $this->event->_output, while the plugin's return value is used for something else — a string is written to error.log as an event error, false as "Plugin … failed!".
So return 'cancel' not only fails to cancel, it also pollutes the log. Cancel through output() and use return only to leave the switch.
For isCancelled(), false and the string 'cancel' are equivalent. What the cancellation means is decided by the calling code:
| Event | What the core does on cancellation |
|---|---|
msOnBeforeImport | The whole import aborts with the ms3_utilities_import_cancelled error |
msOnImportRow | The current row goes into the skipped counter, the import continues |
msOnBeforeSendNotification | The notification is not sent, no error is raised |
<?php
case 'msOnImportRow':
if (empty($scriptProperties['data']['price'])) {
$modx->event->output('cancel');
return;
}
break;After-events in the order manager
msOnCreateOrderProduct, msOnUpdateOrderProduct, msOnRemoveOrderProduct run after save() / remove(). A plugin error is only logged, and the HTTP response to the client stays successful. The operation can be aborted only in the paired msOnBefore*. See Order product events.
Named returnedValues channels (import, msProducts, notifications)
Some events apply EventGate::applyReturnedArray() per key instead of a global merge:
| Event | returnedValues key | Behavior |
|---|---|---|
msOnBeforeImport | params | list → replace; assoc → array_replace |
msOnImportRow | data, tvData, optionData, gallery | same |
msOnProductsLoad | rows | same |
msOnProductPrepare | row | same |
msOnBeforeSendNotification | recipient, channels | same; list channels replaces the channel list |
Import / msProducts / notifications use EventGate::invokeRaw(), not Utils::invokeEvent().
Passing data between plugins
Use $modx->eventData to pass data between plugins of the same event or between events in one request.
Example: price modification
<?php
switch ($modx->event->name) {
case 'msOnGetProductPrice':
$price = $modx->eventData['msOnGetProductPrice']['price']
?? $scriptProperties['price'];
$data = $scriptProperties['data'];
if ($data['parent'] == 5) {
$price = $price * 0.85;
}
$modx->eventData['msOnGetProductPrice']['price'] = $price;
break;
}Example: passing data between events
<?php
switch ($modx->event->name) {
case 'msOnBeforeAddToCart':
$modx->eventData['myPlugin']['original_count'] = $scriptProperties['count'];
$modx->eventData['myPlugin']['added_at'] = time();
break;
case 'msOnAddToCart':
$originalCount = $modx->eventData['myPlugin']['original_count'] ?? null;
$addedAt = $modx->eventData['myPlugin']['added_at'] ?? null;
if ($originalCount) {
$modx->log(modX::LOG_LEVEL_INFO,
"Product added. Original quantity: {$originalCount}, time: {$addedAt}"
);
}
break;
}eventData structure
$modx->eventData = [
'msOnGetProductPrice' => [
'price' => 1500.00,
],
'msOnGetProductFields' => [
'data' => [...],
],
'myPlugin' => [
'custom_key' => 'custom_value',
],
];Data lives for one request
$modx->eventData is cleared after the request. Use $_SESSION or the database to persist data between requests.
Showing messages in the manager
JavaScript alert via addHtml
<?php
switch ($modx->event->name) {
case 'msOnManagerCustomCssJs':
$page = $scriptProperties['page'];
if ($page === 'product_update') {
$controller = $scriptProperties['controller'];
$controller->addHtml('<script>
Ext.onReady(function() {
MODx.msg.alert("Notice", "Remember to check prices!");
});
</script>');
}
break;
}Toast notifications
<?php
switch ($modx->event->name) {
case 'msOnManagerCustomCssJs':
$page = $scriptProperties['page'];
$controller = $scriptProperties['controller'];
if ($page === 'orders') {
$controller->addHtml('<script>
Ext.onReady(function() {
Ext.Msg.notify("MiniShop3", "You have new orders!");
});
</script>');
}
break;
}Plugin priority
Execution order is determined by the plugin priority setting:
- Lower value = runs first
- Default: 0
- Any integer
When priority matters
- Price modification — plugins apply discounts in sequence
- Validation — one plugin may depend on another's result
- Data passing — source plugin must run before consumer
Debugging plugins
Logging
<?php
$modx->log(modX::LOG_LEVEL_ERROR, 'Error');
$modx->log(modX::LOG_LEVEL_WARN, 'Warning');
$modx->log(modX::LOG_LEVEL_INFO, 'Info');
$modx->log(modX::LOG_LEVEL_DEBUG, 'Debug');
$modx->log(modX::LOG_LEVEL_ERROR,
'[MyPlugin::' . $modx->event->name . '] ' . $message
);Checking whether the plugin fires
<?php
// At the top of the plugin
$modx->log(modX::LOG_LEVEL_ERROR, '=== PLUGIN START: ' . $modx->event->name . ' ===');
switch ($modx->event->name) {
case 'msOnBeforeAddToCart':
$modx->log(modX::LOG_LEVEL_ERROR, 'msOnBeforeAddToCart triggered');
// ...
break;
}
$modx->log(modX::LOG_LEVEL_ERROR, '=== PLUGIN END ===');Listing the plugins subscribed to an event
<?php
// Fetch every plugin subscribed to the event
$plugins = $modx->getCollection('modPluginEvent', [
'event' => 'msOnBeforeAddToCart'
]);
foreach ($plugins as $pluginEvent) {
$plugin = $pluginEvent->getOne('Plugin');
$modx->log(modX::LOG_LEVEL_INFO,
'Plugin: ' . $plugin->get('name') . ', priority: ' . $pluginEvent->get('priority')
);
}Common mistakes
1. Forgot return after output()
// Wrong — execution continues
if ($error) {
$modx->event->output('Error');
}
// This runs even on error
doSomething();
// Correct
if ($error) {
$modx->event->output('Error');
return;
}2. Not using a reference for returnedValues
// Wrong
$values = $modx->event->returnedValues;
$values['count'] = 10; // Changes are lost
// Correct
$values = &$modx->event->returnedValues;
$values['count'] = 10;3. Checking in an after-event instead of a before-event
// msOnAddToCart — event AFTER add.
// The product is already in the cart and output() will not take it back.
// The response still turns into an error, so the shopper sees a message
// while the cart has already changed
case 'msOnAddToCart':
$modx->event->output('Error');
break;
// msOnBeforeAddToCart — event BEFORE add
case 'msOnBeforeAddToCart':
$modx->event->output('Error'); // Stops the add
break;A cart that disagrees with the response is worse than an honest refusal: the frontend shows an error while the item counter has already gone up. Put the checks in msOnBefore*.
4. Forgot break in switch
switch ($modx->event->name) {
case 'msOnBeforeAddToCart':
// code
// Forgot break — next case runs too!
case 'msOnAddToCart':
// This also runs for msOnBeforeAddToCart
break;
}Full plugin example
<?php
/**
* Plugin: Cart add control
*
* Events: msOnBeforeAddToCart, msOnAddToCart
*
* - Disallow adding products without price
* - Cap maximum quantity
* - Log adds
*/
switch ($modx->event->name) {
case 'msOnBeforeAddToCart':
/** @var \MiniShop3\Model\msProduct $product */
$product = $scriptProperties['msProduct'];
$count = $scriptProperties['count'];
$options = $scriptProperties['options'] ?? [];
if ($product->get('price') <= 0) {
$modx->event->output($modx->lexicon('ms3_cart_product_not_available'));
return;
}
$maxCount = 99;
if ($count > $maxCount) {
$values = &$modx->event->returnedValues;
$values['count'] = $maxCount;
$modx->eventData['cartControl']['count_limited'] = true;
$modx->eventData['cartControl']['original_count'] = $count;
}
$values = &$modx->event->returnedValues;
$options['added_timestamp'] = time();
$values['options'] = $options;
break;
case 'msOnAddToCart':
$product = $scriptProperties['msProduct'];
$count = $scriptProperties['count'];
$productKey = $scriptProperties['product_key'];
$modx->log(modX::LOG_LEVEL_INFO, sprintf(
'[CartControl] Product added: %s (ID: %d), qty: %d, key: %s',
$product->get('pagetitle'),
$product->get('id'),
$count,
$productKey
));
if (!empty($modx->eventData['cartControl']['count_limited'])) {
$original = $modx->eventData['cartControl']['original_count'];
$modx->log(modX::LOG_LEVEL_WARN, sprintf(
'[CartControl] Quantity capped: requested %d, added %d',
$original,
$count
));
}
break;
}