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 method | Action | Does |
|---|---|---|
create(CreatePostData $data) | CreatePostAction | Creates 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) | PublishPostAction | Published at $at ?? now(); PostPublished. Already published: no-op, date kept, no event. Throws for an archived post. |
schedule(Post $post, CarbonInterface $at) | SchedulePostAction | Scheduled for $at; PostScheduled — always, as it always moves the date. Same archived guard. |
archive(Post $post) | ArchivePostAction | Archived, keeping published_at; PostArchived. Already archived: no-op. |
unpublish(Post $post) | UnpublishPostAction | Back to Draft from any status, published_at cleared; PostDrafted. Already a draft: no-op. |
seo(Post $post, SeoData $data) | UpdatePostSeoAction | setSeo() then save(). |
syncTags(Post $post, iterable $tags) | SyncPostTagsAction | Strings found or created by current-locale name; unlisted tags detached. |
publishDue() | PublishDuePostsAction | Claims 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 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.