How do you schedule blog posts in Laravel without a publish cron job?

How do you schedule blog posts in Laravel without a publish cron job?

Short answer: set status to published, store a future published_at, and make every public query use a published() scope that hides rows until published_at <= now(). Readers never see early drafts; you can write weeks ahead without a separate “go live” command.

I use this on Aviwebsquad to batch high-quality articles, set realistic publish dates, and keep the public site cadence steady while AdSense and search engines see consistent activity.

The pattern in three pieces

1. Database column

blog_posts has nullable published_at (timestamp). Status is a string/enum (draft, reviewing, published, etc.).

2. Query scope (the important part)

public function scopePublished(Builder $query): Builder
{
    return $query
        ->where('status', ContentStatus::Published->value)
        ->where(function (Builder $q): void {
            $q->whereNull('published_at')
                ->orWhere('published_at', '<=', now());
        });
}

Every controller, sitemap, RSS-like listing, and homepage query calls ->published(). A post scheduled for next Tuesday simply does not exist publicly until that moment.

3. Editorial workflow

Editor action status published_at Public visibility
Drafting draft null Hidden
Ready, schedule June 15 published 2026-06-15 09:00 Hidden until then
Publish immediately published now() Visible

Filament (or MCP content_upsert) sets both fields. No midnight artisan command flips a flag.

Why not a dedicated scheduled status?

Extra statuses leak into policies, tests, and Filament filters. published + future timestamp keeps one source of truth:

MCP / agent publishing

Aviwebsquad’s MCP writer accepts published_at in ISO 8601. I draft AEO articles in advance:

{
  "module": "blog_post",
  "operation": "upsert",
  "payload": {
    "title": "...",
    "body_markdown": "...",
    "status": "published",
    "published_at": "2026-06-22T09:00:00+05:30"
  }
}

The post is stored as published but remains invisible until the timestamp—useful for organic publishing cadence.

Edge cases worth handling

Timezone: store UTC or app timezone consistently; display in Filament using local time.

Clock skew: <= now() is inclusive at the second the timestamp passes—good enough for blogs; not for sub-second trading.

Preview for editors: use Filament preview routes or ?preview=token that bypasses published()—never expose that URL publicly.

Unpublishing: set status back to draft or move published_at forward; do not delete if URLs already earned links.

Testing (Pest)

it('hides future published posts from the blog index', function () {
    BlogPost::factory()->create([
        'status' => 'published',
        'published_at' => now()->addWeek(),
    ]);

    $this->get(route('blog.index'))
        ->assertOk()
        ->assertInertia(fn ($page) => $page->has('posts', fn ($posts) => $posts->where('data', [])->etc()));
});

Lock the scope in tests so refactors do not accidentally leak scheduled content.

When you do need a scheduler

Those are side effects, not visibility toggles. Queue a job when published_at arrives, or run a lightweight posts:dispatch-side-effects command—but keep visibility in the scope.

FAQ

Can I schedule pages the same way?

Yes. Any content type with published_at + a published() scope works identically.

Does Google index scheduled posts early?

Not if your routes and sitemap use published(). Future posts should not appear in sitemap.xml.

Should scheduled posts be draft until launch day?

No—that requires a manual or cron status flip. Prefer published + future published_at for less operational risk.

How far ahead should I schedule?

For a solo blog, 2–4 weeks of queued posts is plenty. Quality beats a deep backlog Google never sees until spread over time.

Bottom line

Scheduling in Laravel can be boring—in the best way. One scope, one timestamp, no cron gymnastics. That is how I keep Aviwebsquad publishing steadily while spending writing time in focused batches.