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

Published , updated · 5 min read ·


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

Creating a custom field in Craft CMS

Learn how to cre­ate a cus­tom field in Craft CMS, writ­ing as lit­tle code as pos­si­ble by lever­ag­ing the platform.

A client I work with is build­ing a fair­ly com­pli­cat­ed Craft CMS web­site that includes a very cus­tom online store that uses Craft Com­merce.

They are using a site mod­ule as described in the Enhanc­ing a Craft CMS 3 Web­site with a Cus­tom Mod­ule arti­cle, to make extend­ing and cus­tomiz­ing their site easy to do.

For their con­tent authors, they had the need to cre­ate a cus­tom field that was essen­tial­ly a Drop­down field, but it need­ed the list of options to come from a cus­tom exter­nal data source.

They wrote the field (with scaf­fold­ing gen­er­at­ed by plug​in​fac​to​ry​.io), and called me in to do a code review of it, and help out with the last 10%.

Link Commerce Countries custom field

Craft Com­merce pro­vides a list of coun­tries for you to use, allow­ing you to enable or dis­able them as appro­pri­ate for your store.

Craft Com­merce Countries

The cus­tom field they need­ed was one that took all of the Com­merce coun­tries, and dis­played them in a drop­down menu for con­tent authors.

Com­merce Coun­tries Field

In the field set­tings, rather than pro­vid­ing an editable set of options that the Craft Drop­down field does, they want­ed just a sin­gle light switch Use only enabled Com­merce countries?

This would con­trol whether all coun­tries were list­ed in the drop­down, or if just the ones the con­tent authors had enabled in Craft Com­merce would appear.

Either way, the source of the data was exter­nal” in that it was­n’t defined in the field, it came from Craft Commerce.

Here’s what it looks like in situ in an Entry:

Com­merce Coun­tries Field in an Entry

Link A peek at the code

The code was fine for the field, and it all worked… but the last 10% they need­ed to do includ­ed things like get­ting the field val­i­da­tion to work when it’s added to an Entry.

Noth­ing too com­pli­cat­ed (you’d use getEle­ment­Val­i­da­tion­Rules()), but it made be pause and say:

I wouldn’t approach it this way

The rea­son I said that is I’ve found that the less code you write, the few­er bugs you can intro­duce, and the less tech­ni­cal debt that you end up hav­ing to maintain.

Rather than writ­ing a cus­tom Drop­down clone from scratch, I thought it’d be a bet­ter idea to lever­age the platform.

This meant throw­ing out a bunch of code, and sub-class­ing the built-in Craft Drop­down field, chang­ing only the behav­ior we want­ed to be different.

Thank­ful­ly, my client has always been eager to do things the right way, so we dove right into it.

Link Refactoring the code

We start­ed by refac­tor­ing the code, which in this case meant delet­ing code. Lots of code.

One of the most underrated pleasures in life is deleting code

Delet­ing code might frus­trate some peo­ple, since you’re essen­tial­ly toss­ing out a bunch of work that you’ve already done. How­ev­er, I find it incred­i­bly satisfying.

There’s some­thing pleas­ing in the terse­ness of code, and you can also think of every line of code as tech­ni­cal debt that some­one (prob­a­bly you) will need to maintain.

So with code, go for clear not clever, and tru­ly less is more.

Here’s what we end­ed up with:

<?php

namespace modules\sitemodule\fields;

use Craft;
use craft\commerce\Plugin as Commerce;
use craft\fields\Dropdown;

/**
 * Class CommerceCountriesField
 *
 * @package   SiteModule
 * @since     1.0.0
 */
class CommerceCountriesField extends Dropdown
{
    // Public Properties
    // =========================================================================

    /**
     * @var bool
     */
    public $onlyEnabledCountries = false;

    // Public Methods
    // =========================================================================

    /**
     * @inheritdoc
     */
    public function init()
    {
        // set our options before Craft's BaseOptionsField normalizes them.
        $this->setCommerceCountries();

        parent::init();
    }

    /**
     * @inheritdoc
     */
    public static function displayName(): string
    {
        return Craft::t('site-module', 'Commerce Countries');
    }

    /**
     * @inheritdoc
     */
    public function getSettingsHtml():? string
    {
        // Render the settings template
        return Craft::$app->getView()->renderTemplate(
            'sitemodule/_components/fields/CommerceCountries_settings',
            [
                'field' => $this,
            ]
        );
    }

    // Protected Methods
    // =========================================================================

    /**
     * Set lists of Commerce's countries and their IDs.
     *
     * Commerce countries can be found in the CP by going to
     * Commerce > Store Settings > Countries & States.
     *
     * @return void
     */
    protected function setCommerceCountries(): void
    {
        // get Commerce's countries as an array of Country models
        if ($this->onlyEnabledCountries) {
            $commerceCountries = Commerce::getInstance()->getCountries()->getAllEnabledCountries();
        } else {
            $commerceCountries = Commerce::getInstance()->getCountries()->getAllCountries();
        }

        // prepare the field's display options
        $this->options = [
            [
                'label' => Craft::t('site', 'Choose a Country'),
                'value' => '',
                'disabled' => true,
            ]
        ];

        // add Commerce's countries as options
        foreach ($commerceCountries as $country) {
            $this->options[] = [
                'label' => Craft::t('site', $country->name),
                'value' => $country->id
            ];
        }
    }
}

Our Com­merce Coun­tries field extends Craft’s built-in Drop­down field, which means that we get to lever­age all of the hard work that the Pix­el & Ton­ic devel­op­ers put into writ­ing a Drop­down field.

Then we change just the things that are unique about our par­tic­u­lar field but over­rid­ing meth­ods inher­it­ed from the par­ent Drop­down field.

Let’s break it down:


    public $onlyEnabledCountries = false;

Any pub­lic prop­er­ties you add to a Craft CMS Field object are field set­tings that can be saved along with the field instance. Here we want a sim­ple boolean for our light switch that lets peo­ple con­trol whether all coun­tries should be dis­played or not.

Note that this prop­er­ty is in addi­tion to any prop­er­ties that our field inher­its from the par­ent Drop­down class.


    public function init()
    {
        // set our options before Craft's BaseOptionsField normalizes them.
        $this->setCommerceCountries();

        parent::init();
    }

init() is a method that the Yii2 BaseOb­ject calls from its con­struc­tor after object prop­er­ties have been ini­tial­ized. It allows your object to have a chance to ini­tial­ize itself as it sees fit.

Here we call a pro­tect­ed method on our CommerceCountriesField class named setCommerceCountries() to ini­tial­ize our drop­down options (see below).

Then we call the par­ent class’s init() method too, so it can do any need­ed initialization.


    public static function displayName(): string
    {
        return Craft::t('site-module', 'Commerce Countries');
    }

Here we just over­ride the displayName() method so that we can return our cus­tom field name, rather then it just being list­ed as Drop­down”.


    public function getSettingsHtml():? string
    {
        // Render the settings template
        return Craft::$app->getView()->renderTemplate(
            'sitemodule/_components/fields/CommerceCountries_settings',
            [
                'field' => $this,
            ]
        );
    }

Since we don’t want the nor­mal Drop­down field set­tings, but instead want to just present the light switch Use only enabled Com­merce coun­tries?, we over­ride the getSettingsHtml() method to ren­der our own template.


    protected function setCommerceCountries(): void
    {
        // get Commerce's countries as an array of Country models
        if ($this->onlyEnabledCountries) {
            $commerceCountries = Commerce::getInstance()->getCountries()->getAllEnabledCountries();
        } else {
            $commerceCountries = Commerce::getInstance()->getCountries()->getAllCountries();
        }

        // prepare the field's display options
        $this->options = [
            [
                'label' => Craft::t('site', 'Choose a Country'),
                'value' => '',
                'disabled' => true,
            ]
        ];

        // add Commerce's countries as options
        foreach ($commerceCountries as $country) {
            $this->options[] = [
                'label' => Craft::t('site', $country->name),
                'value' => $country->id
            ];
        }
    }

The setCommerceCountries() method is the bulk of our cus­tom field. It grabs the coun­tries from Craft Com­merce, and over­writes the $options prop­er­ty inher­it­ed from the par­ent Drop­down class.

So we’re forc­ing the options we want our drop­down field to dis­play into the field, and let­ting the Craft Drop­down field take care of the rest.

If you take a look at all of the code in the Drop­down class, as well as the BaseOp­tions­Field it extends from, you’ll see a whole lot of code we did­n’t have to write.

And don’t need to maintain.

For exam­ple, we get getElementValidationRules() for free so our cus­tom field will show val­i­da­tion errors in an Entry, and we get auto­mat­ic GraphQL support.

Beau­ti­ful.

Link Other Trappings

What we’ve pre­sent­ed is just the field itself. In order for it all to work, we also need our Twig tem­plate for our field­’s set­tings that looks like this:


    {{ forms.lightswitchField({
        label: "Use only enabled Commerce countries"|t('site-module'),
        instructions: "Commerce countries can be found in the CP by going to Commerce > Store Settings > Countries & States."|t('site-module'),
        'id': 'onlyEnabledCountries',
        'name': 'onlyEnabledCountries',
        'on': field.onlyEnabledCountries,
        errors: field.getErrors("onlyEnabledCountries"),
    }) }}

And then in our Site Mod­ule or plug­in’s init() method, we need to let Craft CMS know about our new Field:

<?php

namespace modules\sitemodule;

use modules\sitemodule\fields\CommerceCountriesField;

use craft\events\RegisterComponentTypesEvent;
use craft\services\Fields;

class SiteModule extends Module
{

    public function init()
    {
        // Register our Field
        Event::on(
            Fields::class,
            Fields::EVENT_REGISTER_FIELD_TYPES,
            function (RegisterComponentTypesEvent $event) {
                Craft::debug(
                    'Fields::EVENT_REGISTER_FIELD_TYPES',
                    __METHOD__
                );
                $event->types[] = CommerceCountriesField::class;
            }
        );
        
        parent::init();
    }

Link Leverage the platform

Hope­ful­ly there have been a few tid­bits here that have helped you, but the real mes­sage is to lever­age the platform.

What­ev­er the plat­form” hap­pens to be, whether it’s the web brows­er, a fron­tend library you’re using, or a CMS plat­form like Craft CMS… lever­age it.

Stand on the shoul­ders of giants, and take advan­tage of work they’ve already done for you.

Write just the code that defines what makes your imple­men­ta­tion unique.

Hap­py leveraging!