Development of Custom Plugin for Craft CMS
Craft CMS plugins are Composer packages that extend CMS functionality through official API: custom element types, fieldtype fields, dashboard widgets, utilities, new CP sections. Unlike modules, plugins can be published in Plugin Store or reused between projects.
Plugin Structure
craft-myplugin/
├── composer.json
├── CHANGELOG.md
├── src/
│ ├── Plugin.php # main class
│ ├── models/
│ │ └── Settings.php # settings model
│ ├── services/
│ │ └── MyService.php
│ ├── fields/
│ │ └── ColorSwatchField.php
│ ├── elements/
│ │ └── ProductElement.php
│ ├── records/
│ │ └── ProductRecord.php # Yii2 ActiveRecord
│ ├── migrations/
│ │ └── Install.php
│ ├── controllers/
│ │ └── ProductsController.php
│ ├── templates/
│ │ ├── _cp/
│ │ │ └── settings.twig
│ │ └── _fields/
│ │ └── color-swatch-input.twig
│ ├── web/
│ │ └── assets/
│ │ └── CpAsset.php
│ └── translations/
│ └── en/
│ └── myplugin.php
└── icon.svg
composer.json
{
"name": "myvendor/craft-myplugin",
"description": "My custom Craft CMS plugin",
"type": "craft-plugin",
"minimum-stability": "dev",
"require": {
"craftcms/cms": "^4.0.0"
},
"autoload": {
"psr-4": { "myvendor\\myplugin\\": "src/" }
},
"extra": {
"handle": "myplugin",
"name": "My Plugin",
"version": "1.0.0",
"schemaVersion": "1.0.0",
"class": "myvendor\\myplugin\\Plugin",
"developer": "My Company",
"developerUrl": "https://mycompany.com",
"documentationUrl": "https://mycompany.com/plugins/myplugin",
"changelogUrl": "https://raw.githubusercontent.com/myvendor/craft-myplugin/main/CHANGELOG.md",
"hasCpSettings": true,
"hasCpSection": true
}
}
Main Plugin Class
// src/Plugin.php
namespace myvendor\myplugin;
use Craft;
use craft\base\Plugin as BasePlugin;
use craft\events\RegisterComponentTypesEvent;
use craft\services\Fields;
use myvendor\myplugin\fields\ColorSwatchField;
use myvendor\myplugin\services\MyService;
use myvendor\myplugin\models\Settings;
use yii\base\Event;
class Plugin extends BasePlugin
{
public string $schemaVersion = '1.0.0';
public bool $hasCpSettings = true;
public bool $hasCpSection = true;
public static Plugin $instance;
public function init(): void
{
parent::init();
self::$instance = $this;
$this->setComponents([
'myService' => MyService::class,
]);
// Register custom field type
Event::on(
Fields::class,
Fields::EVENT_REGISTER_FIELD_TYPES,
function (RegisterComponentTypesEvent $event) {
$event->types[] = ColorSwatchField::class;
}
);
Craft::info("Plugin {$this->name} loaded", __METHOD__);
}
protected function createSettingsModel(): ?Model
{
return new Settings();
}
protected function settingsHtml(): ?string
{
return Craft::$app->view->renderTemplate('myplugin/_cp/settings', [
'settings' => $this->getSettings(),
]);
}
public function getCpNavItem(): ?array
{
return [
...parent::getCpNavItem(),
'label' => 'My Plugin',
'url' => 'myplugin',
'icon' => '@myvendor/myplugin/icon.svg',
'subnav' => [
'dashboard' => ['label' => 'Dashboard', 'url' => 'myplugin'],
'settings' => ['label' => 'Settings', 'url' => 'myplugin/settings'],
],
];
}
}
Custom Field Type
// src/fields/ColorSwatchField.php
namespace myvendor\myplugin\fields;
use craft\base\Field;
use craft\base\ElementInterface;
class ColorSwatchField extends Field
{
public static function displayName(): string
{
return \Craft::t('myplugin', 'Color Swatch');
}
public static function phpType(): string
{
return 'string';
}
public function getInputHtml(mixed $value, ?ElementInterface $element): string
{
return \Craft::$app->view->renderTemplate(
'myplugin/_fields/color-swatch-input',
[
'id' => $this->getInputId(),
'name' => $this->handle,
'value' => $value,
'swatches' => $this->swatches,
]
);
}
public function normalizeValue(mixed $value, ?ElementInterface $element): mixed
{
// Normalize: add # if missing
return ltrim($value ?? '', '#') ? '#' . ltrim($value, '#') : null;
}
protected function defineRules(): array
{
return array_merge(parent::defineRules(), [
[['value'], 'match', 'pattern' => '/^#[0-9A-Fa-f]{6}$/'],
]);
}
}
Custom Element Type
Custom elements (like Entry, User, Asset) inherit from craft\base\Element:
// src/elements/ProductElement.php — key methods
class ProductElement extends Element
{
public static function displayName(): string { return 'Product'; }
public static function pluralDisplayName(): string { return 'Products'; }
// Search condition
public static function find(): ElementQueryInterface
{
return new ProductQuery(static::class);
}
// Searchable attributes
protected static function defineSearchableAttributes(): array
{
return ['sku', 'title', 'description'];
}
// Save additional data (in own table)
public function afterSave(bool $isNew): void
{
if ($isNew) {
\Craft::$app->db->createCommand()
->insert('{{%myplugin_products}}', [
'id' => $this->id,
'sku' => $this->sku,
'price' => $this->price,
])
->execute();
} else {
\Craft::$app->db->createCommand()
->update('{{%myplugin_products}}', [
'sku' => $this->sku,
'price' => $this->price,
], ['id' => $this->id])
->execute();
}
parent::afterSave($isNew);
}
}
Migrations
// src/migrations/Install.php
namespace myvendor\myplugin\migrations;
use craft\db\Migration;
class Install extends Migration
{
public function safeUp(): bool
{
if (!$this->db->tableExists('{{%myplugin_products}}')) {
$this->createTable('{{%myplugin_products}}', [
'id' => $this->primaryKey(),
'sku' => $this->string()->notNull(),
'price' => $this->decimal(10, 2)->notNull()->defaultValue(0),
'stockCount' => $this->integer()->notNull()->defaultValue(0),
'dateCreated' => $this->dateTime()->notNull(),
'dateUpdated' => $this->dateTime()->notNull(),
'uid' => $this->uid(),
]);
$this->addForeignKey(
null, '{{%myplugin_products}}', 'id',
'{{%elements}}', 'id', 'CASCADE'
);
}
return true;
}
public function safeDown(): bool
{
$this->dropTableIfExists('{{%myplugin_products}}');
return true;
}
}
Testing
# Codeception for Craft
composer require craftcms/craft-pest --dev
./vendor/bin/pest
Development Timeline
| Plugin Type | Time |
|---|---|
| Simple field (FieldType) | 2–3 days |
| Service + CP section | 3–5 days |
| Custom element type | 5–10 days |
| Full plugin (element + CP + API) | 2–4 weeks |
| Plugin Store publication | +1–2 days |







