Publishing lifecycle
A post’s status is the PostStatus enum — Draft, Scheduled, Published, Archived — alongside a published_at timestamp. Each transition on the Posts facade sets both, saves the post, dispatches its event and returns the post:
use RoundlyConsulting\Posts\Facades\Posts;
Posts::publish($post); // PostPublished — published_at = now()
Posts::publish($post, now()->addHour()); // Published with a future date — listed as scheduled until then
Posts::schedule($post, now()->addDay()); // PostScheduled
Posts::archive($post); // PostArchived — keeps published_at
Posts::unpublish($post); // PostDrafted — clears published_at
// The same verbs on the model go through the same manager:
$post->publish();
$post->unpublish();| Method | Status | published_at | Event |
|---|---|---|---|
Posts::publish($post, ?$at) | Published | $at ?? now() | PostPublished |
Posts::schedule($post, $at) | Scheduled | $at | PostScheduled |
Posts::archive($post) | Archived | unchanged | PostArchived |
Posts::unpublish($post) | Draft | null | PostDrafted |
Publishing, archiving or unpublishing a post that already has that status is a no-op: nothing is written, the publish date is kept and no event fires. Re-scheduling always moves the date, so it always fires PostScheduled. Each move is decided against the stored status under a row lock, so a stale copy of a post that a moderator archived meanwhile throws instead of going live.
Dates may carry any timezone and are stored as that same instant in the app timezone (config('app.timezone')) — the zone published() and publishDue() compare against:
// Any timezone — stored as the same instant in config('app.timezone')
Posts::schedule($post, now('Asia/Tokyo')->addHour());Query scopes
Post::query()->published()->get(); // status = published AND published_at <= now
Post::query()->draft()->get();
Post::query()->scheduled()->get(); // scheduled, or published with a future date
Post::query()->archived()->get();published() means status Published and published_at <= now(), so a post published with a future date stays out of public listings and shows up in scheduled() until its time arrives.
Transition rules
An archived post may only move back to Draft (or stay Archived). Anything else throws InvalidPostStatusTransitionException, which extends the package’s abstract PostsException:
use RoundlyConsulting\Posts\Exceptions\InvalidPostStatusTransitionException;
use RoundlyConsulting\Posts\Facades\Posts;
Posts::archive($post);
try {
Posts::publish($post);
} catch (InvalidPostStatusTransitionException $e) {
// "Cannot transition a post from [archived] to [published]."
}
Posts::publish(Posts::unpublish($post)); // archived → draft → published is allowedPublishing scheduled posts
posts:publish-scheduled publishes every Scheduled post whose published_at has arrived. It calls Posts::publishDue(), which publishes each due post of the configured model at its scheduled time, so PostPublished fires for each one:
php artisan posts:publish-scheduled
# Published 3 scheduled post(s).Call it yourself from a job or a custom command:
$count = Posts::publishDue(); // publish every due scheduled post → intSchedule it to run every minute:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('posts:publish-scheduled')->everyMinute()->withoutOverlapping()->onOneServer();withoutOverlapping() and onOneServer() save work, but correctness does not depend on them: each due post is claimed under a row lock — still scheduled, still due — before it is published, so overlapping runs publish it, and fire PostPublished, once, and a post archived, drafted or rescheduled after a run listed it is skipped. onOneServer() needs a cache store shared by your servers.
Status labels & select options
PostStatus uses the Helpers trait from enums-for-laravel, so every case ships readable labels, select options, lookups and a validation rule:
use RoundlyConsulting\Posts\Enums\PostStatus;
PostStatus::Published->label(); // 'Published' (readable, translatable via __())
PostStatus::Published->isPublic(); // true — only Published is public
PostStatus::labels(); // Collection: ['Draft', 'Scheduled', 'Published', 'Archived']
PostStatus::values(); // Collection: ['draft', 'scheduled', 'published', 'archived']
PostStatus::toOptions(); // Collection: ['draft' => 'Draft', …] for a <select>
PostStatus::toArray(); // the same map as a plain array
PostStatus::options(); // Collection<EnumOption{ value, label, name }> for JS/Inertia
PostStatus::validationRule(); // 'in:draft,scheduled,published,archived'
PostStatus::fromName('Published'); // PostStatus::PublishedUse the rule in validation so the allow-list never drifts as cases change:
$request->validate([
'status' => ['required', PostStatus::validationRule()],
]);Labels are the headlined case value passed through Laravel’s __() helper, so you translate them in your app’s JSON translation files:
// lang/sk.json
{
"Draft": "Koncept",
"Scheduled": "Naplánovaný",
"Published": "Publikovaný",
"Archived": "Archivovaný"
}The trait also provides names(), tryFromName(), fromLabel(), tryFromLabel(), hasName(), hasValue(), random(), count(), is(), isNot(), isIn(), isNotIn() and the whenIs() / whenIsNot() / whenIsIn() / whenIsNotIn() conditionals.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.