DEV Community

Cover image for Using the builder/manager pattern in Laravel
Honeybadger Staff for Honeybadger

Posted on

Using the builder/manager pattern in Laravel

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(...);
Enter fullscreen mode Exit fullscreen mode

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(...);
Enter fullscreen mode Exit fullscreen mode

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(...);
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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 = []);
Enter fullscreen mode Exit fullscreen mode

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...
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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...
    }
}
Enter fullscreen mode Exit fullscreen mode

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...
    }
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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),
        );
    }

    // ...

}
Enter fullscreen mode Exit fullscreen mode

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'),
);
Enter fullscreen mode Exit fullscreen mode

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'),
    );
Enter fullscreen mode Exit fullscreen mode

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'),
        );

        // ...
    });
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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'),
);
Enter fullscreen mode Exit fullscreen mode

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'),
    );
Enter fullscreen mode Exit fullscreen mode

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)