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.

referencesdrafts-revisions.md

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

Drafts & Revisions

Complete reference for Craft CMS 5's draft and revision system: draft types, provisional drafts, autosave, creating and applying drafts, merge behavior, revisions, and plugin considerations. For element save lifecycle, see elements.md. For authorization on drafts, see element-authorization.md.

Documentation

Common Pitfalls

  • Triggering side effects (queue jobs, API syncs, webhooks) in afterSave() for drafts — always check !$this->getIsDraft() && !$this->getIsRevision() before side effects.
  • Firing side effects during propagation — check !$this->propagating to avoid duplicate actions when a save propagates to other sites.
  • Not writing to the custom table for drafts — drafts need their data in afterSave(). Only skip side effects, not data persistence.
  • Assuming hasRevisions() is a static method — it's an instance method. It must return true for revision tracking, and hasDrafts() must also return true.
  • Setting maxRevisions to 0 without understanding the consequences — unlimited revisions means the elements table grows indefinitely.
  • Expecting createRevision() to always create one — it silently returns the existing revision when elements.dateUpdated hasn't changed, which is exactly what happens after a raw Db::update(). Pass force: true. See createRevision() skips silently.

Contents

Draft Types

Type How Created Lifespan Visibility
Provisional draft Auto-created when editing a canonical element Until applied, discarded, or purged by GC Only the creator
Saved draft "Create a draft" button or programmatic Until applied or deleted All users with peer draft permissions
Unpublished draft New element created but never published Until published or deleted Draft creator

Provisional drafts are one-per-user-per-element. When a user starts editing a published entry, Craft creates a provisional draft behind the scenes. The editor sees an "unsaved changes" banner. The draft can be applied (publishes changes), converted to a saved draft, or discarded.

Creating Drafts

Programmatic creation

// Create a saved draft
$draft = Craft::$app->getDrafts()->createDraft(
    $canonicalElement,  // The published element
    $creatorId,         // User ID of the creator
    'My Draft Name',    // Optional name
    'Draft notes here', // Optional notes
    [],                 // Optional new attribute overrides
    false               // $provisional — true for provisional drafts
);

// Save an existing unsaved element as a draft.
// Returns a bool; $element is converted into a draft in place.
$success = Craft::$app->getDrafts()->saveElementAsDraft(
    $element,
    $creatorId,
    'Draft Name',
    'Notes',
    true   // $markAsSaved
);

saveElementAsDraft() calls saveElement($element) with runValidation defaulting to true (Drafts.php:269), yet incomplete required field-layout content still persists: the element isn't in SCENARIO_LIVE, which is the only scenario under which field-layout required rules fire (Element.php:3083). Drafts aren't "validation-free" — they validate under a non-LIVE scenario, so attribute rules apply but layout-required content does not. See Applying Drafts. Verified against craftcms/cms 5.10.5.

Authorization

canCreateDrafts(User $user) gates who can create drafts. For entries, this returns true by default — anyone with view access can create drafts. Custom elements must override this method.

Autosave

Edits are automatically saved to the draft element as the user types. The CP shows:

  • A spinner during autosave
  • A caution icon on save failure
  • A checkmark when saved

Drafts don't get their own provisional drafts — changes save directly against the draft. The autosave frequency is controlled by JS in the CP editor.

Applying Drafts

// Apply a draft to its canonical element
$canonicalElement = Craft::$app->getDrafts()->applyDraft($draft);

The apply process:

  1. If trackChanges() is true and the draft is outdated, mergeCanonicalChanges() syncs canonical changes first
  2. Draft content is merged into the canonical element
  3. The canonical is updated via Elements::updateCanonicalElement() → Elements::duplicateElement(), which validates the clone under Element::SCENARIO_ESSENTIALS (Elements.php:1927) — not SCENARIO_LIVE
  4. The draft is deleted
  5. A new revision of the canonical element is created (if hasRevisions())
  6. Returns the updated canonical element

Applying a draft does NOT enforce required field-layout content. The canonical is validated under SCENARIO_ESSENTIALS, and the field-layout required gate only fires under SCENARIO_LIVE ($scenario === self::SCENARIO_LIVE && $layoutElement->required, Element.php:3083). To gate promotion on content completeness, validate under SCENARIO_LIVE yourself first — don't assume applyDraft() rejects an incomplete draft:

$draft->setScenario(Element::SCENARIO_LIVE);
if (!$draft->validate()) {
    // refuse to promote
}
Craft::$app->getDrafts()->applyDraft($draft);

Verified against craftcms/cms 5.10.5 (Drafts::applyDraft → Elements::updateCanonicalElement → duplicateElement).

What happens to other drafts

When a draft is applied, other saved drafts for the same element remain. Their change tracking is updated to reflect the new canonical state — they may become "outdated" relative to the new content.

Merge and Change Tracking

Field-level change tracking

When trackChanges() returns true, Craft records which fields were modified in each draft. Modified fields show status badges in the editor sidebar.

Merge behavior on apply

Scenario Result
Field modified in draft only Draft value wins
Field modified in canonical only Canonical value is merged into draft before apply
Field modified in both Draft value wins (last-write-wins)
Field unchanged in either Canonical value preserved

This merge is automatic — there is no manual merge UI for field-level conflicts. The draft creator's changes always take precedence.

Checking if a draft is outdated

use craft\helpers\ElementHelper;

if (ElementHelper::isOutdated($draft)) {
    // Canonical element has been modified since the draft was created
    // mergeCanonicalChanges() will sync the non-conflicting changes
}

Revisions

Revisions are read-only snapshots of canonical elements, captured each time a canonical element is saved.

Enabling revisions

// On your element class — both are instance methods
public static function hasDrafts(): bool
{
    return true; // Required for hasRevisions() to work
}

public function hasRevisions(): bool
{
    return true;
}

Revision storage

Stored as derivative elements with a revisionId. Each revision captures the full element state including field values and relations. Accessible via the breadcrumb menu in the CP editor.

Restoring a revision

Restoring a revision copies its content into the canonical element and creates a new revision of the current state. History is not rewound — the restore itself becomes the latest change.

// Programmatic restore — $creatorId is required (the user crediting the restore)
$creatorId = Craft::$app->getUser()->getId();
Craft::$app->getRevisions()->revertToRevision($revision, $creatorId);

createRevision() skips silently when dateUpdated didn't move

Revisions::createRevision() has a change-detection guard. Unless $force is true, it looks up the most recent revision and returns its ID unchanged when the canonical element hasn't been touched since:

public function createRevision(
    ElementInterface $canonical,
    ?int $creatorId = null,
    ?string $notes = null,
    array $newAttributes = [],
    bool $force = false,
): int {
    // ...
    if (
        !$force &&
        $lastRevisionInfo &&
        DateTimeHelper::toDateTime($lastRevisionInfo['dateCreated'])->getTimestamp() === $canonical->dateUpdated->getTimestamp() &&
        $canonical::find()->id($lastRevisionInfo['id'])->revisions()->status(null)->siteId($canonical->siteId)->exists()
    ) {
        // The canonical element hasn't been updated since the last revision's
        // creation date, so there's no need to create a new one
        return $lastRevisionInfo['id'];
    }

The comparison is against elements.dateUpdated. That column is bumped by saveElement() — but not by a raw Db::update(). So a plugin that writes content directly (a restore routine, a bulk field fixer, a migration-style repair) and then asks for a revision gets back the existing revision ID, no new revision, and no error. The call returns a plausible int, so nothing looks wrong until someone needs the history.

Pass force: true whenever a revision must exist regardless of change detection:

Craft::$app->getRevisions()->createRevision(
    $entry,
    creatorId: Craft::$app->getUser()->getId(),
    notes: Craft::t('my-plugin', 'Restored from snapshot'),
    force: true,
);

Use the named argument — $force is the fifth parameter, after $newAttributes.

If you're writing content with raw SQL for performance, the alternative is to bump dateUpdated yourself so downstream change detection (revisions, search index, caches) behaves normally. Preferring saveElement() where you can afford it avoids the whole class of problem.

Revision limits

maxRevisions config setting controls how many revisions to keep per element. Default: 50. Set to 0 for unlimited (watch elements table growth). Old revisions are pruned during garbage collection.

Status-Checking Methods

Method Returns true when
getIsDraft() Element has a draftId (is a draft of any type)
getIsRevision() Element has a revisionId
getIsCanonical() !isset($this->_canonicalId) — the element isn't a derivative of another. True for published elements AND unpublished drafts (an unpublished draft is its own canonical), so this is not the opposite of getIsDraft().
getIsDerivative() !getIsCanonical() (i.e. isset($this->_canonicalId)) — a draft of a published element, or a revision. False for an unpublished draft (it's its own canonical).
getIsProvisionalDraft() Auto-created provisional draft
getIsUnpublishedDraft() getIsDraft() && getIsCanonical() — a draft never published as canonical; it is a draft and canonical simultaneously
getCanonical() Returns the canonical element this derives from

Don't use getIsCanonical() alone to mean "not a draft" — an unpublished draft returns true for both. To detect a non-draft element use !getIsDraft(); to detect an unpublished draft specifically use getIsUnpublishedDraft(). Verified against craftcms/cms 5.10.5 (Element::getIsCanonical() line 3254, getIsUnpublishedDraft() line 3390).

Usage in element code

// In afterSave() — safe pattern for side effects
if (!$this->getIsDraft() && !$this->getIsRevision() && !$this->propagating) {
    // Fire queue jobs, API syncs, webhooks
    MyPlugin::getInstance()->getSyncService()->syncElement($this);
}

Draft Ownership and Permissions

Creator access

draftCreatorId identifies who created the draft. The creator can always edit and delete their own drafts, regardless of peer draft permissions.

Peer draft permissions (Entries)

Permission Allows
viewPeerEntryDrafts:{sectionUid} View drafts created by other users
savePeerEntryDrafts:{sectionUid} Edit drafts created by other users
deletePeerEntryDrafts:{sectionUid} Delete drafts created by other users

Provisional draft visibility

Provisional drafts are only accessible to their creator. Other users see the canonical element. The withProvisionalDrafts() query parameter swaps the canonical element with the current user's provisional draft in results.

Query Parameters

Parameter Purpose
drafts(true) Include only drafts in results
draftOf($element) Drafts of a specific canonical element
draftId($id) Specific draft by ID
draftCreator($user) Drafts created by a specific user
provisionalDrafts(true) Only provisional drafts
withProvisionalDrafts() Swap canonical with user's provisional draft
revisions(true) Include only revisions in results
revisionOf($element) Revisions of a specific canonical element
revisionId($id) Specific revision by ID
revisionCreator($user) Revisions created by a specific user

Query examples

// Get all saved drafts for an entry
$drafts = Entry::find()
    ->draftOf($entry)
    ->drafts(true)
    ->all();

// Get the current user's provisional draft
$provisional = Entry::find()
    ->draftOf($entry)
    ->draftCreator(Craft::$app->getUser()->getIdentity())
    ->provisionalDrafts(true)
    ->one();

// Get recent revisions
$revisions = Entry::find()
    ->revisionOf($entry)
    ->revisions(true)
    ->limit(10)
    ->orderBy('dateCreated DESC')
    ->all();

Finding canonicals + unpublished drafts

ElementQuery::$drafts defaults to false (ElementQuery.php:189), and when false the query adds WHERE elements.draftId IS NULL (ElementQuery.php:3346) — so a normal Entry::find() excludes all drafts, including unpublished ones. A draft you created programmatically with saveElementAsDraft() won't show up until you opt drafts back in.

To fetch canonical elements and their unpublished drafts together:

$results = Entry::find()
    ->drafts(null)      // include drafts AND non-drafts
    ->draftOf(false)    // drop derivative drafts (drafts OF a published element)
    ->all();

This is the combination Craft documents on canonicalsOnly() (5.7.0+): "Unpublished drafts can be included as well if drafts(null) and draftOf(false) are also passed" (ElementQueryInterface::canonicalsOnly, line 257). Bare ->drafts(null) alone over-includes derivative and provisional drafts — add ->draftOf(false). Verified against craftcms/cms 5.10.5.

Plugin Considerations

Custom table writes in afterSave()

Always write to your custom table for drafts — they need their data. Only skip side effects:

public function afterSave(bool $isNew): void
{
    // Always write custom table data (drafts need it)
    $record = MyElementRecord::findOne($this->id) ?? new MyElementRecord();
    $record->id = $this->id;
    $record->someField = $this->someField;
    $record->save(false);

    parent::afterSave($isNew);

    // Guard side effects
    if ($this->getIsDraft() || $this->getIsRevision()) {
        return;
    }

    if ($this->propagating) {
        return;
    }

    // Safe for queue jobs, API syncs, etc.
    Craft::$app->getQueue()->push(new SyncElementJob([
        'elementId' => $this->id,
    ]));
}

afterPropagate()

The safest place for side effects that need all sites updated first. Fires after the element has been propagated to all sites.

trackChanges()

Return true on custom elements that support drafts to enable field-level change tracking and merge behavior:

public static function trackChanges(): bool
{
    return true;
}

Config Settings

Setting Default Purpose
maxRevisions 50 Max revisions per element. 0 = unlimited.
purgeUnsavedDraftsDuration 2592000 (30 days) GC cleanup for unsaved provisional drafts

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