Managing Laravel services | Clean Integrations Part 3

Published 23/12/2021 | Last updated 24/12/2021 | 8480 views

What's the best way to switch out dependencies in a Laravel application? Let's take a look at managers, which keep things clean, simple, and isolated.

Managers can simplify switching out dependencies in your Laravel application.

I left my last post about testing APIs and services on a bit of a cliff-hanger:

There is another way of switching out our implementation in tests: managers. But that's a story for another blog post.

Ooh, isn't that annoying? My apologies up front. Well, fear not, because in this post, I shall reveal all about managers in Laravel, what they're used for, and how they can make your life as a Laravel developer simpler.

What is a Manager?

If you refer back to my previous blog post, you'll note that we had to place the following code in our TestCase.

<?php
 
namespace Tests;
 
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Artisan;
use Tests\Doubles\FakeTwitterClient;
 
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
use LazilyRefreshDatabase;
 
protected function setUp(): void
{
parent::setUp();
 
$this->swap(Twitter::class, new FakeTwitterClient());
}
}

This is okay, but somewhat hidden to developers coming into your code because it doesn't follow standard Laravel convention. What is standard Laravel convention for these scenarios? Take a look in config/cache.php. You'll see something like this.

<?php
 
use Illuminate\Support\Str;
 
return [
 
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache connection that gets used while
| using this caching library. This connection is used when another is
| not explicitly specified when executing a given caching function.
|
*/
 
'default' => env('CACHE_DRIVER', 'file'),

This is pretty common in Laravel; we have config files where we can specify the implementation, or driver, that should be used for a certain service. But where is this swapped out with a fake implementation for our tests? Take a look in your project's root for a file called phpunit.xml or phpunit.dist.xml. In there, you'll likely see something like this.

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./app</directory>
</include>
</coverage>
<php>
<server name="APP_ENV" value="testing"/>
<server name="BCRYPT_ROUNDS" value="4"/>
<server name="CACHE_DRIVER" value="array"/>
<server name="DB_CONNECTION" value="sqlite"/>
<server name="DB_DATABASE" value=":memory:"/>
<server name="MAIL_MAILER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/>
<server name="SESSION_DRIVER" value="array"/>
<server name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>

What's happening here? When we run our test suite, Pest or PHPUnit is going to load this xml file. It will take these <server/> nodes and replace any values found in our .env file with these values. So, in this case, our cache driver will always be array in tests, even if we've defined redis in our .env file. What a clean approach!

But how is it that the word file, or redis, or array results in actual implementations of our contract? The answer is a Manager. Let's create one for our Twitter service.

Creating a Manager

I like to place my Manager classes in the same directory as the implementations. So, let's create a new file at app/Services/Twitter/TwitterManager.php.

<?php
 
namespace App\Services\Twitter;
 
use Illuminate\Support\Manager;
 
final class TwitterManager extends Manager
{
public function getDefaultDriver(): string
{
}
}

Note that our class extends Illuminate\Support\Manager. That class has an abstract method that we're expected to implement: getDefaultDriver. The purpose of this method is to decide which implementation of our Twitter contract should be used by the application. Note that it returns a string. A string like file, or redis, or array perhaps. See where we're going here?

Let's update our twitter config in config/services.php to add support for defining our desired implementation.

<?php
 
return [
'mailgun' => [
'domain' => env('MAILGUN_DOMAIN'),
'secret' => env('MAILGUN_SECRET'),
'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
],
 
'postmark' => [
'token' => env('POSTMARK_TOKEN'),
],
 
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
 
'twitter' => [
'default' => env('TWITTER_DRIVER', 'oauth'),
'consumer_key' => env('TWITTER_CONSUMER_KEY'),
'consumer_secret' => env('TWITTER_CONSUMER_SECRET'),
'access_token' => env('TWITTER_ACCESS_TOKEN'),
'access_token_secret' => env('TWITTER_ACCESS_TOKEN_SECRET'),
],
];

Now, back in our TwitterManager, we can access and return this config value.

<?php
 
namespace App\Services\Twitter;
 
use Illuminate\Support\Manager;
 
final class TwitterManager extends Manager
{
public function getDefaultDriver(): string
{
return $this->config->get('services.twitter.default') ?? 'null';
}
}

Note that the manager gives us access to a config property, which is super useful. Also, note that use ?? to catch null values and return the string null instead. This is important.

So, we've defined two possible string values for our implementation: oauth and null. How do we translate those options to their respective classes? Laravel's Manager class will look for a method defined on your manager that follows the following signature: createXDriver, with X being the string value we want to implement. Let's use that knowledge to construct our classes.

<?php
 
namespace App\Services\Twitter;
 
use Illuminate\Support\Manager;
use Tests\Doubles\FakeTwitterClient;
use Abraham\TwitterOAuth\TwitterOAuth;
 
final class TwitterManager extends Manager
{
public function getDefaultDriver(): string
{
return $this->config->get('services.twitter.default') ?? 'null';
}
 
public function createOauthDriver(): OauthClient
{
return new OauthClient();
}
 
public function createNullDriver(): FakeTwitterClient
{
return new FakeTwitterClient();
}
}

Simple! If your implementations need items from the service container, the Manager gives you access to a container property, which you can use to load in those dependencies and pass to your implementations.

Now, our manager won't be called automagically. We need to update the binding we created in our AppServiceProvider to use this manager.

<?php
 
namespace App\Providers;
 
use Illuminate\Support\ServiceProvider;
use App\Contracts\Services\Twitter;
use Illuminate\Contracts\Container\Container;
use App\Services\Twitter\TwitterManager;
 
class AppServiceProvider extends ServiceProvider
{
 
public function register(): void
{
$this->app->bind(Twitter::class, function (Container $container) {
return (new TwitterManager($container))->driver();
});
}
 
}

So, let's now break down the flow our app will go through when we request an implementation of the Twitter contract from the service container:

  1. Laravel will create an instance of our TwitterManager, and call the driver method on it.
  2. The driver method (which is part of the base Manager class) will call the getDefaultDriver method we had to implement in our TwitterManager.
  3. Our getDefaultDriver method will look in our config/services.php file at the twitter.default key.
  4. That key will then be used to call a method on our manager: create[Key]Driver.
  5. The create[Key]Driver method will return the implementation for the requested driver.

Make sense? With this done, we can now update our phpunit.xml file to override whatever is in our .env file with our fake driver.

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./app</directory>
</include>
</coverage>
<php>
<server name="APP_ENV" value="testing"/>
<server name="BCRYPT_ROUNDS" value="4"/>
<server name="CACHE_DRIVER" value="array"/>
<server name="DB_CONNECTION" value="sqlite"/>
<server name="DB_DATABASE" value=":memory:"/>
<server name="MAIL_MAILER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/>
<server name="SESSION_DRIVER" value="array"/>
<server name="TELESCOPE_ENABLED" value="false"/>
<server name="TWITTER_DRIVER" value="null"/>
</php>
</phpunit>

Obviously, you can now remove the manual swap we added in our TestCase.

Conclusion

So, that's managers in Laravel! Pretty simple to integrate, and lots of power. If you ever added a third implementation, it would be a simple task to add a new createThirdImplementation method to our manager and be up and running in seconds.

As always, I hope you learned something new.

Kind Regards, Luke

Like what you see?

If you enjoy reading my content, please consider sponsoring me. I don't spend it on cups of coffee; it all goes towards freeing up more of my time to work on open source, tutorials and more posts like this one.