Skip to content

Instantly share code, notes, and snippets.

@ollieread
Last active May 4, 2024 20:56
Show Gist options
  • Save ollieread/144840c92e6829e07134c17b28878510 to your computer and use it in GitHub Desktop.
Save ollieread/144840c92e6829e07134c17b28878510 to your computer and use it in GitHub Desktop.
Idea for generic dependency-injection attributes for PHP

You could provide a number of generic and agnostic attributes for use with dependency-injection solutions without enforcing any sort of contract or implementing structure. This would be achieved by only targetting things that we know will be used. These attributes could be used with auto-wiring or a compilation state that collects all the data for production deployment or something.

These attributes:

  • Wouldn't enforce a particular implementation
  • Wouldn't restrict the usage of the class
  • Wouldn't even require DI.

Binding

Binding is a generic enough term and it's something that's used by almost every DI solution. Instead of enforcing a class method responsible for binding, which is already handled by the container PSR, you could provide an attribute that allows a class to define its binding.

#[Attribute(Attribute::TARGET_CLASS)]
final readonly class Binding
{
    /** @var class-string */
    public string $abstract;
    
    /** @var array<class-string> */
    public array $aliases;
    
    /**
     * @var class-string $abstract
     * @var class-string ...$aliases
     */
    public function __construct(string $abstract, string ...$aliases)
    {
        $this->abstract = $abstract;
        $this->aliases  = $aliases;
    }
}

A package developer would now include this on their class.

#[Binding(MyInterface::class, MyClass::class)]
class MyClass implements MyInterface
{}

Shared instances/Singletons

Almost every DI solution also has the concept of a "shared" instance, where a single instance is treated as a singleton. This could be achieved with a simple marker attriute.

#[Attribute(Attribute::TARGET_CLASS)]
final class Shared {}

Factories

Not every DI solution has the concept of a "factory", but adding support for it would be trivial. Consider the following interface.

#[Attribute(Attribute::TARGET_METHOD)]
final readonly class Factory 
{
    /** @var class-string */
    public string $abstract;
    
    /**
     * @var class-string $abstract
     */
    public function __construct(string $abstract)
    {
        $this->abstract = $abstract;
    }
}

This particular attribute has two possible usages (which is why the abstract property supports null).

Factory Classes

Consider a class responsible for creating different instances. Rather than create a separate binding for each class it can create, you'd just register a class with the DI solution and it'd parse for methods with this attribute.

class MyFactory {
    #[Factory(MyInterface::class)]
    public static function interface(): MyInterface {}
    
    #[Factory(MyOtherInterface::class)]
    public static function otherInterface(): MyOtherInterface {}
}

Singletons

You could also use the Factory attribute with classes marked as Shared that are actual singletons.

#[Shared]
final class MySingleton
{
    private static self $instance;
    
    #[Factory]
    public static function instance(): self
    {
        if (! isset(self::$instance)) {
            self::$instance = new self;
        }
        
        return self::$instance;
    }
}

In this case you'd have code like the following:

$container->bind(MySingleton::class);

Which would process and detect the attributes, and would be the equivelant of something like:

$container->bind(MySingleton::class, function () {
    return MySingleton::instance();
}, true);

Others

There are a few other processes commonly included in DI solutions, though not so much with PHP because we didn't have attributes like this before.

Qualifying

Sometimes you'll have a class bound as MyDriver::class, but specific classes may need specific implementions. A good example of this would be a PaymentProvider interface with the implementations WorldPayPaymentProvider and StripePaymentProvider. Imagine you then have the class StripeHandler which handles a bunch of different things, and will require an instance of PaymentProvider. With a qualified binding you can have a default implementation bind to PaymentProvider, but qualify it by providing other "sub-bindings". It'd be used something like this:

class StripeHandler {
    public function __construct(#[QualifiedBy('stripe')] PaymentProvider $paymentProvider) {}
}

This approach is useful because it allows for the requirement of particular drivers without an implementation. A great example of why this would be useful is with something Laravel database connections. By default, all connections are instances of the same class, so don't have any distinguishing feature, beyond the name they're registered with.

Scope

The idea behind this one is simple. You mark a class as belonging to a scope, and the DI solution would have a container, that contains multiple other containers, each for a different scope.

Imagine a system where the default binding for Authenticatable is User, which would be a totally sensible thing to do. Now, imagine that the same system as an Admin class that implements Authenticatable. Your server class can still depend on Authenticatable, but my marking the class with #[Scope('admin')] it'd use the container within that scope, and the binding from that.

This is also useful with setups like Swoole and Laravel Octane where a process lives a lot longer than we would ordinarily expect.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment