NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Posts for Laravel

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();
MethodStatuspublished_atEvent
Posts::publish($post, ?$at)Published$at ?? now()PostPublished
Posts::schedule($post, $at)Scheduled$atPostScheduled
Posts::archive($post)ArchivedunchangedPostArchived
Posts::unpublish($post)DraftnullPostDrafted

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 allowed

Publishing 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 → int

Schedule 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::Published

Use 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 crypto

By 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.