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

DI, actions & DTOs

The facade is the recommended default, not the only way in. Three entry points run the same code:

  • The Posts facade — the shortest form.
  • The manager, injected through the constructor — the same API with an explicit dependency and no static calls. RoundlyConsulting\Posts\PostsManager is the facade root: a singleton, non-final so the fake can extend it.
  • Actions — RoundlyConsulting\Posts\Actions\*Action, final readonly classes with one execute(), for composing into your own actions, jobs and commands.

Injecting the manager

Posts::fake() extends PostsManager and swaps it in the container, so injected managers get the fake too:

use RoundlyConsulting\Posts\Models\Post;
use RoundlyConsulting\Posts\PostsManager;

final class PublishController
{
    public function __construct(private PostsManager $posts) {}

    public function __invoke(Post $post): Post
    {
        return $this->posts->publish($post);
    }
}

// The whole facade API is on the manager, builder included:
$this->posts->draft()->title('en', 'Injected')->publish();
$this->posts->publishDue();

Running an action

The manager holds no logic: each facade write resolves one action from the container, so a host binding of an action class applies everywhere:

use RoundlyConsulting\Posts\Actions\SchedulePostAction;
use RoundlyConsulting\Posts\Actions\UpdatePostSeoAction;
use RoundlyConsulting\Posts\DataTransferObjects\SeoData;

app(SchedulePostAction::class)->execute($post, now()->addDay());   // == Posts::schedule($post, $at)

app(UpdatePostSeoAction::class)->execute($post, new SeoData(ogImage: 'https://example.com/cover.jpg'));   // == Posts::seo()

Facade method → action

Facade methodActionDoes
create(CreatePostData $data)CreatePostActionCreates the configured model; dates a Published post now when no date is given, requires a date for Scheduled, fires PostPublished / PostScheduled for those statuses.
publish(Post $post, ?CarbonInterface $at = null)PublishPostActionPublished at $at ?? now(); PostPublished. Already published: no-op, date kept, no event. Throws for an archived post.
schedule(Post $post, CarbonInterface $at)SchedulePostActionScheduled for $at; PostScheduled — always, as it always moves the date. Same archived guard.
archive(Post $post)ArchivePostActionArchived, keeping published_at; PostArchived. Already archived: no-op.
unpublish(Post $post)UnpublishPostActionBack to Draft from any status, published_at cleared; PostDrafted. Already a draft: no-op.
seo(Post $post, SeoData $data)UpdatePostSeoActionsetSeo() then save().
syncTags(Post $post, iterable $tags)SyncPostTagsActionStrings found or created by current-locale name; unlisted tags detached.
publishDue()PublishDuePostsActionClaims each due Scheduled post under a row lock, publishes it at its scheduled time and returns the count.

TransitionPostAction is @internal — the lifecycle actions compose it, so don’t call it from host code. draft(), findBySlug(), published() and query() have no action behind them.

Creating from a DTO

use RoundlyConsulting\Posts\Actions\CreatePostAction;
use RoundlyConsulting\Posts\DataTransferObjects\CreatePostData;
use RoundlyConsulting\Posts\DataTransferObjects\CreatePostTranslationData;
use RoundlyConsulting\Posts\DataTransferObjects\SeoData;
use RoundlyConsulting\Posts\Enums\PostStatus;

$post = app(CreatePostAction::class)->execute(new CreatePostData(
    translations: [
        new CreatePostTranslationData(locale: 'en', title: 'Hello world', perex: 'A short summary', content: '<p>…</p>'),
        new CreatePostTranslationData(locale: 'sk', title: 'Ahoj svet', slug: 'Vlastny Slug'),
    ],
    status: PostStatus::Published,       // dated now when publishedAt is omitted; fires PostPublished
    authorType: $user->getMorphClass(),
    authorId: $user->getKey(),
    seo: new SeoData(canonical: 'https://example.com/hello-world'),
));

CreatePostAction builds the configured post model, applies each non-null translation field, sets the author only when both authorType and authorId are given, stores the SEO bag and saves. A post created Published without a publishedAt is dated now; one created Scheduled must carry a publishedAt, or InvalidPostStatusTransitionException is thrown. Creating a published or scheduled post fires PostPublished or PostScheduled.

The SEO bag takes SeoData’s non-translatable fields only; set meta titles and descriptions per locale through CreatePostTranslationData’s metaTitle and metaDescription.

DTOs

All three are final readonly classes in RoundlyConsulting\Posts\DataTransferObjects:

final readonly class CreatePostData
{
    public function __construct(
        public array $translations,                 // list<CreatePostTranslationData>
        public PostStatus $status = PostStatus::Draft,
        public ?CarbonInterface $publishedAt = null,
        public ?string $authorType = null,
        public int|string|null $authorId = null,
        public ?SeoData $seo = null,
    ) {}
}

final readonly class CreatePostTranslationData
{
    public function __construct(
        public string $locale,
        public ?string $title = null,
        public ?string $slug = null,                // manual slug: normalised + made unique
        public ?string $perex = null,
        public ?string $content = null,
        public ?string $metaTitle = null,
        public ?string $metaDescription = null,
    ) {}
}

final readonly class SeoData
{
    public function __construct(
        public ?string $metaTitle = null,
        public ?string $metaDescription = null,
        public ?string $canonical = null,
        public ?string $ogTitle = null,
        public ?string $ogDescription = null,
        public ?string $ogImage = null,
        public ?string $ogType = null,
        public ?string $twitterCard = null,
        public ?string $twitterSite = null,
        public ?string $twitterCreator = null,
        public ?string $robots = null,
        public ?string $ogSiteName = null,
    ) {}

    public static function fromBag(array $bag, ?string $metaTitle = null, ?string $metaDescription = null): self;
    public function toBag(): array;   // canonical, og_title, og_description, og_image, og_type,
                                      // twitter_card, twitter_site, twitter_creator, robots, og_site_name
}

In the stored seo bag, empty strings read back as null.

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.