---
title: "Publishing lifecycle — Posts for Laravel | Roundly"
description: "Draft, scheduled, published and archived — transition methods, query scopes, rules, scheduled publishing and status helpers."
url: https://roundly-consulting.com/open-source/docs/posts-for-laravel/publishing-lifecycle
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Posts for Laravel](https://roundly-consulting.com/open-source/docs/posts-for-laravel.md)

# 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:

```php
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:

```php
// Any timezone — stored as the same instant in config('app.timezone')
Posts::schedule($post, now('Asia/Tokyo')->addHour());
```

## Query scopes

```php
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:

```php
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:

```bash
php artisan posts:publish-scheduled
# Published 3 scheduled post(s).
```

Call it yourself from a job or a custom command:

```php
$count = Posts::publishDue();   // publish every due scheduled post → int
```

Schedule it to run every minute:

```php
// 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:

```php
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:

```php
$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:

```json
// 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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/posts-for-laravel.md)
