Writing Craft Plugins with Extensible Components
How to write a plugin for Craft CMS that allows others to extend it in a flexible, component-ized way
Andrew Welch / nystudio107
When writing a plugin for Craft CMS, something that you may encounter is a situation where your plugin wants to provide functionality that could be extended. You might want to extend it yourself in the future, or you might want to allow others to extend it.
For example, my ImageOptimize plugin allows you to entirely replace what performs your image transforms, so a service like Imgix or Thumbor can be used instead of Craft’s native transforms. But how can we write this in an extensible way so that if anyone wanted to add another image transform service, they could?
This article will discuss specific strategies for adding extensible functionality to your plugin. If you want to learn more about Craft CMS plugin development in general, check out the So You Wanna Make a Craft 3 Plugin? article.
ImageOptimize: A Concrete Example
So let’s use ImageOptimize as a concrete example. When I came up with the idea of entirely replacing what could do the image transforms in Craft CMS, I decided that the best way to do it was to write it in a modular fashion.
Starting with ImageOptimize 1.5.0, I’m using the exact technique outlined in this article. So let’s check it out.
Focusing on a real-world example can often be more useful than using a theoretical or contrived example. So let’s dive in and see how we can implement image transforms in ImageOptimize in an extensible way.
PHP Interfaces
Fortunately, modern PHP provides us with some tools to help us do this. PHP allows you to write an object interface that defines the methods that an object must implement.
Why bother doing this? Well, it essentially lets you define an API with the methods that all objects that use that interface must implement. So in our case, we have an ImageTransformInterface that looks like this:
<?php
/**
* ImageOptimize plugin for Craft CMS 3.x
*
* Automatically optimize images after they've been transformed
*
* @link https://nystudio107.com
* @copyright Copyright (c) 2018 nystudio107
*/
namespace nystudio107\imageoptimize\imagetransforms;
use craft\base\SavableComponentInterface;
use craft\elements\Asset;
use craft\models\AssetTransform;
/**
* @author nystudio107
* @package ImageOptimize
* @since 1.5.0
*/
interface ImageTransformInterface extends SavableComponentInterface
{
// Static Methods
// =========================================================================
/**
* Return an array that contains the template root and corresponding file
* system directory for the Image Transform's templates
*
* @return array
* @throws \ReflectionException
*/
public static function getTemplatesRoot(): array;
// Public Methods
// =========================================================================
/**
* Return a URL to a transformed images
*
* @param Asset $asset
* @param AssetTransform|null $transform
* @param array $params
*
* @return string|null
*/
public function getTransformUrl(Asset $asset, $transform, array $params = []);
/**
* Return a URL to the webp version of the transformed image
*
* @param string $url
* @param Asset $asset
* @param AssetTransform|null $transform
* @param array $params
*
* @return string
*/
public function getWebPUrl(string $url, Asset $asset, $transform, array $params = []): string;
/**
* Return the URL that should be used to purge the Asset
*
* @param Asset $asset
* @param array $params
*
* @return mixed
*/
public function getPurgeUrl(Asset $asset, array $params = []);
/**
* Purge the URL from the service's cache
*
* @param string $url
* @param array $params
*
* @return bool
*/
public function purgeUrl(string $url, array $params = []): bool;
/**
* Return the URI to the asset
*
* @param Asset $asset
*
* @return mixed
*/
public function getAssetUri(Asset $asset);
/**
* Prefetch the remote file to prime the cache
*
* @param string $url
*/
public function prefetchRemoteFile($url);
/**
* Get the parameters needed for this transform
*
* @return array
*/
public function getTransformParams(): array;
}
Note that we’re not writing any actual code here; we’re just defining the methods that an object that wants to do image transforms needs to implement. We define the method names, and the parameters that must be passed into this method (this is often called the method’s signature).
Think of it like writing a standards document for your classes.
This forces us to think about the problem of an image transform in an abstract way; what would a generic interface to image transforms look like?
Note that our ImageTransformInterface extends another interface: SavableComponentInterface. This is a Craft provided interface that defines the methods that a component that has savable settings must implement.
This is great, we can leverage the work that the fine folks at Pixel & Tonic have done, because we want to be able to have savable settings too! Many components in Craft like Fields, Widgets, etc. all use the SavableComponentInterface so we can, too!
Also note that there are no properties defined in an interface; just methods.
PHP Traits
PHP also implements the idea of traits. They are similar to a PHP class, but they are designed to side-step the limitation that PHP has from a lack of multiple inheritance. In PHP, an object can only inherit (extends, in PHP parlance) from one object.
Other languages allow for multiple inheritance; instead in PHP we can define a trait, and our objects can use that trait. Think of it as a way to provide the properties & methods like a class would, but in a way that multiple objects of different types can use it.
Here’s what the ImageTransformTrait looks like in ImageOptimize:
<?php
/**
* ImageOptimize plugin for Craft CMS 3.x
*
* Automatically optimize images after they've been transformed
*
* @link https://nystudio107.com
* @copyright Copyright (c) 2018 nystudio107
*/
namespace nystudio107\imageoptimize\imagetransforms;
/**
* @author nystudio107
* @package ImageOptimize
* @since 1.5.0
*/
trait ImageTransformTrait
{
// Public Properties
// =========================================================================
}
Seems kinda useless, right? That’s because currently, it is! I’m not defining any properties or any methods in the ImageTransformTrait, I’m just implementing it for future expansion purposes.
In fact, although you can have both properties and methods in a trait, I tend to use them only for defining properties.
The reason I do this is that it’s very common to want to override an object’s methods with your own code, and then call the parent method, e.g.: parent::init(). This gets pretty awkward to do with traits. So instead, we use base abstract classes.
PHP Base Abstract Classes
Finally, the last bit of PHP we’ll take advantage of is abstract classes. Abstract classes are just PHP classes that implement some methods but are never instantiated on their own. They exist simply so that other classes can extend them.
So they provide some base functionality, but you’d never actually create one. Instead, you’d write another class that extends a base abstract class, and override the methods you want to override, calling the parent::method as appropriate.
Here’s what the base abstract ImageTransform class looks like in ImageOptimize:
<?php
/**
* ImageOptimize plugin for Craft CMS 3.x
*
* Automatically optimize images after they've been transformed
*
* @link https://nystudio107.com
* @copyright Copyright (c) 2018 nystudio107
*/
namespace nystudio107\imageoptimize\imagetransforms;
use nystudio107\imageoptimize\helpers\UrlHelper;
use craft\base\SavableComponent;
use craft\elements\Asset;
use craft\helpers\FileHelper;
use craft\helpers\StringHelper;
use craft\models\AssetTransform;
/**
* @author nystudio107
* @package ImageOptimize
* @since 1.5.0
*/
abstract class ImageTransform extends SavableComponent implements ImageTransformInterface
{
// Traits
// =========================================================================
use ImageTransformTrait;
// Static Methods
// =========================================================================
/**
* @inheritdoc
*/
public static function displayName(): string
{
return Craft::t('image-optimize', 'Generic Transform');
}
/**
* @inheritdoc
*/
public static function getTemplatesRoot(): array
{
$reflect = new \ReflectionClass(static::class);
$classPath = FileHelper::normalizePath(
dirname($reflect->getFileName())
. '/../templates'
)
. DIRECTORY_SEPARATOR;
$id = StringHelper::toKebabCase($reflect->getShortName());
return [$id, $classPath];
}
// Public Methods
// =========================================================================
/**
* @inheritdoc
*/
public function getTransformUrl(Asset $asset, $transform, array $params = [])
{
$url = null;
return $url;
}
/**
* @inheritdoc
*/
public function getWebPUrl(string $url, Asset $asset, $transform, array $params = []): string
{
return $url;
}
/**
* @inheritdoc
*/
public function getPurgeUrl(Asset $asset, array $params = [])
{
$url = null;
return $url;
}
/**
* @inheritdoc
*/
public function purgeUrl(string $url, array $params = []): bool
{
return true;
}
/**
* @inheritdoc
*/
public function getAssetUri(Asset $asset)
{
$volume = $asset->getVolume();
$assetPath = $asset->getPath();
// Account for volume types with a subfolder setting
// e.g. craftcms/aws-s3, craftcms/google-cloud
if ($volume->subfolder ?? null) {
return rtrim($volume->subfolder, '/').'/'.$assetPath;
}
return $assetPath;
}
/**
* @param string $url
*/
public function prefetchRemoteFile($url)
{
// Get an absolute URL with protocol that curl will be happy with
$url = UrlHelper::absoluteUrlWithProtocol($url);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => 1,
CURLOPT_FOLLOWLOCATION => 1,
CURLOPT_SSL_VERIFYPEER => 0,
CURLOPT_NOBODY => 1,
]);
curl_exec($ch);
curl_close($ch);
}
/**
* @inheritdoc
*/
public function getTransformParams(): array
{
$params = [
];
return $params;
}
/**
* Append an extension a passed url or path
*
* @param $pathOrUrl
* @param $extension
*
* @return string
*/
public function appendExtension($pathOrUrl, $extension): string
{
$path = $this->decomposeUrl($pathOrUrl);
$path_parts = pathinfo($path['path']);
$new_path = $path_parts['filename'] . '.' . $path_parts['extension'] . $extension;
if (!empty($path_parts['dirname']) && $path_parts['dirname'] !== '.') {
$new_path = $path_parts['dirname'] . DIRECTORY_SEPARATOR . $new_path;
$new_path = preg_replace('/([^:])(\/{2,})/', '$1/', $new_path);
}
$output = $path['prefix'] . $new_path . $path['suffix'];
return $output;
}
// Protected Methods
// =========================================================================
/**
* Decompose a url into a prefix, path, and suffix
*
* @param $pathOrUrl
*
* @return array
*/
protected function decomposeUrl($pathOrUrl): array
{
$result = array();
if (filter_var($pathOrUrl, FILTER_VALIDATE_URL)) {
$url_parts = parse_url($pathOrUrl);
$result['prefix'] = $url_parts['scheme'] . '://' . $url_parts['host'];
$result['path'] = $url_parts['path'];
$result['suffix'] = '';
$result['suffix'] .= empty($url_parts['query']) ? '' : '?' . $url_parts['query'];
$result['suffix'] .= empty($url_parts['fragment']) ? '' : '#' . $url_parts['fragment'];
} else {
$result['prefix'] = '';
$result['path'] = $pathOrUrl;
$result['suffix'] = '';
}
return $result;
}
}
As you can see, it provides a bit of base functionality that may be fine for a particular image transform, but any of these methods can be overridden as needed. It’s also pretty common to provide some generic utilitarian functionality in a base abstract class.
Note that the ImageTransform class also extends the Craft base class SavableComponent so that we get the functionality of a savable component!
Tying it all together
So what we end up with is a hierarchy that looks like this:
Things provided by Craft are colored red; things that we never instantiate are colored grey, and then the actual objects that we use in our plugin are colored aqua.
We have:
- an interface that defines our Image Transform methods (our API)
- a trait that (could) define any properties we want all of our Image Transforms to have
- a base abstract class that defines our core functionality that other classes extend
- …and then multiple classes that extends our base abstract ImageTransform class to implement the functionality
While this might seem at first blush to be complicated, actually what we’ve done is moved various bits around to their own self-contained files, with a defined set of functionality. This will result is clearer, more easily maintainable & extensible code.
Mixing Our Components In
So this is great! We’ve got a nice defined interface & base abstract classes for our Image Transform. This will make our life easier when we’re writing the code to implement our Image Transforms.
It also gives other developers a clearly defined way to write their own Image Transforms, just like Craft gives you a PluginInterface.php interface and base abstract Plugin.php classes.
But how do we mix our Image Transforms into our plugin?
The first thing we do is we have a property in our plugin’s Settings model that holds the fully qualified class name of whatever the currently selected Image Transform is:
/**
* @var string The image transform class to use for image transforms
*/
public $transformClass = CraftImageTransform::class;
We default this to CraftImageTransform::class, but it can end up being any class that implements our ImageTransformInterface.
Next we take advantage of the fact that our plugin is actually a Yii2 Module… and all Yii2 Modules can have Yii2 Components.
In fact, any service classes you define in your plugin are just added as components of your plugin’s Module. See the Enhancing a Craft CMS 3 Website with a Custom Module article for more details on Craft CMS modules.
So we can set our transformMethod component dynamically in our plugin by calling this method in our plugin’s init() method:
/**
* Set the transformMethod component
*/
protected function setImageTransformComponent()
{
$settings = $this->getSettings();
$definition = array_merge(
$settings->imageTransformTypeSettings[$settings->transformClass] ?? [],
['class' => $settings->transformClass]
);
try {
$this->set('transformMethod', $definition);
} catch (InvalidConfigException $e) {
Craft::error($e->getMessage(), __METHOD__ );
}
self::$transformParams = ImageOptimize::$plugin->transformMethod->getTransformParams();
}
All this is doing is calling the set() method that our plugin inherits from Yii2’s ServiceLocator class. You pass in alias that you want to refer to the component as (in this case transformMethod), along with a configuration array that contains a class key with the fully qualified class name to instantiate, along with any other key/value pairs of properties that the class should be initialized with.
In our case, we pass along any settings that our Image Transform might have, so that it’ll be configured with any user-definable settings.
The magic is that after we set() our component on our plugin’s class, we can then access it like any other service: ImageOptimize::$plugin->transformMethod-> to call any of the methods we defined in our ImageTransformInterface.
The plugin doesn’t know, and doesn’t care exactly what class is providing the component, so in this way we can swap in any class that implements our ImageTransformInterface and away we go!
Under the hood, this all uses Yii2’s Dependency Injection Container (DI) to work its magic.
You’ve probably seen this in action before, without even realizing it. When you adjust settings in your config/app.php to, say, add Redis as a caching method, the array you’re providing is just a configuration for DI so it can find and instantiate the cache class to use!
Making Components Discoverable
So it’s great that we can leverage all of this Yii2 goodness to make our lives easier, but we still have the problem of how to let our plugin know about our component classes to begin with. We can take a 3‑pronged approach to this.
First, we simply define an array of built-in classes in our plugin that come baked in:
const DEFAULT_IMAGE_TRANSFORM_TYPES = [
CraftImageTransform::class,
ImgixImageTransform::class,
ThumborImageTransform::class,
];
Then we want to be able to let people just composer require an arbitrary Composer package that implements our ImageTransformInterface, and let ImageOptimize know about it without any additional code. As an example, check out the craft-imageoptimize-imgix and craft-imageoptimize-thumbor packages.
We can do that by having a property in our Settings model (and thus also in the multi-environment config/image-optimize.php):
// The default Image Transform type classes
'defaultImageTransformTypes' => [
],
This allows people to just add the appropriate class to their config/image-optimize.php multi-environment config, which we merge into the built-in Image Transforms:
$imageTransformTypes = array_unique(array_merge(
ImageOptimize::$plugin->getSettings()->defaultImageTransformTypes ?? [],
self::DEFAULT_IMAGE_TRANSFORM_TYPES
), SORT_REGULAR);
So this is awesome, people can add their own Image Transform to our plugin without writing a custom module or plugin to provide it.
But people might also want to wrap their Image Transform in a plugin (to make it easily user-installable from the Craft Plugin Store) or custom site module. We can do this by triggering an event that modules/plugins can listen to in order to register the Image Transforms that they provide:
use craft\events\RegisterComponentTypesEvent;
...
const EVENT_REGISTER_IMAGE_TRANSFORM_TYPES = 'registerImageTransformTypes';
...
$event = new RegisterComponentTypesEvent([
'types' => $imageTransformTypes
]);
$this->trigger(self::EVENT_REGISTER_IMAGE_TRANSFORM_TYPES, $event);
Observant readers will note that this is the exact method that Craft uses to allow plugins to register additional Field types, and other functionality. On the module/plugin side of things, the code they’d have to implement would just look like this:
use vendor\package\imagetransforms\MyImageTransform;
use nystudio107\imageoptimize\services\Optimize;
use craft\events\RegisterComponentTypesEvent;
use yii\base\Event;
Event::on(Optimize::class,
Optimize::EVENT_REGISTER_IMAGE_TRANSFORM_TYPES,
function(RegisterComponentTypesEvent $event) {
$event->types[] = MyImageTransform::class;
}
);
Beautiful! Now we can write our our Image Transforms easily, and other developers can add their own Image Transforms however they want.
We do still have one other subject to cover, which is how exactly do we allow Image Transforms to present their own GUI for settings, and save those settings?
Editing and Storing Component Settings
Since our Image Transform components extend from SavableComponent, we get the scaffolding we need in order to display a GUI for editing plugin settings, as well as saving our settings!
To present a GUI, we just need to implement the getSettingsHtml() method; here’s an example from ImgixImageTransform:
/**
* @inheritdoc
*/
public function getSettingsHtml()
{
return Craft::$app->getView()->renderTemplate('imgix-image-transform/settings/image-transforms/imgix.twig', [
'imageTransform' => $this,
]);
}
We’re just passing in our component in imageTransform and rendering a Twig template:
{% from 'image-optimize/_includes/macros' import configWarning %}
{% import "_includes/forms" as forms %}
<!-- imgixDomain -->
{{ forms.textField({
label: 'Imgix Source Domain',
instructions: "The source domain to use for the Imgix transforms."|t('image-optimize'),
id: 'domain',
name: 'domain',
value: imageTransform.domain,
warning: configWarning('imageTransformTypeSettings', 'image-optimize'),
}) }}
<!-- imgixApiKey -->
{{ forms.textField({
label: 'Imgix API Key',
instructions: "The API key to use for the Imgix transforms (needed for auto-purging changed assets)."|t('image-optimize'),
id: 'apiKey',
name: 'apiKey',
value: imageTransform.apiKey,
warning: configWarning('imageTransformTypeSettings', 'image-optimize'),
}) }}
<!-- imgixSecurityToken -->
{{ forms.textField({
label: 'Imgix Security Token',
instructions: "The optional [security token](https://docs.imgix.com/setup/securing-images) used to sign image URLs from Imgix."|t('image-optimize'),
id: 'securityToken',
name: 'securityToken',
value: imageTransform.securityToken,
warning: configWarning('imageTransformTypeSettings', 'image-optimize'),
}) }}
Since the properties on our Image Transform class are used as the savable settings (just like for Craft Fields and Widgets), we just have those properties in ImgixImageTransform.php:
// Public Properties
// =========================================================================
/**
* @var string
*/
public $domain;
/**
* @var string
*/
public $apiKey;
/**
* @var string
*/
public $securityToken;
To actually render the Image Transform’s settings, we just do this in ImageOptimize’s _settings.twig:
<!-- transformClass -->
{{ forms.selectField({
label: "Transform Method"|t('image-optimize'),
instructions: "Choose from Craft native transforms or an image transform service to handle your image transforms site-wide."|t('image-optimize'),
id: 'transformClass',
name: 'transformClass',
value: settings.transformClass,
options: imageTransformTypeOptions,
class: 'io-transform-method',
warning: configWarning('transformClass', 'image-optimize'),
}) }}
{% for type in allImageTransformTypes %}
{% set isCurrent = (type == className(imageTransform)) %}
<div id="{{ type|id }}" class="io-method-settings {{ 'io-' ~ type|id ~ '-method' }}" {% if not isCurrent %} style="display: none;"{% endif %}>
{% namespace 'imageTransformTypeSettings['~type~']' %}
{% set _imageTransform = isCurrent ? imageTransform : craft.imageOptimize.createImageTransformType(type) %}
{{ _imageTransform.getSettingsHtml()|raw }}
{% endnamespace %}
</div>
{% endfor %}
This just presents a Dropdown for selecting the Transform Method to use, and then loops through the available Image Transform components, and renders their settings HTML.
We then store the result in our Settings model (so that it works nicely with Craft CMS 3.1’s Project Config) in the imageTransformTypeSettings property as an array. The key is the fully qualified class name of the Image Transform, and the value is an array that contains whatever settings that Image Transform provides.
This is what the ImageOptimize.php main plugin class’s settingsHtml() method looks like:
/**
* @inheritdoc
*/
public function settingsHtml()
{
// Get only the user-editable settings
$settings = $this->getSettings();
// Get the image transform types
$allImageTransformTypes = ImageOptimize::$plugin->optimize->getAllImageTransformTypes();
$imageTransformTypeOptions = [];
/** @var ImageTransformInterface $class */
foreach ($allImageTransformTypes as $class) {
if ($class::isSelectable()) {
$imageTransformTypeOptions[] = [
'value' => $class,
'label' => $class::displayName(),
];
}
}
// Sort them by name
ArrayHelper::multisort($imageTransformTypeOptions, 'label');
// Render the settings template
try {
return Craft::$app->getView()->renderTemplate(
'image-optimize/settings/_settings.twig',
[
'settings' => $settings,
'gdInstalled' => \function_exists('imagecreatefromjpeg'),
'imageTransformTypeOptions' => $imageTransformTypeOptions,
'allImageTransformTypes' => $allImageTransformTypes,
'imageTransform' => ImageOptimize::$plugin->transformMethod,
]
);
} catch (\Twig_Error_Loader $e) {
Craft::error($e->getMessage(), __METHOD__ );
} catch (Exception $e) {
Craft::error($e->getMessage(), __METHOD__ );
}
return '';
}
Go Forth and Component-ize!
This is quite a bit to digest, but I think it’ll help with creating plugins that offer extensibility in a very Yii2/Craft-like manner. Armed with this knowledge, you can go forth and make awesome, extensible plugins!
It’s certainly friendlier long-term than hard-coding it all into your plugin (with all of the associated PRs from people wanting to add functionality), and it’s more manageable than requiring additional plugins being installed.
It provides the structure that will help you architect your plugin well, and also allows for great flexibility in allowing others to extend it with additional functionality.
This technique could also easily be used for a suite of plugins that rely on the same core functionality. Instead of requiring a shared plugin be installed, simply component-ize the needed functionality, and add it in as a composer package dependency.
Further Reading
If you want to be notified about new articles, follow nystudio107 on Twitter.
Copyright ©2020 nystudio107. Designed by nystudio107
Top comments (0)