Updated on 11 Jul, 202660 mins read 22 views

Introduction

We will use Botble CMS as our case study because its implementation is relatively clean while still demonstrating the architecture used by many extensible systems.

We will trace the complete execution flow from the moment a plugin registers a callback until the callback is eventually executed.

The Big Picture

Whenever a request reaches Botble CMS, thousands of lines of framework code executes.

During that execution, numerous extension points are reached.

For example:

Dashboard Rendering
	↓
Admin Menu Creation
	↓
Theme Rendering
	↓
Page Rendering
	↓
Widget Loading
	↓
Settings Rendering
	↓
SEO Generation

At each of these location, Botble asks a question:

β€œDoes anyone want to participate here?”

Instead of hardcoding behavior, it delegates responsibility to the hook system.

That is the central idea.

The Hook Lifecycle

Every hook in Botble follows exactly the same lifecycle.

Plugin boots
	↓
Registers callback
	↓
Callback stored
	↓
Framework reaches hook
	↓
Framework fires hook
	↓
Registered callbacks execute
	↓
Framework continues

Everything in Botble's hook system is built around this lifecycle.

Step 1: Laravel boots the application

When Botble starts, Laravel loads every enabled plugin's ServiceProvider.

For instance, the Code plugins, Laravel executes:

CodeServiceProvider
        β”‚
        β–Ό
HookServiceProvider::boot()

Now PHP starts executing the boot() function from top to bottom.

Step 2: Plugin registers a filter

Consider this code inside a plugin.

// platform/plugins/code/src/Providers/HookServicProvider.php

add_filter(
	DASHBOARD_FILTER_ADMIN_LIST,
	[$this, 'registerDashboardWidgets'],
	21,
	2
);

This does not execute registerDashboardWidgets().

Instead, it simply conveys the framework:

β€œWhenever the dashboard widget list is requested, please call this function”

Nothing more.

The callback is merely registered.

DASHBOARD_FILTER_ADMIN_LIST is merely an string defined in the platform/core/dashboard/helpers/constant.php.

<?php

if (! defined('DASHBOARD_FILTER_ADMIN_LIST')) {
    define('DASHBOARD_FILTER_ADMIN_LIST', 'admin_dashboard_list');
}

if (! defined('DASHBOARD_ACTION_REGISTER_SCRIPTS')) {
    define('DASHBOARD_ACTION_REGISTER_SCRIPTS', 'dashboard_register_scripts');
}

if (! defined('DASHBOARD_FILTER_ADMIN_NOTIFICATIONS')) {
    define('DASHBOARD_FILTER_ADMIN_NOTIFICATIONS', 'admin_dashboard_notifications');
}

if (! defined('DASHBOARD_FILTER_TOP_BLOCKS')) {
    define('DASHBOARD_FILTER_TOP_BLOCKS', 'admin_dashboard_top_blocks');
}

You are saying:

β€œIf anyone ever fires the hook admin_dashboard_list, call my function.”

Step 3: The Helper Function

The global helper is surprisingly small.

//platform/core/base/helpers/action-filter.php

<?php

use Botble\Base\Facades\Action;
use Botble\Base\Facades\Filter;
use Illuminate\Support\Arr;

if (! function_exists('add_filter')) {
    function add_filter(
        string|array|null $hook,
        string|array|Closure $callback,
        int $priority = 20,
        int $arguments = 1
    ): void {
        Filter::addListener($hook, $callback, $priority, $arguments);
    }
}

if (! function_exists('remove_filter')) {
    function remove_filter(string $hook): void
    {
        Filter::removeListener($hook);
    }
}

if (! function_exists('add_action')) {
    function add_action(
        string|array|null $hook,
        string|array|Closure $callback,
        int $priority = 20,
        int $arguments = 1
    ): void {
        Action::addListener($hook, $callback, $priority, $arguments);
    }
}

if (! function_exists('apply_filters')) {
    function apply_filters(...$args)
    {
        return Filter::fire(array_shift($args), $args);
    }
}

if (! function_exists('do_action')) {
    function do_action(...$args): void
    {
        Action::fire(array_shift($args), $args);
    }
}

if (! function_exists('get_hooks')) {
    function get_hooks(?string $name = null, bool $isFilter = true): array
    {
        if ($isFilter) {
            $listeners = Filter::getListeners();
        } else {
            $listeners = Action::getListeners();
        }

        if (empty($name)) {
            return $listeners;
        }

        return Arr::get($listeners, $name, []);
    }
}
use Botble\Base\Facades\Filter;

if (! function_exists('add_filter')) {
    function add_filter(
        string|array|null $hook,
        string|array|Closure $callback,
        int $priority = 20,
        int $arguments = 1
    ): void {
        Filter::addListener($hook, $callback, $priority, $arguments);
    }
}

Notice something interesting.

There is no logic here.

It simply forwards everything to the Filter facade.

Plugin
	↓
add_filter()
	↓
Filter Facade

Step 4: Laravel Facade intercepts the call

This is where the control get separated for the filter and action.

For Filter:

<?php

namespace Botble\Base\Facades;

use Illuminate\Support\Facades\Facade;

/**
 * @method static mixed fire(string $action, array $args)
 * @method static void addListener(array|string|null $hook, \Closure|array|string $callback, int $priority = 20, int $arguments = 1)
 * @method static \Botble\Base\Supports\ActionHookEvent removeListener(string $hook)
 * @method static array getListeners()
 *
 * @see \Botble\Base\Supports\Filter
 */
class Filter extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return 'core.filter';
    }
}

For Action:

<?php

namespace Botble\Base\Facades;

use Illuminate\Support\Facades\Facade;

/**
 * @method static void fire(string $action, array $args)
 * @method static void addListener(array|string|null $hook, \Closure|array|string $callback, int $priority = 20, int $arguments = 1)
 * @method static \Botble\Base\Supports\ActionHookEvent removeListener(string $hook)
 * @method static array getListeners()
 *
 * @see \Botble\Base\Supports\Action
 */
class Action extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return 'core.action';
    }
}

Step 5: Service Container

Earlier, during framework boot, BaseServiceProvider executed.

// platform/core/base/src/Providers/BaseServiceProvider.php
use Botble\Base\Supports\Action;
use Botble\Base\Supports\Filter;

class BaseServiceProvider extends ServiceProvider
{
    use LoadAndPublishDataTrait;

    public function register(): void
    {
		$this->app->singleton('core.action', Action::class);
		$this->app->singleton('core.filter', Filter::class);
		
		
...

So the container already contains:

  • core.filter -> Botble\Base\Supports\Filter
  • core.action -> Botble\Base\Supports\Action

Laravel retrieves that object.

Step 6: Inheritance

Now the filter object or action object receives.

For the Filter:

Now the filter object receives, addListener(..)

But…

There is no addListener() inside filter.

<?php

namespace Botble\Base\Supports;

class Filter extends ActionHookEvent
{
    public function fire(string $action, array $args)
    {
        $value = $args[0] ?? ''; // get the value, the first argument is always the value

        $filters = $this->getListeners();

        if (! $filters) {
            return $value;
        }

        foreach ($filters as $hook => $listeners) { // go through each of the priorities
            ksort($listeners);
            foreach ($listeners as $arguments) { // loop all hooks
                if ($hook === $action) { // if the hook responds to the current filter
                    $parameters = [$value];
                    for ($index = 1; $index < $arguments['arguments']; $index++) {
                        if (isset($args[$index])) {
                            $parameters[] = $args[$index]; // add arguments if it is there
                        }
                    }
                    // filter the value
                    $value = call_user_func_array($this->getFunction($arguments['callback']), $parameters);
                }
            }
        }

        return $value;
    }
}

PHP walks up the inheritance tree.

ActionHookEvent
	^
	|
Filter

It finds

ActionHookEvent::addListener()

Now this function starts executing.

<?php

namespace Botble\Base\Supports;

use Closure;
use Illuminate\Support\Arr;

abstract class ActionHookEvent
{
    protected array $listeners = [];

    public function addListener(
        string|array|null $hook,
        string|array|Closure $callback,
        int $priority = 20,
        int $arguments = 1
    ): void {
        if (! is_array($hook)) {
            $hook = [$hook];
        }

        foreach ($hook as $hookName) {
            while (isset($this->listeners[$hookName][$priority])) {
                $priority += 1;
            }

            $this->listeners[$hookName][$priority] = compact('callback', 'arguments');
        }
    }

    public function removeListener(string $hook): self
    {
        Arr::forget($this->listeners, $hook);

        return $this;
    }

    public function getListeners(): array
    {
        foreach ($this->listeners as $listeners) {
            uksort($listeners, function ($param1, $param2) {
                return strnatcmp($param1, $param2);
            });
        }

        return $this->listeners;
    }

    protected function getFunction(string|array|Closure|null $callback): bool|array|Closure|string
    {
        if (is_string($callback)) {
            if (strpos($callback, '@')) {
                $callback = explode('@', $callback);

                return [app('\\' . $callback[0]), $callback[1]];
            }

            return $callback;
        } elseif ($callback instanceof Closure) {
            return $callback;
        } elseif (is_array($callback)) {
            return $callback;
        }

        return false;
    }

    abstract public function fire(string $action, array $args);
}

Step 7: Store the callback

Initially

$listeners = [];

Now this code runs

$this->listeners[$hook][$priority] =
[
    'callback' => $callback,
    'arguments' => $arguments
];

After execution the memory looks like:

listeners
β”‚
└── admin_dashboard_list
      β”‚
      └── 21
           β”‚
           β”œβ”€β”€ callback
  registerDashboardWidgets
           β”‚
           └── arguments 2

The callback is now stored.

Nothing has been executed.

The application continues booting.

Much later…

The user opens the Dashboard.

Now Laravel reaches

DashboardController

inside:

getDashboard()

Eventually PHP reaches

$widgetData = apply_filters(
	DASHBOARD_FILTER_ADMIN_LIST,
	[],
	$widgets
);

This is where the filters actually executes.

Step 8: apply_filters()

PHP enters: action-filter.php file again.

// platform/core/base/helpers/action-filter.php

<?php

use Botble\Base\Facades\Action;
use Botble\Base\Facades\Filter;
use Illuminate\Support\Arr;

if (! function_exists('add_filter')) {
    function add_filter(
        string|array|null $hook,
        string|array|Closure $callback,
        int $priority = 20,
        int $arguments = 1
    ): void {
        Filter::addListener($hook, $callback, $priority, $arguments);
    }
}

if (! function_exists('remove_filter')) {
    function remove_filter(string $hook): void
    {
        Filter::removeListener($hook);
    }
}

if (! function_exists('add_action')) {
    function add_action(
        string|array|null $hook,
        string|array|Closure $callback,
        int $priority = 20,
        int $arguments = 1
    ): void {
        Action::addListener($hook, $callback, $priority, $arguments);
    }
}

if (! function_exists('apply_filters')) {
    function apply_filters(...$args)
    {
        return Filter::fire(array_shift($args), $args);
    }
}

if (! function_exists('do_action')) {
    function do_action(...$args): void
    {
        Action::fire(array_shift($args), $args);
    }
}

if (! function_exists('get_hooks')) {
    function get_hooks(?string $name = null, bool $isFilter = true): array
    {
        if ($isFilter) {
            $listeners = Filter::getListeners();
        } else {
            $listeners = Action::getListeners();
        }

        if (empty($name)) {
            return $listeners;
        }

        return Arr::get($listeners, $name, []);
    }
}
if (! function_exists('apply_filters')) {
    function apply_filters(...$args)
    {
        return Filter::fire(array_shift($args), $args);
    }
}

The helper removes admin_dashboard_list from the argument list.

So internally it becomes:

In:
	admin_dashboard_list, [], $widgets
Out:
	[
		[],
		$widgets
	]

Step 9: Facade again

Again Laravel resolves:

Filter
	↓
core.filter
	↓
Filter Object

Now

Filter::fire()
<?php

namespace Botble\Base\Supports;

class Filter extends ActionHookEvent
{
    public function fire(string $action, array $args)
    {
        $value = $args[0] ?? ''; // get the value, the first argument is always the value

        $filters = $this->getListeners();

        if (! $filters) {
            return $value;
        }

        foreach ($filters as $hook => $listeners) { // go through each of the priorities
            ksort($listeners);
            foreach ($listeners as $arguments) { // loop all hooks
                if ($hook === $action) { // if the hook responds to the current filter
                    $parameters = [$value];
                    for ($index = 1; $index < $arguments['arguments']; $index++) {
                        if (isset($args[$index])) {
                            $parameters[] = $args[$index]; // add arguments if it is there
                        }
                    }
                    // filter the value
                    $value = call_user_func_array($this->getFunction($arguments['callback']), $parameters);
                }
            }
        }

        return $value;
    }
}

Step 10: Filter::fire()

The first line:

$value = $args[0];
// stores [] inside the $value

Remember, For Filters – the first parameter is always the thing being modified.

$value
↓
[]

Now:

$filters = $this->getListeners();

returns:

listeners
β”‚
└── admin_dashboard_list
      β”‚
      └── priority 21

Now search for matching hook:

foreach ($filters as $hook => $listeners)

Suppose it finds admin_dashboard_list

It compares:

if ($hook == $action)

admin_dashboard_list == admin_dashboard_list
 |
 V
True

So execution continues

Now, Sort priorities:

ksort($listeners)

Suppose:

Priority
5
20
21
50

Now callbacks execute:

5
↓
20
↓
21
↓
50

Lowest priority first.

Build parameters, Now Botble creates:

$parameters
$value
↓
[]
$widgets

because arguments = 2

So the callback receives

registerDashboardWidgets(
    [],
    $widgets
)

Execute callback

call_user_func_array(...)

finally executes

registerDashboardWidgets()

This is the first time your plugin code actually runs.

Callback returns

Suppose:

registerDashboardWidgets()

returns:

[
    Widget A,
    Widget B
]

Botble stores:

$value = returned value

Now:

$value
	↓
[Widget A, Widget B]

If another filter exists:

it receives:

Widget A
Widget B

as input.

Eventually:

Filter::fire()

returns the final value to DashboardController

Complete Workflow of an Action

Actions follow almost the same registration process.

Registration Phase

Application Boots
↓
HookServiceProvider::boot()
↓
add_action()
↓
Action::addListener()
↓
ActionHookEvent::addListener()
↓
Store callback

Exactly the same.

Execution Phase

Later somewhere in the framwork.

Suppose:

do_action(
    BASE_ACTION_PUBLIC_RENDER_SINGLE,
    $screen,
    $post
);

Step 1: The helper

do_action(...)

calls

Action::fire()

Step 2: Laravel resolves

core.action
↓
Action Object

Step 3: Action::fire()

gets all listeners

listeners
↓
BASE_ACTION_PUBLIC_RENDER_SINGLE
↓
callback A
callback B
callback C

Step 4: Callbacks execute one after another

Callback A
↓
Callback B
↓
Callback C

Each receives:

$screen
$post

No callback returns anything.

No callback modifies a pipeline value.

They simply perform work.

For example:

  • inject advertisements
  • register breadcrumbs
  • log analytics
  • send notification
  • dispatch another event
  • modify the $post object by reference
  • add JSON-LD schema

After the last callback finishes, execution returns to the framework.

The Biggest Difference

A Filter is a pipeline:

Input
↓
Filter 1
↓
Modified Value
↓
Filter 2
↓
Modified Value
↓
Filter 3
↓
Final Value

An Action is a notification:

Framework says

"This event happened."
	↓
Listener A reacts
	↓
Listener B reacts
	↓
Listener C reacts
	↓
   Done

That's the mental model you should keep: Filters transform data; Actions announce that something happened.

The Overall Architecture

Plugin
 β”‚
 β”‚
 β–Ό
HookServiceProvider::boot()
 β”‚
 β”‚
 β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚              β”‚
 β–Ό              β–Ό
add_filter()   add_action()
 β”‚              β”‚
 β–Ό              β–Ό
Filter       Action
 Facade       Facade
 β”‚              β”‚
 β–Ό              β–Ό
core.filter  core.action
(singleton)  (singleton)
 β”‚             |
Filter.php   Action.php
 β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
        β–Ό
 ActionHookEvent
        β”‚
 stores listeners

Later:

Framework
↓
apply_filters()
↓
Filter::fire()
↓
registered callbacks
↓
modified value returned

or:

Framework
↓
do_action()
↓
Action::fire()
↓
registered callbacks executed
↓
nothing returned

 

Buy Me A Coffee

Leave a comment

Your email address will not be published. Required fields are marked *