This article was originally written by Ashley Allen on the Honeybadger Developer Blog.
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:
Storage::disk('local')->put(...);
However, if you wanted to store a file in an AWS S3 bucket, you might want to the use s3
driver like so:
Storage::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:
Storage::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:
namespace Illuminate\Support;
use Closure;
use Illuminate\Contracts\Container\Container;
use InvalidArgumentException;
abstract class Manager
{
/**
* The container instance.
*
* @var \Illuminate\Contracts\Container\Container
*/
protected $container;
/**
* The configuration repository instance.
*
* @var \Illuminate\Contracts\Config\Repository
*/
protected $config;
/**
* The registered custom driver creators.
*
* @var array
*/
protected $customCreators = [];
/**
* The array of created "drivers".
*
* @var array
*/
protected $drivers = [];
/**
* Create a new manager instance.
*
* @param \Illuminate\Contracts\Container\Container $container
* @return void
*/
public function __construct(Container $container)
{
$this->container = $container;
$this->config = $container->make('config');
}
/**
* Get the default driver name.
*
* @return string
*/
abstract public function getDefaultDriver();
/**
* Get a driver instance.
*
* @param string|null $driver
* @return mixed
*
* @throws \InvalidArgumentException
*/
public function driver($driver = null)
{
$driver = $driver ?: $this->getDefaultDriver();
if (is_null($driver)) {
throw new InvalidArgumentException(sprintf(
'Unable to resolve NULL driver for [%s].', static::class
));
}
// If the given driver has not been created before, we will create the instances
// here and cache it so we can return it next time very quickly. If there is
// already a driver created by this name, we'll just return that instance.
if (! isset($this->drivers[$driver])) {
$this->drivers[$driver] = $this->createDriver($driver);
}
return $this->drivers[$driver];
}
/**
* Create a new driver instance.
*
* @param string $driver
* @return mixed
*
* @throws \InvalidArgumentException
*/
protected function createDriver($driver)
{
// First, we will determine if a custom driver creator exists for the given driver and
// if it does not we will check for a creator method for the driver. Custom creator
// callbacks allow developers to build their own "drivers" easily using Closures.
if (isset($this->customCreators[$driver])) {
return $this->callCustomCreator($driver);
} else {
$method = 'create'.Str::studly($driver).'Driver';
if (method_exists($this, $method)) {
return $this->$method();
}
}
throw new InvalidArgumentException("Driver [$driver] not supported.");
}
/**
* Call a custom driver creator.
*
* @param string $driver
* @return mixed
*/
protected function callCustomCreator($driver)
{
return $this->customCreators[$driver]($this->container);
}
/**
* Register a custom driver creator Closure.
*
* @param string $driver
* @param \Closure $callback
* @return $this
*/
public function extend($driver, Closure $callback)
{
$this->customCreators[$driver] = $callback;
return $this;
}
/**
* Get all of the created "drivers".
*
* @return array
*/
public function getDrivers()
{
return $this->drivers;
}
/**
* Get the container instance used by the manager.
*
* @return \Illuminate\Contracts\Container\Container
*/
public function getContainer()
{
return $this->container;
}
/**
* Set the container instance used by the manager.
*
* @param \Illuminate\Contracts\Container\Container $container
* @return $this
*/
public function setContainer(Container $container)
{
$this->container = $container;
return $this;
}
/**
* Forget all of the resolved driver instances.
*
* @return $this
*/
public function forgetDrivers()
{
$this->drivers = [];
return $this;
}
/**
* Dynamically call the default driver instance.
*
* @param string $method
* @param array $parameters
* @return mixed
*/
public function __call($method, $parameters)
{
return $this->driver()->$method(...$parameters);
}
}
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:
namespace Illuminate\Hashing;
use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Support\Manager;
class HashManager extends Manager implements Hasher
{
/**
* Create an instance of the Bcrypt hash Driver.
*
* @return \Illuminate\Hashing\BcryptHasher
*/
public function createBcryptDriver()
{
return new BcryptHasher($this->config->get('hashing.bcrypt') ?? []);
}
/**
* Create an instance of the Argon2i hash Driver.
*
* @return \Illuminate\Hashing\ArgonHasher
*/
public function createArgonDriver()
{
return new ArgonHasher($this->config->get('hashing.argon') ?? []);
}
/**
* Create an instance of the Argon2id hash Driver.
*
* @return \Illuminate\Hashing\Argon2IdHasher
*/
public function createArgon2idDriver()
{
return new Argon2IdHasher($this->config->get('hashing.argon') ?? []);
}
/**
* Get information about the given hashed value.
*
* @param string $hashedValue
* @return array
*/
public function info($hashedValue)
{
return $this->driver()->info($hashedValue);
}
/**
* Hash the given value.
*
* @param string $value
* @param array $options
* @return string
*/
public function make($value, array $options = [])
{
return $this->driver()->make($value, $options);
}
/**
* Check the given plain value against a hash.
*
* @param string $value
* @param string $hashedValue
* @param array $options
* @return bool
*/
public function check($value, $hashedValue, array $options = [])
{
return $this->driver()->check($value, $hashedValue, $options);
}
/**
* Check if the given hash has been hashed using the given options.
*
* @param string $hashedValue
* @param array $options
* @return bool
*/
public function needsRehash($hashedValue, array $options = [])
{
return $this->driver()->needsRehash($hashedValue, $options);
}
/**
* Get the default driver name.
*
* @return string
*/
public function getDefaultDriver()
{
return $this->config->get('hashing.driver', 'bcrypt');
}
}
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.
namespace Illuminate\Contracts\Hashing;
interface Hasher
{
/**
* Get information about the given hashed value.
*
* @param string $hashedValue
* @return array
*/
public function info($hashedValue);
/**
* Hash the given value.
*
* @param string $value
* @param array $options
* @return string
*/
public function make($value, array $options = []);
/**
* Check the given plain value against a hash.
*
* @param string $value
* @param string $hashedValue
* @param array $options
* @return bool
*/
public function check($value, $hashedValue, array $options = []);
/**
* Check if the given hash has been hashed using the given options.
*
* @param string $hashedValue
* @param array $options
* @return bool
*/
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:
namespace App\Hashing;
use Illuminate\Contracts\Hashing\Hasher;
class CustomHasher implements Hasher
{
public function info($hashedValue)
{
// Custom implementation here...
}
public function make($value, array $options = [])
{
// Custom implementation here...
}
public function check($value, $hashedValue, array $options = [])
{
// Custom implementation here...
}
public function needsRehash($hashedValue, array $options = [])
{
// Custom implementation here...
}
}
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:
namespace App\Providers;
use App\Hashing\CustomHasher;
use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
// ...
public function boot()
{
Hash::extend(
driver: 'custom-hasher',
callback: static fn (): Hasher => new CustomHasher()
);
}
}
Now that we've done this, we can use our custom hashing driver in our application code like so:
Hash::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
:
namespace App\Interfaces;
use Carbon\CarbonInterface;
interface ExchangeRateApiDriver
{
public function exchangeRate(string $from, string $to, CarbonInterface $date): string;
}
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:
namespace App\Services\ExchangeRates;
use App\Interfaces\ExchangeRateApiDriver;
class FixerIODriver implements ExchangeRateApiDriver
{
public function exchangeRate(string $from, string $to, CarbonInterface $date): string
{
// Implementation goes here...
}
}
Likewise, we'll also create our OpenExchangeRatesDriver
class:
namespace App\Services\ExchangeRates;
use App\Interfaces\ExchangeRateApiDriver;
class OpenExchangeRatesDriver implements ExchangeRateApiDriver
{
public function exchangeRate(string $from, string $to, CarbonInterface $date): string
{
// Implementation goes here...
}
}
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:
namespace App\Services\ExchangeRates;
use App\Interfaces\ExchangeRateApiDriver;
use Illuminate\Support\Manager;
class ExchangeRatesManager extends Manager
{
public function createFixerIoDriver(): ExchangeRateApiDriver
{
return new FixerIoDriver();
}
public function createOpenExchangeRatesDriver(): ExchangeRateApiDriver
{
return new OpenExchangeRatesDriver();
}
public function getDefaultDriver()
{
return $this->config->get('exchange-rates.driver', 'fixer-io');
}
}
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:
public function exchangeRate(string $from, string $to, CarbonInterface $date): string
{
return $this->driver()->exchangeRate($from, $to, $date);
}
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:
namespace App\Providers;
use App\Services\ExchangeRates\ExchangeRatesManager;
use Carbon\CarbonInterface;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(
abstract: ExchangeRatesManager::class,
concrete: fn (Application $app) => new ExchangeRatesManager($app),
);
}
// ...
}
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:
app(ExchangeRatesManager::class)->exchangeRate(
from: 'GBP',
to: 'USD',
date: Carbon::parse('2021-01-01'),
);
Similarly, if we wanted to explicitly define the driver to use, we could do something like this:
app(ExchangeRatesManager::class)
->driver('open-exchange-rates')
->exchangeRate(
from: 'GBP',
to: 'USD',
date: Carbon::parse('2021-01-01'),
);
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:
namespace App\Http\Controllers;
use App\Services\ExchangeRates\ExchangeRatesManager;
class ExchangeRatesController extends Controller
{
public function index(ExchangeRatesManager $exchangeRatesManager)
{
$rate = $exchangeRatesManager->exchangeRate(
from: 'GBP',
to: 'USD',
date: Carbon::parse('2021-01-01'),
);
// ...
});
}
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:
namespace App\Facades;
use App\Services\ExchangeRates\ExchangeRatesManager;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\Facade;
/**
* @method static string driver(string $driver = null)
* @method static string exchangeRate(string $from, string $to, CarbonInterface $date)
*
* @see ExchangeRatesManager
*/
class ExchangeRate extends Facade
{
protected static function getFacadeAccessor()
{
return ExchangeRatesManager::class;
}
}
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:
use App\Facades\ExchangeRate;
ExchangeRate::exchangeRate(
from: 'GBP',
to: 'USD',
date: Carbon::parse('2021-01-01'),
);
If we wanted to specify which driver to use, we could also do the following:
use App\Facades\ExchangeRate;
ExchangeRate::driver('open-exchange-rates')
->exchangeRate(
from: 'GBP',
to: 'USD',
date: Carbon::parse('2021-01-01'),
);
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.
Top comments (0)