looking into
The Illuminate Container

Ian Littman / @iansltx

Bulgaria PHP Conference 2019

https://ian.im/icbg19

What we'll cover

  • How to use the Container outside Laravel
  • Features of the Illuminate Container
    • Basic binding
    • Contextual binding
    • Autowiring
  • How these features work under the hood
  • Changes in these features in recent Laravel versions

What we Won't Cover

Looking under the hood...

Code has been edited to better fit on each slide. Actual Illuminate Cotainer code is PSR-2 compliant (or close to it), has comments, and doesn't include scalar parameter types.

For example

composer require illuminate/container 6.x@dev

Wait...why do I have to install @dev?

  • Current stable release required two functions from illuminate/support
  • Laravel PR #30518 extracted those two functions, removing the dep
  • Dropping illuminate/support results in way fewer dependencies
    • illuminate/contracts
    • psr/simple-cache (transitive from illuminate/contracts)
    • PHP ^7.2
    • 136 lines in composer.lock vs. 524
  • This will be stable in v6.5.1 or v6.6.0; guessing we'll see a release ~Nov 12

Use it with Slim 4

$container = new Illuminate\Container\Container();

// if everything can't be autowired, bind here...

\Slim\Factory\AppFactory::setContainer($container);

// ...or here

$app = AppFactory::create();

// ...or here

$app->run();

This works with autowiring

class Auth
{
    protected $rs;

    public function __construct(RaffleService $rs) {
        $this->rs = $rs;
    }

    // snip
}

$container->get(Auth::class); // instance of Auth
$container->get(Auth::class); // different instance

What does get() do?

public function get($id)
{
    try {
        return $this->resolve($id);
    } catch (Exception $e) {
        if ($this->has($id)) {
            throw $e;
        }

        throw new EntryNotFoundException($id);
    }
}

What does has() do?

public function has($id)
{
    return $this->bound($id);
}

public function bound($abstract)
{
    return isset($this->bindings[$abstract]) ||
           isset($this->instances[$abstract]) ||
           $this->isAlias($abstract);
}

Make sure you're using 5.7+ if you use the PSR-11 interface

Prior to version 5.7, the get() method would fail if a dependency was autowired rather than declared explicitly. Laravel PR #25870 fixed this.

...actually, we just want a single global Auth instance...

// in service config
$container->singleton(Auth::class);

// or you could use
$container->bind(Auth::class, null, $shared = true);

 

// Use Laravel? You're probably used to...
$container->make(Auth::class);

// ...or we can use ArrayAccess
$container[Auth::class]; // same instance as above

Is make() any different than get()?

Yes, but it's identical to pulling a dependency via ArrayAccess.

 

public function offsetGet($key)
{
    return $this->make($key);
}

public function make($abstract, array $parameters = [])
{
    return $this->resolve($abstract, $parameters);
}

Under the hood: singleton

public function singleton($abstract, $concrete = null)
{
    $this->bind($abstract, $concrete, true);
}

// available as of 6.x
public function singletonIf($abstract, $concrete = null)
{
    if (! $this->bound($abstract)) {
        $this->singleton($abstract, $concrete);
    }
}

Under the hood: Binding

public function bind($abstract, $concrete = null, $shared = false)
{
    $this->dropStaleInstances($abstract);

    if (is_null($concrete)) $concrete = $abstract;

    if (! $concrete instanceof Closure)
        $concrete = $this->getClosure($abstract, $concrete);

    $this->bindings[$abstract] = 
        compact('concrete', 'shared');

    if ($this->resolved($abstract)) $this->rebound($abstract);
}

Under the hood: Binding

public function bind($abstract, $concrete = null, $shared = false)
{
    $this->dropStaleInstances($abstract);

    if (is_null($concrete)) $concrete = $abstract;

    if (! $concrete instanceof Closure)
        $concrete = $this->getClosure($abstract, $concrete);

    $this->bindings[$abstract] = 
        compact('concrete', 'shared');

    if ($this->resolved($abstract)) $this->rebound($abstract);
}

Under the hood: Binding

protected function dropStaleInstances($abstract)
{
    unset(
        $this->instances[$abstract],
        $this->aliases[$abstract]
    );
}

Under the hood: Binding

public function bind($abstract, $concrete = null, $shared = false)
{
    $this->dropStaleInstances($abstract);

    if (is_null($concrete)) $concrete = $abstract;

    if (! $concrete instanceof Closure)
        $concrete = $this->getClosure($abstract, $concrete);

    $this->bindings[$abstract] = 
        compact('concrete', 'shared');

    if ($this->resolved($abstract)) $this->rebound($abstract);
}

Under the hood: Binding

public function bind($abstract, $concrete = null, $shared = false)
{
    $this->dropStaleInstances($abstract);

    if (is_null($concrete)) $concrete = $abstract;

    if (! $concrete instanceof Closure)
        $concrete = $this->getClosure($abstract, $concrete);

    $this->bindings[$abstract] = 
        compact('concrete', 'shared');

    if ($this->resolved($abstract)) $this->rebound($abstract);
}

Under the hood: Binding

protected function getClosure($abstract, $concrete)
{
    return function ($container, $parameters = [])
        use ($abstract, $concrete)
    {
        if ($abstract == $concrete) {
            return $container->build($concrete);
        }

        // switched from using make() in 5.8
        return $container->resolve(
            $concrete, $parameters, $raiseEvents = false
        );
    };
}

If you want to bring your own Closure...

function (Container $c, $params = []) {
    return new MyService(
        'foo',
        'bar',
        $c->get(SomeDependency::class)
    );
}

An example Closure from Raphple

$container->bind(
    ExtendedPdo::class,
    function () use ($env) {
        return new \Aura\Sql\ExtendedPdo(
            /* DSN here */,
            $env['DB_USER'],
            $env['DB_PASSWORD']
        );
    }
);

Under the hood: Binding

public function bind($abstract, $concrete = null, $shared = false)
{
    $this->dropStaleInstances($abstract);

    if (is_null($concrete)) $concrete = $abstract;

    if (! $concrete instanceof Closure)
        $concrete = $this->getClosure($abstract, $concrete);

    $this->bindings[$abstract] = 
        compact('concrete', 'shared');

    if ($this->resolved($abstract)) $this->rebound($abstract);
}

Under the hood: Binding

// WARNING: not valid PHP code

protected $bindings = [
    string => [
        concrete => Closure(Container, array): mixed,
        shared => boolean
    ], ...
];

Under the hood: Binding

public function bind($abstract, $concrete = null, $shared = false)
{
    $this->dropStaleInstances($abstract);

    if (is_null($concrete)) $concrete = $abstract;

    if (! $concrete instanceof Closure)
        $concrete = $this->getClosure($abstract, $concrete);

    $this->bindings[$abstract] = 
        compact('concrete', 'shared');

    if ($this->resolved($abstract)) $this->rebound($abstract);
}

Under the hood: Binding

public function resolved(string $abstract)
{
    if ($this->isAlias($abstract)) {
        $abstract = $this->getAlias($abstract);
    }

    return isset($this->resolved[$abstract]) ||
           isset($this->instances[$abstract]);
}

Under the hood: Binding

protected function rebound(string $abstract)
{
    $instance = $this->make($abstract);
    foreach (
        $this->getReboundCallbacks($abstract) as $callback
    ) {
        call_user_func($callback, $this, $instance);
    }
}

protected function getReboundCallbacks($abstract) {
    // set via rebinding($abstract, Closure)
    return $this->reboundCallbacks[$abstract] ?? [];
}

Is there a singletonIf() equivalent to bind()?

Yes, and it has been around forever.

 

public function bindIf(
    $abstract,
    $concrete = null,
    $shared = false
) {
    if (! $this->bound($abstract)) {
        $this->bind($abstract, $concrete, $shared);
    }
}

Let's use our Auth service in a Slim action closure

$app->post('/{id}', function ($req, $res, $args) {
    $id = $args['id'];

    // snip

    if (!$this->auth->isAuthorized($req, $id))
        return $res->withRedirect('/');

    // snip
}

...wait...

  1. How can we access the auth service via a property?
  2. How is it accessible as all-lower-case "auth"?

How can we access the auth service via a property?

Slim binds the container to $this in route closures, and...

 

public function __get($key)
{
    return $this[$key];
}

How is it accessible as all-lower-case "auth"?

$container->alias(Auth::class, 'auth');

 

public function alias($abstract, $alias)
{
    // check moved from getAlias() in 5.8
    if ($alias === $abstract)
        throw new LogicException(
            "[{$abstract}] is aliased to itself."
        );

    // forward lookup
    $this->aliases[$alias] = $abstract;
    // reverse lookup
    $this->abstractAliases[$abstract][] = $alias;
}

Under the Hood: Aliases

public function isAlias($name)
{
    return isset($this->aliases[$name]);
}

public function getAlias($abstract)
{
    if (! isset($this->aliases[$abstract])) {
        return $abstract;
    }

    return $this->getAlias($this->aliases[$abstract]);
}

Under the Hood: Aliases in use via Resolve()

protected function resolve(
    string $abstract,
    array $parameters = [],
    bool $raiseEvents = true
) {
    $abstract = $this->getAlias($abstract);

    // do things with the un-aliased abstract identifier
}

 

getAlias() is used in 10 other places in the Container class, including in getAlias()

...but I'm in a legacy app that pre-defines a dependency!

$container->instance(MyClass::class, $preBuiltInstance);

public function instance($abstract, $instance)
{
    $this->removeAbstractAlias($abstract);

    $isBound = $this->bound($abstract);

    unset($this->aliases[$abstract]);

    $this->instances[$abstract] = $instance;

    if ($isBound) $this->rebound($abstract);

    return $instance;
}

not to be confused with...

public static function getInstance()
{
    if (is_null(static::$instance)) {
        static::$instance = new static;
    }

    return static::$instance;
}

public static function setInstance(
    ContainerContract $container = null
) {
    return static::$instance = $container;
}

This can't be autowired without some help

class RaffleService {
    // properties for dependencies

    public function __construct(
        ExtendedPdoInterface $db, // interface
        SMS $sms, // interface
        $phone_number // string
    ) {
        // set properties
    }

    // snip
}

Parameter #1: Bind abstract to concrete

$container->bind(
    ExtendedPdoInterface::class,
    ExtendedPdo::class
);

Parameter #2: Bind abstract To closure

$container[SMS::class] = function() use ($env) {
    if (isset($env['TWILIO_SID']))
        return new TwilioSMS(/* env params */);

    if (isset($env['NEXMO_KEY']))
        return new NexmoSMS(/* env params */);

    if (isset($env['DUMMY_SMS_WAIT_MS']))
        return new DummySMS($env['DUMMY_SMS_WAIT_MS']);
    
    throw new InvalidArgumentException(/* some message */);
};

Under the hood: array setting is slightly different

public function offsetSet($key, $value)
{
    $this->bind(
        $key,
        $value instanceof Closure
            ? $value
            : function () use ($value) { return $value; }
   );
}

Parameter #3: Contextual Binding

$container
    ->when(RaffleService::class) // can be an array as of 5.7
    ->needs('$phone_number')
    ->give($env['PHONE_NUMBER']); // can be a Closure

// or, for a simple cases like this...

$container->addContextualBinding(
    RaffleService::class,
    '$phone_number',
    $env['PHONE_NUMBER']
);

Under the Hood: Contextual Binding

public function when($concrete)
{
    $aliases = [];

    foreach (Util::arrayWrap($concrete) as $c) {
        $aliases[] = $this->getAlias($c);
    }

    return new ContextualBindingBuilder($this, $aliases);
}

Under the Hood: Contextual Binding

// inside ContextualBindingBuilder

public function needs($abstract) {
    $this->needs = $abstract;
    return $this;
}

public function give($implementation) {
    foreach (Util::arrayWrap($this->concrete) as $concrete)
        $this->container->addContextualBinding(
            $concrete,
            $this->needs,
            $implementation
        );
}

Under the Hood: Contextual Binding

// back inside Container

public function addContextualBinding(
    $concrete,
    $abstract,
    $implementation
) {
    $this->contextual[$concrete][$this->getAlias($abstract)]
        = $implementation;
}

...but how do we resolve() dependencies?

Under the Hood: Resolve()

protected function resolve($abstract, $parameters = [], $raiseEvents = true)
{
    $abstract = $this->getAlias($abstract);

    $needsContextualBuild = !empty($parameters)
       || !is_null($this->getContextualConcrete($abstract));

    if (isset($this->instances[$abstract])
            && !$needsContextualBuild)
        return $this->instances[$abstract];

    // snip
}

Under the Hood: Resolve()

protected function resolve($abstract, $parameters = [], $raiseEvents = true)
{
    $abstract = $this->getAlias($abstract);

    $needsContextualBuild = !empty($parameters)
       || !is_null($this->getContextualConcrete($abstract));

    if (isset($this->instances[$abstract])
            && !$needsContextualBuild)
        return $this->instances[$abstract];

    // snip
}

Under the Hood: getContextualConcrete()

protected function getContextualConcrete($abstract)
{
    // e.g. RaffleService::class
    if (! is_null($binding = $this->findInContextualBindings($abstract)))
        return $binding;

    if (empty($this->abstractAliases[$abstract]))
        return;

    // e.g. "raffleService"
    foreach ($this->abstractAliases[$abstract] as $alias)
        if (! is_null($binding = $this->findInContextualBindings($alias)))
            return $binding;
}

Under the Hood: FindInContextualBindings()

protected function findInContextualBindings($abstract)
{
    return $this->contextual[end($this->buildStack)][$abstract]
        ?? null;
}

Under the Hood: Resolve()

protected function resolve($abstract, $parameters = [], $raiseEvents = true)
{
    $abstract = $this->getAlias($abstract);

    $needsContextualBuild = !empty($parameters)
       || !is_null($this->getContextualConcrete($abstract));

    if (isset($this->instances[$abstract])
            && !$needsContextualBuild)
        return $this->instances[$abstract];

    // snip
}

Under the Hood: Resolve()

protected function resolve(/* snip */)
{
    // snip

    $this->with[] = $parameters;

    $concrete = $this->getConcrete($abstract); 

    // snip
}

Under the Hood: Resolve()

protected function resolve(/* snip */)
{
    // snip

    $this->with[] = $parameters;

    $concrete = $this->getConcrete($abstract); 

    // snip
}

Under the Hood: Resolve()

protected function getConcrete(string $abstract)
{
    if (! is_null($concrete = $this->getContextualConcrete($abstract))) {
        return $concrete;
    }

    // if we have an explicit binding, use it
    if (isset($this->bindings[$abstract])) {
        return $this->bindings[$abstract]['concrete'];
    }

    // otherwise, let autowiring do its job
    return $abstract;
}

Under the Hood: Resolve()

protected function resolve(/* snip */)
{
    // $concrete was just set to $this->getConcrete($abstract)

    if ($this->isBuildable($concrete, $abstract)) {
        $object = $this->build($concrete);
    } else {
        $object = $this->make($concrete);
    } 

    // snip
}

Under the Hood: Resolve()

protected function isBuildable($concrete, $abstract)
{
    return $concrete === $abstract
        || $concrete instanceof Closure;
}

Under the Hood: Resolve()

protected function resolve(/* snip */)
{
    // snip

    if ($this->isBuildable($concrete, $abstract)) {
        $object = $this->build($concrete);
    } else { // non-Closure contextual concrete
        $object = $this->make($concrete);
    } 

    // snip
}

get ready for some Reflection

Reflection

Under the Hood: Build()

public function build($concrete)
{
    if ($concrete instanceof Closure)
        return $concrete($this, $this->getLastParameterOverride());

    try {
        $reflector = new ReflectionClass($concrete);
    } catch (ReflectionException $e) { // try-catch as of 6.x
        throw new BindingResolutionException(
            "Target class [$concrete] does not exist.", 0, $e
        );
    }

    if (! $reflector->isInstantiable()) // abstract or interface
        return $this->notInstantiable($concrete);

    // snip
}

Under the Hood: Build()

public function build($concrete)
{
    // snip

    $this->buildStack[] = $concrete;

    $constructor = $reflector->getConstructor();

    if (is_null($constructor)) {
        array_pop($this->buildStack);

        return new $concrete;
    }

    // snip
}

Under the Hood: Build()

public function build($concrete)
{
    // snip

    $this->buildStack[] = $concrete;

    $constructor = $reflector->getConstructor();

    if (is_null($constructor)) {
        array_pop($this->buildStack);

        return new $concrete;
    }

    // snip
}

Under the Hood: Build()

public function build($concrete)
{
    // snip
    $dependencies = $constructor->getParameters();

    try {
        $instances = $this->resolveDependencies($dependencies);
    } catch (BindingResolutionException $e) {
        array_pop($this->buildStack);

        throw $e;
    }

    array_pop($this->buildStack);

    return $reflector->newInstanceArgs($instances);
}

Under the Hood: Build()

public function build($concrete)
{
    // snip
    $dependencies = $constructor->getParameters();

    try {
        $instances = $this->resolveDependencies($dependencies);
    } catch (BindingResolutionException $e) {
        array_pop($this->buildStack);

        throw $e;
    }

    array_pop($this->buildStack);

    return $reflector->newInstanceArgs($instances);
}

Under the Hood: Build()

protected function resolveDependencies(array $dependencies)
{
    $results = [];

    foreach ($dependencies as $dependency) {
        if ($this->hasParameterOverride($dependency)) {
            $results[] = $this->getParameterOverride($dependency);

            continue;
        }

        $results[] = is_null($dependency->getClass())
                        ? $this->resolvePrimitive($dependency)
                        : $this->resolveClass($dependency);
    }

    return $results;
}

Under the Hood: Build()

protected function hasParameterOverride($dependency) {
    return array_key_exists(
        $dependency->name, $this->getLastParameterOverride()
    );
}

protected function getLastParameterOverride() {
    return count($this->with) ? end($this->with) : [];
}

protected function getParameterOverride($dependency) {
    return $this->getLastParameterOverride()[$dependency->name];
}

Under the Hood: Build()

protected function resolveDependencies(array $dependencies)
{
    $results = [];

    foreach ($dependencies as $dependency) {
        if ($this->hasParameterOverride($dependency)) {
            $results[] = $this->getParameterOverride($dependency);

            continue;
        }

        $results[] = is_null($dependency->getClass())
                        ? $this->resolvePrimitive($dependency)
                        : $this->resolveClass($dependency);
    }

    return $results;
}

Under the Hood: Resolving primitives

protected function resolvePrimitive(ReflectionParameter $parameter)
{
    if (! is_null(
            $concrete = $this->getContextualConcrete('$'.$parameter->name)
    )) {
        return $concrete instanceof Closure ? $concrete($this) : $concrete;
    }

    if ($parameter->isDefaultValueAvailable()) {
        return $parameter->getDefaultValue();
    }

    $this->unresolvablePrimitive($parameter);
}

Under the Hood: Resolving primitives

protected function resolvePrimitive(ReflectionParameter $parameter)
{
    if (! is_null(
            $concrete = $this->getContextualConcrete('$'.$parameter->name)
    )) {
        return $concrete instanceof Closure ? $concrete($this) : $concrete;
    }

    if ($parameter->isDefaultValueAvailable()) {
        return $parameter->getDefaultValue();
    }

    $this->unresolvablePrimitive($parameter);
}

Under the Hood: Resolving primitives

protected function resolvePrimitive(ReflectionParameter $parameter)
{
    if (! is_null(
            $concrete = $this->getContextualConcrete('$'.$parameter->name)
    )) {
        return $concrete instanceof Closure ? $concrete($this) : $concrete;
    }

    if ($parameter->isDefaultValueAvailable()) {
        return $parameter->getDefaultValue();
    }

    $this->unresolvablePrimitive($parameter);
}

Under the Hood: building Classes

protected function resolveClass(ReflectionParameter $parameter)
{
    try {
        return $this->make($parameter->getClass()->name);
    } catch (BindingResolutionException $e) {
        if ($parameter->isOptional()) {
            return $parameter->getDefaultValue();
        }

        throw $e;
    }
}

Under the Hood: building Classes

protected function resolveClass(ReflectionParameter $parameter)
{
    try {
        return $this->make($parameter->getClass()->name);
    } catch (BindingResolutionException $e) {
        if ($parameter->isOptional()) {
            return $parameter->getDefaultValue();
        }

        throw $e;
    }
}

...and that's how Container::Build() works!

Under the Hood: Resolve()

protected function resolve(/* snip */)
{
    // $object = $this->build($concrete) or $this->make($concrete)

 

    foreach ($this->getExtenders($abstract) as $extender) {
        $object = $extender($object, $this);
    }

    if ($this->isShared($abstract) && ! $needsContextualBuild) {
        $this->instances[$abstract] = $object;
    }

    // snip
}

Under the Hood: Resolve()

protected function resolve(/* snip */)
{
    // $object = $this->build($concrete) or $this->make($concrete)

 

    foreach ($this->getExtenders($abstract) as $extender) {
        $object = $extender($object, $this);
    }

    if ($this->isShared($abstract) && ! $needsContextualBuild) {
        $this->instances[$abstract] = $object;
    }

    // snip
}

Under the Hood: Extenders

protected function getExtenders($abstract)
{
    $abstract = $this->getAlias($abstract);

    return $this->extenders[$abstract] ?? [];
}

 

See Extender docs for how to use, and this code block for the extend() implementation

Under the Hood: Resolve()

protected function resolve(/* snip */)
{
    // $object = $this->build($concrete) or $this->make($concrete)

 

    foreach ($this->getExtenders($abstract) as $extender) {
        $object = $extender($object, $this);
    }

    if ($this->isShared($abstract) && ! $needsContextualBuild) {
        $this->instances[$abstract] = $object;
    }
    // snip
}

Under the Hood: Resolve()

protected function resolve(/* snip */)
{
    // snip; object has been extended and persisted to instances if needed

    if ($raiseEvents) {
        $this->fireResolvingCallbacks($abstract, $object);
    }

    $this->resolved[$abstract] = true;

    array_pop($this->with);

    return $object;
}

Under the Hood: Resolution callbacks

protected function fireResolvingCallbacks($abstract, $object) {
    // set by resolving(Closure)
    $this->fireCallbackArray($object, $this->globalResolvingCallbacks);

    $this->fireCallbackArray( // set by resolving($abstract, Closure)
        $object,
        $this->getCallbacksForType(
            $abstract,
            $object,
            $this->resolvingCallbacks
        )
    );

    // set by afterResolving(Closure) && afterResolving($abstract, Closure)
    $this->fireAfterResolvingCallbacks($abstract, $object);
}

Under the Hood: Resolve()

protected function resolve(/* snip */)
{
    // snip; object has been extended and persisted to instances if needed

    if ($raiseEvents) {
        $this->fireResolvingCallbacks($abstract, $object);
    }

    $this->resolved[$abstract] = true;

    array_pop($this->with);

    return $object;
}

...and that's how you resolve a dependency

What we learned

  • How to use a few of the Illuminate Container's major features
  • How those features work under the hood
  • How these features have changed in recent releases
  • How to use the Container outside Laravel

Bonus: Other changes in versions

Bonus: Method Calls

Method bindings

  • bindMethod($method, $callback)
    • $method can be Class@method or array-style
  • hasMethodBinding()
  • callMethodBinding($method, $instance)
    • $instance is first param passed to the bound method
    • Container is second

injecting callables with call() and BoundMethod::call()

You can also curry closures with wrap($closure, $params = [])

Thanks! Questions?

Looking into the Illuminate Container - Bulgaria PHP 2019

By Ian Littman

Looking into the Illuminate Container - Bulgaria PHP 2019

If you use Laravel, you’re taking advantage of a feature-filled, somewhat complex depedency injection container. In this presentation we’ll pull back the curtain on the magic behind the container’s auto-wiring, contextual binding, and other such features, stopping along the way to highlight upgrades that the package has gotten over succesive versions of Laravel since 5.5. If you aren’t a Laravel dev and need to dependency-inject your application, you might even come out of this presentation deciding that the Illuminate container is the best solution for your particular use case!

  • 53
Loading comments...

More from Ian Littman