---
title: "DI, actions & DTOs — Posts for Laravel | Roundly"
description: "Inject PostsManager instead of calling the facade, or run one action class per write — with typed DTOs for controllers, jobs and imports."
url: https://roundly-consulting.com/open-source/docs/posts-for-laravel/actions
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Posts for Laravel](https://roundly-consulting.com/open-source/docs/posts-for-laravel.md)

# 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:

```php
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:

```php
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

```php
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:

```php
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/posts-for-laravel.md)
