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
- Drafts: https://craftcms.com/docs/5.x/system/elements.html#drafts
- Element types: https://craftcms.com/docs/5.x/extend/element-types.html
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->propagatingto 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 returntruefor revision tracking, andhasDrafts()must also returntrue. - Setting
maxRevisionsto0without understanding the consequences — unlimited revisions means theelementstable grows indefinitely. - Expecting
createRevision()to always create one — it silently returns the existing revision whenelements.dateUpdatedhasn't changed, which is exactly what happens after a rawDb::update(). Passforce: true. See createRevision() skips silently.
Contents
- Draft Types
- Creating Drafts
- Autosave
- Applying Drafts
- Merge and Change Tracking
- Revisions
- Status-Checking Methods
- Draft Ownership and Permissions
- Query Parameters
- Plugin Considerations
- Config Settings
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()callssaveElement($element)withrunValidationdefaulting totrue(Drafts.php:269), yet incomplete required field-layout content still persists: the element isn't inSCENARIO_LIVE, which is the only scenario under which field-layoutrequiredrules fire (Element.php:3083). Drafts aren't "validation-free" — they validate under a non-LIVE scenario, so attribute rules apply but layout-requiredcontent does not. See Applying Drafts. Verified againstcraftcms/cms5.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:
- If
trackChanges()istrueand the draft is outdated,mergeCanonicalChanges()syncs canonical changes first - Draft content is merged into the canonical element
- The canonical is updated via
Elements::updateCanonicalElement()→Elements::duplicateElement(), which validates the clone underElement::SCENARIO_ESSENTIALS(Elements.php:1927) — notSCENARIO_LIVE - The draft is deleted
- A new revision of the canonical element is created (if
hasRevisions()) - 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-layoutrequiredgate only fires underSCENARIO_LIVE($scenario === self::SCENARIO_LIVE && $layoutElement->required, Element.php:3083). To gate promotion on content completeness, validate underSCENARIO_LIVEyourself first — don't assumeapplyDraft()rejects an incomplete draft:$draft->setScenario(Element::SCENARIO_LIVE); if (!$draft->validate()) { // refuse to promote } Craft::$app->getDrafts()->applyDraft($draft);Verified against
craftcms/cms5.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 returnstruefor both. To detect a non-draft element use!getIsDraft(); to detect an unpublished draft specifically usegetIsUnpublishedDraft(). Verified againstcraftcms/cms5.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 |