Skip to content

Conversation

OlegAnTo2000
Copy link

@OlegAnTo2000 OlegAnTo2000 commented Sep 17, 2025

What does it do?

This PR introduces runtime and closure-based event listeners with support for priority and optional names in modX.

I tried to leave 100% backward compatibility - we get a similar addEventListener syntax and correct operation of plugins and events, while getting a powerful tool for managing listeners in packages.

Main technical changes:

  • Added two new internal maps:

    • runtimeEventMap — stores runtime listeners added via addEventListener() so they are not overwritten by _initEventMap().
    • closureEventMap — stores closure-based listeners with priority, name, and callback.
  • Added new method:

    public function addEventListenerClosure(
        string $event,
        \Closure $callback,
        int $priority = 0,
        ?string $name = null,
        bool $replace = true
    ): bool

    This allows developers to register closure-based listeners with configurable execution order and optional unique names.

  • Updated invokeEvent():

    1. Executes persistent DB plugins (eventMap) as before.
    2. Executes runtime listeners (runtimeEventMap).
    3. Executes closure listeners (closureEventMap), sorted by priority (lower = earlier).
  • Added universal removal method:

    public function removeEventListenerClosure(
        ?string $event = null,
        ?int $priority = null,
        ?string $name = null
    ): bool

    Behavior:

    • If only $event is provided → remove listeners for that event.
    • If $event is null and $name provided → remove all listeners with that name across all events.
    • If $event is null and $priority provided → remove all listeners with that priority across all events.
    • If everything is null → clear all closure listeners.
  • Improved the removeEventListener method:

    • It can now remove listeners by plugin ID or all handlers if the plugin ID is not specified.
    • It also removes from the new runtime and closure event maps.
  • Clearing of runtimeEventMap and closureEventMap has been added to the old removeAllEventListener method.

  • Added removeAllRuntimeEventListeners method which clears new event maps runtimeEventMap and closureEventMap.

Why is it needed?

Currently, addEventListener() writes into $eventMap, which is later reset by _initEventMap() when loading from DB. As a result, listeners registered in bootstrap.php or other runtime code are lost.

With this PR:

  • Runtime listeners persist throughout the request lifecycle.
  • Developers can attach closures without needing to define plugins in the database.
  • Execution order is predictable thanks to priority.
  • Closure Listeners can be easily replaced or removed by name, which simplifies package development and testing.

This feature makes it possible to extend MODX in a more lightweight and programmatic way.


How to test

  • Use regular modx project

  • Go to project core directory and change modX.php file from my repo

    cd yourmodxroot/core
    curl https://raw.githubusercontent.com/OlegAnTo2000/revolution/fix-bootstrap-events/core/src/Revolution/modX.php -o src/Revolution/modX.php
    
  • Download and install (or create your own) package with bootstrap.php file, for example pdoTools.

  • Register a closure listener in core/components/yourextra/bootstrap.php:

    $modx->addEventListenerClosure(
        'OnWebPagePrerender',
        function (array $params, \MODX\Revolution\modX $modx) {
            $output = &$modx->resource->_output;
            $output .= "\n<!-- TEST CLOSURE: " . date('Y-m-d H:i:s') . " -->";
            return null;
        },
        priority: 5,
        name: 'appendTestComment'
    );
    • Open any frontend page.

    • View page source.

    • Expected: the HTML ends with a comment like:

      <!-- TEST CLOSURE: 2025-09-17 20:45:00 -->
  • Verify priority order:

    $modx->addEventListenerClosure(
        'OnWebPagePrerender',
        function (array $params, \MODX\Revolution\modX $modx) {
            $output = &$modx->resource->_output;
            $output .= "\n<!-- TEST CLOSURE 1: " . date('Y-m-d H:i:s') . " -->";
            return null;
        },
        priority: 5,
        name: 'appendTestComment',
        replace: false
    );
    $modx->addEventListenerClosure(
        'OnWebPagePrerender',
        function (array $params, \MODX\Revolution\modX $modx) {
            $output = &$modx->resource->_output;
            $output .= "\n<!-- TEST CLOSURE 2: " . date('Y-m-d H:i:s') . " -->";
            return null;
        },
        priority: 10,
        name: 'appendTestComment',
        replace: false
    );
    • Refresh the page.

    • Expected order:

      <!-- TEST CLOSURE 1 ... -->
      <!-- TEST CLOSURE 2 ... -->
      

    Swap priorities and confirm that order changes accordingly.

  • Test replace by name:

    $modx->addEventListenerClosure(
        'OnWebPagePrerender',
        function (array $params, \MODX\Revolution\modX $modx) {
            $output = &$modx->resource->_output;
            $output .= "\n<!-- TEST CLOSURE 1: " . date('Y-m-d H:i:s') . " -->";
            return null;
        },
        priority: 5,
        name: 'appendTestComment',
        replace: true // can be replaced
    );
    $modx->addEventListenerClosure(
        'OnWebPagePrerender',
        function (array $params, \MODX\Revolution\modX $modx) {
            $output = &$modx->resource->_output;
            $output .= "\n<!-- TEST CLOSURE 2: " . date('Y-m-d H:i:s') . " -->";
            return null;
        },
        priority: 10,
        name: 'appendTestComment',
        replace: true // can be replaced
    );
    • Refresh the page.

    • Only the second comment was expected:

      <!-- TEST CLOSURE 2 ... -->
      
  • Test removal by event:

    $modx->removeEventListenerClosure('OnWebPagePrerender');
    • Refresh the page.
    • Expected: no closure-based comments at all.
  • Test removal by name (global):

    $modx->removeEventListenerClosure(null, null, 'appendTestComment');
    • Only listeners named appendComment should be removed across all events.
  • Test removal by priority:

    $modx->removeEventListenerClosure(null, 5, null);
    • All closure listeners with priority 5 should be removed.
  • Test error handling:

    $modx->addEventListenerClosure('OnWebPagePrerender', function () {
        throw new \Exception("Boom!");
    }, name: 'errorTest');
    • Refresh the page.

    • Expected: page renders normally, but error log contains:

      [OnWebPagePrerender] Closure failed: Boom!
      

Related issue(s)/PR(s)

Closes #16768

…priority

- Added `runtimeEventMap` to store listeners added at runtime via addEventListener()
  so they are not overwritten by _initEventMap().
- Introduced `closureEventMap` for closure-based event listeners.
- Implemented new method `addEventListenerClosure($event, \Closure $callback, int $priority = 0)`
  to register inline listeners with configurable priority.
- Updated `invokeEvent()` to execute:
  1) persistent DB plugins,
  2) runtime listeners,
  3) closure listeners (sorted by priority, lower values run first).
- Added `removeEventListenerClosure()` to remove closure listeners
  either entirely per event or selectively by priority.
- Extended `removeAllEventListener()` to clear runtime and closure maps.

This change allows developers to safely attach event listeners
in `bootstrap.php` or packages without being lost during eventMap initialization,
and to use lightweight closures with fine-grained execution order.
- Changed visibility of `runtimeEventMap` and `closureEventMap` to public for easier access.
- Introduced `closureEventSeq` to maintain stable order for closure listeners with the same priority.
- Updated closures sorting logic in `invokeEvent()` to consider both priority and sequence.
- Enhanced `removeEventListenerClosure()` to allow removal by event name and priority, or clear all listeners.
- Modified `addEventListenerClosure()` to support named listeners and optional replacement.
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.

Support persistent event listeners in core/components/*/bootstrap.php and allow anonymous functions
1 participant