Mythical PHP DI

Andrii Prakapas · December 26, 2022

What if PHP DI1 will look like this?: $object = new Application(cache: new DbCache());


Or full example:

class Application
{
  public function __construct(
    private readonly CacheInterface $cache,
    private readonly LoggerInterface $logger,
  ) {
  }
...
}

$object = new Application(cache: new DbCache());

You don’t need to use container explicitly like $container->create(... instead of that you can use operator new2 as native PHP construction.

Beginnings

I am overwhelmed to use divers PHP DI tools. Each of them have some individualities, however we all know what they do under the hood - they inject dependencies. It doesn’t matter what DI you use, basically - if you need create class by hand - you cannot just write new ClassName() because the class might have additional dependencies. With codebase expansion you more often find some strange construction in the same class (or in the method even):

public function methodName(string $iAmParameter): void
{
  $object = Yii::createObject(CreatedByDI::class, ['param' => $iAmParameter]);
  ...
  $entity = new SimpleEntity($object->getRequiredParameterForSimpleEntity());
  ...
}

Maybe, after stumbling a couple times to that misconception, you decided to use only DI construction. And it is good.

public function methodName(string $iAmParameter): void
{
  $object = Yii::createObject(CreatedByDI::class, ['param' => $iAmParameter]);
  ...
  $entity = Yii::createObject(SimpleEntity::class, ['param' => $object->getRequiredParameterForSimpleEntity()]);
  ...
}

However do you really excited of the Yii::createObject(?

(it’s just a coincidence that I used Yii3 for example)

I don’t.

Continuing

Some of the DIs are really rich4 and help you create factories on the fly, or put object into any method/function without additional string of code. Nevertheless, on practical usage it is not really what I want in my business code (except configuration), I need a simplicity. One mechanism to create an object without rethought all dependencies.

Surprisingly, PHP already have it - new operator. Let’s edit the example above:

public function methodName(string $iAmParameter): void
{
  $object = new CreatedByDI(param: $iAmParameter);
  ...
  $entity = new SimpleEntity($object->getRequiredParameterForSimpleEntity());
  ...
}

The code is straight and solid, however the power of new operator with DI abilities appears in the deep.

The Deep

Let’s imagine you are using framework with DI and have a huge codebase that includes many different modules and one monolith with bussiness logic as glue between modules installed by composer.

⚠️ I’m going to use $container variable like alias to any DI you know.

ℹ️ Yes, maybe the examples isn’t really good, however you can find better in a real codebase.

// Order module
class OrderConvertService implements OrderConvertServiceInterface
{
  public function __construct(
    private readonly UserConvertServiceInterface $userService,
    private readonly ProductConvertServiceInterface $productService,
    private readonly DeliveryConvertServiceInterface $deliveryService,
  ) {
  }

  public function convert(OrderEntity $order): OrderDto
  {
    return $container->get(OrderDto::class, [
      'id' => $order->getId(),
      'date' => $order->getDate(),
      'status' => $order->getStatus(),
      'user' => $this->userService->convert($order->user ?? $container->get(AnonymousUserEntity::class)),
      'products' => $this->productService->convert($order->products),
      'delivery' => $this->deliveryService->convert($order->delivery),
    ]);
  }
}

...

// Global order event
class EventOrderCreated implements EventInterface
{
  public function __construct(
    private readonly OrderDto $order,
  ) {
  }
  
  public function getOrder(): OrderDto
  {
    return $this->order;
  }
}

...

// Mail module
class OrderCreatedListener implements ListenerInterface
{
  public function __construct(
    private readonly SendComponentInterface $sendComponent,
  ) {
  }

  public function handle(EventInterface $event): void
  {
    $order = $event->getOrder();
    if ($order->getUser() instanceof AnonymousUserEntity::class) {
      $phoneComponent = $container->get(PhoneSendComponent::class, [
        // some params here, but nobody know what exactly
      ]);
      $phoneComponent->notifyClient($order);
    
      return;
    }
  
    $this->sendComponent->notifyClient($order);
  }
}

...

// DI config
return [
  OrderConvertServiceInterface::class => OrderConvertService::class,
  UserConvertServiceInterface::class => UserConvertService::class,
  ProductConvertServiceInterface::class => ProductConvertService::class,
  DeliveryConvertServiceInterface::class => DeliveryConvertService::class,
  AnonymousUserEntity::class => static fn(ContainerInterface $c) => $c->create(AnonymousUserEntity::class, [
    'phone' => $c->get(OrderSessionInterface::class)->getPhone(),
    'name' => $c->get(OrderSessionInterface::class)->getName(),
  ]),
  OrderCreatedListener::class => static fn(ContainerInterface $c) => $c->get(OrderCreatedListener::class, [
    'sendComponent' => $c->get(MailSendComponent::class),
  ]),
];

First of all:

return $container->get(OrderDto::class, [ ...

can be changed into new OrderDto( because DTO objects should be simple, however in the name of convenience developers often put inherritance here as well. Consequently, if we use new with DI the line will turn into :

return new OrderDto(
  id: $order->getId(),
  date: $order->getDate(),
  status: $order->getStatus(),
  user: $this->userService->convert($order->user ?? new AnonymousUserEntity()),
  products: $this->productService->convert($order->products),
  delivery: $this->deliveryService->convert($order->delivery),
);

Of course, new AnonymousUserEntity() should be created by some factory, in that case we can use $config settings as well, even with new operator.

Therefore Listener will look like this:

class OrderCreatedListener implements ListenerInterface
{
  public function handle(EventInterface $event): void
  {
    $order = $event->getOrder();
    if ($order->getUser() instanceof AnonymousUserEntity::class) {
      (new PhoneSendComponent())->notifyClient($order);
    
      return;
    }
  
    (new MailSendComponent())->notifyClient($order);
  }
}

Hence, we get PHP native new operator that works like DI and can use usually $config DI settings for classes injection.

Implementation

I don’t have a work example, it is only just an idea that looks interesting for me. However I made a couple experiments.

Let’s imagine we have simple app, where directory src under App namespace:

.
├── .gitignore
├── composer.json
├── composer.lock
├── src
│   ├── Cache
│   │   └── ArrayCache.php
│   ├── Config
│   │   └── config.php
│   ├── Interfaces
│   │   ├── ApplicationInterface.php
│   │   ├── CacheInterface.php
│   │   └── LoggerInterface.php
│   ├── Logger
│   │   └── ConsoleLogger.php
│   └── Application.php
├── vendor
│   ├── di
│   ├── ... other packages ...
│   └── autoload.php
├── tmp
│   └── .gitignore
└── index.php

config.php:

return [
  CacheInterface::class => ArrayCache::class,
  LoggerInterface::class => ConsoleLogger::class,
  ArrayCache::class => static fn(ContainerInterface $c) => $c->singletone(ArrayCache::class),
];

ArrayCache.php

class ArrayCache implements CacheInterface
{
  private array $data = [];

  public function set(string $key, mixed $value): void
  {
    $this->data[$key] = $value;
  }
  
  public function get(string $key): mixed
  {
    return $this->data[$key];
  }
}

ConsoleLogger.php

class ConsoleLogger implement LoggerInterface
{
  public function log(string $message)
  {
    echo $message, PHP_EOL;
  }
}

Application.php

class Application implements ApplicationInterface
{
  public function _construct(
    private readonly CacheInterface $cache,
    private readonly LoggerInterface $logger,
  ) {
  }
  
  public function run(): int
  {
    $this->logger->log("Start Application");
    
    $this->logger->log($this->cache->get('message'));
    
    $this->logger->log("End Application");
    
    return 0;
  }
}

index.php

require __DIR__ '/vendor/autoload.php';

$cache = new ArrayCache();
$cache->set('message', $argv[1] ?? '--empty--');

$app = new Application();
if (0 !== $app->run()) {
  echo "ERROR!";
}

In the case when we use new ArrayCache(); we already use DI and object will be created even it has dependencies, as well as $app = new Application(); will be created with default cache and logger object. In any line of code we can change our logger:

...
$app = new Application(
  logger: new DbLogger(),
);
...

Or use $config settings like in any other DI:

...
Application::class => static fn(ApplicationParams $p) => $p->isCli() ? new Application() : new Application(logger: new DbLogger()),
...

In our case ContainerInterface it is a wrapper for method like: singletone or some kind of factory if we need it in configuration file. In other cases operator new is the really one solution for the code.

The idea is that we register custom autoload function5:

spl_autoload_register(static function (string $class) {
  $diNamespacePrefix = "DI\\";
  $diDirectory = "tmp/DI";
  $fileName = $diDirectory . DIRECTORY_SEPARATOR . str_replace("\\", DIRECTORY_SEPARATOR, $class) . '.php';

  if (!str_starts_with($class, $diNamespacePrefix) && file_exists($fileName)) {
    include $fileName;
  } else {
    include $class;
  }
});

So we literally, check if we can load substitution class instead of specific in application or we just load native class instead.

ℹ️ This approach isn’t multipurposes so we can load same Flyweight class for each native class so Flyweight will return native class in the runtime.

For example, this is Application’s class wrapper:

namespace \DI\Application;

class Application implements ApplicationInterface
{
  public function _construct(
    private readonly ?CacheInterface $cache = null,
    private readonly ?LoggerInterface $logger = null,
  ) {
  }
  
  // Or use magic method `__call`
  public function run(): int
  {
    return $globalContainer->create(\App\Application)->run();
  }
}

However it doesn’t work now (while I’m writing the article). There are couple reasons:

  1. Default composer loader override our loader or vise-versa.
  2. We cannot distinguish from what namespace the class is loading, spl_autoload_register callable receives only classname, so when we load \App\Application from DI namespace the loader loads \DI\Application instead original recusrively.

Summary

In my opinion it is interesting idea migrate from some additional tools, like DI, to new. Of course is isn’t super goal or something like a revolution, for PHP’s sake. It is just an opinion and thinkings over DI situation, because - as you saw - we still use DI under the new, and it may be written differently under the hood, the article more about how PHP class loader may works and gives us an ability to manipulate with classes. On the other hand, somebody may has an opinion that new must stay pure as it is, without any unusual behaviors and they will right.

Everyone chooses their side.

And as always: it is all about fun.


Twitter, Facebook