Andrew Welch · Insights · #craft-3 #plugin #webdev

Published , updated · 5 min read ·


Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.

Writing Craft Plugins with Extensible Components

How to write a plu­g­in for Craft CMS that allows oth­ers to extend it in a flex­i­ble, com­po­nent-ized way

When writ­ing a plu­g­in for Craft CMS, some­thing that you may encounter is a sit­u­a­tion where your plu­g­in wants to pro­vide func­tion­al­i­ty that could be extend­ed. You might want to extend it your­self in the future, or you might want to allow oth­ers to extend it.

For exam­ple, my Ima­geOp­ti­mize plu­g­in allows you to entire­ly replace what per­forms your image trans­forms, so a ser­vice like Imgix or Thum­bor can be used instead of Craft’s native trans­forms. But how can we write this in an exten­si­ble way so that if any­one want­ed to add anoth­er image trans­form ser­vice, they could?

Components are popular these days, because there are tangible benefits to coding in a modularized way

This arti­cle will dis­cuss spe­cif­ic strate­gies for adding exten­si­ble func­tion­al­i­ty to your plu­g­in. If you want to learn more about Craft CMS plu­g­in devel­op­ment in gen­er­al, check out the So You Wan­na Make a Craft 3 Plu­g­in? article.

Link ImageOptimize: A Concrete Example

So let’s use Ima­geOp­ti­mize as a con­crete exam­ple. When I came up with the idea of entire­ly replac­ing what could do the image trans­forms in Craft CMS, I decid­ed that the best way to do it was to write it in a mod­u­lar fashion.

Start­ing with Ima­geOp­ti­mize 1.5.0, I’m using the exact tech­nique out­lined in this arti­cle. So let’s check it out.

Focus­ing on a real-world exam­ple can often be more use­ful than using a the­o­ret­i­cal or con­trived exam­ple. So let’s dive in and see how we can imple­ment image trans­forms in Ima­geOp­ti­mize in an exten­si­ble way.

Link PHP Interfaces

For­tu­nate­ly, mod­ern PHP pro­vides us with some tools to help us do this. PHP allows you to write an object inter­face that defines the meth­ods that an object must implement.

Why both­er doing this? Well, it essen­tial­ly lets you define an API with the meth­ods that all objects that use that inter­face must imple­ment. 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 writ­ing any actu­al code here; we’re just defin­ing the meth­ods that an object that wants to do image trans­forms needs to imple­ment. We define the method names, and the para­me­ters that must be passed into this method (this is often called the method­’s sig­na­ture).

Think of it like writ­ing a stan­dards doc­u­ment for your classes.

This forces us to think about the prob­lem of an image trans­form in an abstract way; what would a gener­ic inter­face to image trans­forms look like?

Even if you’re writing code​ for yourself, sitting down and thinking about the problem in an abstract way can be very helpful.

Note that our ImageTransformInterface extends anoth­er inter­face: Sav­able­Com­po­nentIn­ter­face. This is a Craft pro­vid­ed inter­face that defines the meth­ods that a com­po­nent that has sav­able set­tings must implement.

This is great, we can lever­age the work that the fine folks at Pix­el & Ton­ic have done, because we want to be able to have sav­able set­tings too! Many com­po­nents in Craft like Fields, Wid­gets, etc. all use the SavableComponentInterface so we can, too!

Also note that there are no prop­er­ties defined in an inter­face; just methods.

Link PHP Traits

PHP also imple­ments the idea of traits. They are sim­i­lar to a PHP class, but they are designed to side-step the lim­i­ta­tion that PHP has from a lack of mul­ti­ple inher­i­tance. In PHP, an object can only inher­it (extends, in PHP par­lance) from one object.

Oth­er lan­guages allow for mul­ti­ple inher­i­tance; instead in PHP we can define a trait, and our objects can use that trait. Think of it as a way to pro­vide the prop­er­ties & meth­ods like a class would, but in a way that mul­ti­ple objects of dif­fer­ent 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 kin­da use­less, right? That’s because cur­rent­ly, it is! I’m not defin­ing any prop­er­ties or any meth­ods in the ImageTransformTrait, I’m just imple­ment­ing it for future expan­sion purposes.

In fact, although you can have both prop­er­ties and meth­ods in a trait, I tend to use them only for defin­ing properties.

The rea­son I do this is that it’s very com­mon to want to over­ride an objec­t’s meth­ods with your own code, and then call the par­ent method, e.g.: parent::init(). This gets pret­ty awk­ward to do with traits. So instead, we use base abstract classes.

Link PHP Base Abstract Classes

Final­ly, the last bit of PHP we’ll take advan­tage of is abstract class­es. Abstract class­es are just PHP class­es that imple­ment some meth­ods but are nev­er instan­ti­at­ed on their own. They exist sim­ply so that oth­er class­es can extend them.

So they pro­vide some base func­tion­al­i­ty, but you’d nev­er actu­al­ly cre­ate one. Instead, you’d write anoth­er class that extends a base abstract class, and over­ride the meth­ods you want to over­ride, call­ing the parent::method as appropriate.

Here’s what the base abstract Image­Trans­form 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 pro­vides a bit of base func­tion­al­i­ty that may be fine for a par­tic­u­lar image trans­form, but any of these meth­ods can be over­rid­den as need­ed. It’s also pret­ty com­mon to pro­vide some gener­ic util­i­tar­i­an func­tion­al­i­ty in a base abstract class.

Note that the ImageTransform class also extends the Craft base class SavableComponent so that we get the func­tion­al­i­ty of a sav­able component!

Link Tying it all together

So what we end up with is a hier­ar­chy that looks like this:

Image­Trans­form class hierarchy

Things pro­vid­ed by Craft are col­ored red; things that we nev­er instan­ti­ate are col­ored grey, and then the actu­al objects that we use in our plu­g­in are col­ored aqua.

We have:

  • an inter­face that defines our Image Trans­form meth­ods (our API)
  • a trait that (could) define any prop­er­ties we want all of our Image Trans­forms to have
  • a base abstract class that defines our core func­tion­al­i­ty that oth­er class­es extend
  • …and then mul­ti­ple class­es that extends our base abstract ImageTransform class to imple­ment the functionality

While this might seem at first blush to be com­pli­cat­ed, actu­al­ly what we’ve done is moved var­i­ous bits around to their own self-con­tained files, with a defined set of func­tion­al­i­ty. This will result is clear­er, more eas­i­ly main­tain­able & exten­si­ble code.

Link Mixing Our Components In

So this is great! We’ve got a nice defined inter­face & base abstract class­es for our Image Trans­form. This will make our life eas­i­er when we’re writ­ing the code to imple­ment our Image Transforms.

It also gives oth­er devel­op­ers a clear­ly defined way to write their own Image Trans­forms, just like Craft gives you a PluginInterface.php inter­face and base abstract Plugin.php classes.

But how do we mix our Image Trans­forms into our plugin?

The first thing we do is we have a prop­er­ty in our plug­in’s Settings mod­el that holds the ful­ly qual­i­fied class name of what­ev­er the cur­rent­ly select­ed Image Trans­form 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 imple­ments our ImageTransformInterface.

Next we take advan­tage of the fact that our plu­g­in is actu­al­ly a Yii2 Mod­ule… and all Yii2 Mod­ules can have Yii2 Com­po­nents.

In fact, any ser­vice class­es you define in your plu­g­in are just added as com­po­nents of your plug­in’s Mod­ule. See the Enhanc­ing a Craft CMS 3 Web­site with a Cus­tom Mod­ule arti­cle for more details on Craft CMS modules.

So we can set our transformMethod com­po­nent dynam­i­cal­ly in our plu­g­in by call­ing this method in our plug­in’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 call­ing the set() method that our plu­g­in inher­its from Yii2’s Ser­vice­Lo­ca­tor class. You pass in alias that you want to refer to the com­po­nent as (in this case transformMethod), along with a con­fig­u­ra­tion array that con­tains a class key with the ful­ly qual­i­fied class name to instan­ti­ate, along with any oth­er key/​value pairs of prop­er­ties that the class should be ini­tial­ized with.

In our case, we pass along any set­tings that our Image Trans­form might have, so that it’ll be con­fig­ured with any user-defin­able settings.

The mag­ic is that after we set() our com­po­nent on our plug­in’s class, we can then access it like any oth­er ser­vice: ImageOptimize::$plugin->transformMethod-> to call any of the meth­ods we defined in our ImageTransformInterface.

The plu­g­in does­n’t know, and does­n’t care exact­ly what class is pro­vid­ing the com­po­nent, so in this way we can swap in any class that imple­ments our ImageTransformInterface and away we go!

Under the hood, this all uses Yii2’s Depen­den­cy Injec­tion Con­tain­er (DI) to work its magic.

You’ve prob­a­bly seen this in action before, with­out even real­iz­ing it. When you adjust set­tings in your config/app.php to, say, add Redis as a caching method, the array you’re pro­vid­ing is just a con­fig­u­ra­tion for DI so it can find and instan­ti­ate the cache class to use!

Link Making Components Discoverable

So it’s great that we can lever­age all of this Yii2 good­ness to make our lives eas­i­er, but we still have the prob­lem of how to let our plu­g­in know about our com­po­nent class­es to begin with. We can take a 3‑pronged approach to this.

First, we sim­ply define an array of built-in class­es in our plu­g­in that come baked in:

const DEFAULT_IMAGE_TRANSFORM_TYPES = [
        CraftImageTransform::class,
        ImgixImageTransform::class,
        ThumborImageTransform::class,
    ];

Then we want to be able to let peo­ple just composer require an arbi­trary Com­pos­er pack­age that imple­ments our ImageTransformInterface, and let Ima­geOp­ti­mize know about it with­out any addi­tion­al code. As an exam­ple, check out the craft-ima­geop­ti­mize-imgix and craft-ima­geop­ti­mize-thum­bor packages.

We can do that by hav­ing a prop­er­ty in our Set­tings mod­el (and thus also in the mul­ti-envi­ron­ment config/image-optimize.php):

// The default Image Transform type classes
'defaultImageTransformTypes' => [
],

This allows peo­ple to just add the appro­pri­ate class to their config/image-optimize.php mul­ti-envi­ron­ment con­fig, 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 awe­some, peo­ple can add their own Image Trans­form to our plu­g­in with­out writ­ing a cus­tom mod­ule or plu­g­in to pro­vide it.

But peo­ple might also want to wrap their Image Trans­form in a plu­g­in (to make it eas­i­ly user-instal­lable from the Craft Plu­g­in Store) or cus­tom site mod­ule. We can do this by trig­ger­ing an event that modules/​plugins can lis­ten to in order to reg­is­ter the Image Trans­forms 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);

Obser­vant read­ers will note that this is the exact method that Craft uses to allow plu­g­ins to reg­is­ter addi­tion­al Field types, and oth­er func­tion­al­i­ty. On the module/​plugin side of things, the code they’d have to imple­ment 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;
     }
);

Beau­ti­ful! Now we can write our our Image Trans­forms eas­i­ly, and oth­er devel­op­ers can add their own Image Trans­forms how­ev­er they want.

We do still have one oth­er sub­ject to cov­er, which is how exact­ly do we allow Image Trans­forms to present their own GUI for set­tings, and save those settings?

Link Editing and Storing Component Settings

Since our Image Trans­form com­po­nents extend from SavableComponent, we get the scaf­fold­ing we need in order to dis­play a GUI for edit­ing plu­g­in set­tings, as well as sav­ing our settings!

To present a GUI, we just need to imple­ment the getSettingsHtml() method; here’s an exam­ple from ImgixImageTransform:

/**
 * @inheritdoc
 */
public function getSettingsHtml()
{
    return Craft::$app->getView()->renderTemplate('imgix-image-transform/settings/image-transforms/imgix.twig', [
        'imageTransform' => $this,
    ]);
}

We’re just pass­ing in our com­po­nent in imageTransform and ren­der­ing 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 prop­er­ties on our Image Trans­form class are used as the sav­able set­tings (just like for Craft Fields and Wid­gets), we just have those prop­er­ties in ImgixImageTransform.php:

// Public Properties
// =========================================================================

/**
 * @var string
 */
public $domain;

/**
 * @var string
 */
public $apiKey;

/**
 * @var string
 */
public $securityToken;

To actu­al­ly ren­der the Image Trans­for­m’s set­tings, we just do this in Ima­geOp­ti­mize’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 Drop­down for select­ing the Trans­form Method to use, and then loops through the avail­able Image Trans­form com­po­nents, and ren­ders their set­tings HTML.

We then store the result in our Set­tings mod­el (so that it works nice­ly with Craft CMS 3.1’s Project Con­fig) in the imageTransformTypeSettings prop­er­ty as an array. The key is the ful­ly qual­i­fied class name of the Image Trans­form, and the val­ue is an array that con­tains what­ev­er set­tings that Image Trans­form provides.

This is what the ImageOptimize.php main plu­g­in 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 '';
}

Link Go Forth and Component-ize!

This is quite a bit to digest, but I think it’ll help with cre­at­ing plu­g­ins that offer exten­si­bil­i­ty in a very Yii2/Craft-like man­ner. Armed with this knowl­edge, you can go forth and make awe­some, exten­si­ble plugins!

It’s cer­tain­ly friend­lier long-term than hard-cod­ing it all into your plu­g­in (with all of the asso­ci­at­ed PRs from peo­ple want­i­ng to add func­tion­al­i­ty), and it’s more man­age­able than requir­ing addi­tion­al plu­g­ins being installed.

It pro­vides the struc­ture that will help you archi­tect your plu­g­in well, and also allows for great flex­i­bil­i­ty in allow­ing oth­ers to extend it with addi­tion­al functionality.

This tech­nique could also eas­i­ly be used for a suite of plu­g­ins that rely on the same core func­tion­al­i­ty. Instead of requir­ing a shared plu­g­in be installed, sim­ply com­po­nent-ize the need­ed func­tion­al­i­ty, and add it in as a com­pos­er pack­age dependency.