At some point in your journey as a developer, you will likely come across the term "design pattern". However, what is it? How does it work? What is its intended use?
In this article, we'll briefly look at what design patterns are and why they're important. We'll then take a look at the "builder pattern" (or, specifically, a variation of it that's frequently used in Laravel, the "manager pattern"). We'll take a dive into the code and look at how the manager pattern is implemented in the Laravel framework itself. We'll then explore how we can implement the manager pattern in our own project's application code.
What Are Design Patterns and Why Are They Important?
According to Source Making, a design pattern "is a general repeatable solution to a commonly occurring problem in software design". You can view design patterns as a toolbox of tried and tested solutions to common problems. They are not code that you can copy and paste into your project. Instead, it's best to think of them as principles that are predictable and consistent and can be used to resolve issues in your code.
A significant benefit of learning about using design patterns is that they aren't always tied to a specific programming language. Although you'd write the code differently depending on the language you're using, the underlying principle behind how and why a pattern works would be the same. This is a major benefit because it means that you can learn about design patterns once and then apply them to any language you're using.
Having a predictable and consistent way to solve problems also allows you to write high-quality code that is easier to understand and maintain by not only yourself but also other developers. This makes it easier for new developers on your team to start contributing meaningful code to your projects sooner because they may already be familiar with the patterns you're using.
However, it's important to note that design patterns are not a silver bullet. They aren't always the best solution to every problem and should be used within the context of the code on which you're working. This is especially true when you first learn about design patterns; it may seem logical to assume that all of your code must follow a given pattern. However, this isn't always the case; sometimes, it can add unnecessary complexity to your code when simpler code could have done the same job. Therefore, it's important to use your best judgement when deciding whether to use a design pattern.
In general, design patterns can be split into three distinct categories that describe the patterns' purpose:
- Creational patterns - These patterns are used to create objects that are flexible and easy to change, which encourages code reusability. Examples of these patterns are the factory pattern, builder pattern, and singleton pattern.
- Structural patterns - These patterns are used to assemble objects into larger structures that can then be used to achieve a common goal. Examples of these patterns are the adapter pattern, facade pattern, and decorator pattern.
- Behavioral patterns - These patterns are used to describe how objects interact with each other. Examples of these patterns are the observer pattern, strategy pattern, and command pattern.
For the remainder of this article, we'll be focusing on one of the creational patterns, the "builder pattern" (or, more specifically, the "manager pattern" in Laravel).
What Is the Manager Pattern?
Before we look at what the manager pattern is, we first need to understand the builder pattern.
The builder pattern is a creational design pattern that you can use to build complex (but similar) objects in small steps. It allows you to create objects that use the same construction code but represent different things.
In the Laravel world, a specific variation of the builder pattern is often referred to as the "manager pattern". This is due to the use of "manager classes" to handle the creation of the objects.
If you've used Laravel before, you'll likely have interacted with the manager pattern without realizing.
For example, when you use the Storage
facade, you interact with the underlying Illuminate\Filesystem\FilesystemManager
class. This class is responsible for creating the driver classes (for reading and writing to different file storage systems) that you interact with when you want to interact with file storage in your projects.
The framework also implements the manager pattern in other places and provides other manager classes, such as the following:
-
Illuminate\Auth\AuthManager
-
Illuminate\Auth\Passwords\PasswordBrokerManager
-
Illuminate\Broadcasting\BroadcastManager
-
Illuminate\Cache\CacheManager
-
Illuminate\Database\DatabaseManager
-
Illuminate\Filesystem\FilesystemManager
-
Illuminate\Hashing\HashManager
-
Illuminate\Log\LogManager
-
Illuminate\Mail\MailManager
-
Illuminate\Notifications\ChannelManager
-
Illuminate\Queue\QueueManager
-
Illuminate\Redis\RedisManager
-
Illuminate\Session\SessionManager
To give this pattern some context, let's take a look at a small code example.
In your Laravel project, if you wanted to store a file in your "local" file storage, you might use the local
driver:
1Storage::disk('local')->put(...);2```
However, if you wanted to store a file in an AWS S3 bucket, you might want to the use s3
driver like so:
1Storage::disk('s3')->put(...);
Similarly, if you've defined a default storage disk by setting the default
config key in your config/filesystems.php
, then you might not want to manually specify the disk and would prefer to use something like this:
1Storage::put(...);
Although these examples may seem simple, they have a lot of complexity behind the scenes. The Storage
facade allows us to switch between different drivers without having to worry about the underlying complexity of how each driver works.
This is especially useful in the case of the default driver because, theoretically, you could change the default driver in your config/filesystems.php
config file and wouldn't need to change any of your application's code to start using the new file system storage. This is a result of decoupling our code from the underlying implementation details of the drivers.
How Does Laravel Implement the Manager Pattern?
Now that you have a high-level understanding of how you interact with Laravel's implementations of the manager pattern, let's take a look at how it all works under the hood.
Some of the more complex manager classes, such as Illuminate\Filesystem\FilesystemManager
, in the framework use a bespoke class for defining their behavior and functionality. However, some of the simpler classes, such as Illuminate\Hashing\HashManager
, extend from an abstract class called Illuminate\Support\Manager
. This abstract class provides a lot of the base functionality that the manager classes need to work.
The manager classes, regardless of whether they extend from the Illuminate\Support\Manager
class, all have a similar structure and are responsible for creating the driver classes with which you interact. To understand how these classes work under the hood, let's take a look at the Illuminate\Support\Manager
class:
1namespace Illuminate\Support; 2 3use Closure; 4use Illuminate\Contracts\Container\Container; 5use InvalidArgumentException; 6 7abstract class Manager 8{ 9 /** 10 * The container instance. 11 * 12 * @var \Illuminate\Contracts\Container\Container 13 */ 14 protected $container; 15 16 /** 17 * The configuration repository instance. 18 * 19 * @var \Illuminate\Contracts\Config\Repository 20 */ 21 protected $config; 22 23 /** 24 * The registered custom driver creators. 25 * 26 * @var array 27 */ 28 protected $customCreators = []; 29 30 /** 31 * The array of created "drivers". 32 * 33 * @var array 34 */ 35 protected $drivers = []; 36 37 /** 38 * Create a new manager instance. 39 * 40 * @param \Illuminate\Contracts\Container\Container $container 41 * @return void 42 */ 43 public function __construct(Container $container) 44 { 45 $this->container = $container; 46 $this->config = $container->make('config'); 47 } 48 49 /** 50 * Get the default driver name. 51 * 52 * @return string 53 */ 54 abstract public function getDefaultDriver(); 55 56 /** 57 * Get a driver instance. 58 * 59 * @param string|null $driver 60 * @return mixed 61 * 62 * @throws \InvalidArgumentException 63 */ 64 public function driver($driver = null) 65 { 66 $driver = $driver ?: $this->getDefaultDriver(); 67 68 if (is_null($driver)) { 69 throw new InvalidArgumentException(sprintf( 70 'Unable to resolve NULL driver for [%s].', static::class 71 )); 72 } 73 74 // If the given driver has not been created before, we will create the instances 75 // here and cache it so we can return it next time very quickly. If there is 76 // already a driver created by this name, we'll just return that instance. 77 if (! isset($this->drivers[$driver])) { 78 $this->drivers[$driver] = $this->createDriver($driver); 79 } 80 81 return $this->drivers[$driver]; 82 } 83 84 /** 85 * Create a new driver instance. 86 * 87 * @param string $driver 88 * @return mixed 89 * 90 * @throws \InvalidArgumentException 91 */ 92 protected function createDriver($driver) 93 { 94 // First, we will determine if a custom driver creator exists for the given driver and 95 // if it does not we will check for a creator method for the driver. Custom creator 96 // callbacks allow developers to build their own "drivers" easily using Closures. 97 if (isset($this->customCreators[$driver])) { 98 return $this->callCustomCreator($driver); 99 } else {100 $method = 'create'.Str::studly($driver).'Driver';101 102 if (method_exists($this, $method)) {103 return $this->$method();104 }105 }106 107 throw new InvalidArgumentException("Driver [$driver] not supported.");108 }109 110 /**111 * Call a custom driver creator.112 *113 * @param string $driver114 * @return mixed115 */116 protected function callCustomCreator($driver)117 {118 return $this->customCreators[$driver]($this->container);119 }120 121 /**122 * Register a custom driver creator Closure.123 *124 * @param string $driver125 * @param \Closure $callback126 * @return $this127 */128 public function extend($driver, Closure $callback)129 {130 $this->customCreators[$driver] = $callback;131 132 return $this;133 }134 135 /**136 * Get all of the created "drivers".137 *138 * @return array139 */140 public function getDrivers()141 {142 return $this->drivers;143 }144 145 /**146 * Get the container instance used by the manager.147 *148 * @return \Illuminate\Contracts\Container\Container149 */150 public function getContainer()151 {152 return $this->container;153 }154 155 /**156 * Set the container instance used by the manager.157 *158 * @param \Illuminate\Contracts\Container\Container $container159 * @return $this160 */161 public function setContainer(Container $container)162 {163 $this->container = $container;164 165 return $this;166 }167 168 /**169 * Forget all of the resolved driver instances.170 *171 * @return $this172 */173 public function forgetDrivers()174 {175 $this->drivers = [];176 177 return $this;178 }179 180 /**181 * Dynamically call the default driver instance.182 *183 * @param string $method184 * @param array $parameters185 * @return mixed186 */187 public function __call($method, $parameters)188 {189 return $this->driver()->$method(...$parameters);190 }191}
As previously mentioned, because the Manager
class is an abstract class, it can't be instantiated on its own but must be extended by another class first. Therefore, for the purposes of this article, let's also take a look at the HashManager
class, which extends the Manager
class for hashing and verifying passwords:
1namespace Illuminate\Hashing; 2 3use Illuminate\Contracts\Hashing\Hasher; 4use Illuminate\Support\Manager; 5 6class HashManager extends Manager implements Hasher 7{ 8 /** 9 * Create an instance of the Bcrypt hash Driver.10 *11 * @return \Illuminate\Hashing\BcryptHasher12 */13 public function createBcryptDriver()14 {15 return new BcryptHasher($this->config->get('hashing.bcrypt') ?? []);16 }17 18 /**19 * Create an instance of the Argon2i hash Driver.20 *21 * @return \Illuminate\Hashing\ArgonHasher22 */23 public function createArgonDriver()24 {25 return new ArgonHasher($this->config->get('hashing.argon') ?? []);26 }27 28 /**29 * Create an instance of the Argon2id hash Driver.30 *31 * @return \Illuminate\Hashing\Argon2IdHasher32 */33 public function createArgon2idDriver()34 {35 return new Argon2IdHasher($this->config->get('hashing.argon') ?? []);36 }37 38 /**39 * Get information about the given hashed value.40 *41 * @param string $hashedValue42 * @return array43 */44 public function info($hashedValue)45 {46 return $this->driver()->info($hashedValue);47 }48 49 /**50 * Hash the given value.51 *52 * @param string $value53 * @param array $options54 * @return string55 */56 public function make($value, array $options = [])57 {58 return $this->driver()->make($value, $options);59 }60 61 /**62 * Check the given plain value against a hash.63 *64 * @param string $value65 * @param string $hashedValue66 * @param array $options67 * @return bool68 */69 public function check($value, $hashedValue, array $options = [])70 {71 return $this->driver()->check($value, $hashedValue, $options);72 }73 74 /**75 * Check if the given hash has been hashed using the given options.76 *77 * @param string $hashedValue78 * @param array $options79 * @return bool80 */81 public function needsRehash($hashedValue, array $options = [])82 {83 return $this->driver()->needsRehash($hashedValue, $options);84 }85 86 /**87 * Get the default driver name.88 *89 * @return string90 */91 public function getDefaultDriver()92 {93 return $this->config->get('hashing.driver', 'bcrypt');94 }95}
Before we delve into what these classes are doing, it's worth noting that each of the three hashing driver classes (BcryptHasher
, ArgonHasher
, and Argon2IdHasher
) implement the following Illuminate\Contracts\Hashing\Hasher
interface. It's not necessary to understand the interface in detail, but it may help to give the example and descriptions some more context.
1namespace Illuminate\Contracts\Hashing; 2 3interface Hasher 4{ 5 /** 6 * Get information about the given hashed value. 7 * 8 * @param string $hashedValue 9 * @return array10 */11 public function info($hashedValue);12 13 /**14 * Hash the given value.15 *16 * @param string $value17 * @param array $options18 * @return string19 */20 public function make($value, array $options = []);21 22 /**23 * Check the given plain value against a hash.24 *25 * @param string $value26 * @param string $hashedValue27 * @param array $options28 * @return bool29 */30 public function check($value, $hashedValue, array $options = []);31 32 /**33 * Check if the given hash has been hashed using the given options.34 *35 * @param string $hashedValue36 * @param array $options37 * @return bool38 */39 public function needsRehash($hashedValue, array $options = []);
Now there are 5 parts of the parent Manager
class that we're particularly interested in:
- The
getDefaultDriver
abstract method definition. - The
driver
method. - The
createDriver
method. - The
__call
method. - The
extend
method.
The getDefaultDriver
Method
The getDefaultDriver
abstract method specifies that in the child class (in this case, the Illuminate\Hashing\HashManager
), there must be a method called getDefaultDriver
that returns the default driver name. This is the driver that will be used if no driver is specified when calling the driver
method.
For context, if you don't call the driver
method and use something like Hash::make('password')
, then the getDefaultDriver
method will be called to determine which driver to use.
The driver
Method
The driver
method is the method that will be called whenever you interact with the manager class. It attempts to return the necessary driver class for using. However, if the driver hasn't been created yet (presumably if it's the first time you're calling a method in the driver), then it will create the driver and then store it as a class-level variable for later use.
It's also worth noting that you can explicitly call the driver
method yourself rather than letting the __call
method do it for you. For example, you could call Hash::driver('argon')->make('password')
to use the Argon hashing driver.
The createDriver
Method
The createDriver
method is responsible for creating the driver class. It first checks to see if there is a custom driver creator for the driver that you're trying to create. If there is, then it will call the custom driver creator and return the result. However, if there isn't, then it will attempt to call a method on the manager class that is named create{DriverName}Driver
. For example, if we wanted to create a new driver for our bcrypt
hash driver, then the method that would be called is createBcryptDriver
. Therefore, by default, each driver should have their own method in the manager class that determines how the driver should be created and returned. We can see this in the HashManager
class with the createBcryptDriver
, createArgonDriver
, and createArgon2idDriver
methods.
The __call
Method
The __call
method is called whenever you call a method on the manager class that doesn't exist. Instead, it will attempt to forward the method call to the driver class.
The extend
Method
The extend
method is used to register a custom driver creator. Therefore, in our example of the HashManager
, if we wanted to define our own driver for creating hashes, we could use this method to define our own new driver so that we could call it in our application code.
For example, if we wanted to create our own hashing driver, we could create a class that implements the Hasher
interface like so:
1namespace App\Hashing; 2 3use Illuminate\Contracts\Hashing\Hasher; 4 5class CustomHasher implements Hasher 6{ 7 public function info($hashedValue) 8 { 9 // Custom implementation here...10 }11 12 public function make($value, array $options = [])13 {14 // Custom implementation here...15 }16 17 public function check($value, $hashedValue, array $options = [])18 {19 // Custom implementation here...20 }21 22 public function needsRehash($hashedValue, array $options = [])23 {24 // Custom implementation here...25 }26}
We can then make use of the extend
method on the Hash
facade to register our custom hashing driver. We can do this in the boot
method of the AppServiceProvider
like so:
app/Providers/AppServiceProvider.php
1namespace App\Providers;23use App\Hashing\CustomHasher;4use Illuminate\Contracts\Hashing\Hasher;5use Illuminate\Support\Facades\Hash;6use Illuminate\Support\ServiceProvider;78class AppServiceProvider extends ServiceProvider9{1011 // ...1213 public function boot()14 {15 Hash::extend(16 driver: 'custom-hasher',17 callback: static fn (): Hasher => new CustomHasher()18 );19 }20}
Now that we've done this, we can use our custom hashing driver in our application code like so:
1Hash::driver('custom-hasher')->make('password');
By using the extend
method, you can make use of the existing manager classes that are already integral to the Laravel framework and add your own custom drivers to suit your projects. For additional examples of how to use this type of approach, you can check out the Custom filesystems section of the Laravel documentation; it includes an example of how to register a custom file storage driver to interact with Dropbox.
Note: I've used the HashManager
class as an example in this article purely due to its simplicity in comparison to some of the other manager classes so that the concepts of the pattern can be explained easier. It's worth noting that you're strongly advised not to create your own hashing algorithms. Instead, you should use one of the existing hashing algorithms provided by the PHP core. If you want to create your own hashing algorithm, be aware that you're responsible for ensuring that your algorithm is secure and that it's not vulnerable to any known attacks.
Implementing the Manager Pattern Yourself
Now that we've looked at how the underlying manager classes work, let's look at how we can implement the manager pattern ourselves in our own projects. For this example, we'll create a manager class that will be responsible for creating and managing our own simple drivers for communicating with some exchange-rate APIs.
Creating the Driver Classes
To get started, we'll create a simple interface that the exchange-rate API drivers can implement. The interface will define a single exchangeRate
method that gets the exchange rate between a currency pair on a given date. We'll place this interface in an app/Interfaces
directory and call it ExchangeRateApiDriver
:
app/Interfaces/ExchangeRateApiDriver.php
1namespace App\Interfaces;23use Carbon\CarbonInterface;45interface ExchangeRateApiDriver6{7 public function exchangeRate(string $from, string $to, CarbonInterface $date): string;8}
We can then create our two drivers and place them in an app/Services/ExchangeRates
directory. Both of the driver classes will implement the ExchangeRateApiDriver
interface.
One driver (which we'll call FixerIODriver
) will interact with the Fixer.io API and the other (which we'll call OpenExchangeRatesDriver
) will interact with the Open Exchange Rates API.
We'll create a FixerIODriver
class:
app/Services/ExchangeRates/FixerIODriver
1namespace App\Services\ExchangeRates;23use App\Interfaces\ExchangeRateApiDriver;45class FixerIODriver implements ExchangeRateApiDriver6{7 public function exchangeRate(string $from, string $to, CarbonInterface $date): string8 {9 // Implementation goes here...10 }11}
Likewise, we'll also create our OpenExchangeRatesDriver
class:
app/Services/ExchangeRates/OpenExchangeRatesDriver
1namespace App\Services\ExchangeRates;23use App\Interfaces\ExchangeRateApiDriver;45class OpenExchangeRatesDriver implements ExchangeRateApiDriver6{7 public function exchangeRate(string $from, string $to, CarbonInterface $date): string8 {9 // Implementation goes here...10 }11}
Creating the Manager Class
Now that we have both of our drivers ready to go, we can create our manager class that will be used to create the drivers. We'll create an ExchangeRatesManager
class in an app/Services/ExchangeRates
directory:
app/Services/ExchangeRates/ExchangeRatesManager.php
1namespace App\Services\ExchangeRates;23use App\Interfaces\ExchangeRateApiDriver;4use Illuminate\Support\Manager;56class ExchangeRatesManager extends Manager7{8 public function createFixerIoDriver(): ExchangeRateApiDriver9 {10 return new FixerIoDriver();11 }1213 public function createOpenExchangeRatesDriver(): ExchangeRateApiDriver14 {15 return new OpenExchangeRatesDriver();16 }1718 public function getDefaultDriver()19 {20 return $this->config->get('exchange-rates.driver', 'fixer-io');21 }22}
As you can see, we've created two new methods (createFixerIoDriver
and createOpenExchangeRatesDriver
) that can be used to resolve the drivers. The methods’ names follow the structure that the underlying Manager
class expects. In our example, we aren't doing anything special in the methods, but you could add additional logic to the methods if desired. For example, you may want to pass some driver-specific config to the drivers (such as API keys).
You may also have noticed that we've implemented a getDefaultDriver
method. This method is used to determine which driver should be used by default if no driver is specified when resolving the driver. In our example, we're using the exchange-rates.driver
config value (which would typically be set in the driver
field of a config/exchange-rates.php
config file) to determine which driver should be used by default. If the config value isn't set, then we'll default to the fixer-io
driver.
For the purpose of this guide, I've not made the ExchangeRatesManager
class implement the ExchangeRatesApiDriver
interface. Instead, I'm relying on the __call
method in the abstract parent Manager
class to forward the method call to the resolved driver. However, if you'd prefer the manager class to implement the interface, you can, but you’ll need to manually forward the method call to the resolved driver yourself (similar to how the HashManager
class does it). For example, you could add a method like this to your manager class:
1public function exchangeRate(string $from, string $to, CarbonInterface $date): string2{3 return $this->driver()->exchangeRate($from, $to, $date);4}
Registering the Manager Class
Now that we have the manager class prepared, we can register it as a singleton in the service container. Thus, the manager class will only be instantiated once and will be available to be resolved from the container in our application code. It means that the driver classes will also only be instantiated once. We can do this by updating our AppServiceProvider
like so:
app/Providers/AppServiceProvider.php
1namespace App\Providers;23use App\Services\ExchangeRates\ExchangeRatesManager;4use Carbon\CarbonInterface;5use Illuminate\Contracts\Foundation\Application;6use Illuminate\Support\ServiceProvider;78class AppServiceProvider extends ServiceProvider9{10 public function register(): void11 {12 $this->app->singleton(13 abstract: ExchangeRatesManager::class,14 concrete: fn (Application $app) => new ExchangeRatesManager($app),15 );16 }1718 // ...1920}
We've defined that whenever we try to resolve the ExchangeRatesManager
class from the service container, we want to resolve the ExchangeRatesManager
itself.
That's it! We should now be ready to interact with our drivers in our application code!
Using the Manager Class
For example, if we wanted to use the default driver, we could do something like this:
1app(ExchangeRatesManager::class)->exchangeRate(2 from: 'GBP',3 to: 'USD',4 date: Carbon::parse('2021-01-01'),5);
Similarly, if we wanted to explicitly define the driver to use, we could do something like this:
1app(ExchangeRatesManager::class)2 ->driver('open-exchange-rates')3 ->exchangeRate(4 from: 'GBP',5 to: 'USD',6 date: Carbon::parse('2021-01-01'),7 );
Alternatively, if we wanted to use the ExchangeRatesManager
class in a part of our application code that supports dependency injection (such as a controller method), we could do avoid using the app
helper and pass it as an argument like so:
app/Http/Controllers/ExchangeRatesController.php
1namespace App\Http\Controllers;23use App\Services\ExchangeRates\ExchangeRatesManager;45class ExchangeRatesController extends Controller6{7 public function index(ExchangeRatesManager $exchangeRatesManager)8 {9 $rate = $exchangeRatesManager->exchangeRate(10 from: 'GBP',11 to: 'USD',12 date: Carbon::parse('2021-01-01'),13 );1415 // ...16 });17}
Using the Manager Class via a Facade
Although the topic is contentious, if you wanted to make your code look more Laravel-y, you could potentially also introduce a "facade" into your code.
To do this, you could create an ExchangeRates
facade class in an app/Facades
directory:
app/Facades/ExchangeRate.php
1namespace App\Facades;23use App\Services\ExchangeRates\ExchangeRatesManager;4use Carbon\CarbonInterface;5use Illuminate\Support\Facades\Facade;67/**8 * @method static string driver(string $driver = null)9 * @method static string exchangeRate(string $from, string $to, CarbonInterface $date)10 *11 * @see ExchangeRatesManager12 */13class ExchangeRate extends Facade14{15 protected static function getFacadeAccessor()16 {17 return ExchangeRatesManager::class;18 }19}
As you can see above, we've created a new facade and used docblocks to document the available methods and the underlying class that will be resolved when using the facade.
By creating the facade, we would now be able to use the ExchangeRate
facade in our application code like so:
1use App\Facades\ExchangeRate;2 3ExchangeRate::exchangeRate(4 from: 'GBP',5 to: 'USD',6 date: Carbon::parse('2021-01-01'),7);
If we wanted to specify which driver to use, we could also do the following:
1use App\Facades\ExchangeRate;2 3ExchangeRate::driver('open-exchange-rates')4 ->exchangeRate(5 from: 'GBP',6 to: 'USD',7 date: Carbon::parse('2021-01-01'),8 );
Conclusion
Hopefully, this article has given you a good understanding of what the manager pattern is and the benefits of using it. It should have also shown you how it's used in Laravel and given some insight into how it's implemented under the hood. You should now be able to implement the manager pattern in your own projects so that you can write flexible and reusable code.