In this post, we're diving into the fascinating world of callable decorators—an advanced technique that can add a new layer of functionality to your functions or methods. Callable decorators can transform the way you think about code organization and reuse.
So, grab your favorite beverage, and let’s unlock the potential of PHP decorators together!
This article presents a personal workaround for PHP decorators, as PHP doesn't natively support them. The implementation is based on my custom code and practices, not an official PHP feature. Disclaimer (Click here)
Before you continue, please review these prerequisites if you're not familiar with them:
- PHP: Callbacks / Callables
- PHP: First Class Callable Syntax
- PHP: Attributes
- Design Patterns: Decorator
Are you ready? let's start with the following example:
namespace Banking\Account;
class TransferAccountFunds
{
public function execute(Account $sender, Account $receiver, Money $amount): void
{
// Check if balance is sufficient; throw an exception otherwise
// Decrease balance with given amount
// Add transaction record for debit operation
$sender->debit($amount);
// Increase balance with given amount
// Add transaction record for credit operation
$receiver->credit($amount);
}
}
Consider this service class that transfers funds between bank accounts. This operation involves multiple database changes, so it must be wrapped in a single atomic transaction to ensure all data is either fully saved or none at all if an exception occurs.
A feasible approach is to inject your database connection service, wrap your code explicitly in a try/catch
block, and manually handle the "start transaction," "commit," and "rollback" statements.
class TransferAccountFunds
{
public function __construct(private DatabaseConnection $conn)
{
}
public function execute(Account $sender, Account $receiver, Money $amount): void
{
$this->conn->startTransaction();
try {
$sender->debit($amount);
$receiver->credit($amount);
$this->conn->commit();
} catch (Exception $e) {
$this->conn->rollback();
throw $e;
}
}
}
This method works but mixes persistence concerns with your business logic, complicating your code. Plus, you’d need to repeat it for every similar use case, reducing maintainability.
So, how can we accomplish this without modifying our business logic? 🥁
Callable Decorator to the Rescue!!
Since PHP 8, #[attributes] let you add structured metadata to code elements like methods, which can be inspected at runtime. They act as an embedded configuration language, decoupling the generic implementation of a feature from its concrete use.
Combining attributes with the decorator pattern lets us elegantly manage the transactional requirement. But first, let’s dive into what a callable decorator is.
A "Callable Decorator" adds new behaviors to a callable by wrapping it in another callable that includes the additional behaviors. It’s a simple concept, but it can be a bit tricky to grasp. Let’s break down how to create one.
function decorate(callable $func): callable
{
return function (mixed ...$args) use ($func): mixed {
// do something before
$result = $func(...$args);
// do something after
return $result;
};
}
A callable decorator takes a callable as an argument, wraps it in a new function (the “wrapping phase”), and then adds additional functionality before or after the function call (the “enhancement and call phase”).
By returning a callable, this approach requires executing the decoration first and then calling the decorated function as if it were the original.
$func = function (int $x): void {
echo $x;
};
$func = decorate($func); // decoration phase
$func(x: 0); // enhancement and calling phase.
Want to give it a try? Check this out: https://3v4l.org/bbNql
Callable Decorators are especially useful in situations where you want to:
- Decouple functionality from its actual usage without using a interface to achieve it.
- Perform an action before or after a function call (e.g. logging, security checks, validation).
- Conditionally skip the function call by returning early (e.g. caching).
- Modify the function’s result (e.g. serialization, normalization, formatting).
Unlike traditional "Object Decorators", Callable Decorators focus on functions and methods only, making them ideal for modifying specific behaviors without altering the entire object.
This technique consists of two main elements:
- The decorator attribute: Holds all metadata and links a callable to its decorator.
- The decorator itself: Takes a callable as an argument and returns another callable with additional functionality.
Shall we create our transactional decorator using this approach? Let’s do it!
#[Attribute(Attribute::TARGET_METHOD)]
class Transactional
{
public function decoratedBy(): string
{
return TransactionalDecorator::class;
}
}
class TransactionalDecorator
{
public function __construct(private DatabaseConnection $conn)
{
}
public function decorate(callable $func): callable
{
return function (mixed ...$args) use ($func): mixed {
$this->conn->startTransaction();
try {
$result = $func(...$args);
$this->conn->commit();
return $result;
} catch (Exception $e) {
$this->conn->rollback();
throw $e;
}
};
}
}
So far, we’ve built a generic decorator that can wrap any callable in a transactional database operation, no matter its origin. Next, we’ll use the #[Transactional]
attribute to specify which callable should be wrapped.
Let’s decorate our initial example:
class TransferAccountFunds
{
#[Transactional]
public function execute(Account $sender, Account $receiver, Money $amount): void
{
$sender->debit($amount);
$receiver->credit($amount);
}
}
Is that all? Not quite. We’re missing the step where the attribute is read at runtime and the linked decorator is applied to ensure the TransferAccountFunds::execute()
method is correctly wrapped before it’s called.
The proper implementation of this step will be covered in the next article of this series, where we’ll see how to apply callable decorators to enhance your code’s functionality, providing a flexible way to manage cross-cutting concerns.
Stay tuned!
Because I love the Symfony framework, I’m introducing this approach for Symfony Controllers through a new Decorator component. However, you can find this integration already available in two PHP libraries: yceruto/decorator and yceruto/decorator-bundle (compatible with Symfony 6.4 or superior).
Top comments (1)
Nice! Without using decorators we can’t build a decent application. They are everywhere!