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::… | Returns | What it does |
|---|---|---|
create(CreatePostData $data) | Post | Create a post from a DTO. |
draft() | PendingPost | The fluent builder — below. |
publish(Post $post, ?CarbonInterface $at = null) | Post | Publish now or at $at; fires PostPublished. |
schedule(Post $post, CarbonInterface $at) | Post | Schedule; fires PostScheduled. |
archive(Post $post) | Post | Archive, keeping the publish date; fires PostArchived. |
unpublish(Post $post) | Post | Back to draft, clearing the publish date; fires PostDrafted. |
seo(Post $post, SeoData $data) | Post | Store the SEO fields and save. |
syncTags(Post $post, iterable $tags) | Post | Sync tags — models or current-locale names. |
publishDue() | int | Publish every due scheduled post (what posts:publish-scheduled runs). |
findBySlug(string $slug, ?string $locale = null) | ?Post | Find 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() | PostsFake | Swap 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| Terminal | Does |
|---|---|
save(): Post | create(data()) as a Draft, then syncTags() when tags were given. |
publish(?CarbonInterface $at = null): Post | save(), then publish($post, $at). |
schedule(CarbonInterface $at): Post | save(), then schedule($post, $at). |
data(): CreatePostData | The 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 cryptoBy 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.