diff --git a/extensions/mentions/src/Formatter/FormatGroupMentions.php b/extensions/mentions/src/Formatter/FormatGroupMentions.php
index b6ad163170..be8bddcd63 100644
--- a/extensions/mentions/src/Formatter/FormatGroupMentions.php
+++ b/extensions/mentions/src/Formatter/FormatGroupMentions.php
@@ -27,9 +27,7 @@ public function __invoke(Renderer $renderer, mixed $context, string $xml): strin
return Utils::replaceAttributes($xml, 'GROUPMENTION', function ($attributes) use ($context) {
/** @var Group|null $group */
$group = match (true) {
- $context instanceof AbstractModel && $context->isRelation('mentionsGroups') => $context->relationLoaded('mentionsGroups')
- ? $context->mentionsGroups->find($attributes['id']) // @phpstan-ignore-line
- : $context->mentionsGroups()->find($attributes['id']), // @phpstan-ignore-line
+ $context instanceof AbstractModel && $context->relationLoaded('mentionsGroups') => $context->mentionsGroups->find($attributes['id']), // @phpstan-ignore-line
default => Group::query()->find($attributes['id']),
};
diff --git a/extensions/mentions/src/Formatter/FormatPostMentions.php b/extensions/mentions/src/Formatter/FormatPostMentions.php
index 75a20ba368..9140d74fca 100644
--- a/extensions/mentions/src/Formatter/FormatPostMentions.php
+++ b/extensions/mentions/src/Formatter/FormatPostMentions.php
@@ -33,9 +33,7 @@ public function __invoke(Renderer $renderer, mixed $context, string $xml): strin
return Utils::replaceAttributes($xml, 'POSTMENTION', function ($attributes) use ($context) {
/** @var Post|null $post */
$post = match (true) {
- $context instanceof AbstractModel && $context->isRelation('mentionsPosts') => $context->relationLoaded('mentionsPosts')
- ? $context->mentionsPosts->find($attributes['id']) // @phpstan-ignore-line
- : $context->mentionsPosts()->find($attributes['id']), // @phpstan-ignore-line
+ $context instanceof AbstractModel && $context->relationLoaded('mentionsPosts') => $context->mentionsPosts->find($attributes['id']), // @phpstan-ignore-line
default => Post::query()->find($attributes['id']),
};
diff --git a/extensions/mentions/src/Formatter/FormatTagMentions.php b/extensions/mentions/src/Formatter/FormatTagMentions.php
index 6cad73ca6c..ca64181786 100644
--- a/extensions/mentions/src/Formatter/FormatTagMentions.php
+++ b/extensions/mentions/src/Formatter/FormatTagMentions.php
@@ -22,9 +22,7 @@ public function __invoke(Renderer $renderer, mixed $context, string $xml, ?Reque
return Utils::replaceAttributes($xml, 'TAGMENTION', function ($attributes) use ($context) {
/** @var Tag|null $tag */
$tag = match (true) {
- $context instanceof AbstractModel && $context->isRelation('mentionsTags') => $context->relationLoaded('mentionsTags')
- ? $context->mentionsTags->find($attributes['id']) // @phpstan-ignore-line
- : $context->mentionsTags()->find($attributes['id']), // @phpstan-ignore-line
+ $context instanceof AbstractModel && $context->relationLoaded('mentionsTags') => $context->mentionsTags->find($attributes['id']), // @phpstan-ignore-line
default => Tag::query()->find($attributes['id']),
};
diff --git a/extensions/mentions/src/Formatter/FormatUserMentions.php b/extensions/mentions/src/Formatter/FormatUserMentions.php
index fbbbe28f73..9658b98e1e 100644
--- a/extensions/mentions/src/Formatter/FormatUserMentions.php
+++ b/extensions/mentions/src/Formatter/FormatUserMentions.php
@@ -29,9 +29,7 @@ public function __invoke(Renderer $renderer, mixed $context, string $xml): strin
return Utils::replaceAttributes($xml, 'USERMENTION', function ($attributes) use ($context) {
/** @var User|null $user */
$user = match (true) {
- $context instanceof AbstractModel && $context->isRelation('mentionsUsers') => $context->relationLoaded('mentionsUsers')
- ? $context->mentionsUsers->find($attributes['id']) // @phpstan-ignore-line
- : $context->mentionsUsers()->find($attributes['id']), // @phpstan-ignore-line
+ $context instanceof AbstractModel && $context->relationLoaded('mentionsUsers') => $context->mentionsUsers->find($attributes['id']), // @phpstan-ignore-line
default => User::query()->find($attributes['id']),
};
diff --git a/extensions/mentions/src/Formatter/UnparsePostMentions.php b/extensions/mentions/src/Formatter/UnparsePostMentions.php
index ee9f0d24d4..d1a59d5bf4 100644
--- a/extensions/mentions/src/Formatter/UnparsePostMentions.php
+++ b/extensions/mentions/src/Formatter/UnparsePostMentions.php
@@ -40,9 +40,7 @@ protected function updatePostMentionTags(mixed $context, string $xml): string
return Utils::replaceAttributes($xml, 'POSTMENTION', function ($attributes) use ($context) {
/** @var Post|null $post */
$post = match (true) {
- $context instanceof AbstractModel && $context->isRelation('mentionsPosts') => $context->relationLoaded('mentionsPosts')
- ? $context->mentionsPosts->find($attributes['id']) // @phpstan-ignore-line
- : $context->mentionsPosts()->find($attributes['id']), // @phpstan-ignore-line
+ $context instanceof AbstractModel && $context->relationLoaded('mentionsPosts') => $context->mentionsPosts->find($attributes['id']), // @phpstan-ignore-line
default => Post::query()->find($attributes['id']),
};
diff --git a/extensions/mentions/src/Formatter/UnparseTagMentions.php b/extensions/mentions/src/Formatter/UnparseTagMentions.php
index 8623f47985..d4a19c71dd 100644
--- a/extensions/mentions/src/Formatter/UnparseTagMentions.php
+++ b/extensions/mentions/src/Formatter/UnparseTagMentions.php
@@ -34,9 +34,7 @@ protected function updateTagMentionTags(mixed $context, string $xml): string
return Utils::replaceAttributes($xml, 'TAGMENTION', function (array $attributes) use ($context) {
/** @var Tag|null $tag */
$tag = match (true) {
- $context instanceof AbstractModel && $context->isRelation('mentionsTags') => $context->relationLoaded('mentionsTags')
- ? $context->mentionsTags->find($attributes['id']) // @phpstan-ignore-line
- : $context->mentionsTags()->find($attributes['id']), // @phpstan-ignore-line
+ $context instanceof AbstractModel && $context->relationLoaded('mentionsTags') => $context->mentionsTags->find($attributes['id']), // @phpstan-ignore-line
default => Tag::query()->find($attributes['id']),
};
diff --git a/extensions/mentions/src/Formatter/UnparseUserMentions.php b/extensions/mentions/src/Formatter/UnparseUserMentions.php
index b40b1a5615..14bc1834dc 100644
--- a/extensions/mentions/src/Formatter/UnparseUserMentions.php
+++ b/extensions/mentions/src/Formatter/UnparseUserMentions.php
@@ -40,9 +40,7 @@ protected function updateUserMentionTags(mixed $context, string $xml): string
return Utils::replaceAttributes($xml, 'USERMENTION', function ($attributes) use ($context) {
/** @var User|null $user */
$user = match (true) {
- $context instanceof AbstractModel && $context->isRelation('mentionsUsers') => $context->relationLoaded('mentionsUsers')
- ? $context->mentionsUsers->find($attributes['id']) // @phpstan-ignore-line
- : $context->mentionsUsers()->find($attributes['id']), // @phpstan-ignore-line
+ $context instanceof AbstractModel && $context->relationLoaded('mentionsUsers') => $context->mentionsUsers->find($attributes['id']), // @phpstan-ignore-line
default => User::query()->find($attributes['id']),
};
diff --git a/extensions/mentions/tests/integration/api/MentionsDuringSavingTest.php b/extensions/mentions/tests/integration/api/MentionsDuringSavingTest.php
new file mode 100644
index 0000000000..9562c275e4
--- /dev/null
+++ b/extensions/mentions/tests/integration/api/MentionsDuringSavingTest.php
@@ -0,0 +1,121 @@
+content` is read during the `Saving` lifecycle event (before
+ * the post's mention pivot tables have been populated), the mention
+ * formatters must still resolve the mentioned entity by its own existence
+ * rather than the not-yet-synced pivot — otherwise the mention is wrongly
+ * unparsed to the `[deleted]` / `[unknown]` fallback text.
+ */
+class MentionsDuringSavingTest extends TestCase
+{
+ use RetrievesAuthorizedUsers;
+
+ /**
+ * Content observed by the Saving listener, captured for assertion.
+ */
+ protected ?string $observedContent = null;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->extension('flarum-mentions');
+
+ $this->extend(
+ (new Extend\Event())
+ ->listen(Saving::class, function (Saving $event) {
+ // Reading content here triggers unparse() while the post's
+ // mention pivots are not yet synced — the #4823 scenario.
+ $this->observedContent = $event->post->content;
+ })
+ );
+
+ $this->prepareDatabase([
+ User::class => [
+ $this->normalUser(),
+ ['id' => 3, 'username' => 'mentioned_user', 'email' => 'mentioned_user@machine.local', 'is_email_confirmed' => 1],
+ ],
+ Discussion::class => [
+ ['id' => 1, 'title' => 'Test discussion', 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1],
+ ],
+ Post::class => [
+ ['id' => 1, 'number' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now(), 'user_id' => 1, 'type' => 'comment', 'content' => 'Original
'],
+ ],
+ ]);
+ }
+
+ #[Test]
+ public function user_mention_resolves_during_saving_of_a_new_post(): void
+ {
+ $response = $this->send(
+ $this->request('POST', '/api/posts', [
+ 'authenticatedAs' => 1,
+ 'json' => [
+ 'data' => [
+ 'attributes' => [
+ 'content' => '@"mentioned_user"#3',
+ ],
+ 'relationships' => [
+ 'discussion' => ['data' => ['type' => 'discussions', 'id' => '1']],
+ ],
+ ],
+ ],
+ ])
+ );
+
+ $this->assertEquals(201, $response->getStatusCode(), $response->getBody()->getContents());
+
+ // The Saving listener must have seen the real mention, NOT the deleted
+ // fallback — this is the regression from #4823.
+ $this->assertNotNull($this->observedContent);
+ $this->assertStringContainsString('mentioned_user', $this->observedContent);
+ $this->assertStringNotContainsString('[deleted]', $this->observedContent);
+ $this->assertStringNotContainsString('[unknown]', $this->observedContent);
+ }
+
+ #[Test]
+ public function user_mention_resolves_during_saving_when_editing_a_post(): void
+ {
+ $response = $this->send(
+ $this->request('PATCH', '/api/posts/1', [
+ 'authenticatedAs' => 1,
+ 'json' => [
+ 'data' => [
+ 'attributes' => [
+ 'content' => '@"mentioned_user"#3 edited',
+ ],
+ ],
+ ],
+ ])
+ );
+
+ $this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents());
+
+ $this->assertNotNull($this->observedContent);
+ $this->assertStringContainsString('mentioned_user', $this->observedContent);
+ $this->assertStringNotContainsString('[deleted]', $this->observedContent);
+ $this->assertStringNotContainsString('[unknown]', $this->observedContent);
+ }
+}
diff --git a/extensions/mentions/tests/integration/api/PostMentionsTest.php b/extensions/mentions/tests/integration/api/PostMentionsTest.php
index 0b9cd9c8e1..c07a77089a 100644
--- a/extensions/mentions/tests/integration/api/PostMentionsTest.php
+++ b/extensions/mentions/tests/integration/api/PostMentionsTest.php
@@ -480,7 +480,7 @@ public function editing_a_post_with_deleted_author_that_has_a_mention_works()
'data' => [
'type' => 'posts',
'attributes' => [
- 'content' => '@"Bad _ User"#p9',
+ 'content' => '@"Bad _ User"#p9 edited',
],
],
],
@@ -494,7 +494,7 @@ public function editing_a_post_with_deleted_author_that_has_a_mention_works()
$response = json_decode($body, true);
$this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']);
- $this->assertEquals('@"Bad _ User"#p9', $response['data']['attributes']['content']);
+ $this->assertEquals('@"Bad _ User"#p9 edited', $response['data']['attributes']['content']);
$this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']);
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(9));
}
diff --git a/extensions/mentions/tests/integration/api/TagMentionsTest.php b/extensions/mentions/tests/integration/api/TagMentionsTest.php
index 3515e1045e..5134f748f4 100644
--- a/extensions/mentions/tests/integration/api/TagMentionsTest.php
+++ b/extensions/mentions/tests/integration/api/TagMentionsTest.php
@@ -40,7 +40,7 @@ protected function setUp(): void
],
Post::class => [
['id' => 4, 'number' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '#test_old_slug'],
- ['id' => 7, 'number' => 5, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 2021, 'type' => 'comment', 'content' => '#deleted_relation'],
+ ['id' => 7, 'number' => 5, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 2021, 'type' => 'comment', 'content' => '#deleted_relation'],
['id' => 8, 'number' => 6, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '#i_am_a_deleted_tag'],
['id' => 10, 'number' => 11, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '#laravel'],
],
diff --git a/extensions/mentions/tests/integration/api/UserMentionsTest.php b/extensions/mentions/tests/integration/api/UserMentionsTest.php
index 962eb59df8..db0b3983d4 100644
--- a/extensions/mentions/tests/integration/api/UserMentionsTest.php
+++ b/extensions/mentions/tests/integration/api/UserMentionsTest.php
@@ -465,7 +465,7 @@ public function editing_a_post_with_deleted_author_that_has_a_mention_works()
'data' => [
'type' => 'posts',
'attributes' => [
- 'content' => '@"Bad _ User"#5',
+ 'content' => '@"Bad _ User"#5 edited',
],
],
],
@@ -479,7 +479,7 @@ public function editing_a_post_with_deleted_author_that_has_a_mention_works()
$response = json_decode($body, true);
$this->assertStringContainsString('Bad "#p6 User', $response['data']['attributes']['contentHtml']);
- $this->assertEquals('@"Bad _ User"#5', $response['data']['attributes']['content']);
+ $this->assertEquals('@"Bad _ User"#5 edited', $response['data']['attributes']['content']);
$this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']);
$this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(5));
}