DEV Community

loading...

Laravel: Bind an interface based on request parameters

kyam profile image Kyam ・4 min read

Recently at work, we encountered a problem that I couldn't find a solution to anywhere. So I thought I'd share our process and solution to help anyone else who might come across the same problem.

The Problem

We have an application with a small set of users. For reasons I won't go in to, if one user hits a certain Service class, we wanted the service to call one of its dependencies, DependencyA. However if any other user came through we needed the service to use DependencyB. The service looked something like this

private $depA;
private $depB;

public function __construct(DependencyA $depA, DependencyB $depB)
{
    $this->depA = $depA;
    $this->depB = $depB;
}

public function buildRequest(Request $request, string $user)
{
    if ($user === 'userA') {
        $stuff = $this->depA->doThing($request);
    } else {
        $stuff = $this->$depB->doThing($request);
    }

    return response()->json($stuff);
}
Enter fullscreen mode Exit fullscreen mode

Which looks reasonably neat. However, the problem for us lay in the fact that in no scenario will both of this classes dependencies be used - UserA will hit depA but never depB, and UserB will hit depB but never depA.

Ideally we want a scenario where the service only knows about the dependencies it needs for a given operation. We could implement a separate route, and service, for the two user groups, but we felt we could fix this with a code solution with minimal duplication.

Only care about interfaces

So, where do we start? Well, both dependencies do a similar job, just different results, they both have the method doThing. So we could make them both implement a shared interface and type hint our service dependency by the interface:

private $depA;

public function __construct(Thingable $depA)
{
    $this->depA = $depA;
}

public function buildRequest(Request $request)
{
    $stuff = $this->depA->doThing($request);

    return response()->json($stuff);
}
Enter fullscreen mode Exit fullscreen mode

Neater, right? Or at least less code.
Previously, when our service needed DependencyA and DependencyB, the Laravel IoC container would find those concrete classes and inject them into our service. However, now we only type hint by the Thingable interface, how does Laravel know what to inject? Somewhere, based on the request params, we need to tell it something like:

App::bind(
    'App\Thingable', \\ The interface we want to bind to
    'App\DependencyA' \\ The implementation - or DependencyB.
);
Enter fullscreen mode Exit fullscreen mode

At this point it is worth knowing that the users name is being discerned by the URL they are hitting, eg api/kyam/url

So we need to do the above somewhere where
a) we have access to the Application and
b) we have access to the request parameters.

What we tried

Middleware

We currently have some middleware that checks for the user name, so maybe we could hook into that flow and set the binding at that time?

public function handle($request, Closure $next)
{
    $userName = $request->user_name;

    if ($userName === 'userA') {
        App::bind(
            'App\Thingable',
            'App\DependencyA'
        );
    } else {
        App::bind(
            'App\Thingable',
            'App\DependencyB'
        );
    }
    return $next($request);
}
Enter fullscreen mode Exit fullscreen mode

So, while we definitely have access to the user name here, we had problems setting anything with the application. It seemed like the App hadn't booted up yet, and so we couldn't set anything up on it. This makes sense, because we are in Middleware, which all happens before we hit the actual application, so it stands to reason that we can't access application code yet. Moving on.

App Service Provider

This is where you are supposed to set your application bindings, in the register method.

public function register()
{
    $userName = $request()->user_name;

    if ($userName === 'userA') {
        App::bind(
            'App\Thingable',
            'App\DependencyA'
        );
    } else {
        App::bind(
            'App\Thingable',
            'App\DependencyB'
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Here we definitely have the ability to set Application Bindings, so no problem there. However, getting parameters off of the request proved to be a problem. A Laracasts thread from 4 years ago suggested some solutions, but we couldn't get any of them to return us what we needed, and so this solution wouldn't work for us either.

Controller

Next, as a bit of a stab in the dark to get something working, we tried to set dependencies in the Controller before the code hit our Service. This did not work. The problem here was that the Controller depended on the Service, and the Service depended on a Thingable which wasn't bound yet, and so the Application struggled to load before we could set our dependency. We were in last chance saloon, and soon we were going to have to revert to our two-dependency solution we started with, for fear of dumping endless time into an unfixable problem.

App Service Provider, Revisited

Looking on the Laravel docs, we found out about Contextual Binding. We weren't sure that this would work, but we were willing to try anything at this point! Our code looked like this:

public function register()
{
    $this->app->when(Service::class)
        ->needs(Thingable::class)
        ->give(function () {
            return request()->user_name === 'userA'
                ? $this->app->get(DependencyA::class)
                : $this->app->get(DependencyB::class);
        });
}
Enter fullscreen mode Exit fullscreen mode

What is this doing? It is setting that when the App needs to make a Service class that needs a Thingable, run the following callback function. This will then do our user check logic, and return the correct dependency.

This worked. Why? Well, I don't really know. I presume that we are running this after the app is booted, and so request() returns us what we expect. But we haven't constructed the Service class yet in our code, and so we are able to make changes to its dependencies. This is also nice because we are still able to set these dependencies where we are supposed to, in the AppServiceProvider.

And that's it! Hopefully this is helpful to anyone else who might find themselves in this situation, but going through this problem helped me understand how Laravel dependencies are loaded and hopefully it did you too.

And if anyone has any more details to fill in the I don't really know why... bits of this, or another approach to solving this problem I'd love to learn more!

Discussion

pic
Editor guide