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

The Posts facade

Everything a host does with posts goes through one facade, RoundlyConsulting\Posts\Facades\Posts, also aliased as Posts:

use RoundlyConsulting\Posts\DataTransferObjects\SeoData;
use RoundlyConsulting\Posts\Facades\Posts;

// Write a post with the builder, then save it as a draft, publish it or schedule it
$post = Posts::draft()
    ->title('en', 'Hello world')->title('sk', 'Ahoj svet')
    ->perex('en', 'A short intro')
    ->content('en', '<p>…</p>')
    ->by($user)                                   // any author model, bigint/uuid/ulid keyed
    ->tags(['laravel', 'php'])                    // Tag models, or names found/created in the current locale
    ->seo(new SeoData(canonical: 'https://example.test/hello-world'))
    ->publish();                                  // or ->save() (draft), ->publish($at), ->schedule($at)

// Or from a DTO
Posts::create($createPostData);

// Lifecycle
Posts::publish($post);                            // now, or Posts::publish($post, $at)
Posts::schedule($post, now()->addDay());
Posts::archive($post);
Posts::unpublish($post);                          // back to draft
Posts::publishDue();                              // publish every scheduled post whose time has come → int

// SEO and tags
Posts::seo($post, new SeoData(metaTitle: 'Custom title', robots: 'index,follow'));
Posts::syncTags($post, ['eloquent', $tag]);

// Reads — always the configured posts.model
Posts::findBySlug('hello-world');                 // current locale → fallback → any locale
Posts::findBySlug('ahoj-svet', 'sk');             // exactly one locale — needs sk in sluggable.locales.supported
Posts::published()->latest('published_at')->paginate();
Posts::query()->inCategory('laravel')->get();
Posts::…ReturnsWhat it does
create(CreatePostData $data)PostCreate a post from a DTO.
draft()PendingPostThe fluent builder — below.
publish(Post $post, ?CarbonInterface $at = null)PostPublish now or at $at; fires PostPublished.
schedule(Post $post, CarbonInterface $at)PostSchedule; fires PostScheduled.
archive(Post $post)PostArchive, keeping the publish date; fires PostArchived.
unpublish(Post $post)PostBack to draft, clearing the publish date; fires PostDrafted.
seo(Post $post, SeoData $data)PostStore the SEO fields and save.
syncTags(Post $post, iterable $tags)PostSync tags — models or current-locale names.
publishDue()intPublish every due scheduled post (what posts:publish-scheduled runs).
findBySlug(string $slug, ?string $locale = null)?PostFind by slug through the posts.model seam.
published()Builder<Post>Published posts whose date has passed.
query()Builder<Post>A query on the configured posts.model.
fake()PostsFakeSwap in the recording fake — see Testing.

Reads always go through the configured posts.model, so a subclass you configure is what findBySlug(), published() and query() return.

The draft() builder

Posts::draft() returns a PendingPost. Set per-locale fields, the author, tags and SEO, then finish with a terminal:

$post = Posts::draft()
    ->title('en', 'Hello world')          // per-locale fields, any number of locales
    ->slug('sk', 'Vlastny Slug')          // manual slug: normalised + made unique
    ->perex('en', 'Intro')
    ->content('en', '<p>…</p>')           // stored and rendered as is — sanitise untrusted input first
    ->metaTitle('en', 'Meta title')
    ->metaDescription('en', 'Meta description')
    ->by($author)                         // any Model; its morph class + key
    ->tags(['php', $tag])                 // synced after create
    ->seo(new SeoData(canonical: '…'))
    ->save();                             // Draft

$data = Posts::draft()->title('en', 'Queued')->data();   // the CreatePostData it would create
TerminalDoes
save(): Postcreate(data()) as a Draft, then syncTags() when tags were given.
publish(?CarbonInterface $at = null): Postsave(), then publish($post, $at).
schedule(CarbonInterface $at): Postsave(), then schedule($post, $at).
data(): CreatePostDataThe DTO it would create — for a queued job or create().

Each step goes through the manager, so the fake records create, syncTags and publish or schedule separately. An unsaved author model contributes no author.

Model verbs

The model keeps its convenience methods, and each goes through the same manager — behaviour, events and the fake are identical whichever form you use. A posts.model subclass can override them to hook in:

$post->publish();                  // == Posts::publish($post)
$post->schedule(now()->addDay());  // == Posts::schedule($post, $at)
$post->archive();                  // == Posts::archive($post)
$post->unpublish();                // == Posts::unpublish($post)
$post->syncTags(['php']);          // == Posts::syncTags($post, [...])

There are no sub-accessors or scoped handles: a post has no parent scope.

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.