Skip to Content
Course content

72: Understanding Hook-Based Architecture

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've been looking at a legacy order processing system today, and it's a mess. Every time the business wants a new feature—like sending a Slack notification when a high-value order comes in or adding a custom tax for a specific region—someone has to open OrderProcessor.php and add another if statement or another service dependency to the constructor. It's a nightmare to test and a disaster for version control.

The Hard-Coded Headache

Let's look at how this usually starts. I've got a simple method that handles an order. It looks clean at first, right?

class OrderProcessor {
    public function process($order) {
        // Save to database
        $this->db->save($order);
        
        // Send confirmation email
        $this->mailer->send($order->customer_email, "Order Confirmed!");
        
        echo "Order processed successfully.";
    }
}

But then my boss comes in and says, "We need to notify the warehouse team via SMS for orders over $500." Then the marketing lead says, "We need to add the customer to our loyalty program if they've spent more than $1,000 in total."

If I keep adding those calls directly into process(), that method will eventually be 300 lines long. I don't want the OrderProcessor to know about SMS gateways, loyalty programs, or Slack APIs. It should just process the order.

Opening a Door for External Code

I want a way to say, "I'm at the point where the order is finished; if anyone cares, do your thing now," without the processor actually knowing who "anyone" is. I'll try creating a simple registry for callbacks.

class HookManager {
    private static $hooks = [];

    public static function add_action($hook_name, $callback) {
        self::$hooks[$hook_name][] = $callback;
    }

    public static function do_action($hook_name, ...$args) {
        if (isset(self::$hooks[$hook_name])) {
            foreach (self::$hooks[$hook_name] as $callback) {
                call_user_func($callback, ...$args);
            }
        }
    }
}

Now, let's see if we can strip the OrderProcessor of its dependencies. I'll replace the hard-coded mailer with a hook.

class OrderProcessor {
    public function process($order) {
        $this->db->save($order);
        
        // Instead of calling $this->mailer, we trigger a hook
        HookManager::do_action('after_order_processed', $order);
        
        echo "Order processed successfully.";
    }
}

// Now, elsewhere in the app (maybe in a Service Provider), I attach the logic:
HookManager::add_action('after_order_processed', function($order) {
    echo "Sending email to " . $order->customer_email . "...\n";
});

HookManager::add_action('after_order_processed', function($order) {
    if ($order->total > 500) {
        echo "Sending SMS to warehouse...\n";
    }
});

This is much better. The OrderProcessor is now "blind" to the side effects. I can add ten more notifications without ever touching the core processing logic again. This is the essence of an "Action" hook.

When We Need to Change the Data

But there's a catch. Sometimes I don't just want to trigger a side effect; I want to modify a value before it gets used. For example, imagine we want to apply a discount to the order total, but that discount logic varies wildly based on the user's membership level.

If I use do_action, I can't easily pass a value back. I need a "Filter." I'll add a new method to my HookManager that actually returns a modified value.

class HookManager {
    // ... keep the previous add_action and do_action ...

    private static $filters = [];

    public static function add_filter($hook_name, $callback) {
        self::$filters[$hook_name][] = $callback;
    }

    public static function apply_filters($hook_name, $value, ...$args) {
        if (isset(self::$filters[$hook_name])) {
            foreach (self::$filters[$hook_name] as $callback) {
                // We pass the result of one filter into the next
                $value = call_user_func($callback, $value, ...$args);
            }
        }
        return $value;
    }
}

Now, look at how this changes the workflow. I can "filter" the price before the order is saved:

$total = 100.00;
$order_id = 123;

// Apply filters to the total
$final_total = HookManager::apply_filters('calculate_order_total', $total, $order_id);

echo "Final Price: " . $final_total;

// Example filter: A 10% discount for VIPs
HookManager::add_filter('calculate_order_total', function($price, $id) {
    return $price * 0.9; 
});

// Example filter: Adding a $5 flat shipping fee
HookManager::add_filter('calculate_order_total', function($price, $id) {
    return $price + 5.00;
});

If you run this, the total becomes 95.00 (100 * 0.9 + 5). The beauty here is the pipeline. Each function takes the output of the previous one. I've effectively built a plug-in system. I can write a whole new PHP file for "Summer Sales" that just adds a few filters, and the main application code never has to change.

The Trade-off

I'll be honest: this isn't a silver bullet. The biggest downside is "traceability." When you're debugging and you see apply_filters('calculate_order_total', $total), you can't just click "Go to Definition" in your IDE to see what's happening to the price. The logic is scattered across the codebase.

That's why you should only use hook-based architecture for things that are truly extensible. Don't use a hook for your primary business logic; use it for the "edges" of your application—notifications, data transformations, and third-party integrations.




📋 Practical Task

Building a Dynamic Discount Engine

You are tasked with creating a checkout system that allows different "Discount Modules" to modify the final price of a shopping cart without modifying the Checkout class itself.

  • Implement a HookManager class with add_filter and apply_filters methods.
  • Create a Checkout class with a method calculateFinalPrice($basePrice) that uses the HookManager to apply filters to the price.
  • Register at least two different filters:
    1. A Percentage Discount (e.g., 20% off).
    2. A Flat Coupon (e.g., subtract $10).
  • Instantiate the Checkout class and print the final price to the screen to verify that both filters were applied in sequence.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.