From 06f83a657173d15d9a543f351f87c5d44ec4da84 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 11:19:01 -0300 Subject: [PATCH 01/12] Track post creation origin via created_via. Persist whether a post was created through web, MCP, API, or automation so we can attribute entry points without guessing from request context. Co-authored-by: Cursor --- .../Automation/Node/RunGenerateNode.php | 2 ++ app/Actions/Post/CreatePost.php | 6 ++++ app/Enums/Post/CreatedVia.php | 13 +++++++++ app/Http/Controllers/Api/PostController.php | 3 ++ app/Http/Controllers/App/PostController.php | 2 ++ .../App/PostTemplateController.php | 2 ++ app/Jobs/Ai/StreamPostCreation.php | 2 ++ app/Mcp/Tools/Post/CreatePostTool.php | 3 ++ app/Models/Post.php | 3 ++ ..._141454_add_created_via_to_posts_table.php | 28 +++++++++++++++++++ tests/Feature/Actions/Post/CreatePostTest.php | 19 +++++++++++++ tests/Feature/Api/PostApiTest.php | 5 +++- tests/Feature/Mcp/PostToolTest.php | 5 +++- tests/Feature/PostControllerTest.php | 2 ++ 14 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 app/Enums/Post/CreatedVia.php create mode 100644 database/migrations/2026_07_24_141454_add_created_via_to_posts_table.php diff --git a/app/Actions/Automation/Node/RunGenerateNode.php b/app/Actions/Automation/Node/RunGenerateNode.php index c44652562..41e7da30d 100644 --- a/app/Actions/Automation/Node/RunGenerateNode.php +++ b/app/Actions/Automation/Node/RunGenerateNode.php @@ -12,6 +12,7 @@ use App\DataTransferObjects\Automation\NodeRunResult; use App\Enums\Ai\ContentStyle; use App\Enums\Ai\GeneratorFormat; +use App\Enums\Post\CreatedVia; use App\Enums\PostPlatform\ContentType; use App\Models\AutomationRun; use App\Models\SocialAccount; @@ -145,6 +146,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult 'content' => $generated->content, 'media' => $generated->media, 'platforms' => $platforms, + 'created_via' => CreatedVia::Automation, ]); $run->update(['generated_post_id' => $post->id]); diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index 215838b6e..7950339d4 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -4,6 +4,7 @@ namespace App\Actions\Post; +use App\Enums\Post\CreatedVia; use App\Enums\Post\Status as PostStatus; use App\Events\PostCreated; use App\Models\Post; @@ -26,11 +27,15 @@ class CreatePost * `label_ids[]` are attached after creation so the same set of UUIDs * works for REST, MCP, and web callers. * + * `created_via` records which entry point created the post (web, mcp, + * api, or automation). Callers must set it explicitly. + * * @param array{ * content?: ?string, * media?: array, * date?: ?string, * scheduled_at?: ?string, + * created_via: CreatedVia, * platforms?: array}>, * label_ids?: array * } $data @@ -45,6 +50,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P 'content' => data_get($data, 'content', ''), 'media' => data_get($data, 'media', []), 'status' => PostStatus::Draft, + 'created_via' => data_get($data, 'created_via'), 'scheduled_at' => $scheduledAt, ]); diff --git a/app/Enums/Post/CreatedVia.php b/app/Enums/Post/CreatedVia.php new file mode 100644 index 000000000..bd7143328 --- /dev/null +++ b/app/Enums/Post/CreatedVia.php @@ -0,0 +1,13 @@ +owner, $data); $post->load(['postPlatforms.socialAccount']); diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index 3b6e6ef7c..7ef6f6332 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -12,6 +12,7 @@ use App\Ai\Templates\AiContentTemplate; use App\Ai\Templates\AiTemplateRegistry; use App\Enums\Post\Action as PostAction; +use App\Enums\Post\CreatedVia; use App\Enums\Post\Status as PostStatus; use App\Enums\SocialAccount\Platform; use App\Http\Requests\App\Post\StorePostRequest; @@ -189,6 +190,7 @@ public function store(StorePostRequest $request): RedirectResponse|\Symfony\Comp $post = CreatePost::execute($workspace, $request->user(), [ 'date' => $request->input('date'), 'media' => $request->input('media', []), + 'created_via' => CreatedVia::Web, ]); return Inertia::location(route('app.posts.edit', $post)); diff --git a/app/Http/Controllers/App/PostTemplateController.php b/app/Http/Controllers/App/PostTemplateController.php index c7889097b..18ce4cdc8 100644 --- a/app/Http/Controllers/App/PostTemplateController.php +++ b/app/Http/Controllers/App/PostTemplateController.php @@ -6,6 +6,7 @@ use App\Actions\Post\CreatePost; use App\Enums\Media\Type as MediaType; +use App\Enums\Post\CreatedVia; use App\Http\Requests\App\PostTemplate\ApplyPostTemplateRequest; use App\Http\Requests\App\PostTemplate\IndexPostTemplateRequest; use App\Http\Resources\App\PostTemplateResource; @@ -89,6 +90,7 @@ public function apply(ApplyPostTemplateRequest $request, string $slug, TemplateI 'content' => $content, 'media' => $media, 'date' => $request->input('date'), + 'created_via' => CreatedVia::Web, ]); return response()->json([ diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index f8c9eb144..ad8b8f264 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -14,6 +14,7 @@ use App\Enums\Ai\GeneratorFormat; use App\Enums\Notification\Channel as NotificationChannel; use App\Enums\Notification\Type as NotificationType; +use App\Enums\Post\CreatedVia; use App\Enums\PostPlatform\ContentType; use App\Events\Ai\PostCreationReady; use App\Jobs\SendNotification; @@ -195,6 +196,7 @@ private function createPostFromGenerated(Workspace $workspace, GeneratedPost $ge 'content' => $generated->content, 'media' => $generated->media, 'date' => $this->date, + 'created_via' => CreatedVia::Web, ]); if ($generated->contentType && $socialAccount) { diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index 80851f06a..50c1ce08d 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -5,6 +5,7 @@ namespace App\Mcp\Tools\Post; use App\Actions\Post\CreatePost; +use App\Enums\Post\CreatedVia; use App\Enums\PostPlatform\ContentType; use App\Http\Resources\Api\PostResource; use App\Rules\ContentTypeMatchesPlatform; @@ -41,6 +42,8 @@ public function handle(Request $request): ResponseFactory ...PostPlatformMetaRules::rules(), ]); + $validated['created_via'] = CreatedVia::Mcp; + $post = CreatePost::execute($workspace, $request->user(), $validated); $post->load(['postPlatforms.socialAccount', 'labels']); diff --git a/app/Models/Post.php b/app/Models/Post.php index 1fa414560..8d587d7f1 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -6,6 +6,7 @@ use App\DataTransferObjects\MediaItem; use App\Enums\Media\Type; +use App\Enums\Post\CreatedVia; use App\Enums\Post\Status as PostStatus; use App\Enums\SocialAccount\Platform; use App\Observers\PostObserver; @@ -34,6 +35,7 @@ class Post extends Model 'content', 'media', 'status', + 'created_via', 'scheduled_at', 'published_at', ]; @@ -42,6 +44,7 @@ protected function casts(): array { return [ 'status' => PostStatus::class, + 'created_via' => CreatedVia::class, 'media' => 'array', 'scheduled_at' => 'datetime', 'published_at' => 'datetime', diff --git a/database/migrations/2026_07_24_141454_add_created_via_to_posts_table.php b/database/migrations/2026_07_24_141454_add_created_via_to_posts_table.php new file mode 100644 index 000000000..17cd8c7de --- /dev/null +++ b/database/migrations/2026_07_24_141454_add_created_via_to_posts_table.php @@ -0,0 +1,28 @@ +string('created_via')->nullable()->after('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('created_via'); + }); + } +}; diff --git a/tests/Feature/Actions/Post/CreatePostTest.php b/tests/Feature/Actions/Post/CreatePostTest.php index 4a1252e0b..690f3bb9f 100644 --- a/tests/Feature/Actions/Post/CreatePostTest.php +++ b/tests/Feature/Actions/Post/CreatePostTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Actions\Post\CreatePost; +use App\Enums\Post\CreatedVia; use App\Events\PostCreated; use App\Models\User; use App\Models\Workspace; @@ -16,6 +17,7 @@ $post = CreatePost::execute($workspace, $user, [ 'content' => 'Hello world', + 'created_via' => CreatedVia::Web, ]); Event::assertDispatched( @@ -24,3 +26,20 @@ && $event->post->workspace_id === $workspace->id, ); }); + +test('execute persists created_via for each entry point', function (CreatedVia $createdVia) { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + + $post = CreatePost::execute($workspace, $user, [ + 'content' => 'Hello world', + 'created_via' => $createdVia, + ]); + + expect($post->fresh()->created_via)->toBe($createdVia); +})->with([ + 'web' => CreatedVia::Web, + 'mcp' => CreatedVia::Mcp, + 'api' => CreatedVia::Api, + 'automation' => CreatedVia::Automation, +]); diff --git a/tests/Feature/Api/PostApiTest.php b/tests/Feature/Api/PostApiTest.php index 5163af934..444bcbfd8 100644 --- a/tests/Feature/Api/PostApiTest.php +++ b/tests/Feature/Api/PostApiTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\Post\CreatedVia; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; @@ -72,7 +73,9 @@ ->assertCreated() ->assertJsonPath('status', PostStatus::Draft->value); - expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(1); + $post = Post::where('workspace_id', $this->workspace->id)->first(); + expect($post)->not->toBeNull(); + expect($post->created_via)->toBe(CreatedVia::Api); }); it('creates a post with content, media, and labels', function () { diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index dfd631b49..ba60f0101 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\Post\CreatedVia; use App\Enums\SocialAccount\Platform; use App\Enums\UserWorkspace\Role; use App\Mcp\Servers\TryPostServer; @@ -109,7 +110,9 @@ ->etc(); }); - expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(1); + $post = Post::where('workspace_id', $this->workspace->id)->first(); + expect($post)->not->toBeNull(); + expect($post->created_via)->toBe(CreatedVia::Mcp); }); test('create post with platforms enables only those', function () { diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index a7da0b149..2ea4c55b5 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\Post\CreatedVia; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; use App\Enums\PostPlatform\Status; @@ -257,6 +258,7 @@ $post = Post::where('workspace_id', $this->workspace->id)->first(); expect($post)->not->toBeNull(); expect($post->status)->toBe(PostStatus::Draft); + expect($post->created_via)->toBe(CreatedVia::Web); expect($post->postPlatforms)->toHaveCount(1); }); From 26d0b2cdb233db326af58388dad75d90fa80bb01 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 11:22:28 -0300 Subject: [PATCH 02/12] chore: added strict types --- .../2026_07_24_141454_add_created_via_to_posts_table.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/database/migrations/2026_07_24_141454_add_created_via_to_posts_table.php b/database/migrations/2026_07_24_141454_add_created_via_to_posts_table.php index 17cd8c7de..3e44bf5e6 100644 --- a/database/migrations/2026_07_24_141454_add_created_via_to_posts_table.php +++ b/database/migrations/2026_07_24_141454_add_created_via_to_posts_table.php @@ -1,5 +1,7 @@ Date: Fri, 24 Jul 2026 11:30:15 -0300 Subject: [PATCH 03/12] Harden created_via: require it, cover duplicate and all entry points. Reject CreatePost calls without CreatedVia, set Web on DuplicatePost, and assert wiring for templates, AI, automation, and API spoof attempts. Co-authored-by: Cursor --- app/Actions/Post/CreatePost.php | 13 ++++++-- app/Actions/Post/DuplicatePost.php | 2 ++ tests/Feature/Actions/Post/CreatePostTest.php | 9 ++++++ .../Actions/Post/DuplicatePostTest.php | 31 +++++++++++++++++++ tests/Feature/Ai/StreamPostCreationTest.php | 4 ++- tests/Feature/Api/PostApiTest.php | 17 ++++++++++ .../Automation/Node/GenerateNodeTest.php | 2 ++ tests/Feature/PostTemplateControllerTest.php | 4 +++ 8 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/Actions/Post/DuplicatePostTest.php diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index 7950339d4..1462f4253 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -12,6 +12,7 @@ use App\Models\Workspace; use Carbon\Carbon; use Illuminate\Support\Facades\DB; +use InvalidArgumentException; class CreatePost { @@ -39,18 +40,26 @@ class CreatePost * platforms?: array}>, * label_ids?: array * } $data + * + * @throws InvalidArgumentException when created_via is missing or not a CreatedVia case */ public static function execute(Workspace $workspace, User $user, array $data): Post { + $createdVia = data_get($data, 'created_via'); + + if (! $createdVia instanceof CreatedVia) { + throw new InvalidArgumentException('created_via must be a CreatedVia enum case.'); + } + $scheduledAt = self::resolveScheduledAt($data); - $post = DB::transaction(function () use ($workspace, $user, $data, $scheduledAt): Post { + $post = DB::transaction(function () use ($workspace, $user, $data, $scheduledAt, $createdVia): Post { $post = $workspace->posts()->create([ 'user_id' => $user->id, 'content' => data_get($data, 'content', ''), 'media' => data_get($data, 'media', []), 'status' => PostStatus::Draft, - 'created_via' => data_get($data, 'created_via'), + 'created_via' => $createdVia, 'scheduled_at' => $scheduledAt, ]); diff --git a/app/Actions/Post/DuplicatePost.php b/app/Actions/Post/DuplicatePost.php index 3fdd67ecc..1e40854e2 100644 --- a/app/Actions/Post/DuplicatePost.php +++ b/app/Actions/Post/DuplicatePost.php @@ -4,6 +4,7 @@ namespace App\Actions\Post; +use App\Enums\Post\CreatedVia; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Models\Post; @@ -25,6 +26,7 @@ public static function execute(Post $original, User $user): Post 'content' => $original->content, 'media' => $original->media, 'status' => PostStatus::Draft, + 'created_via' => CreatedVia::Web, 'scheduled_at' => null, 'published_at' => null, ]); diff --git a/tests/Feature/Actions/Post/CreatePostTest.php b/tests/Feature/Actions/Post/CreatePostTest.php index 690f3bb9f..e0d2d7f4e 100644 --- a/tests/Feature/Actions/Post/CreatePostTest.php +++ b/tests/Feature/Actions/Post/CreatePostTest.php @@ -43,3 +43,12 @@ 'api' => CreatedVia::Api, 'automation' => CreatedVia::Automation, ]); + +test('execute requires created_via', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + + CreatePost::execute($workspace, $user, [ + 'content' => 'Hello world', + ]); +})->throws(InvalidArgumentException::class, 'created_via must be a CreatedVia enum case.'); diff --git a/tests/Feature/Actions/Post/DuplicatePostTest.php b/tests/Feature/Actions/Post/DuplicatePostTest.php new file mode 100644 index 000000000..628c69439 --- /dev/null +++ b/tests/Feature/Actions/Post/DuplicatePostTest.php @@ -0,0 +1,31 @@ +create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $original = Post::factory()->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + 'content' => 'Original content', + 'created_via' => CreatedVia::Api, + 'status' => PostStatus::Published, + ]); + + $copy = DuplicatePost::execute($original, $user); + + expect($copy->id)->not->toBe($original->id) + ->and($copy->content)->toBe('Original content') + ->and($copy->status)->toBe(PostStatus::Draft) + ->and($copy->created_via)->toBe(CreatedVia::Web) + ->and($copy->scheduled_at)->toBeNull() + ->and($copy->published_at)->toBeNull(); +}); diff --git a/tests/Feature/Ai/StreamPostCreationTest.php b/tests/Feature/Ai/StreamPostCreationTest.php index 513da29bd..457d1f642 100644 --- a/tests/Feature/Ai/StreamPostCreationTest.php +++ b/tests/Feature/Ai/StreamPostCreationTest.php @@ -4,6 +4,7 @@ use App\Ai\Agents\PostContentGenerator; use App\Ai\Agents\PostContentHumanizer; +use App\Enums\Post\CreatedVia; use App\Enums\PostPlatform\ContentType; use App\Enums\UserWorkspace\Role; use App\Jobs\Ai\StreamPostCreation; @@ -111,7 +112,8 @@ function runStreamPostCreation(string $format, SocialAccount $account, int $imag $post = $this->user->currentWorkspace->posts()->latest()->first(); expect($post->content)->toBe('Hello world\n\nSecond para.') - ->and($post->media)->toHaveCount(1); + ->and($post->media)->toHaveCount(1) + ->and($post->created_via)->toBe(CreatedVia::Web); $platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail(); diff --git a/tests/Feature/Api/PostApiTest.php b/tests/Feature/Api/PostApiTest.php index 444bcbfd8..567607a60 100644 --- a/tests/Feature/Api/PostApiTest.php +++ b/tests/Feature/Api/PostApiTest.php @@ -78,6 +78,23 @@ expect($post->created_via)->toBe(CreatedVia::Api); }); +it('ignores a client-supplied created_via and always records api', function () { + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'created_via' => 'web', + 'platforms' => [ + [ + 'social_account_id' => $this->socialAccount->id, + 'content_type' => 'linkedin_post', + ], + ], + ]) + ->assertCreated(); + + $post = Post::where('workspace_id', $this->workspace->id)->first(); + expect($post->created_via)->toBe(CreatedVia::Api); +}); + it('creates a post with content, media, and labels', function () { $label = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]); diff --git a/tests/Feature/Automation/Node/GenerateNodeTest.php b/tests/Feature/Automation/Node/GenerateNodeTest.php index f8fa56739..368a99de1 100644 --- a/tests/Feature/Automation/Node/GenerateNodeTest.php +++ b/tests/Feature/Automation/Node/GenerateNodeTest.php @@ -7,6 +7,7 @@ use App\Ai\Agents\PostContentHumanizer; use App\Enums\Ai\ContentStyle; use App\Enums\Ai\GeneratorFormat; +use App\Enums\Post\CreatedVia; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; use App\Models\Automation; @@ -47,6 +48,7 @@ $post = Post::find($run->generated_post_id); expect($post)->not->toBeNull(); expect($post->status)->toBe(PostStatus::Draft); + expect($post->created_via)->toBe(CreatedVia::Automation); }); it('applies brand voice by default', function () { diff --git a/tests/Feature/PostTemplateControllerTest.php b/tests/Feature/PostTemplateControllerTest.php index be4b34bd7..7e20d520c 100644 --- a/tests/Feature/PostTemplateControllerTest.php +++ b/tests/Feature/PostTemplateControllerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\Post\CreatedVia; use App\Enums\UserWorkspace\Role; use App\Models\Account; use App\Models\SocialAccount; @@ -72,6 +73,9 @@ expect($response->json('post_id'))->toBeString(); expect($response->json('redirect_url'))->toContain('edit'); + + $post = $this->workspace->posts()->find($response->json('post_id')); + expect($post->created_via)->toBe(CreatedVia::Web); }); test('apply interpolates brand_name in content', function () { From a545d29cfb534fcf6a0b3b289c61f67e47606df0 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 11:34:24 -0300 Subject: [PATCH 04/12] Treat created_via as non-blocking analytics data. Default to web when the value is missing, null, or invalid so post creation never fails over provenance. Co-authored-by: Cursor --- app/Actions/Post/CreatePost.php | 38 ++++++++++++------- tests/Feature/Actions/Post/CreatePostTest.php | 24 ++++++++++-- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index 1462f4253..85adfb066 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -12,7 +12,6 @@ use App\Models\Workspace; use Carbon\Carbon; use Illuminate\Support\Facades\DB; -use InvalidArgumentException; class CreatePost { @@ -29,37 +28,30 @@ class CreatePost * works for REST, MCP, and web callers. * * `created_via` records which entry point created the post (web, mcp, - * api, or automation). Callers must set it explicitly. + * api, or automation). Analytical only — defaults to web when omitted + * or invalid, and never blocks creation. * * @param array{ * content?: ?string, * media?: array, * date?: ?string, * scheduled_at?: ?string, - * created_via: CreatedVia, + * created_via?: CreatedVia, * platforms?: array}>, * label_ids?: array * } $data - * - * @throws InvalidArgumentException when created_via is missing or not a CreatedVia case */ public static function execute(Workspace $workspace, User $user, array $data): Post { - $createdVia = data_get($data, 'created_via'); - - if (! $createdVia instanceof CreatedVia) { - throw new InvalidArgumentException('created_via must be a CreatedVia enum case.'); - } - $scheduledAt = self::resolveScheduledAt($data); - $post = DB::transaction(function () use ($workspace, $user, $data, $scheduledAt, $createdVia): Post { + $post = DB::transaction(function () use ($workspace, $user, $data, $scheduledAt): Post { $post = $workspace->posts()->create([ 'user_id' => $user->id, 'content' => data_get($data, 'content', ''), 'media' => data_get($data, 'media', []), 'status' => PostStatus::Draft, - 'created_via' => $createdVia, + 'created_via' => self::resolveCreatedVia($data), 'scheduled_at' => $scheduledAt, ]); @@ -105,6 +97,26 @@ public static function execute(Workspace $workspace, User $user, array $data): P return $post; } + /** + * Analytical only — never fail creation over a missing/invalid value. + * + * @param array $data + */ + private static function resolveCreatedVia(array $data): CreatedVia + { + $value = data_get($data, 'created_via'); + + if ($value instanceof CreatedVia) { + return $value; + } + + if (is_string($value)) { + return CreatedVia::tryFrom($value) ?? CreatedVia::Web; + } + + return CreatedVia::Web; + } + /** * @param array $data */ diff --git a/tests/Feature/Actions/Post/CreatePostTest.php b/tests/Feature/Actions/Post/CreatePostTest.php index e0d2d7f4e..20b684900 100644 --- a/tests/Feature/Actions/Post/CreatePostTest.php +++ b/tests/Feature/Actions/Post/CreatePostTest.php @@ -44,11 +44,29 @@ 'automation' => CreatedVia::Automation, ]); -test('execute requires created_via', function () { +test('execute defaults created_via to web when omitted', function () { $user = User::factory()->create(); $workspace = Workspace::factory()->create(['user_id' => $user->id]); - CreatePost::execute($workspace, $user, [ + $post = CreatePost::execute($workspace, $user, [ + 'content' => 'Hello world', + ]); + + expect($post->fresh()->created_via)->toBe(CreatedVia::Web); +}); + +test('execute defaults created_via to web when null or invalid', function (mixed $createdVia) { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + + $post = CreatePost::execute($workspace, $user, [ 'content' => 'Hello world', + 'created_via' => $createdVia, ]); -})->throws(InvalidArgumentException::class, 'created_via must be a CreatedVia enum case.'); + + expect($post->fresh()->created_via)->toBe(CreatedVia::Web); +})->with([ + 'null' => null, + 'invalid string' => 'not-a-channel', + 'integer' => 1, +]); From 588891767bf0580fb170333ec11a65103e3a33f7 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 11:36:02 -0300 Subject: [PATCH 05/12] Leave created_via null when omitted or invalid. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provenance is optional analytics — only persist a CreatedVia value when callers set a valid one. Co-authored-by: Cursor --- app/Actions/Post/CreatePost.php | 12 ++++++------ tests/Feature/Actions/Post/CreatePostTest.php | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index 85adfb066..f4089db5c 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -28,15 +28,15 @@ class CreatePost * works for REST, MCP, and web callers. * * `created_via` records which entry point created the post (web, mcp, - * api, or automation). Analytical only — defaults to web when omitted - * or invalid, and never blocks creation. + * api, or automation). Analytical only — null when omitted or invalid, + * and never blocks creation. * * @param array{ * content?: ?string, * media?: array, * date?: ?string, * scheduled_at?: ?string, - * created_via?: CreatedVia, + * created_via?: ?CreatedVia, * platforms?: array}>, * label_ids?: array * } $data @@ -102,7 +102,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P * * @param array $data */ - private static function resolveCreatedVia(array $data): CreatedVia + private static function resolveCreatedVia(array $data): ?CreatedVia { $value = data_get($data, 'created_via'); @@ -111,10 +111,10 @@ private static function resolveCreatedVia(array $data): CreatedVia } if (is_string($value)) { - return CreatedVia::tryFrom($value) ?? CreatedVia::Web; + return CreatedVia::tryFrom($value); } - return CreatedVia::Web; + return null; } /** diff --git a/tests/Feature/Actions/Post/CreatePostTest.php b/tests/Feature/Actions/Post/CreatePostTest.php index 20b684900..c9d7ae648 100644 --- a/tests/Feature/Actions/Post/CreatePostTest.php +++ b/tests/Feature/Actions/Post/CreatePostTest.php @@ -44,7 +44,7 @@ 'automation' => CreatedVia::Automation, ]); -test('execute defaults created_via to web when omitted', function () { +test('execute leaves created_via null when omitted', function () { $user = User::factory()->create(); $workspace = Workspace::factory()->create(['user_id' => $user->id]); @@ -52,10 +52,10 @@ 'content' => 'Hello world', ]); - expect($post->fresh()->created_via)->toBe(CreatedVia::Web); + expect($post->fresh()->created_via)->toBeNull(); }); -test('execute defaults created_via to web when null or invalid', function (mixed $createdVia) { +test('execute leaves created_via null when null or invalid', function (mixed $createdVia) { $user = User::factory()->create(); $workspace = Workspace::factory()->create(['user_id' => $user->id]); @@ -64,7 +64,7 @@ 'created_via' => $createdVia, ]); - expect($post->fresh()->created_via)->toBe(CreatedVia::Web); + expect($post->fresh()->created_via)->toBeNull(); })->with([ 'null' => null, 'invalid string' => 'not-a-channel', From 227ee45c2bb2518dec03c3ef6965c319bbdf7e42 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 11:38:22 -0300 Subject: [PATCH 06/12] Simplify created_via persistence to a plain data_get. Callers already pass a CreatedVia enum or omit it; drop the defensive resolver. Co-authored-by: Cursor --- app/Actions/Post/CreatePost.php | 25 ++----------------- tests/Feature/Actions/Post/CreatePostTest.php | 16 ------------ 2 files changed, 2 insertions(+), 39 deletions(-) diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index f4089db5c..11c5a1f43 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -28,8 +28,7 @@ class CreatePost * works for REST, MCP, and web callers. * * `created_via` records which entry point created the post (web, mcp, - * api, or automation). Analytical only — null when omitted or invalid, - * and never blocks creation. + * api, or automation). Analytical only — null when omitted. * * @param array{ * content?: ?string, @@ -51,7 +50,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P 'content' => data_get($data, 'content', ''), 'media' => data_get($data, 'media', []), 'status' => PostStatus::Draft, - 'created_via' => self::resolveCreatedVia($data), + 'created_via' => data_get($data, 'created_via'), 'scheduled_at' => $scheduledAt, ]); @@ -97,26 +96,6 @@ public static function execute(Workspace $workspace, User $user, array $data): P return $post; } - /** - * Analytical only — never fail creation over a missing/invalid value. - * - * @param array $data - */ - private static function resolveCreatedVia(array $data): ?CreatedVia - { - $value = data_get($data, 'created_via'); - - if ($value instanceof CreatedVia) { - return $value; - } - - if (is_string($value)) { - return CreatedVia::tryFrom($value); - } - - return null; - } - /** * @param array $data */ diff --git a/tests/Feature/Actions/Post/CreatePostTest.php b/tests/Feature/Actions/Post/CreatePostTest.php index c9d7ae648..410257dd9 100644 --- a/tests/Feature/Actions/Post/CreatePostTest.php +++ b/tests/Feature/Actions/Post/CreatePostTest.php @@ -54,19 +54,3 @@ expect($post->fresh()->created_via)->toBeNull(); }); - -test('execute leaves created_via null when null or invalid', function (mixed $createdVia) { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create(['user_id' => $user->id]); - - $post = CreatePost::execute($workspace, $user, [ - 'content' => 'Hello world', - 'created_via' => $createdVia, - ]); - - expect($post->fresh()->created_via)->toBeNull(); -})->with([ - 'null' => null, - 'invalid string' => 'not-a-channel', - 'integer' => 1, -]); From 2c50a60790a69e7e2046fa93725f6e5482fadc95 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 11:44:47 -0300 Subject: [PATCH 07/12] Capture post.created to PostHog with created_via. Dispatch TrackPost from PostCreated so we can chart which entry points create the most posts. Co-authored-by: Cursor --- app/Enums/PostHog/PostEvent.php | 10 ++ app/Jobs/PostHog/TrackPost.php | 67 +++++++++++++ app/Listeners/PostHog/TrackPostCreated.php | 21 ++++ tests/Feature/Jobs/PostHog/TrackPostTest.php | 96 +++++++++++++++++++ .../PostHog/TrackPostCreatedTest.php | 71 ++++++++++++++ 5 files changed, 265 insertions(+) create mode 100644 app/Enums/PostHog/PostEvent.php create mode 100644 app/Jobs/PostHog/TrackPost.php create mode 100644 app/Listeners/PostHog/TrackPostCreated.php create mode 100644 tests/Feature/Jobs/PostHog/TrackPostTest.php create mode 100644 tests/Feature/Listeners/PostHog/TrackPostCreatedTest.php diff --git a/app/Enums/PostHog/PostEvent.php b/app/Enums/PostHog/PostEvent.php new file mode 100644 index 000000000..e67b1e30d --- /dev/null +++ b/app/Enums/PostHog/PostEvent.php @@ -0,0 +1,10 @@ +onQueue('posthog'); + } + + public function handle(PostHogService $postHog): void + { + if (! PostHogService::isEnabled()) { + return; + } + + $post = Post::query() + ->with(['workspace.account.plan', 'user']) + ->find($this->postId); + + if (! $post) { + return; + } + + $account = $post->workspace?->account; + + if (! $account) { + return; + } + + $distinctId = (string) ($post->user_id ?? $account->owner_id); + + if ($distinctId === '') { + return; + } + + $postHog->capture( + $distinctId, + PostEvent::Created->value, + [ + 'post_id' => (string) $post->id, + 'workspace_id' => (string) $post->workspace_id, + 'created_via' => $post->created_via?->value, + 'status' => $post->status->value, + ], + $account, + ); + } +} diff --git a/app/Listeners/PostHog/TrackPostCreated.php b/app/Listeners/PostHog/TrackPostCreated.php new file mode 100644 index 000000000..eab05d0e7 --- /dev/null +++ b/app/Listeners/PostHog/TrackPostCreated.php @@ -0,0 +1,21 @@ +post->id); + } +} diff --git a/tests/Feature/Jobs/PostHog/TrackPostTest.php b/tests/Feature/Jobs/PostHog/TrackPostTest.php new file mode 100644 index 000000000..350042a8e --- /dev/null +++ b/tests/Feature/Jobs/PostHog/TrackPostTest.php @@ -0,0 +1,96 @@ + true, 'services.posthog.api_key' => 'phc_test_key']); + + $this->account = Account::factory()->create(); + $this->user = User::factory()->create(['account_id' => $this->account->id]); + $this->account->update(['owner_id' => $this->user->id]); + $this->workspace = Workspace::factory()->create([ + 'account_id' => $this->account->id, + 'user_id' => $this->user->id, + ]); +}); + +test('job is queued on the posthog queue', function () { + $job = new TrackPost((string) Str::uuid()); + + expect($job->queue)->toBe('posthog'); +}); + +test('handle captures post.created with created_via and account group', function () { + Queue::fake(); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'created_via' => CreatedVia::Api, + ]); + + (new TrackPost((string) $post->id))->handle(app(PostHogService::class)); + + Queue::assertPushed(SendEvent::class, function ($job) use ($post) { + return $job->method === 'capture' + && $job->payload['event'] === PostEvent::Created->value + && $job->payload['distinctId'] === (string) $this->user->id + && $job->payload['properties']['post_id'] === (string) $post->id + && $job->payload['properties']['workspace_id'] === (string) $this->workspace->id + && $job->payload['properties']['created_via'] === CreatedVia::Api->value + && $job->payload['properties']['$groups']['account'] === (string) $this->account->id; + }); +}); + +test('handle falls back to account owner when post has no user', function () { + Queue::fake(); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => null, + 'created_via' => CreatedVia::Mcp, + ]); + + (new TrackPost((string) $post->id))->handle(app(PostHogService::class)); + + Queue::assertPushed( + SendEvent::class, + fn ($job) => $job->payload['distinctId'] === (string) $this->user->id + && $job->payload['properties']['created_via'] === CreatedVia::Mcp->value, + ); +}); + +test('handle returns silently when post does not exist', function () { + Queue::fake(); + + (new TrackPost((string) Str::uuid()))->handle(app(PostHogService::class)); + + Queue::assertNothingPushed(); +}); + +test('handle does not push when PostHog is disabled', function () { + config(['services.posthog.api_key' => null]); + Queue::fake(); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'created_via' => CreatedVia::Web, + ]); + + (new TrackPost((string) $post->id))->handle(app(PostHogService::class)); + + Queue::assertNotPushed(SendEvent::class); +}); diff --git a/tests/Feature/Listeners/PostHog/TrackPostCreatedTest.php b/tests/Feature/Listeners/PostHog/TrackPostCreatedTest.php new file mode 100644 index 000000000..311d39c11 --- /dev/null +++ b/tests/Feature/Listeners/PostHog/TrackPostCreatedTest.php @@ -0,0 +1,71 @@ + true, 'services.posthog.api_key' => 'phc_test_key']); + + $this->account = Account::factory()->create(); + $this->user = User::factory()->create(['account_id' => $this->account->id]); + $this->account->update(['owner_id' => $this->user->id]); + $this->workspace = Workspace::factory()->create([ + 'account_id' => $this->account->id, + 'user_id' => $this->user->id, + ]); +}); + +test('listener dispatches TrackPost with the post id', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'created_via' => CreatedVia::Web, + ]); + + Bus::fake(); + + (new TrackPostCreated)->handle(new PostCreated($post)); + + Bus::assertDispatched( + TrackPost::class, + fn ($job) => $job->postId === (string) $post->id, + ); +}); + +test('listener is wired to the PostCreated event via auto-discovery', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'created_via' => CreatedVia::Automation, + ]); + + Bus::fake(); + + PostCreated::dispatch($post); + + Bus::assertDispatched(TrackPost::class); +}); + +test('listener does not dispatch when PostHog is disabled', function () { + config(['services.posthog.enabled' => false]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + Bus::fake(); + + (new TrackPostCreated)->handle(new PostCreated($post)); + + Bus::assertNotDispatched(TrackPost::class); +}); From 8b1ce057c31376960d7ade415c5462be2c349348 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 11:56:44 -0300 Subject: [PATCH 08/12] Fire PostCreated on duplicate and drop unused PostHog eager load. Duplicates now sync usage and track post.created like CreatePost; TrackPost only eager-loads what capture needs. Co-authored-by: Cursor --- app/Actions/Post/DuplicatePost.php | 7 ++++++- app/Jobs/PostHog/TrackPost.php | 2 +- .../Actions/Post/DuplicatePostTest.php | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/app/Actions/Post/DuplicatePost.php b/app/Actions/Post/DuplicatePost.php index 1e40854e2..53b5d1342 100644 --- a/app/Actions/Post/DuplicatePost.php +++ b/app/Actions/Post/DuplicatePost.php @@ -7,6 +7,7 @@ use App\Enums\Post\CreatedVia; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\Status as PostPlatformStatus; +use App\Events\PostCreated; use App\Models\Post; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -20,7 +21,7 @@ class DuplicatePost { public static function execute(Post $original, User $user): Post { - return DB::transaction(function () use ($original, $user): Post { + $copy = DB::transaction(function () use ($original, $user): Post { $copy = $original->workspace->posts()->create([ 'user_id' => $user->id, 'content' => $original->content, @@ -56,5 +57,9 @@ public static function execute(Post $original, User $user): Post return $copy; }); + + PostCreated::dispatch($copy); + + return $copy; } } diff --git a/app/Jobs/PostHog/TrackPost.php b/app/Jobs/PostHog/TrackPost.php index 15137b0b5..bd31ff04d 100644 --- a/app/Jobs/PostHog/TrackPost.php +++ b/app/Jobs/PostHog/TrackPost.php @@ -33,7 +33,7 @@ public function handle(PostHogService $postHog): void } $post = Post::query() - ->with(['workspace.account.plan', 'user']) + ->with(['workspace.account.plan']) ->find($this->postId); if (! $post) { diff --git a/tests/Feature/Actions/Post/DuplicatePostTest.php b/tests/Feature/Actions/Post/DuplicatePostTest.php index 628c69439..93f6705c2 100644 --- a/tests/Feature/Actions/Post/DuplicatePostTest.php +++ b/tests/Feature/Actions/Post/DuplicatePostTest.php @@ -5,9 +5,11 @@ use App\Actions\Post\DuplicatePost; use App\Enums\Post\CreatedVia; use App\Enums\Post\Status as PostStatus; +use App\Events\PostCreated; use App\Models\Post; use App\Models\User; use App\Models\Workspace; +use Illuminate\Support\Facades\Event; test('execute clones the post as a draft created via web', function () { $user = User::factory()->create(); @@ -29,3 +31,21 @@ ->and($copy->scheduled_at)->toBeNull() ->and($copy->published_at)->toBeNull(); }); + +test('execute dispatches PostCreated for the duplicated post', function () { + Event::fake([PostCreated::class]); + + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $original = Post::factory()->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + ]); + + $copy = DuplicatePost::execute($original, $user); + + Event::assertDispatched( + PostCreated::class, + fn (PostCreated $event) => $event->post->id === $copy->id, + ); +}); From d1d38865efbf35e34d5f264bf14cf1460bac9dcf Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 12:01:01 -0300 Subject: [PATCH 09/12] Dispatch PostCreated from PostObserver on every create. Centralize provenance/broadcast/PostHog triggers so CreatePost and DuplicatePost no longer fire the event by hand. Co-authored-by: Cursor --- app/Actions/Post/CreatePost.php | 3 -- app/Actions/Post/DuplicatePost.php | 7 +--- app/Observers/PostObserver.php | 11 ++++- tests/Feature/Actions/Post/CreatePostTest.php | 2 +- .../Actions/Post/DuplicatePostTest.php | 6 +-- tests/Feature/Observers/PostObserverTest.php | 40 +++++++++++++++++++ 6 files changed, 54 insertions(+), 15 deletions(-) create mode 100644 tests/Feature/Observers/PostObserverTest.php diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index 11c5a1f43..26535cecf 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -6,7 +6,6 @@ use App\Enums\Post\CreatedVia; use App\Enums\Post\Status as PostStatus; -use App\Events\PostCreated; use App\Models\Post; use App\Models\User; use App\Models\Workspace; @@ -91,8 +90,6 @@ public static function execute(Workspace $workspace, User $user, array $data): P return $post; }); - PostCreated::dispatch($post); - return $post; } diff --git a/app/Actions/Post/DuplicatePost.php b/app/Actions/Post/DuplicatePost.php index 53b5d1342..1e40854e2 100644 --- a/app/Actions/Post/DuplicatePost.php +++ b/app/Actions/Post/DuplicatePost.php @@ -7,7 +7,6 @@ use App\Enums\Post\CreatedVia; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\Status as PostPlatformStatus; -use App\Events\PostCreated; use App\Models\Post; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -21,7 +20,7 @@ class DuplicatePost { public static function execute(Post $original, User $user): Post { - $copy = DB::transaction(function () use ($original, $user): Post { + return DB::transaction(function () use ($original, $user): Post { $copy = $original->workspace->posts()->create([ 'user_id' => $user->id, 'content' => $original->content, @@ -57,9 +56,5 @@ public static function execute(Post $original, User $user): Post return $copy; }); - - PostCreated::dispatch($copy); - - return $copy; } } diff --git a/app/Observers/PostObserver.php b/app/Observers/PostObserver.php index 33456b275..c9cfd84a7 100644 --- a/app/Observers/PostObserver.php +++ b/app/Observers/PostObserver.php @@ -6,11 +6,18 @@ use App\Enums\Automation\Trigger\Type as TriggerType; use App\Enums\Post\Status as PostStatus; +use App\Events\PostCreated; use App\Jobs\Automation\DispatchPostTriggerAutomationsJob; use App\Models\Post; +use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit; -class PostObserver +class PostObserver implements ShouldHandleEventsAfterCommit { + public function created(Post $post): void + { + PostCreated::dispatch($post); + } + public function saved(Post $post): void { if (! $post->wasChanged('status')) { @@ -27,6 +34,6 @@ public function saved(Post $post): void return; } - DispatchPostTriggerAutomationsJob::dispatch($post, $triggerType)->afterCommit(); + DispatchPostTriggerAutomationsJob::dispatch($post, $triggerType); } } diff --git a/tests/Feature/Actions/Post/CreatePostTest.php b/tests/Feature/Actions/Post/CreatePostTest.php index 410257dd9..3a50dceee 100644 --- a/tests/Feature/Actions/Post/CreatePostTest.php +++ b/tests/Feature/Actions/Post/CreatePostTest.php @@ -9,7 +9,7 @@ use App\Models\Workspace; use Illuminate\Support\Facades\Event; -test('execute dispatches PostCreated with the persisted post', function () { +test('execute relies on the observer to dispatch PostCreated', function () { Event::fake([PostCreated::class]); $user = User::factory()->create(); diff --git a/tests/Feature/Actions/Post/DuplicatePostTest.php b/tests/Feature/Actions/Post/DuplicatePostTest.php index 93f6705c2..13e43daf4 100644 --- a/tests/Feature/Actions/Post/DuplicatePostTest.php +++ b/tests/Feature/Actions/Post/DuplicatePostTest.php @@ -32,9 +32,7 @@ ->and($copy->published_at)->toBeNull(); }); -test('execute dispatches PostCreated for the duplicated post', function () { - Event::fake([PostCreated::class]); - +test('execute relies on the observer to dispatch PostCreated for the duplicate', function () { $user = User::factory()->create(); $workspace = Workspace::factory()->create(['user_id' => $user->id]); $original = Post::factory()->create([ @@ -42,6 +40,8 @@ 'user_id' => $user->id, ]); + Event::fake([PostCreated::class]); + $copy = DuplicatePost::execute($original, $user); Event::assertDispatched( diff --git a/tests/Feature/Observers/PostObserverTest.php b/tests/Feature/Observers/PostObserverTest.php new file mode 100644 index 000000000..ebda452b2 --- /dev/null +++ b/tests/Feature/Observers/PostObserverTest.php @@ -0,0 +1,40 @@ +create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + + $post = Post::factory()->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + ]); + + Event::assertDispatched( + PostCreated::class, + fn (PostCreated $event) => $event->post->id === $post->id, + ); +}); + +test('creating a post quietly does not dispatch PostCreated', function () { + Event::fake([PostCreated::class]); + + $user = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + + Post::factory()->createQuietly([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + ]); + + Event::assertNotDispatched(PostCreated::class); +}); From 52f6506b3cfaba39e4a8f88182e58547fcf2d2c5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 12:08:16 -0300 Subject: [PATCH 10/12] Scope after-commit to PostCreated instead of the whole observer. Restore the saved() automation afterCommit hook and let the event wait for commit on its own. Co-authored-by: Cursor --- app/Events/PostCreated.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Events/PostCreated.php b/app/Events/PostCreated.php index 013f20d50..6234ba0ee 100644 --- a/app/Events/PostCreated.php +++ b/app/Events/PostCreated.php @@ -8,10 +8,11 @@ use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class PostCreated implements ShouldBroadcast +class PostCreated implements ShouldBroadcast, ShouldDispatchAfterCommit { use Dispatchable, InteractsWithSockets, SerializesModels; From 254b7d514ba8a7c3f5ed4d1ee7c4dfeb67fe70f9 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 12:08:37 -0300 Subject: [PATCH 11/12] Revert observer-wide after-commit; keep saved() job hook. PostCreated already waits for commit via ShouldDispatchAfterCommit. Co-authored-by: Cursor --- app/Observers/PostObserver.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/Observers/PostObserver.php b/app/Observers/PostObserver.php index c9cfd84a7..be6a3e6c0 100644 --- a/app/Observers/PostObserver.php +++ b/app/Observers/PostObserver.php @@ -9,9 +9,8 @@ use App\Events\PostCreated; use App\Jobs\Automation\DispatchPostTriggerAutomationsJob; use App\Models\Post; -use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit; -class PostObserver implements ShouldHandleEventsAfterCommit +class PostObserver { public function created(Post $post): void { @@ -34,6 +33,6 @@ public function saved(Post $post): void return; } - DispatchPostTriggerAutomationsJob::dispatch($post, $triggerType); + DispatchPostTriggerAutomationsJob::dispatch($post, $triggerType)->afterCommit(); } } From 2aef07dde6f57a45e3f315b8b9fbd15e7f83f5f5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 12:17:43 -0300 Subject: [PATCH 12/12] Defer PostCreated with DB::afterCommit in the observer. Keep after-commit local to created(), matching the saved() job hook, instead of marking the event itself. Co-authored-by: Cursor --- app/Events/PostCreated.php | 3 +-- app/Observers/PostObserver.php | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Events/PostCreated.php b/app/Events/PostCreated.php index 6234ba0ee..013f20d50 100644 --- a/app/Events/PostCreated.php +++ b/app/Events/PostCreated.php @@ -8,11 +8,10 @@ use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; -use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class PostCreated implements ShouldBroadcast, ShouldDispatchAfterCommit +class PostCreated implements ShouldBroadcast { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Observers/PostObserver.php b/app/Observers/PostObserver.php index be6a3e6c0..1486cfc04 100644 --- a/app/Observers/PostObserver.php +++ b/app/Observers/PostObserver.php @@ -9,12 +9,13 @@ use App\Events\PostCreated; use App\Jobs\Automation\DispatchPostTriggerAutomationsJob; use App\Models\Post; +use Illuminate\Support\Facades\DB; class PostObserver { public function created(Post $post): void { - PostCreated::dispatch($post); + DB::afterCommit(fn () => PostCreated::dispatch($post)); } public function saved(Post $post): void