lemaur/eloquent-publishing
A Laravel package that provides a Publishes trait to make Eloquent models publishable, along with schema blueprint helpers for adding publishing date fields to migrations. Install via Composer and apply the trait to your models.
Requirements: Laravel 11+ and PHP 8.2+ as of 4.0.0 (the v4 release dropped Laravel 10 / PHP 8.1). If you're on an older stack, pin ^3.3.
composer require lemaur/eloquent-publishing
// app/Models/Post.php
use Illuminate\Database\Eloquent\Model;
use Lemaur\Publishing\Database\Eloquent\Publishes;
class Post extends Model
{
use Publishes;
protected $casts = [
'published_at' => 'datetime',
];
}
// database/migrations/xxxx_create_posts_table.php
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->longText('body')->nullable();
$table->timestamps();
$table->publishes(); // nullable timestamp `published_at`
$table->index('published_at'); // you will filter/order on this constantly
});
src/Database/Eloquent/Publishes.php — the whole package is one trait. Read it before adopting; you need to know exactly which scopes are registered, how isPublished() compares dates, and whether the trait merges a published_at cast for you. It's small enough to read in a few minutes.publishes(), publishesTz(), dropPublishes() actually emit. If $table->publishes() throws BadMethodCallException, package auto-discovery didn't run (composer dump-autoload, check bootstrap/cache).CHANGELOG.md — the only release notes with real signal are the v4 breaking change and the Laravel 11/12/13 support bumps.This is what the package is for. Replace the hand-rolled published_at boolean dance in your Post model with:
$post->publish(); // now
$post->publish(Carbon::parse('tomorrow')); // scheduled
$post->unpublish(); // back to draft
Then in your public controller:
$posts = Post::query()
->onlyPublished()
->latestPublished()
->paginate(20);
That's the 80% case. Everything else below is about not leaking drafts and keeping the "planned" concept coherent.
The package models three states via one nullable timestamp:
| State | Meaning |
|---|---|
published_at IS NULL |
draft |
published_at <= now() |
published |
published_at > now() |
planned / scheduled |
Map this onto your admin UI explicitly — a "Draft / Scheduled / Published" badge driven by isNotPublished(), isPlanned(), isPublished() — rather than a boolean toggle. The publish date doubles as the schedule, and unpublish() nulls it, so a scheduled post that you "unpublish" loses its schedule.
// app/Http/Controllers/Admin/PostPublishController.php
public function store(Request $request, Post $post)
{
$this->authorize('publish', $post);
$validated = $request->validate([
'publish_at' => ['nullable', 'date'],
]);
$when = $validated['publish_at']
? Carbon::parse($validated['publish_at'])->setTimezone(config('app.timezone'))
: null;
$when ? $post->publish($when) : $post->unpublish();
return back()->with('status', 'ok');
}
Keep published_at out of $fillable. If it's mass-assignable, Post::create($request->all()) lets a user self-publish and bypass your policy entirely. The only writers should be publish() / unpublish().
The scopes are opt-in — there is no global scope. Establish a project convention so drafts never reach the front end:
// app/Models/Post.php — your own semantic layer over the package scopes
public function scopeVisible(Builder $query): Builder
{
return $query->onlyPublished();
}
…then use Post::visible() everywhere in public controllers, sitemaps, RSS feeds, search indexes, and Route::bind/resolveRouteBinding overrides. Any place that does Post::all() or Post::latest() is a draft leak waiting to happen.
Combine scopes for the common listings:
Post::onlyPublished()->latestPublished()->paginate(); // public index
Post::onlyPlanned()->oldestPlanned()->paginate(); // admin "scheduled" tab
Post::withoutPlannedAndPublished()->latest()->paginate(); // admin "drafts" tab
Post::onlyPlannedAndPublished()->latestPublished()->get(); // everything that will ever be live
The clever part of the timestamp design: a "planned" post becomes published by the passage of time, with no queue worker and no scheduled command. onlyPublished() simply starts returning it once now() passes the date.
The catch: nothing fires at that moment. If you need side effects at go-live (send newsletter, clear CDN, post to Slack, fire PostWasPublished), add a small scheduled command that detects the transition:
// app/Console/Commands/FlushDuePublications.php — run every minute
Post::onlyPublished()
->whereNull('announced_at') // your own idempotency marker column
->where('published_at', '<=', now()->subMinute())
->each(function (Post $post) {
event(new PostWentLive($post));
$post->forceFill(['announced_at' => now()])->saveQuietly();
});
Use a dedicated marker column rather than trying to detect the transition from published_at alone — otherwise every command run re-notifies.
publishing / published / unpublishing / unpublished are dispatched by the trait. Verify the dispatch mechanism in the trait source before wiring listeners — if they go through the Eloquent event dispatcher you'll hook them as model events, otherwise as plain events:
// If fired as Eloquent model events:
Event::listen('eloquent.published: App\Models\Post', fn (Post $post) => Cache::forget("post:{$post->slug}"));
// If fired as plain events, register in EventServiceProvider::$listen or with the attribute:
protected $listen = [\Lemaur\Publishing\Events\Published::class => [PurgePostCache::class]];
This is the right extension point for search indexing (Scout), sitemap regeneration, and response-cache busting — cleaner than overriding save().
If your schema already uses something else (publish_date, go_live_at), you must change both sides:
class Post extends Model
{
use Publishes;
const PUBLISHED_AT = 'publish_date';
}
$table->publishes('publish_date');
Note this is a class constant, not a configurable property — you can't flip it per-tenant or from .env. If you need runtime configurability, the trait is small enough to copy into app/Support/Concerns/Publishes.php and convert the constant into a protected function publishedAtColumn(): string. Given the package's size (20 stars, single maintainer), forking the trait is a legitimate strategy if you need more.
use Illuminate\Foundation\Testing\RefreshDatabase;
public function test_scheduled_post_is_hidden_then_visible(): void
{
$post = Post::factory()->create();
$post->publish(now()->addDay());
$this->assertTrue($post->isPlanned());
$this->get('/blog')->assertDontSee($post->title);
$this->travelTo(now()->addDays(2));
$this->get('/blog')->assertSee($post->title);
}
travelTo() / Carbon::setTestNow() is essential here — half the package's behaviour is time-dependent, and tests that rely on real now() are flaky. Also assert the negative: that a draft 404s on its public route.
Adding to an existing table:
Schema::table('posts', function (Blueprint $table) {
$table->publishes()->after('body')->index();
});
// down()
Schema::table('posts', function (Blueprint $table) {
$table->dropPublishes();
});
Backfilling — after adding the column, every existing row is a draft. If that's wrong for a blog with live content:
DB::table('posts')->update(['published_at' => DB::raw('created_at')]);
Time-zone-aware columns: publishesTz() emits timestampTz. Pick one variant per table and stick with it; don't mix publishes() and publishesTz() across models you join or sort together.
isPublished() is a point-in-time check. Anything cached, memoized, or rendered at request start is stale by the time a planned post goes live. If you cache the public index page, cache TTL becomes your publish latency.publish() stores whatever DateTimeInterface you give it — no automatic conversion. Always ->setTimezone(config('app.timezone')) (and make sure app.timezone matches your DB session timezone) before passing in a Carbon from user input.$table->publishes('published_at', $precision) — precision 0 (the default) truncates sub-second values. Fine for editorial publishing; don't be surprised when two posts published in the same second sort non-deterministically. Add ->orderByDesc('id') as a tiebreaker in latestPublished() chains.latestPublished() orders by the publish date but will happily return drafts with a NULL date (depending on your DB's NULL ordering, they land first or last). Always pair ordering with onlyPublished().$table->publishes() macro is registered by the package's service provider. In orchestra/testbench packages or apps with dont-discover, you must register the provider manually.published_at in $fillable. See above — it's a common, quiet authorization hole.publish, unpublish, isPublished, isNotPublished, isPlanned, isNotPlanned plus scopes. If a model already has publish() (e.g. from a media/publishing library), you'll get a fatal trait conflict. Grep your models before adopting on an existing codebase.publish() does not appear to guard the current state. Calling publish() on an already-published post re-stamps the date and re-fires publishing/published. Guard it yourself in domain code (if ($post->isNotPublished())) or your listeners must be idempotent.unpublish() destroys the schedule. There's no separate "scheduled" column, so an unpublish → republish round-trip loses the original planned date. If schedule history matters, add your own scheduled_for audit column or an events/log table.Event::dispatch() calls — read the trait before writing listeners; guessing here is a wasted afternoon.app/Support/Concerns/ is a cheap escape hatch if the package stalls or you need per-model column configurability. Weigh that against the value: it's ~30 lines of where('published_at', '<=', now()) you could also write yourself in an afternoon.UPGRADE.md-style notes (they're in the changelog) before bumping across a major.How can I help you explore Laravel packages today?