All skills

Craft CMS 5 plugin and module development — extending Craft with PHP. Covers elements, element queries, services, models, records, controllers, migrations, queue jobs, console commands, field types, native fields, events, behaviors, Twig extensions, widgets, filesystems, permissions, project config, GraphQL, testing, and debugging. Triggers on: beforePrepare()/afterSave()/defineSources()/defineTableAttributes()/attributeHtml(), MemoizableArray, BaseNativeField, EVENT_REGISTER_*/DEFINE_*/BEFORE_*/AFTER_*, CraftVariable, custom element or field type (normalizeValue/serializeValue/inputHtml), webhook, API endpoint, queue/batch job, CP section, dashboard widget, utility page, element action/exporter/condition, registerUserPermissions, requirePermission vs requireAdmin, kebab-case permission handles, allowAdminChanges, canView/canSave/canDelete, defineRules, elevated session, project-config/apply, drafts/revisions, element edit sidebar (EVENT_DEFINE_SIDEBAR_HTML) + toolbar buttons, metaFieldsHtml, VueAdminTable, GeneralConfig, getIsMultiSite/refreshSites stale after creating a site, deleteSite phantom sites, element query stops filtering by siteId, naive UTC datetime columns and strtotime drift, element-query ['like'] tuple matches nothing, configWarning config-file overrides. Always use when writing, editing, or reviewing Craft plugin/module PHP — even when no API is named. For plugin-specific work also load craft-plugins. Do NOT trigger for front-end Twig (craft-site) or content modeling (craft-content-modeling).

Use this Skill: https://skilld.dev/gh/michtio/craftcms-claude-skills/craftcms

This session only. Nothing lands on disk.

referencesfields.md

≈4.7k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Fields — Field Types, Native Fields, Field Layout Elements

Documentation

Common Pitfalls

  • Creating a field layout element class for a custom field type — Craft automatically wraps your field in CustomField. A layout element class would create a duplicate entry in the field layout designer.
  • Forgetting Fields::EVENT_REGISTER_FIELD_TYPES — without registration, the field type won't appear in the Settings → Fields type selector, even though the class exists.
  • Not calling parent::afterElementSave() — the base implementation handles relation updates and field lifecycle hooks. Skipping it silently breaks custom field data persistence.
  • Using afterElementSave() without checking $element->isFieldDirty() — runs expensive processing (API calls, re-indexing) on every save even when the field value didn't change.
  • Confusing native fields with custom field types — native fields are layout elements for element attributes (Title, Slug), custom fields are the configurable types users add in Settings → Fields. Different base classes, different registration events.

Table of Contents

When to Use What

Craft's field system has three distinct concepts. Understanding which one you need prevents building the wrong thing.

Custom Field Types — Standalone field plugins

A custom field type is a plugin whose primary purpose is providing a new kind of field that works across ALL element types. Users install the plugin, then add the field in Settings → Fields, and assign it to any field layout — entries, assets, users, your custom elements, anything.

Examples: Colour Swatches, Hyper, ImageOptimize, CKEditor. These are standalone Composer packages.

Extend: craft\base\Field (or craft\fields\BaseRelationField for relation fields). Register: Fields::EVENT_REGISTER_FIELD_TYPES. Scaffold: ddev craft make field-type --with-docblocks.

You build a custom field type when you're creating a reusable field that isn't tied to a specific element type.

Native Fields — Your element's own properties in the field layout designer

Native fields expose your custom element's native properties (like postDate, externalId, category) in the field layout designer, so administrators can position and configure them alongside custom fields.

These are NOT standalone plugins — they're part of your element type's implementation. Every custom element type with editable properties needs native fields. Craft's own Title, Slug, and Post Date fields are native fields.

Extend: craft\fieldlayoutelements\BaseNativeField (or use TextField for simple attributes). Register: FieldLayout::EVENT_DEFINE_NATIVE_FIELDS (scoped to your element type). No generator — create manually or use ad-hoc TextField definitions.

You build native fields when you're building a custom element type and need its properties in the editor.

UI Elements — Non-data display components

UI elements are read-only layout components: sync status panels, info widgets, visual separators. They don't correspond to element properties — they provide feedback or structure.

Extend: craft\fieldlayoutelements\BaseUiElement. Register: FieldLayout::EVENT_DEFINE_UI_ELEMENTS.

You build UI elements when you need display-only components in the element editor.

Custom Field Types

Scaffold and Registration

ddev craft make field-type --with-docblocks
Event::on(Fields::class, Fields::EVENT_REGISTER_FIELD_TYPES,
    function(RegisterComponentTypesEvent $event) {
        $event->types[] = MyField::class;
    }
);

Key Methods

class MyField extends Field
{
    public string $someOption = 'default';

    public static function displayName(): string
    {
        return Craft::t('my-plugin', 'My Field');
    }

    // Column type(s) for the content table. Return null to manage storage yourself.
    public static function dbType(): array|string|null
    {
        return Schema::TYPE_STRING;
    }

    public function getSettingsHtml(): ?string
    {
        return Craft::$app->getView()->renderTemplate('my-plugin/_field/settings', ['field' => $this]);
    }

    protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inline): string
    {
        return Craft::$app->getView()->renderTemplate('my-plugin/_field/input', [
            'field' => $this,
            'value' => $value,
            'element' => $element,
        ]);
    }

    public function normalizeValue(mixed $value, ?ElementInterface $element): mixed
    {
        // DB/POST → working type
        return $value instanceof MyValueObject ? $value : new MyValueObject($value);
    }

    public function serializeValue(mixed $value, ?ElementInterface $element): mixed
    {
        // Working type → DB storage
        return $value instanceof MyValueObject ? $value->toArray() : $value;
    }

    public function afterElementSave(ElementInterface $element, bool $isNew): void
    {
        if ($element->isFieldDirty($this->handle)) {
            // Only process when value actually changed
        }
        parent::afterElementSave($element, $isNew);
    }
}

Content Column Options

Schema::TYPE_STRING    // VARCHAR
Schema::TYPE_TEXT      // TEXT
Schema::TYPE_INTEGER   // INT
Schema::TYPE_JSON      // JSON
// Multiple columns:
['date' => Schema::TYPE_DATETIME, 'timezone' => Schema::TYPE_STRING]
// No column (manage yourself):
null

Relation Fields

Extend craft\fields\BaseRelationField for element relation fields:

class MyRelationField extends BaseRelationField
{
    public static function elementType(): string { return MyElement::class; }
    public static function displayName(): string { return Craft::t('my-plugin', 'My Elements'); }
}

Native Fields

Register via FieldLayout::EVENT_DEFINE_NATIVE_FIELDS, scoped to your element type:

Event::on(FieldLayout::class, FieldLayout::EVENT_DEFINE_NATIVE_FIELDS,
    function(DefineFieldLayoutFieldsEvent $event) {
        $layout = $event->sender;
        if ($layout->type === MyElement::class) {
            $event->fields[] = PostDateField::class;
        }
    }
);

Ad-Hoc (No Dedicated Class)

$event->fields[] = [
    'class' => \craft\fieldlayoutelements\TextField::class,
    'attribute' => 'externalId',
    'label' => Craft::t('my-plugin', 'External ID'),
    'mandatory' => true,
];

Custom Native Field Class

class PostDateField extends BaseNativeField
{
    public bool $mandatory = true;

    public function attribute(): string { return 'postDate'; }

    protected function defaultLabel(?ElementInterface $element = null): ?string
    {
        return Craft::t('app', 'Post Date');
    }

    protected function inputHtml(?ElementInterface $element = null, bool $static = false): ?string
    {
        return Cp::dateTimeFieldHtml([
            'id' => 'postDate',
            'name' => 'postDate',
            'value' => $element?->postDate,
            'disabled' => $static,
        ]);
    }
}

Base Definitions

Class Purpose
BaseNativeField Element attributes with input
TextField Simple text/number — no custom class needed
BaseUiElement Read-only display
CustomField Wraps custom field types — never extend

UI Elements

Event::on(FieldLayout::class, FieldLayout::EVENT_DEFINE_UI_ELEMENTS,
    function(DefineFieldLayoutFieldsEvent $event) {
        $event->elements[] = SyncStatusWidget::class;
    }
);

Field-Layout UI Elements Reference

Craft ships several built-in UI elements you can reuse in a layout definition, or reference for structure when writing your own. They live in fieldlayoutelements/.

Built-in elements

Element Renders Base class Notes
Heading <h2> (Heading.php) BaseUiElement Configurable heading text; translated through the site category.
Tip .pane + style class (Tip.php) BaseUiElement Single class covering both styles via STYLE_TIP/STYLE_WARNING (there is no separate "Warning" class). Optional dismissible adds a close button.
HorizontalRule <hr> (HorizontalRule.php) FieldLayoutElement Bespoke .fld-hr selector markup.
LineBreak .line-break div (LineBreak.php) FieldLayoutElement Bespoke .fld-br selector markup; forces subsequent elements onto a new row.
Markdown rendered Markdown (Markdown.php) BaseUiElement
Template rendered Twig template (Template.php) BaseUiElement
Html raw HTML (Html.php) FieldLayoutElement Bespoke selector markup, like the rules above.

Tip is a single class — its appearance is driven by the style property (STYLE_TIP vs STYLE_WARNING), not by two separate element types.

Selector markup

A BaseUiElement's field-layout-designer selector wrapper is a single .fld-ui-element div carrying a data-type attribute (the escaped class name), assembled in BaseUiElement.php:46-65. It is not a combined .fld-element fld-ui-element class — matching on the latter will miss these elements.

HorizontalRule, LineBreak, and Html extend FieldLayoutElement directly rather than BaseUiElement, so they do not use the .fld-ui-element wrapper — each defines its own selectorHtml() (.fld-hr, .fld-br, and custom markup respectively).

Registering your own

Plugins register components via FieldLayout events (models/FieldLayout.php):

  • Custom UI elements — FieldLayout::EVENT_DEFINE_UI_ELEMENTS (FieldLayout.php:130); extend BaseUiElement (see UI Elements above).
  • Native fields — FieldLayout::EVENT_DEFINE_NATIVE_FIELDS (FieldLayout.php:75); see Native Fields.

Both events pass a DefineFieldLayoutFieldsEvent. For the full registration event catalog, see events.md.

Suppressing the condition builders

Every field-layout component inherits the Visibility/Editability condition-builder UI from FieldLayoutComponent. The sanctioned way to remove it on a custom component is overriding protected function conditional(): bool to return false (base/FieldLayoutComponent.php:124 — both hasSettings() and conditionalSettingsHtml() consult it). Don't hide the builders with CSS or strip them out of the settings markup.

FieldLayoutBehavior

For element types with multiple variants (like entry types):

class MyCategory extends Model
{
    public ?int $fieldLayoutId = null;

    protected function defineBehaviors(): array
    {
        return [
            'fieldLayout' => [
                'class' => FieldLayoutBehavior::class,
                'elementType' => MyElement::class,
            ],
        ];
    }
}

Validation

Element Validation Rules

getElementValidationRules() defines validation applied to the element when the field value is saved. Rules are added to the element's rules() automatically. Standard Yii validators work: string, number, email, url, required, in, match, etc.

public function getElementValidationRules(): array
{
    return [
        ['string', 'max' => $this->maxLength],
        ['required', 'when' => fn($model) => $this->required],
    ];
}

Field Settings Validation

defineRules() validates the field's own settings (configured in Settings > Fields), not the element value. Always call parent::defineRules() -- skipping it silently drops validation for handle, name, and other core settings.

protected function defineRules(): array
{
    $rules = parent::defineRules();
    $rules[] = [['maxLength'], 'number', 'integerOnly' => true, 'min' => 1];
    $rules[] = [['placeholder'], 'string', 'max' => 255];
    return $rules;
}

Search Keywords

Without getSearchKeywords(), custom field content is invisible to Craft's search. Return an empty string to opt out of indexing (appropriate for non-textual data like coordinates or binary flags).

public function getSearchKeywords(mixed $value, ElementInterface $element): string
{
    return $value instanceof MyValueObject ? $value->getSearchableText() : (string)$value;
}

After changing this method on an existing field, rebuild: ddev craft db/search-indexes.

GraphQL Integration

Three methods control how a custom field type appears in Craft's GraphQL API. All optional -- if omitted, the field won't be queryable via GraphQL. See graphql.md for full GQL patterns.

use GraphQL\Type\Definition\Type;

// Return type for queries
public function getContentGqlType(): array|Type
{
    return Type::string();
    // For complex types: return ['name' => $this->handle, 'type' => Type::string(), 'resolve' => fn($source) => ...]
}

// Input type for mutations
public function getContentGqlMutationArgumentType(): array|Type
{
    return ['name' => $this->handle, 'type' => Type::string()];
}

// Argument type for query filtering
public function getContentGqlQueryArgumentType(): array|Type
{
    return ['name' => $this->handle, 'type' => Type::listOf(Type::string())];
}

Lifecycle Methods

Override these in your field class to hook into element CRUD operations. The before* methods can return false to cancel the operation.

Method When it fires Notes
beforeElementSave($element, $isNew) Before save Return false to cancel
afterElementSave($element, $isNew) After save Most common -- sync data, process relations
afterElementPropagate($element, $isNew) After multi-site propagation Runs after all sites updated
beforeElementDelete($element) Before soft/hard delete Return false to prevent
afterElementDelete($element) After delete Common -- clean up resources
beforeElementRestore($element) Before restore from soft delete Return false to prevent
afterElementRestore($element) After restore Re-establish connections
public function afterElementSave(ElementInterface $element, bool $isNew): void
{
    if ($element->isFieldDirty($this->handle)) {
        $value = $element->getFieldValue($this->handle);
        // Process only when value changed
    }
    parent::afterElementSave($element, $isNew); // Always call parent
}

Multi-Site Translation

The translationMethod property determines how field values are shared across sites.

Constant Behavior
Field::TRANSLATION_METHOD_NONE Same value across all sites (default)
Field::TRANSLATION_METHOD_SITE Unique value per site
Field::TRANSLATION_METHOD_SITE_GROUP Shared within site group, unique across groups
Field::TRANSLATION_METHOD_LANGUAGE Shared when sites have the same language
Field::TRANSLATION_METHOD_CUSTOM Custom logic via getTranslationKey()

getTranslationKey

With TRANSLATION_METHOD_CUSTOM, elements with the same translation key share the same field value.

public function getTranslationKey(ElementInterface $element): string
{
    return $element->getSite()->currency ?? 'default';
}

getTranslationDescription

Human-readable description shown in the field layout designer tooltip.

public function getTranslationDescription(): ?string
{
    return Craft::t('my-plugin', 'Values are shared across sites with the same currency.');
}

supportedTranslationMethods() declares which methods the field type supports -- see Craft 5 Static Configuration Methods.

Static and Preview HTML

getStaticHtml — Read-only rendering

Called when rendering the field in a non-editable context: draft sidebars, disabled fields, or without edit permission.

public function getStaticHtml(mixed $value, ElementInterface $element): string
{
    return $value ? Html::encode((string)$value) : '';
}

getPreviewHtml — Element index table/card preview

Called when rendering a compact preview in element index tables and cards. Keep output short.

public function getPreviewHtml(mixed $value, ElementInterface $element): string
{
    return $value ? Html::encode(StringHelper::truncate((string)$value, 50)) : '';
}

Both methods must return HTML-safe strings. Use Html::encode() for plain text to prevent XSS.

Craft 5 Static Configuration Methods

Static methods that define field type metadata. Craft calls these without an instance to build the field type selector, generate IDE hints, and configure database storage.

Method Return Type Purpose
icon() ?string SVG icon path or icon name for the field type picker
phpType() string PHP type hint for IDE autocompletion (e.g., 'string|null')
dbType() string|null Database column type -- replaces older getContentColumnType()
isMultiInstance() bool Whether multiple instances can exist in one layout (default true)
supportedTranslationMethods() array Array of TRANSLATION_METHOD_* constants the field supports
public static function icon(): ?string { return 'palette'; }
public static function phpType(): string { return 'string|null'; }
public static function isMultiInstance(): bool { return true; }

public static function dbType(): string|null
{
    return \yii\db\Schema::TYPE_TEXT;
    // Return null when the field manages its own storage (relation tables, external APIs)
}

Craft 5 removed the old instance method getContentColumnType() — the static dbType() is now the sole mechanism for declaring a custom field's column type.

Source: SKILL.md on GitHub

No alerts16d3 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    The craftcms skill is a comprehensive reference guide for Craft CMS 5 plugin and module development. It provides extensive documentation on security best practices, including authorization layers, data handling, and common architectural pitfalls. No malicious patterns or security risks were identified.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: LOW · No issues

Signed by skilld at 614a89e. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 2 weeks ago

README badge

README badge for michtio/craftcms-claude-skills/craftcms