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.

referenceselement-index.md

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

Element Index — CP Presentation

Custom element types (plugins/modules): sources, table attributes, sort options, chips/cards, actions. This is PHP on the element class.

For site content Entries index sources after creating sections (project config elementSources, sidebar headings, matching peer columns), use the craft-content-modeling skill → references/element-index-sources.md. Do not implement site section placement via defineSources() on craft\elements\Entry.

Contents

  • Common Pitfalls
  • Sources (defineSources, context values)
  • Table Attributes (defineTableAttributes, attributeHtml, status pills, prepElementQueryForTableAttribute)
  • Sort Options (defineSortOptions)
  • Element Display Modes — chips, cards, table rows: when each renders, customization methods
  • Card Attributes (Craft 5.5+)
  • Relation Field Display/Props — viewMode, previewMode, maxRelations, sources, maintainHierarchy
  • Thumbnails — hasThumbs, thumbUrl, thumbSvg, checkered/rounded
  • Conditions (createCondition, custom condition rules)
  • Actions (defineActions, includeSetStatusAction, custom actions, per-element edit-screen action menu)
  • Exporters
  • Sidebar and Metadata
  • Preview Targets
  • Index View Modes — table, cards, structure; defaultIndexViewMode()
  • Extending Element Indexes via Events — adding columns, sources, bulk actions, condition rules to any element type

Documentation

Common Pitfalls

  • Using the old tableAttributeHtml() — renamed to attributeHtml() in Craft 5. The old method name will silently not be called.
  • Not implementing prepElementQueryForTableAttribute() for custom attributes — Craft won't eager-load the data your column needs, causing N+1 queries or empty columns.
  • Forgetting includeSetStatusAction() — without it, users can't bulk-change status from the index, even though your element has statuses.
  • Sources returning stale data — sources are memoized per-request. Use service getters that read from MemoizableArray, not static arrays.
  • Returning all table attributes as defaults — clutters the index. Pick the 4-5 columns users need most; they can customize from there.
  • Using <span class="status red"> for status labels — produces a 10×10 px dot, not a pill. Text inside it wraps vertically one character per line. Use Cp::statusLabelHtml() instead.
  • Registering bulk actions (EVENT_REGISTER_ACTIONS) but forgetting per-element actions (EVENT_DEFINE_ACTION_MENU_ITEMS) — plugin actions appear on the index multi-select menu but not on the per-element edit screen "..." menu.
  • Loading all elements in defineSources() to extract grouping values — defineSources() runs on every element index page load. Use an aggregate query (GROUP BY) to get distinct values, never ::find()->all() followed by array extraction.

Sources

Sources define the sidebar navigation in element indexes:

protected static function defineSources(string $context): array
{
    $sources = [
        [
            'key' => '*',
            'label' => Craft::t('my-plugin', 'All Items'),
            'criteria' => [],
            'defaultSort' => ['postDate', 'desc'],
        ],
    ];

    foreach (MyPlugin::$plugin->getCategories()->getAllCategories() as $category) {
        $sources[] = [
            'key' => 'category:' . $category->uid,
            'label' => $category->name,
            'criteria' => ['categoryId' => $category->id],
            'data' => ['handle' => $category->handle],
            'defaultSort' => ['postDate', 'desc'],
        ];
    }

    return $sources;
}

Source key '*' = all elements. Use prefix patterns like 'category:{uid}' — consistent with Craft core (section:{uid}, folder:{uid}).

Context values: 'index' (element index page), 'modal' (selection modal), 'field' (relation field), 'settings' (admin).

Table Attributes

Define available columns in the element index table view:

protected static function defineTableAttributes(): array
{
    return [
        'externalId' => ['label' => Craft::t('my-plugin', 'External ID')],
        'category' => ['label' => Craft::t('my-plugin', 'Category')],
        'postDate' => ['label' => Craft::t('app', 'Post Date')],
        'expiryDate' => ['label' => Craft::t('app', 'Expiry Date')],
        'dateCreated' => ['label' => Craft::t('app', 'Date Created')],
        'link' => ['label' => Craft::t('app', 'Link'), 'icon' => 'world'],
    ];
}

Default Table Attributes

Pick the 4-5 most useful columns shown by default:

protected static function defineDefaultTableAttributes(string $source): array
{
    return ['category', 'postDate', 'expiryDate', 'dateCreated'];
}

Custom Attribute HTML (Craft 5)

Override attributeHtml() (renamed from tableAttributeHtml() in Craft 5) for custom column rendering:

protected function attributeHtml(string $attribute): string
{
    return match ($attribute) {
        'category' => $this->getCategory()?->name ?? '',
        'externalId' => Html::tag('code', Html::encode($this->externalId)),
        default => parent::attributeHtml($attribute),
    };
}

Status Pills in Table Attributes

Status pills in custom table attributes use Cp::statusLabelHtml(), not <span class="status">. The raw <span class="status red"> produces a 10×10 px dot indicator — putting text inside it causes one-character-per-line vertical wrapping in narrow element-index columns.

The correct helper (Craft 5.2.0+) is \craft\helpers\Cp::statusLabelHtml() — the same helper Craft uses for the native Status column's "ACTIVE" pill:

use craft\enums\Color;
use craft\helpers\Cp;

protected function attributeHtml(string $attribute): string
{
    return match ($attribute) {
        'syncStatus' => Cp::statusLabelHtml([
            'color' => Color::Red,           // Color enum: Red, Orange, Amber, Yellow, Green, Teal, Blue, …
            'label' => 'Reset required',
            'icon' => 'warning',             // optional — Lucide icon key
            'indicatorClass' => 'pending',   // optional — overrides the dot's CSS class
        ]),
        default => parent::attributeHtml($attribute),
    };
}

The Color enum at \craft\enums\Color ships 20 named colors. For elements that implement Statusable, use Cp::componentStatusLabelHtml($component) as a one-call wrapper that resolves the color + label from the component's status definition.

Symptom of the wrong pattern: label text appears stacked vertically one character per line ("R\ne\ns\ne\nt") in narrow element-index cells.

Prepare Query for Table Attribute

When a table attribute needs eager-loaded data, override to prevent N+1:

protected static function prepElementQueryForTableAttribute(
    ElementQueryInterface $elementQuery,
    string $attribute
): void {
    switch ($attribute) {
        case 'category':
            // Add eager loading or joins for this attribute
            break;
        default:
            parent::prepElementQueryForTableAttribute($elementQuery, $attribute);
    }
}

Sort Options

protected static function defineSortOptions(): array
{
    return [
        'title' => Craft::t('app', 'Title'),
        'postDate' => Craft::t('app', 'Post Date'),
        'expiryDate' => Craft::t('app', 'Expiry Date'),
        [
            'label' => Craft::t('my-plugin', 'Category'),
            'orderBy' => 'my_elements.categoryId',
            'attribute' => 'category',
        ],
        'dateCreated' => Craft::t('app', 'Date Created'),
    ];
}

Simple form: 'column' => 'Label'. Array form: label, orderBy (SQL expression or column), optional attribute (matches a table attribute key).

Element Display Modes

Craft 5 renders elements in three display modes depending on context. Each mode uses different methods for customization.

Chips (compact inline)

Chips appear in relation fields, breadcrumbs, inline references, and anywhere elements are shown compactly. Rendered by Cp::elementChipHtml() or the Twig function elementChip().

// Customize the chip label (default: uiLabel, which is usually the title)
// Public override — NOT a Craft-4-era chipHtml(); the whole-chip markup is
// assembled by Cp::elementChipHtml(), you only supply the label.
public function getChipLabelHtml(): string
{
    return Html::encode($this->getUiLabel());
}

// Control whether the status pip shows on chips
public function showStatusIndicator(): bool  // Since 5.4.0
{
    return true;
}

Sizing: Cp::CHIP_SIZE_LARGE, Cp::CHIP_SIZE_SMALL. Plugins can modify chip output via Cp::EVENT_DEFINE_ELEMENT_CHIP_HTML.

Cards (visual with thumbnail)

Cards appear in card view of element indexes, Matrix card mode, and relation field card display. Rendered by Cp::elementCardHtml() or the Twig function elementCard().

// Custom card body content (default: renders card body elements from field layout)
// Public override — NOT a Craft-4-era cardHtml(); Cp::elementCardHtml() builds
// the card shell, you only supply the body.
public function getCardBodyHtml(): ?string
{
    // Default iterates getFieldLayout()->getCardBodyElements($this)
    return parent::getCardBodyHtml();
}

// Custom card title (public, since 5.7.0)
public function getCardTitle(): ?string
{
    return $this->title;
}

Card layout (which attributes appear in the card body) is configured via FieldLayout::getCardView()/setCardView() (since 5.5.0).

Table rows (column-based)

Table rows are the classic element index view. Each column is rendered by attributeHtml():

protected function attributeHtml(string $attribute): string
{
    return match ($attribute) {
        'category' => $this->getCategory()?->name ?? '',
        'externalId' => Html::tag('code', Html::encode($this->externalId)),
        default => parent::attributeHtml($attribute),
    };
}

The base implementation handles contentBlock:* and generatedField:* prefixed attributes automatically.

Summary

Mode Where it appears Customization method Size control
Chip Relation fields, breadcrumbs, inline refs getChipLabelHtml() CHIP_SIZE_SMALL, CHIP_SIZE_LARGE
Card Card view, Matrix cards, relation card display getCardBodyHtml(), defineCardAttributes() Field layout card view config
Table Table view of element indexes attributeHtml(), defineTableAttributes() Column selection

Card Attributes (Craft 5.5+)

Define which attributes are available in card view:

protected static function defineCardAttributes(): array
{
    $attributes = parent::defineCardAttributes();

    $attributes['category'] = [
        'label' => Craft::t('my-plugin', 'Category'),
        'placeholder' => fn() => 'Example Category',
    ];

    $attributes['postDate'] = [
        'label' => Craft::t('app', 'Post Date'),
        'placeholder' => fn() => (new \DateTime())->sub(new \DateInterval('P7D')),
    ];

    return $attributes;
}

protected static function defineDefaultCardAttributes(): array
{
    return ['category', 'postDate'];
}

Placeholders show in the field layout designer as preview content. Default card attributes include dateCreated, dateUpdated, id, uid, plus link/slug/uri for elements with URIs.

Relation Field Display/Props

All relation fields (Entries, Categories, Assets, Users, Tags, and custom BaseRelationField subclasses — see fields.md) share the same elementSelect input UI, so their display settings live on BaseRelationField. The rendered element uses the chip/card/thumbnail methods documented above, so nothing here duplicates those — this covers the field-level knobs that decide how those elements are laid out.

viewMode

viewMode selects the layout of related elements in the input. Five modes are defined as VIEW_MODE_* constants (BaseRelationField.php:81-89):

Value Constant Renders as
list VIEW_MODE_LIST Vertical list of chips (default for most relation fields, BaseRelationField.php:474)
list-inline VIEW_MODE_LIST_INLINE Chips flowing inline
cards VIEW_MODE_CARDS Stacked element cards
cards-grid VIEW_MODE_CARDS_GRID Cards in a multi-column grid
thumbs VIEW_MODE_THUMBS Thumbnail tiles

The constructor defaults viewMode to list (BaseRelationField.php:474).

large is not a view mode. It's a legacy alias remapped to thumbs in the constructor (BaseRelationField.php:481-482) — reading or writing large yields thumbs. Do not treat it as a distinct value.

previewMode (Assets only)

previewMode is an independent axis on the Assets field (fields/Assets.php:52-56), not a viewMode value — do not conflate the two. It controls asset preview size:

Value Constant Default
full PREVIEW_MODE_FULL yes (fields/Assets.php:189)
thumbs PREVIEW_MODE_THUMBS

An Assets field therefore has both a viewMode (shared list/cards/thumbs layout) and a previewMode (full/thumbs) that further tunes the asset chip preview.

Core selection props

Prop Type Notes
maxRelations ?int Cap on related elements (BaseRelationField.php:346). The limit config key remaps to it in the constructor (BaseRelationField.php:426). 1 = single-select. Only enforced when allowLimit is true.
selectionLabel ?string Overrides the "Add …" button label (BaseRelationField.php:357).
sources string|string[]|null Source keys the field can relate from; default '*' (BaseRelationField.php:286). Used when allowMultipleSources is true; the singular source (:291) is used otherwise.
showSiteMenu bool Whether the selection modal shows a site menu (BaseRelationField.php:302, default false; defaults to true for pre-existing fields).
maintainHierarchy bool Auto-relate structural ancestors (BaseRelationField.php:308). When on, the constructor clears minRelations/maxRelations in favour of branchLimit.
branchLimit ?int Max distinct structure branches when maintainHierarchy is on (BaseRelationField.php:315); cleared to null when hierarchy maintenance is off.

maintainHierarchy and maxRelations/minRelations are mutually exclusive by construction — the constructor nulls one set depending on the other (BaseRelationField.php:453-454), so a hierarchy-maintaining field is limited by branches, not by a flat relation count.

Thumbnails

Thumbnails appear on chips (small) and cards (larger). The resolution chain:

  1. getThumbHtml(int $size) — checks field layout's thumbFieldKey first (admin-configurable)
  2. Falls back to thumbUrl(int $size) — override this for custom thumb logic
  3. Falls back to thumbSvg() — SVG placeholder when no image exists
// Declare that this element type supports thumbnails
public static function hasThumbs(): bool
{
    return true;
}

// Provide the thumbnail URL
protected function thumbUrl(int $size): ?string
{
    return $this->getFeatureImage()?->getThumbUrl($size);
}

// SVG fallback when no thumb URL exists — returns SVG markup, not a file path
protected function thumbSvg(): ?string
{
    return file_get_contents(Craft::getAlias('@my-plugin/icon.svg'));
}

// Checkered background for transparent images (like Assets)
protected function hasCheckeredThumb(): bool
{
    return false;
}

// Rounded thumbnail (like Users)
protected function hasRoundedThumb(): bool
{
    return false;
}

Thumbnails are lazy-loaded in the CP via Craft.cp.elementThumbLoader — no custom JS needed.

Conditions

public static function createCondition(): ElementConditionInterface
{
    return Craft::createObject(MyElementCondition::class);
}

Custom Condition Rules

No scaffold — create manually. Extend craft\elements\conditions\ElementConditionRule:

class CategoryConditionRule extends BaseElementSelectConditionRule
{
    public function getLabel(): string
    {
        return Craft::t('my-plugin', 'Category');
    }

    public function getExclusiveQueryParams(): array
    {
        return ['categoryId'];
    }

    public function modifyQuery(ElementQueryInterface $query): void
    {
        $query->categoryId($this->getElementId());
    }
}

Register in your condition class:

class MyElementCondition extends ElementCondition
{
    protected function selectableConditionRules(): array
    {
        return array_merge(parent::selectableConditionRules(), [
            CategoryConditionRule::class,
        ]);
    }
}

Actions

Craft auto-adds Edit, Duplicate, Delete. Define additional bulk actions:

protected static function defineActions(string $source): array
{
    return [
        SyncItems::class,
        SetStatus::class,
    ];
}

protected static function includeSetStatusAction(): bool
{
    return true;
}

Custom Element Actions

ddev craft make element-action --with-docblocks
class SyncItems extends ElementAction
{
    public static function displayName(): string
    {
        return Craft::t('my-plugin', 'Sync Items');
    }

    public function performAction(ElementQueryInterface $query): bool
    {
        foreach ($query->all() as $element) {
            Craft::$app->getQueue()->push(new SyncItem([
                'elementId' => $element->id,
            ]));
        }

        $this->setMessage(Craft::t('my-plugin', 'Sync jobs queued.'));
        return true;
    }
}

Per-Element Edit-Screen Action Menu

Bulk actions (EVENT_REGISTER_ACTIONS / defineActions()) and the per-element edit-screen "..." disclosure menu are two different surfaces, wired to two different events. Registering a bulk action does not add it to the per-element menu.

The per-element menu is built from Element::getActionMenuItems() and fires Element::EVENT_DEFINE_ACTION_MENU_ITEMS (since Craft 5.0). Plugins append to $event->items using the Cp::disclosureMenu() item shape:

use craft\base\Element;
use craft\elements\User;
use craft\events\DefineMenuItemsEvent;
use yii\base\Event;

Event::on(
    User::class,
    Element::EVENT_DEFINE_ACTION_MENU_ITEMS,
    function(DefineMenuItemsEvent $event): void {
        /** @var User $user */
        $user = $event->sender;

        $event->items[] = [
            'id' => 'pp-force-reset',
            'icon' => 'rotate',
            'label' => Craft::t('my-plugin', 'Force password reset'),
            'action' => 'my-plugin/user-password/force-reset',
            // OR 'url' => UrlHelper::cpUrl(...) for navigable items
        ];
    },
);

For destructive items (red-tinted, segregated below safe items), pass 'destructive' => true.

Which surface to register on: match the registration to the security model of the action. "Change password..." should never be a bulk action — applying the same input to N users is a security anti-pattern. "Force password reset" and "Send password reset email" are useful on both surfaces. If in doubt, register per-element only and add bulk when the action is safe for N-at-a-time.

Symptom of forgetting the per-element hook: plugin actions appear absent from the per-user edit screen "..." menu, even though the index shows them when you bulk-select.

Exporters

Craft provides Raw and Expanded by default:

protected static function defineExporters(string $source): array
{
    $exporters = parent::defineExporters($source);
    $exporters[] = MyCustomExporter::class;
    return $exporters;
}

Sidebar and Metadata

Edit Screen Metadata

protected function metadata(): array
{
    return [
        Craft::t('my-plugin', 'External ID') => $this->externalId ?: false,
        Craft::t('my-plugin', 'Category') => $this->getCategory()?->name ?: false,
        Craft::t('app', 'Date Created') => $this->dateCreated?->format('Y-m-d H:i'),
    ];
}

Values of false are omitted from the sidebar.

Custom Sidebar HTML

public function getSidebarHtml(bool $static): string
{
    $html = parent::getSidebarHtml($static);

    $html .= Craft::$app->getView()->renderTemplate(
        'my-plugin/_sidebar',
        ['element' => $this]
    );

    return $html;
}

Preview Targets

protected function previewTargets(): array
{
    $targets = parent::previewTargets();

    if ($this->uri) {
        $targets[] = [
            'label' => Craft::t('app', 'Primary page'),
            'url' => $this->getUrl(),
        ];
    }

    return $targets;
}

Index View Modes

Element indexes support multiple view modes. Users switch between them via toolbar buttons.

Built-in modes

Mode Icon When to use Rendering
Table list Default for all elements. Column-based with sort headers. attributeHtml() per column
Cards grid Visual overview with thumbnails. Good for media, products. getCardBodyHtml() + defineCardAttributes()
Structure structure Hierarchical elements with drag-to-reorder. Table with nesting indicators

Table mode is always available. Cards require defineCardAttributes(). Structure requires the element type to support structures.

Registering view modes

public static function indexViewModes(): array
{
    $viewModes = parent::indexViewModes();

    // Add structure view for hierarchical elements
    $viewModes[] = [
        'mode' => 'structure',
        'title' => Craft::t('app', 'Display in a structure'),
        'icon' => 'structure',
    ];

    return $viewModes;
}

Each mode entry has: mode (string key), title (tooltip text), icon (CP icon name).

Default view mode

protected static function defaultIndexViewMode(): string
{
    return 'cards'; // or 'table' (default) or 'structure'
}

This controls which view the user sees when first opening the element index. Users can switch and their preference is remembered per-element-type.

Extending Element Indexes via Events

Plugins can extend any element type's index (including Craft's built-in Users, Entries, Assets) without modifying the element class. Most events below fire on craft\base\Element and are inherited by all element types. Condition rules are the exception — they live on craft\base\conditions\BaseCondition.

Event Reference

Event Constant Event Class Purpose
EVENT_REGISTER_TABLE_ATTRIBUTES RegisterElementTableAttributesEvent Add custom columns
EVENT_REGISTER_DEFAULT_TABLE_ATTRIBUTES RegisterElementDefaultTableAttributesEvent Set which columns show by default
EVENT_DEFINE_ATTRIBUTE_HTML DefineAttributeHtmlEvent Custom column rendering
EVENT_PREP_QUERY_FOR_TABLE_ATTRIBUTE ElementIndexTableAttributeEvent Modify query for custom column data
EVENT_REGISTER_SOURCES RegisterElementSourcesEvent Add sidebar sources
EVENT_REGISTER_ACTIONS RegisterElementActionsEvent Add bulk actions (index multi-select)
EVENT_DEFINE_ACTION_MENU_ITEMS DefineMenuItemsEvent Add per-element edit-screen "..." menu items
EVENT_REGISTER_SORT_OPTIONS RegisterElementSortOptionsEvent Add sort options
EVENT_REGISTER_SEARCHABLE_ATTRIBUTES RegisterElementSearchableAttributesEvent Add searchable attributes
EVENT_REGISTER_CARD_ATTRIBUTES RegisterElementCardAttributesEvent Add card view attributes (5.5+)
EVENT_REGISTER_DEFAULT_CARD_ATTRIBUTES RegisterElementDefaultCardAttributesEvent Set default card attributes (5.5+)
EVENT_DEFINE_METADATA DefineMetadataEvent Add metadata to element editor sidebar

Condition rules live on craft\base\conditions\BaseCondition:

Event Constant Event Class Purpose
EVENT_REGISTER_CONDITION_RULES RegisterConditionRulesEvent Add custom condition rules

Adding a custom column to the Users index

use craft\base\Element;
use craft\elements\User;
use craft\events\DefineAttributeHtmlEvent;
use craft\events\RegisterElementTableAttributesEvent;
use yii\base\Event;

// Step 1: Register the column
Event::on(
    User::class,
    Element::EVENT_REGISTER_TABLE_ATTRIBUTES,
    function(RegisterElementTableAttributesEvent $event) {
        $event->tableAttributes['lastPasswordChange'] = [
            'label' => Craft::t('my-plugin', 'Last Password Change'),
        ];
    }
);

// Step 2: Render the column value
Event::on(
    User::class,
    Element::EVENT_DEFINE_ATTRIBUTE_HTML,
    function(DefineAttributeHtmlEvent $event) {
        if ($event->attribute === 'lastPasswordChange') {
            /** @var User $user */
            $user = $event->sender;
            $date = MyPlugin::getInstance()
                ->getPasswordService()
                ->getLastPasswordChange($user->id);

            $event->html = $date
                ? Craft::$app->getFormatter()->asDatetime($date)
                : '';

            $event->handled = true;
        }
    }
);

Preparing query data for custom columns

When your custom column needs data not on the element by default, modify the query to avoid N+1:

use craft\elements\User;
use craft\events\ElementIndexTableAttributeEvent;
use yii\base\Event;

Event::on(
    User::class,
    Element::EVENT_PREP_QUERY_FOR_TABLE_ATTRIBUTE,
    function(ElementIndexTableAttributeEvent $event) {
        if ($event->attribute === 'lastPasswordChange') {
            // Add a subquery or join so the data is available
            $event->query
                ->leftJoin(
                    '{{%my_password_history}} ph',
                    '[[ph.userId]] = [[elements.id]]'
                )
                ->addSelect(['ph.dateChanged AS lastPasswordChange']);
        }
    }
);

Adding a sidebar source

use craft\elements\User;
use craft\events\RegisterElementSourcesEvent;
use yii\base\Event;

Event::on(
    User::class,
    Element::EVENT_REGISTER_SOURCES,
    function(RegisterElementSourcesEvent $event) {
        $event->sources[] = [
            'heading' => Craft::t('my-plugin', 'Password Status'),
        ];

        $event->sources[] = [
            'key' => 'my-plugin:expired-passwords',
            'label' => Craft::t('my-plugin', 'Expired Passwords'),
            'criteria' => [],  // Custom filtering handled in EVENT_BEFORE_PREPARE
            'data' => ['type' => 'expired-passwords'],
            'defaultSort' => ['dateCreated', 'desc'],
        ];

        $event->sources[] = [
            'key' => 'my-plugin:reset-required',
            'label' => Craft::t('my-plugin', 'Reset Required'),
            'criteria' => ['passwordResetRequired' => true],
            'defaultSort' => ['dateCreated', 'desc'],
        ];
    }
);

Adding a custom bulk action

Element actions implement craft\base\ElementActionInterface. Register them per source:

use craft\elements\User;
use craft\events\RegisterElementActionsEvent;
use yii\base\Event;

Event::on(
    User::class,
    Element::EVENT_REGISTER_ACTIONS,
    function(RegisterElementActionsEvent $event) {
        $event->actions[] = ForcePasswordReset::class;
    }
);

The action class:

use craft\base\ElementAction;
use craft\elements\db\ElementQueryInterface;

class ForcePasswordReset extends ElementAction
{
    /**
     * @inheritdoc
     */
    public static function displayName(): string
    {
        return Craft::t('my-plugin', 'Force Password Reset');
    }

    /**
     * @inheritdoc
     */
    public function performAction(ElementQueryInterface $query): bool
    {
        /** @var User[] $users */
        $users = $query->all();

        foreach ($users as $user) {
            $user->passwordResetRequired = true;
            Craft::$app->getElements()->saveElement($user);
        }

        $this->setMessage(Craft::t(
            'my-plugin',
            '{count} user(s) flagged for password reset.',
            ['count' => count($users)]
        ));

        return true;
    }
}

Adding condition rules

Condition rules allow users to filter the element index. Listen on the element's condition class:

use craft\elements\conditions\users\UserCondition;
use craft\base\conditions\BaseCondition;
use craft\events\RegisterConditionRulesEvent;
use yii\base\Event;

Event::on(
    UserCondition::class,
    BaseCondition::EVENT_REGISTER_CONDITION_RULES,
    function(RegisterConditionRulesEvent $event) {
        $event->conditionRules[] = PasswordExpiredConditionRule::class;
    }
);

See conditions.md for building custom condition rule classes.

Adding metadata to the element editor sidebar

use craft\elements\User;
use craft\events\DefineMetadataEvent;
use yii\base\Event;

Event::on(
    User::class,
    Element::EVENT_DEFINE_METADATA,
    function(DefineMetadataEvent $event) {
        /** @var User $user */
        $user = $event->sender;

        $lastChange = MyPlugin::getInstance()
            ->getPasswordService()
            ->getLastPasswordChange($user->id);

        if ($lastChange) {
            $event->metadata[Craft::t('my-plugin', 'Password Changed')] =
                Craft::$app->getFormatter()->asDatetime($lastChange);
        }
    }
);

Adding sort options

use craft\elements\User;
use craft\events\RegisterElementSortOptionsEvent;
use yii\base\Event;

Event::on(
    User::class,
    Element::EVENT_REGISTER_SORT_OPTIONS,
    function(RegisterElementSortOptionsEvent $event) {
        $event->sortOptions['lastPasswordChange'] = [
            'label' => Craft::t('my-plugin', 'Last Password Change'),
            'orderBy' => 'my_password_history.dateChanged',
            'defaultDir' => 'desc',
        ];
    }
);

User index built-in sources

The User element provides these sidebar sources by default:

  • * — All Users
  • admins — Admins
  • credentialed — Credentialed (users who can log in)
  • group:{uid} — One per user group

Plugin sources append after these via EVENT_REGISTER_SOURCES.

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