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 GenerationAt 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 continuesEverything 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 FacadeStep 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\Filtercore.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
^
|
FilterIt 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 2The callback is now stored.
Nothing has been executed.
The application continues booting.
Much later…
The user opens the Dashboard.
Now Laravel reaches
DashboardControllerinside:
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 ObjectNow
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 $valueRemember, For Filters – the first parameter is always the thing being modified.
$value
↓
[]Now:
$filters = $this->getListeners();returns:
listeners
│
└── admin_dashboard_list
│
└── priority 21Now 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
TrueSo execution continues
Now, Sort priorities:
ksort($listeners)Suppose:
Priority
5
20
21
50Now callbacks execute:
5
↓
20
↓
21
↓
50Lowest priority first.
Build parameters, Now Botble creates:
$parameters$value
↓
[]
$widgetsbecause 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 valueNow:
$value
↓
[Widget A, Widget B]If another filter exists:
it receives:
Widget A
Widget Bas 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 callbackExactly 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 ObjectStep 3: Action::fire()
gets all listeners
listeners
↓
BASE_ACTION_PUBLIC_RENDER_SINGLE
↓
callback A
callback B
callback CStep 4: Callbacks execute one after another
Callback A
↓
Callback B
↓
Callback CEach receives:
$screen
$postNo 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
$postobject 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 ValueAn Action is a notification:
Framework says
"This event happened."
↓
Listener A reacts
↓
Listener B reacts
↓
Listener C reacts
↓
DoneThat'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 listenersLater:
Framework
↓
apply_filters()
↓
Filter::fire()
↓
registered callbacks
↓
modified value returnedor:
Framework
↓
do_action()
↓
Action::fire()
↓
registered callbacks executed
↓
nothing returned
Leave a comment
Your email address will not be published. Required fields are marked *


