-
-
-
-
+
-
+
@@ -61,6 +62,7 @@ export default {
data() {
return {
action: this.canManagePublishState ? 'publish' : 'revision',
+ publishRevisionAt: null,
revisionMessage: null,
saving: false,
};
@@ -72,6 +74,7 @@ export default {
if (this.canManagePublishState) {
options.push({ value: 'publish', label: __('Publish Now') });
+ options.push({ value: 'publishLater', label: __('Publish Later') });
if (this.published) {
options.push({ value: 'unpublish', label: __('Unpublish') });
@@ -95,12 +98,18 @@ export default {
return __('messages.publish_actions_unpublish');
case 'revision':
return __('messages.publish_actions_create_revision');
+ case 'publishLater':
+ return __('messages.publish_actions_schedule_revision');
}
},
submitButtonText() {
return this.options.find((o) => o.value === this.action).label;
},
+
+ canSubmit() {
+ return this.action !== 'publishLater' || !!this.publishRevisionAt;
+ },
},
methods: {
@@ -188,9 +197,17 @@ export default {
.catch((e) => this.handleAxiosError(e));
},
- submitRevision() {
+ submitPublishLater() {
+ this.submitRevision(this.publishRevisionAt);
+ },
+
+ submitRevision(publishRevisionAt) {
const payload = { message: this.revisionMessage };
+ if (publishRevisionAt) {
+ payload.publish_at = publishRevisionAt.toAbsoluteString();
+ }
+
this.$axios
.post(this.actions.createRevision, payload)
.then((response) => {
diff --git a/resources/js/components/revision-history/Revision.vue b/resources/js/components/revision-history/Revision.vue
index 211e06cdfee..e62781f47e3 100644
--- a/resources/js/components/revision-history/Revision.vue
+++ b/resources/js/components/revision-history/Revision.vue
@@ -21,32 +21,15 @@
by {{ revision.user.name || revision.user.email }}
+
-
+
authorize('edit', $entry);
+ $request->validate(['publish_at' => 'nullable|date|after:now']);
+
$entry->createRevision([
'message' => $request->message,
'user' => User::fromUser($request->user()),
+ 'publish_at' => $request->date('publish_at'),
]);
return new EntryResource($entry);
diff --git a/src/Jobs/HandleRevisionSchedule.php b/src/Jobs/HandleRevisionSchedule.php
new file mode 100644
index 00000000000..10fd7af34c4
--- /dev/null
+++ b/src/Jobs/HandleRevisionSchedule.php
@@ -0,0 +1,33 @@
+dueRevisions()
+ ->each(fn (Revision $revision) => Entries::find($revision->attribute('id'))?->publishRevision($revision));
+ }
+
+ private function dueRevisions(): Collection
+ {
+ return Revisions::query()
+ ->whereNotNull('publish_at')
+ ->where('publish_at', '<=', now())
+ ->get()
+ ->sortBy->publishAt();
+ }
+}
diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php
index 768cf89d890..72776abb0d5 100644
--- a/src/Providers/AppServiceProvider.php
+++ b/src/Providers/AppServiceProvider.php
@@ -25,6 +25,7 @@
use Statamic\Http\Middleware\PingOutpost;
use Statamic\Icons\IconManager;
use Statamic\Jobs\HandleEntrySchedule;
+use Statamic\Jobs\HandleRevisionSchedule;
use Statamic\Licensing\Radio;
use Statamic\Notifications\ElevatedSessionVerificationCode;
use Statamic\Sites\Sites;
@@ -152,6 +153,10 @@ public function boot()
if (config('statamic.system.handle_scheduled_entries')) {
$this->app->make(Schedule::class)->job(HandleEntrySchedule::class)->everyMinute();
+
+ if (config('statamic.revisions.enabled')) {
+ $this->app->make(Schedule::class)->job(HandleRevisionSchedule::class)->everyMinute();
+ }
}
$this->app->make(Schedule::class)
diff --git a/src/Revisions/Revisable.php b/src/Revisions/Revisable.php
index 17595a375db..26d20893831 100644
--- a/src/Revisions/Revisable.php
+++ b/src/Revisions/Revisable.php
@@ -178,9 +178,39 @@ public function createRevision($options = [])
->makeRevision()
->user($options['user'] ?? null)
->message($options['message'] ?? null)
+ ->publishAt($options['publish_at'] ?? null)
->save();
}
+ public function publishRevision($revision)
+ {
+ $item = $this->makeFromRevision($revision);
+
+ $saved = $item
+ ->published(true)
+ ->updateLastModified($user = $revision->user())
+ ->save();
+
+ if (! $saved) {
+ return false;
+ }
+
+ $item
+ ->makeRevision()
+ ->user($user)
+ ->message($revision->message())
+ ->action('publish')
+ ->save();
+
+ $revision->publishAt(null)->save();
+
+ if ($item->workingCopy()?->attributes() == $revision->attributes()) {
+ $item->deleteWorkingCopy();
+ }
+
+ return $item;
+ }
+
public function revisionsEnabled()
{
return config('statamic.revisions.enabled') && Statamic::pro();
diff --git a/src/Revisions/Revision.php b/src/Revisions/Revision.php
index 0e3f9b298de..d7f7a1bcedf 100644
--- a/src/Revisions/Revision.php
+++ b/src/Revisions/Revision.php
@@ -28,6 +28,7 @@ class Revision implements Arrayable, ContainsQueryableValues, Contract
protected $user;
protected $userId;
protected $message;
+ protected $publishAt;
protected $action = 'revision';
protected $attributes = [];
protected $withEvents = true;
@@ -67,6 +68,11 @@ public function message($message = null)
return $this->fluentlyGetOrSet('message')->args(func_get_args());
}
+ public function publishAt($dateTime = null)
+ {
+ return $this->fluentlyGetOrSet('publishAt')->args(func_get_args());
+ }
+
public function attributes($attributes = null)
{
return $this->fluentlyGetOrSet('attributes')->args(func_get_args());
@@ -109,6 +115,7 @@ public function fileData()
'date' => $this->date->timestamp,
'user' => $this->userId ?: null,
'message' => $this->message ?: null,
+ 'publish_at' => $this->publishAt?->timestamp,
'attributes' => $this->attributes,
];
}
@@ -131,6 +138,7 @@ public function toArray()
'date' => $this->date()->timestamp,
'user' => $user,
'message' => $this->message,
+ 'publish_at' => $this->publishAt?->timestamp,
'attributes' => $this->attributes,
];
}
@@ -192,6 +200,7 @@ public function getQueryableValue(string $field)
'date' => $this->date(),
'user' => $this->userId,
'message' => $this->message(),
+ 'publish_at' => $this->publishAt(),
'path' => $this->path(),
default => throw new \Exception('Field ['.$field.'] is not queryable on Revision.'),
};
diff --git a/src/Stache/Stores/RevisionsStore.php b/src/Stache/Stores/RevisionsStore.php
index 7e06deb684d..541b417ec9d 100644
--- a/src/Stache/Stores/RevisionsStore.php
+++ b/src/Stache/Stores/RevisionsStore.php
@@ -32,6 +32,7 @@ public function makeItemFromFile($path, $contents)
->date(($date = $yaml['date'] ?? null) ? Carbon::createFromTimestamp($date, config('app.timezone')) : null)
->user($yaml['user'] ?? false)
->message($yaml['message'] ?? null)
+ ->publishAt(($publishAt = $yaml['publish_at'] ?? null) ? Carbon::createFromTimestamp($publishAt, config('app.timezone')) : null)
->attributes($yaml['attributes'] ?? []);
}
}
diff --git a/tests/Feature/Entries/EntryRevisionsTest.php b/tests/Feature/Entries/EntryRevisionsTest.php
index cddb4d23d56..ba30782a7e2 100644
--- a/tests/Feature/Entries/EntryRevisionsTest.php
+++ b/tests/Feature/Entries/EntryRevisionsTest.php
@@ -417,9 +417,10 @@ public function it_creates_a_revision()
$this->assertFalse($entry->published());
$this->assertCount(0, $entry->revisions());
+ $publishAt = now()->addDay()->startOfMinute();
$this
->actingAs($user)
- ->post($entry->createRevisionUrl(), ['message' => 'Test!'])
+ ->post($entry->createRevisionUrl(), ['message' => 'Test!', 'publish_at' => $publishAt->toIso8601String()])
->assertOk();
$entry = Entry::find($entry->id());
@@ -444,10 +445,43 @@ public function it_creates_a_revision()
], $revision->attributes());
$this->assertEquals('user-1', $revision->user()->id());
$this->assertEquals('Test!', $revision->message());
+ $this->assertEquals($publishAt->timestamp, $revision->publishAt()->timestamp);
$this->assertEquals('revision', $revision->action());
$this->assertTrue($entry->hasWorkingCopy());
}
+ #[Test]
+ #[DataProvider('invalidPublishAtProvider')]
+ public function it_validates_publish_at_when_creating_a_revision($publishAt)
+ {
+ $this->setTestBlueprint('test', ['foo' => ['type' => 'text']]);
+ $this->setTestRoles(['test' => ['access cp', 'edit blog entries']]);
+ $user = User::make()->id('user-1')->assignRole('test')->save();
+
+ $entry = EntryFactory::id('1')
+ ->slug('test')
+ ->collection('blog')
+ ->data(['blueprint' => 'test', 'title' => 'Title'])
+ ->create();
+
+ $this
+ ->actingAs($user)
+ ->postJson($entry->createRevisionUrl(), ['publish_at' => $publishAt])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors('publish_at');
+
+ $this->assertCount(0, $entry->revisions());
+ }
+
+ public static function invalidPublishAtProvider()
+ {
+ return [
+ 'not a date' => ['nope'],
+ 'array' => [['2030-01-01']],
+ 'in the past' => ['2010-12-29T11:00:00Z'],
+ ];
+ }
+
#[Test]
public function it_denies_restoring_a_revision_without_permission_to_edit_entry()
{
diff --git a/tests/Feature/Revisions/RevisionsTest.php b/tests/Feature/Revisions/RevisionsTest.php
index 648a7fa4757..33af53243ed 100644
--- a/tests/Feature/Revisions/RevisionsTest.php
+++ b/tests/Feature/Revisions/RevisionsTest.php
@@ -56,4 +56,28 @@ public function a_revision_can_be_made_from_a_dated_entry()
$this->assertEquals(['id' => '123', 'published' => true, 'slug' => 'my-entry', 'data' => ['foo' => 'bar'], 'date' => '1482624000'], $revision->attributes());
}
+
+ #[Test]
+ public function converts_publish_at_to_timestamp_when_saving()
+ {
+ Carbon::setTestNow(now());
+
+ $revision = (new Revision)
+ ->date(now())
+ ->publishAt(now());
+
+ $this->assertEquals(now()->timestamp, $revision->fileData()['publish_at']);
+ }
+
+ #[Test]
+ public function outputs_publish_at_when_to_array()
+ {
+ Carbon::setTestNow(now());
+
+ $revision = (new Revision)
+ ->date(now())
+ ->publishAt(now());
+
+ $this->assertEquals(now()->timestamp, $revision->toArray()['publish_at']);
+ }
}
diff --git a/tests/Jobs/HandleRevisionScheduleTest.php b/tests/Jobs/HandleRevisionScheduleTest.php
new file mode 100644
index 00000000000..b757abce6a7
--- /dev/null
+++ b/tests/Jobs/HandleRevisionScheduleTest.php
@@ -0,0 +1,148 @@
+ true]);
+ Collection::make('blog')->revisionsEnabled(true)->save();
+ Carbon::setTestNow(Carbon::parse('2026-09-02 10:00'));
+ }
+
+ #[Test]
+ public function it_publishes_due_revisions()
+ {
+ $user = User::make()->id('user-1')->save();
+
+ $this->scheduleRevision('1', 'Scheduled', '2026-09-01 08:00', ['user' => $user, 'message' => 'Go live']);
+ $this->scheduleRevision('2', 'Scheduled', '2026-09-02 12:01');
+ $unscheduled = EntryFactory::id('3')->collection('blog')->published(false)->data(['title' => 'Original'])->create();
+ $unscheduled->createRevision();
+
+ $this->runJobAt('2026-09-02 12:00');
+
+ $entry = Entry::find('1');
+ $this->assertTrue($entry->published());
+ $this->assertEquals('Scheduled', $entry->get('title'));
+ $this->assertEquals('user-1', $entry->lastModifiedBy()->id());
+ $this->assertFalse($entry->hasWorkingCopy());
+ $this->assertCount(2, $entry->revisions());
+ $this->assertNull($entry->revisions()->first()->publishAt());
+ $published = $entry->latestRevision();
+ $this->assertEquals('publish', $published->action());
+ $this->assertEquals('Go live', $published->message());
+ $this->assertEquals('user-1', $published->user()->id());
+ $this->assertEquals(['title' => 'Scheduled'], $published->attributes()['data']);
+
+ $entry = Entry::find('2');
+ $this->assertFalse($entry->published());
+ $this->assertEquals('Original', $entry->get('title'));
+ $this->assertTrue($entry->hasWorkingCopy());
+ $this->assertCount(1, $entry->revisions());
+ $this->assertEquals(Carbon::parse('2026-09-02 12:01'), $entry->latestRevision()->publishAt());
+
+ $entry = Entry::find('3');
+ $this->assertFalse($entry->published());
+ $this->assertCount(1, $entry->revisions());
+ }
+
+ #[Test]
+ public function it_does_not_republish_an_already_applied_revision()
+ {
+ $this->scheduleRevision('1', 'Scheduled', '2026-09-02 11:00');
+
+ $this->runJobAt('2026-09-02 12:00');
+ $this->runJobAt('2026-09-02 12:01');
+
+ $this->assertCount(2, Entry::find('1')->revisions());
+ }
+
+ #[Test]
+ public function it_applies_multiple_due_revisions_in_order()
+ {
+ $this->scheduleRevision('1', 'Second', '2026-09-02 11:30');
+ Carbon::setTestNow(Carbon::parse('2026-09-02 10:01'));
+ $this->scheduleRevision('1', 'First', '2026-09-02 11:00');
+
+ $this->runJobAt('2026-09-02 12:00');
+
+ $entry = Entry::find('1');
+ $this->assertTrue($entry->published());
+ $this->assertEquals('Second', $entry->get('title'));
+ $this->assertEquals(
+ [null, null],
+ $entry->revisions()->filter(fn ($revision) => $revision->action() === 'revision')->map->publishAt()->values()->all()
+ );
+ }
+
+ #[Test]
+ public function it_keeps_a_working_copy_that_changed_after_scheduling()
+ {
+ $this->scheduleRevision('1', 'Scheduled', '2026-09-02 11:00');
+ $entry = Entry::find('1');
+ tap($entry->workingCopy(), function ($copy) {
+ $attrs = $copy->attributes();
+ $attrs['data']['title'] = 'Newer edits';
+ $copy->attributes($attrs);
+ })->save();
+
+ $this->runJobAt('2026-09-02 12:00');
+
+ $entry = Entry::find('1');
+ $this->assertTrue($entry->published());
+ $this->assertEquals('Scheduled', $entry->get('title'));
+ $this->assertTrue($entry->hasWorkingCopy());
+ $this->assertEquals('Newer edits', $entry->workingCopy()->attributes()['data']['title']);
+ }
+
+ #[Test]
+ public function it_skips_revisions_whose_entry_no_longer_exists()
+ {
+ $this->scheduleRevision('1', 'Scheduled', '2026-09-02 11:00');
+ Entry::find('1')->delete();
+
+ $this->runJobAt('2026-09-02 12:00');
+
+ $this->assertNull(Entry::find('1'));
+ }
+
+ private function runJobAt($time)
+ {
+ Carbon::setTestNow(Carbon::parse($time));
+
+ (new HandleRevisionSchedule)->handle();
+ }
+
+ private function scheduleRevision($id, $title, $publishAt, $options = [])
+ {
+ $entry = Entry::find($id) ?? EntryFactory::id($id)->collection('blog')->published(false)->data(['title' => 'Original'])->create();
+
+ if (! $entry->hasWorkingCopy()) {
+ $entry->makeWorkingCopy()->save();
+ }
+
+ tap($entry->workingCopy(), function ($copy) use ($title) {
+ $attrs = $copy->attributes();
+ $attrs['data']['title'] = $title;
+ $copy->attributes($attrs);
+ })->save();
+
+ $entry->createRevision($options + ['publish_at' => Carbon::parse($publishAt)]);
+ }
+}
diff --git a/tests/Revisions/RepositoryTest.php b/tests/Revisions/RepositoryTest.php
index a8e1071f9bf..aae35c349c5 100644
--- a/tests/Revisions/RepositoryTest.php
+++ b/tests/Revisions/RepositoryTest.php
@@ -15,7 +15,9 @@
class RepositoryTest extends TestCase
{
private $stache;
+
private $directory;
+
private $repo;
public function setUp(): void
@@ -115,4 +117,12 @@ public function it_finds_the_working_copy_without_using_the_query_builder()
$this->assertInstanceOf(Revision::class, $revision);
$this->assertEquals('working', $revision->action());
}
+
+ #[Test]
+ public function it_loads_publish_at_as_carbon()
+ {
+ $revision = $this->repo->whereKey('123')->last();
+
+ $this->assertEquals(1553644800, $revision->publishAt()->timestamp);
+ }
}
diff --git a/tests/Revisions/__fixtures__/123/1553546422.yaml b/tests/Revisions/__fixtures__/123/1553546422.yaml
index af3b65bb764..7a1669b18d1 100644
--- a/tests/Revisions/__fixtures__/123/1553546422.yaml
+++ b/tests/Revisions/__fixtures__/123/1553546422.yaml
@@ -1,2 +1,3 @@
date: 1553546422
+publish_at: 1553644800
attributes: {}