Andrew Welch
Published , updated · 5 min read · RSS Feed
Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.
Creating a custom field in Craft CMS
Learn how to create a custom field in Craft CMS, writing as little code as possible by leveraging the platform.
A client I work with is building a fairly complicated Craft CMS website that includes a very custom online store that uses Craft Commerce.
They are using a site module as described in the Enhancing a Craft CMS 3 Website with a Custom Module article, to make extending and customizing their site easy to do.
For their content authors, they had the need to create a custom field that was essentially a Dropdown field, but it needed the list of options to come from a custom external data source.
They wrote the field (with scaffolding generated by pluginfactory.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 Commerce provides a list of countries for you to use, allowing you to enable or disable them as appropriate for your store.
The custom field they needed was one that took all of the Commerce countries, and displayed them in a dropdown menu for content authors.
In the field settings, rather than providing an editable set of options that the Craft Dropdown field does, they wanted just a single light switch Use only enabled Commerce countries?
This would control whether all countries were listed in the dropdown, or if just the ones the content authors had enabled in Craft Commerce would appear.
Either way, the source of the data was “external” in that it wasn’t defined in the field, it came from Craft Commerce.
Here’s what it looks like in situ 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 needed to do included things like getting the field validation to work when it’s added to an Entry.
Nothing too complicated (you’d use getElementValidationRules()), but it made be pause and say:
I wouldn’t approach it this way
The reason I said that is I’ve found that the less code you write, the fewer bugs you can introduce, and the less technical debt that you end up having to maintain.
Rather than writing a custom Dropdown clone from scratch, I thought it’d be a better idea to leverage the platform.
This meant throwing out a bunch of code, and sub-classing the built-in Craft Dropdown field, changing only the behavior we wanted to be different.
Thankfully, my client has always been eager to do things the right way, so we dove right into it.
Link Refactoring the code
We started by refactoring the code, which in this case meant deleting code. Lots of code.
One of the most underrated pleasures in life is deleting code
Deleting code might frustrate some people, since you’re essentially tossing out a bunch of work that you’ve already done. However, I find it incredibly satisfying.
There’s something pleasing in the terseness of code, and you can also think of every line of code as technical debt that someone (probably you) will need to maintain.
So with code, go for clear not clever, and truly less is more.
Here’s what we ended 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 Commerce Countries field extends Craft’s built-in Dropdown field, which means that we get to leverage all of the hard work that the Pixel & Tonic developers put into writing a Dropdown field.
Then we change just the things that are unique about our particular field but overriding methods inherited from the parent Dropdown field.
Let’s break it down:
public $onlyEnabledCountries = false;
Any public properties you add to a Craft CMS Field object are field settings that can be saved along with the field instance. Here we want a simple boolean for our light switch that lets people control whether all countries should be displayed or not.
Note that this property is in addition to any properties that our field inherits from the parent Dropdown class.
public function init()
{
// set our options before Craft's BaseOptionsField normalizes them.
$this->setCommerceCountries();
parent::init();
}
init() is a method that the Yii2 BaseObject calls from its constructor after object properties have been initialized. It allows your object to have a chance to initialize itself as it sees fit.
Here we call a protected method on our CommerceCountriesField class named setCommerceCountries() to initialize our dropdown options (see below).
Then we call the parent class’s init() method too, so it can do any needed initialization.
public static function displayName(): string
{
return Craft::t('site-module', 'Commerce Countries');
}
Here we just override the displayName() method so that we can return our custom field name, rather then it just being listed as “Dropdown”.
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 normal Dropdown field settings, but instead want to just present the light switch Use only enabled Commerce countries?, we override the getSettingsHtml() method to render 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 custom field. It grabs the countries from Craft Commerce, and overwrites the $options property inherited from the parent Dropdown class.
So we’re forcing the options we want our dropdown field to display into the field, and letting the Craft Dropdown field take care of the rest.
If you take a look at all of the code in the Dropdown class, as well as the BaseOptionsField it extends from, you’ll see a whole lot of code we didn’t have to write.
And don’t need to maintain.
For example, we get getElementValidationRules() for free so our custom field will show validation errors in an Entry, and we get automatic GraphQL support.
Beautiful.
Link Other Trappings
What we’ve presented is just the field itself. In order for it all to work, we also need our Twig template for our field’s settings 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 Module or plugin’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
Hopefully there have been a few tidbits here that have helped you, but the real message is to leverage the platform.
Whatever “the platform” happens to be, whether it’s the web browser, a frontend library you’re using, or a CMS platform like Craft CMS… leverage it.
Stand on the shoulders of giants, and take advantage of work they’ve already done for you.
Write just the code that defines what makes your implementation unique.
Happy leveraging!