Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Eloquent Publishing Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Minimal setup

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
});

Where to look first

  1. 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.
  2. The service provider registering the Blueprint macros — this tells you what publishes(), publishesTz(), dropPublishes() actually emit. If $table->publishes() throws BadMethodCallException, package auto-discovery didn't run (composer dump-autoload, check bootstrap/cache).
  3. CHANGELOG.md — the only release notes with real signal are the v4 breaking change and the Laravel 11/12/13 support bumps.

First use case: a blog/CMS "publish date"

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.


Implementation Patterns

Treat publishing as three states, not two

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.

Admin actions: thin controller, model methods

// 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().

Public vs. admin query scopes

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

Scheduled publishing without a cron job

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.

Listen to the package events for cache and search

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().

Custom column name

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.

Testing

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.

Migration patterns

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.


Gotchas and Tips

  • A timestamp is not a boolean. 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.
  • Time zones are the #1 source of "it's published but the site says draft." 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.
  • Ordering scopes don't filter. 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().
  • Blueprint macro not found in a package/test context. The $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.
  • Don't put published_at in $fillable. See above — it's a common, quiet authorization hole.
  • Method collisions. The trait defines 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.
  • Events may not be conventional. The README doesn't say whether these are Eloquent model events, custom event classes, or Event::dispatch() calls — read the trait before writing listeners; guessing here is a wasted afternoon.
  • Small package, thin surface. 20 stars, one maintainer, no dependents. That's a real supply-chain consideration for a core content model — but the API is a single trait and five Blueprint macros, so vendoring the trait into 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.
  • Semantic versioning caveat: v4 dropped Laravel 10/PHP 8.1 in a major bump — the maintainer does respect majors — but read UPGRADE.md-style notes (they're in the changelog) before bumping across a major.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package