Laravel Under The Hood - The Strategy Pattern
Photo by Hassan Pasha on Unsplash
Hello 👋
Wikipedia: In computer programming, the strategy pattern (also known as the policy pattern) is a behavioral software design pattern that enables an algorithm's behavior to be selected at runtime.
For the first time, a Wikipedia definition in an IT context makes sense to me. Nonetheless, we'll be discussing the strategy pattern in this article and how Laravel uses it under the hood. It's commonly referred to as the Manager pattern in the Laravel community. I've also encountered it labeled as the "Builder" pattern in a book, something I don't agree with, and I'll explain why later on. In simple terms, the strategy pattern allows you to switch the implementation (or algorithm) based on a condition. Now, before we dive deeper, it's important to understand that these patterns are not sacred texts; they can be implemented in various ways 🤷. Patterns will always address the same problem, but can introduce some tweaks, and that's exactly what Laravel has done.
What problem are we solving and how?
In Laravel, you have probably called the driver()
method (at least once), when using the Cache
facade, with the Mail
, or when logging. Let's stick with caching.
Cache::put(key: 'foo', value: 'bar');
This will cache the value 'bar'
, using the database driver. Now the problem is we don't want to force users to use a single driver; they can choose different ones, like a file driver, a Redis driver, or whatever driver. So we need a way to swap between those implementations based on a condition that the users set, or can affect. If nothing is set, we can always fall back to the default driver.
Laravel solves this by implementing the strategy pattern (or the manager pattern), which allows you to set a driver to be used. For example, if we want to use the file driver instead of the database, we can simply call the driver()
method:
Cache::driver('file')->put('foo', 'bar');
The file driver will be used instead of the database because we altered a condition that picks the behavior (implementation) at runtime. Let's see how.
Demystifying the magic
When you call the driver method on the facade, it's proxied to a manager, depending on which facade you're using. In the caching scenario, it's directed to the <a href="https://github.com/laravel/framework/blob/master/src/Illuminate/Cache/CacheManager.php" target="_blank">CacheManager
</a>. Let's inspect its code.
Curious about how we got to the Manager from the facade? I highly recommend reading <a href="https://blog.oussama-mater.tech/laravel-core-facades/" target="_blank">"Facades Under The Hood"</a>.
<?php
namespace Illuminate\Cache;
use Illuminate\Contracts\Cache\Factory as FactoryContract;
/**
* @mixin \Illuminate\Cache\Repository
* @mixin \Illuminate\Contracts\Cache\LockProvider
*/
class CacheManager implements FactoryContract
{
// omitted for brevity
/**
* Get a cache driver instance.
*
* @param string|null $driver
* @return \Illuminate\Contracts\Cache\Repository
*/
public function driver($driver = null)
{
return $this->store($driver);
}
// omitted for brevity
}
Here, you'll find the driver()
method, which optionally accepts a driver. This method returns whatever results from store()
.
<?php
namespace Illuminate\Cache;
use Illuminate\Contracts\Cache\Factory as FactoryContract;
/**
* @mixin \Illuminate\Cache\Repository
* @mixin \Illuminate\Contracts\Cache\LockProvider
*/
class CacheManager implements FactoryContract
{
// omitted for brevity
/**
* Get a cache store instance by name, wrapped in a repository.
*
* @param string|null $name
* @return \Illuminate\Contracts\Cache\Repository
*/
public function store($name = null)
{
// This is the condition we modified by passing a driver.
$name = $name ?: $this->getDefaultDriver();
return $this->stores[$name] ??= $this->resolve($name);
}
// omitted for brevity
}
If no $name
(driver) is set by the user, it defaults to the default driver specified in the configuration under cache.default
(have a quick look <a href="https://github.com/laravel/framework/blob/master/src/Illuminate/Cache/CacheManager.php#L356" target="_blank">here</a>). Subsequently, it attempts to resolve that driver. Now, let's inspect the resolve()
method.
<?php
namespace Illuminate\Cache;
use Illuminate\Contracts\Cache\Factory as FactoryContract;
/**
* @mixin \Illuminate\Cache\Repository
* @mixin \Illuminate\Contracts\Cache\LockProvider
*/
class CacheManager implements FactoryContract
{
// omitted for brevity
/**
* Resolve the given store.
*
* @param string $name
* @return \Illuminate\Contracts\Cache\Repository
*
* @throws \InvalidArgumentException
*/
public function resolve($name)
{
$config = $this->getConfig($name);
if (is_null($config)) {
throw new InvalidArgumentException("Cache store [{$name}] is not defined.");
}
$config = Arr::add($config, 'store', $name);
if (isset($this->customCreators[$config['driver']])) {
return $this->callCustomCreator($config);
}
$driverMethod = 'create' . ucfirst($config['driver']) . 'Driver';
if (method_exists($this, $driverMethod)) {
return $this->{$driverMethod}($config);
}
throw new InvalidArgumentException("Driver [{$config['driver']}] is not supported.");
}
// omitted for brevity
}
You notice that we first fetch the configuration for that driver, so we can build it. Then, we check if the developer <a href="https://laravel.com/docs/11.x/cache#adding-custom-cache-drivers" target="_blank">extended the cache drivers</a>. Finally, we create a method name following the convention create[Name]driver
. In our case, it will be createFileDriver
. Afterward, we call this method (which indeed <a href="https://github.com/laravel/framework/blob/master/src/Illuminate/Cache/CacheManager.php#L147" target="_blank">exists</a>) and return the cache implementation for the file driver. This way, the put()
and get()
methods are called on that implementation.
This implies that if we called Cache::driver('redis')
, we would be invoking a method called createRedisDriver
, which in turn would return the implementation for the redis driver, and so forth.
The strategy (cache implementation) changes depending on the condition (the given name).
Sounds cool, can we leverage it?
Yes! That's the beauty of source diving. You can now make use of this in your application if you want to swap between different implementations. And the fun part is, there is already a base manager ready to be used!
Let's imagine we are building a notifications system. There are multiple channels: SMS, Emails, Slack, and Discord. Our code will look like this:
<?php
namespace App\Managers;
use App\Notification\DiscordNotification;
use App\Notification\EmailNotification;
use App\Notification\SlackNotification;
use App\Notification\SmsNotification;
use Illuminate\Support\Manager;
class NotificationsManager extends Manager
{
public function createSmsDriver() // create[Name]Driver
{
return new SmsNotification();
}
public function createEmailDriver() // create[Name]Driver
{
return new EmailNotification();
}
public function createSlackDriver() // create[Name]Driver
{
return new SlackNotification();
}
public function createDiscordDriver() // create[Name]Driver
{
return new DiscordNotification();
}
public function getDefaultDriver()
{
return 'slack'; // will turn into createSlackDriver
}
}
You can see we didn't define the driver()
method ourselves, instead, we extended the <a href="https://github.com/laravel/framework/blob/master/src/Illuminate/Support/Manager.php" target="_blank">base manager</a> which already has it. Now all you are left to do is to wrap this NotificationsManager
in a Notification
facade.
I won't cover how to do it in this article. That's a good exercise for you. Stuck? here is <a href="https://blog.oussama-mater.tech/laravel-core-facades/#lets-make-our-facade" target="_blank">how</a>.
Suppose we created the facade; you can now do
<?php
use App\Facades\Notification;
Notification::send(); // will use the default driver, slack
Notification::driver('discord') // will use the discord driver
Notification::driver('email') // will use the email driver
Notification::driver('sms') // will use the sms driver
That's it, you created your own manager!
Change My Mind
In the introduction, I mentioned my disagreement with associating the manager pattern with the builder pattern. Perhaps the author saw similarities with the builder pattern more than with the strategy pattern, but for me, it doesn't align. The builder pattern allows you to construct complex objects with dynamic attributes.
Wikipedia: The builder pattern is an object creation software design pattern with the intention of finding a solution to the telescoping constructor anti-pattern.
A basic implementation of the builder pattern might look like this
(new BurgerBuilder())
->addPatty()
->addLettuce()
->addTomato()
->prepare();
Each burger can be different; some might have cheese, some might not have lettuce (I HATE LETTUCE, IT FREAKING SUCKS). This pattern fits perfectly, otherwise, we would end up with a bloated constructor like this
public function __construct($cheese = true, $patty = true, $tomato = true, $lettuce = false)
{
}
Similarly, when sending an email in Laravel, we do
Mail::to($request->user())
->cc($moreUsers)
->bcc($evenMoreUsers)
->when($this->condition(...))
->locale($request->user()->locale)
->send(new OrderShipped($order));
Each mail object will vary from case to case; one object might only have the to()
method. Again, if it weren't for this implementation, we would end up with a constructor like this
public function __construct($users, $cc = null, $bcc = null, $when = null, $locale = null)
{
}
They look pretty similar, right? They're almost identical.
Well, one for burgers, the other for mails, but hey you got me 🍔
The Laravel example is known as the "pending pattern", which is essentially a builder pattern. That's why I prefer not to associate the manager pattern with the latter.
Let me know if you want me to write about the pending object!
Conclusion
Patterns solve problems. You don't have to use them everywhere, nor do you have to overdo it. However, it's valuable to understand which pattern is suitable for which situation and what is being used in your framework. This understanding can simplify your workflow. As you've seen, we implemented our manager in just a few lines of code because we understand how Laravel works. Don't expect the implementation or the naming to always be the same; they are not sacred texts. They can be tweaked to suit your needs. Problems may be similar, but they are not always identical.
driesvints, krishat2017, flavius-constantin liked this article
Other articles you might like
Laravel Custom Query Builders Over Scopes
Hello 👋 Alright, let's talk about Query Scopes. They're awesome, they make queries much easier to r...
Access Laravel before and after running Pest tests
How to access the Laravel ecosystem by simulating the beforeAll and afterAll methods in a Pest test....
🍣 Sushi — Your Eloquent model driver for other data sources
In Laravel projects, we usually store data in databases, create tables, and run migrations. But not...
The Laravel portal for problem solving, knowledge sharing and community building.
The community