CoolCMS API

MessageThreadManager extends Manager
in package
uses SaveTrait, AtomicTransactionTrait

Private messages: orchestrates a whole conversation (thread, participants and messages).

The business methods live here on purpose and not on MessageManager: on every message the thread must recount messageCount and lastMessageDatetime and advance the participants' state. Doing that in MessageManager would need MessageThreadManager and create a DI cycle, so MessageManager stays a thin CRUD.

Tags
extends

Table of Contents

Constants

SETTING_MODULE  : int = 2
Operator switch "Message read receipts" (Site settings, Private messages): module 2 = Base, value `'1'`/`'0'`, default `0`. When on, the thread detail may show that the other side has read.
SETTING_READ_RECEIPTS  : string = 'message.readReceipts'
DELETED_IDS_META  : string = '__deletedIds'
Reserved `$meta` key with the ids of the entities being deleted, captured before the first hook.
EMAIL_KEY  : string = 'BS_PRIVATE_MESSAGE'
Template key in `cms_system_emails`.
PREVIEW_LENGTH  : int = 200
Length of the text preview in the e-mail; the full text is available only after login.

Properties

$service  : Service<string|int, TEntity>
Manager service; subclasses narrow the type (`@property` in the Api/Admin/Front manager).
$appContext  : AppContext
$assetFormatRegistry  : AssetFormatRegistry|null
Format processor registry for file uploads, used by {@see \App\Core\Traits\Api\Models\Managers\AssetProcessingTrait}.
$cacheTagInvalidator  : CacheTagInvalidator|null
Post-commit invalidator of the wire-payload query cache (tag bump per entity class).
$internalCallContext  : InternalCallContext|null
Tells whether the call is an internal bridge call; the single source for reading uploaded items with `path` ({@see readUploadedItem()}).
$languageContext  : LanguageContext|null
$nonInvalidatingColumns  : array<string|int, string>
Columns whose update does not invalidate the cache: view counters (`registerEvent()` bumps the counter on every pageview). Without this every view would flush the query cache of the whole class; counters are therefore stale up to the TTL. Override per manager by redefining the property.
$paramsConfig  : Config|null
Neon parameters (`%params%`: server paths and directories), set by the decorator (`decorators.neon`) like `setAppContext()`.
$presenterContext  : PresenterContext
$settingContext  : SettingContext
$totalCount  : int|null
$useTransactionForDelete  : bool
$useTransactionForSave  : bool
$em  : EntityManagerInterface
$emailManager  : EmailManager
$messageManager  : MessageManager
$participantManager  : MessageParticipantManager
$readListeners  : MessageThreadReadListenerRegistry|null
$rejectedUploads  : array<int, array{name: string, reason: string}>
New uploaded items that the Api did not save during the `save()` that is running right now (type, dimensions, unreadable, …). Filled by {@see recordRejectedUpload()} and returned in `saveInfo.rejectedUploads`.
$userManager  : UserManager

Methods

__construct()  : mixed
addMessage()  : Result
Adds a message to an existing thread and advances the derived state: the counter and time of the last message on the thread, and unread counts of the other participants.
addParticipant()  : Result
An admin invites another participant (arbiter, other user) into the thread. Admin only: the Api key `:Api:Base:MessageThread:addParticipant` is granted to the `admin` role only and the action has no `action*()` (404 externally).
countUnreadThreadsForUser()  : int
Returns the number of threads with an unread message, for the header badge.
delete()  : Result
Deletes the entity or entities by id. Optional hooks (opt-in via `method_exists`, like {@see SaveTrait}) allow cleanup of derived or denormalized state during the delete:
deleteAll()  : Result
find()  : T|null
findAll()  : T> : list>)
findBy()  : T|null
findMessages()  : array<string|int, Message>
Returns the thread messages in chronological order. Does not verify membership: the caller must do that first ({@see findParticipant()}); this is a building block, not an entry point.
findParticipant()  : MessageParticipant|null
Returns the thread participant for the given user; also the "may see the thread" test.
findParticipants()  : array<string|int, MessageParticipant>
Returns the thread participants; by default only active ones (no unread counts are added to others, no e-mail).
findParticipantViews()  : array<int, array{id: int, isMe: bool, isGuest: bool, isFormer: bool, name: string, email: ?string, phone: ?string, lastReadDatetime?: ?string}>
Returns the thread participants in a shape sufficient to render the detail; members of the thread only.
findRelatedIds()  : array<int, int>|null
Returns the ids of target entities that already belong to the record `$parentId` through the relation collection `$property` (for example `('assetRels', 'asset')`, `('fileRels', 'file')`). Reads straight from the database, not through the Api read path (no `UoW::clear()` in the middle of save hooks).
findThread()  : MessageThread|null
findThreadsForUser()  : MessageParticipant[], totalCount: int}
Returns the threads the user participates in, with the user's own participant rows.
getAppContext()  : AppContext
getDefaultSkipFields()  : array<string|int, string>
Returns keys that the generic `service->save()` must not copy onto the managed entity.
getEntityClass()  : TEntity>
Returns the root Doctrine entity class managed by this manager.
getExternalMetaAllowlist()  : array<string|int, string>
Returns the meta keys allowed through the external HTTP path ({@see \App\Core\Traits\Api\Presenters\SaveTrait::actionSave()}).
getExternalOwnerData()  : array<string, mixed>
Returns `data` keys that fill the owner from the server when an external save creates a record.
getExternalRemoteIpData()  : array<string, mixed>
Returns `data` keys that fill the caller's IP from the server when an external save creates a record; sibling of {@see getExternalOwnerData()}.
getExternalUpdateColumnsAllowlist()  : array<string|int, string>
Returns the columns the external HTTP path may change through {@see \App\Core\Traits\Api\Presenters\SaveTrait::actionUpdateColumns()}.
getGraphDataKeys()  : array<string|int, string>
Returns the keys an external client may send nested in `data`.
getLanguageContext()  : LanguageContext|null
getPresenterContext()  : PresenterContext
getServiceClass()  : class-string
Returns the fully qualified service class name, the identity for the per-service rollout allowlist of the wire-payload query cache ({@see \App\Core\Cache\Config::isServiceEnabled()}).
getSettingContext()  : SettingContext
getSettings()  : array<int, array<string, mixed>>
getTotalCount()  : int|null
hideForUser()  : Result
Hides the thread in one participant's mailbox ("delete for me"); it stays for the others.
initializeAllContexts()  : void
markRead()  : Result
Marks the thread as read for the given user. Returns a failure when the user is not a participant: reading someone else's conversation must not pass even by mistake.
markReadIfOnlyFirstUnread()  : Result
Response to thread: the recipient marked the response as read, so mark the thread too, but only when the only unread message in the thread is the root one from the response (`unreadCount <= 1`). A thread with newer messages from the other side stays unread because the recipient has not seen them yet.
openThread()  : Result
Opens a conversation: thread, participants and the root message.
registerEvent()  : Result
removeParticipant()  : Result
An admin removes a participant from the thread (admin only, key `:Api:Base:MessageThread:removeParticipant`).
replyAsUser()  : Result
Adds a user's reply to a thread; the entry point for the front end.
resolveCallerRoles()  : array{member: bool, owner: bool, receiver: bool}
Resolves the caller's roles over the thread for RBAC (`owner` and `receiver`, see {@see \App\Core\Traits\Api\Presenters\MessageThreadRolesTrait}).
save()  : Result
setAppContext()  : $this
setAssetFormatRegistry()  : static
setCacheTagInvalidator()  : static
setInternalCallContext()  : static
setLanguageContext()  : $this
setParamsConfig()  : static
setPresenterContext()  : $this
setSettingContext()  : $this
updateAllRelations()  : Result
updateColumn()  : Result
updateColumns()  : Result
updateRelations()  : Result
beginRejectedUploads()  : array<int, array{name: string, reason: string}>
Starts a save ({@see save()}): the list of rejected items belongs to this save only; the outer one (a nested save of the same manager) is returned to the caller and restored after the save through {@see finishRejectedUploads()}.
bumpCacheTags()  : void
Bumps cache tags for the entity after commit: its class, the owned-cascade classes from metadata and the optional `additionalCacheTags()` of the manager.
bumpCacheTagsExplicit()  : void
Bumps the given classes explicitly (raw-DBAL paths with a cross-entity effect).
bumpCacheTagsForClass()  : void
Bumps cache tags by class name when there is no instance (bulk delete, `updateColumns()`, raw DBAL, tree operations).
classCacheTags()  : array<string|int, string>
Returns extra tags for a bump that has no entity, typically tree operations ({@see \App\Core\Traits\Api\Models\Managers\TreeTrait::treeTransaction()}) that rewrite `lft`/`rgt` on many rows.
finishRejectedUploads()  : void
Ends a save: whatever {@see recordRejectedUpload()} wrote during it is added to the data of a successful result as `saveInfo.rejectedUploads` (the Api presenter passes it on to the caller, the admin builds a warning from it). A rejected item does not fail the save; the accepted files stay.
getDeletedId()  : int|null
Returns the id `$entity` had before the delete, from the snapshot in `$meta` that {@see delete()} hands to every hook. Null for an entity the snapshot does not know (not loaded by this delete, or without a simple id).
getService()  : Service<string|int, TEntity>
Returns the service backing the manager traits.
inAtomicTransaction()  : Result
Runs `$work` in a transaction: a failed `Result` or an exception means rollback, success means commit.
participantDisplayName()  : string
Returns the display name: `guestName` for a guest, first and last name for a registered user, otherwise the username.
readReceiptsEnabled()  : bool
Returns whether the operator enabled "Message read receipts".
readUploadedItem()  : string|null
Reads the content of an uploaded item (`{name, data}` base64 or `{name, path}` from the dropzone) through the single reader {@see UploadedItem}.
recordRejectedUpload()  : void
Records a new uploaded item that the save skipped ({@see UploadRejection} `REASON_*`).
visibleThreadIds()  : array<int, int>
Returns which of the given threads the user may see in the mailbox: the thread is not deleted and has at least one undeleted message.
addMessageSteps()  : Result
Performs the writes of {@see addMessage()} in a transaction: message, thread counter, participants' unread counts.
addParticipantSteps()  : Result
Performs the steps of {@see addParticipant()} in a transaction: the participant row (new or reactivated) and a system message.
bumpDeleteCacheTags()  : void
Post-commit bump after a delete. With entities it goes through {@see \App\Core\Base\BaseManager::bumpCacheTags()}, which also collects `additionalCacheTags()` (Comment → CommentThread); without them it falls back to bumping the manager class itself, the original behavior.
bumpParticipantsAfterMessage()  : Result
After a new message: adds one unread to the other participants and resets "archived" and "deleted for me" for everyone, because a new message returns the conversation to the mailbox.
captureDeletedIds()  : WeakMap<object, int>
Keys the ids by the loaded entity objects, not by the input ids: an input id that loaded nothing must not shift the pairing. A weak map, so the snapshot never keeps an entity alive.
columnsInvalidateCache()  : bool
deleteBatch()  : Result
Bulk delete of a list of ids as one transaction. `Service::deleteAll()` flushes per entity; in autocommit the rows deleted before a database failure would stay deleted while the caller got either an escaped `EntityManagerClosed` or an "OK" with an error text. Here the service stops at the technical failure, this wrapper rolls the whole batch back and returns the failure Result with `deletedCount = 0` and `rolledBack = true`: nothing of the batch is durable and nothing is attempted over the closed EntityManager. A domain partial success (missing ids) keeps the existing contract and is committed.
finishTransaction()  : Result
Closes the transaction according to the result of the steps.
isAssetPathAllowed()  : bool
Checks only entities composing {@see AssetPathTrait}; the trait is the allowlist of "relative file path" columns, other `assetPath`-like fields are untouched.
loadEntitiesForCacheTags()  : array<int, object>
Loads the entities being deleted only to find out their `additionalCacheTags()`. Fail-soft: what cannot be loaded is simply missing and the bump falls back to the class; it must never stop the delete.
notifyParticipants()  : void
Sends a "you have a new message" e-mail to the other participants of the thread.
openThreadSteps()  : Result
Performs the steps of {@see openThread()}; split out only for the transaction wrapper.
participantEmail()  : string|null
Returns the participant address: the account e-mail for a logged-in user, `guestEmail` for a guest; null when there is nowhere to write.
participantName()  : string
Returns the name for the e-mail header: `guestName` for a guest, otherwise the account; empty string when unknown.
removeParticipantSteps()  : Result
Performs the steps of {@see removeParticipant()} in a transaction. The removed participant is marked before the message so no unread count is added to them.
resolveReplyChannel()  : MessageReplyChannel
rollbackBatch()  : void
rollbackQuietly()  : void
saveWithHooks()  : Result
Runs the save itself: transaction, `beforeSave` / `afterSave` / `completedSave`, tree, cache.
threadLink()  : string
Returns the absolute link into the mailbox. Without a presenter (CLI, cron) it returns an empty string; the template then renders the e-mail without a link, which is still better than an unsent e-mail.
usesAssetPathTrait()  : bool

Constants

SETTING_MODULE

Operator switch "Message read receipts" (Site settings, Private messages): module 2 = Base, value `'1'`/`'0'`, default `0`. When on, the thread detail may show that the other side has read.

public int SETTING_MODULE = 2

DELETED_IDS_META

Reserved `$meta` key with the ids of the entities being deleted, captured before the first hook.

protected string DELETED_IDS_META = '__deletedIds'

Read it only through getDeletedId().

EMAIL_KEY

Template key in `cms_system_emails`.

private string EMAIL_KEY = 'BS_PRIVATE_MESSAGE'

PREVIEW_LENGTH

Length of the text preview in the e-mail; the full text is available only after login.

private int PREVIEW_LENGTH = 200

Properties

$service

Manager service; subclasses narrow the type (`@property` in the Api/Admin/Front manager).

public Service<string|int, TEntity> $service

$assetFormatRegistry

Format processor registry for file uploads, used by {@see \App\Core\Traits\Api\Models\Managers\AssetProcessingTrait}.

protected AssetFormatRegistry|null $assetFormatRegistry = null

Set by a decorator (decorators.neon) like setParamsConfig(): more than ten managers process assets and each has its own constructor, so constructor injection would touch all of them.

$cacheTagInvalidator

Post-commit invalidator of the wire-payload query cache (tag bump per entity class).

protected CacheTagInvalidator|null $cacheTagInvalidator = null

Set by the decorator (decorators.neon) like setParamsConfig(). Nullable and fail-open: without wiring (or without Redis) a bump is a no-op.

$internalCallContext

Tells whether the call is an internal bridge call; the single source for reading uploaded items with `path` ({@see readUploadedItem()}).

protected InternalCallContext|null $internalCallContext = null

Only ApiBridge::internalCall() sets it, never the payload and not the RBAC guard, so the emergency apiRbac.internalMode: off cannot disable it. Set by a decorator like setAssetFormatRegistry(). Without it (direct manager call from cron or a test) the call counts as external.

$nonInvalidatingColumns

Columns whose update does not invalidate the cache: view counters (`registerEvent()` bumps the counter on every pageview). Without this every view would flush the query cache of the whole class; counters are therefore stale up to the TTL. Override per manager by redefining the property.

protected array<string|int, string> $nonInvalidatingColumns = ['views', 'viewsList', 'viewsDetail']

Covers all view counters across modules: views (Blog Article), viewsList (list impression of Advert/Message) and viewsDetail (detail view of Advert/Message, registerEvent() in the *DetailTraits). IMPORTANT: viewsDetail must stay in the list, otherwise every open of an advert/post detail would invalidate all lists of the class.

$paramsConfig

Neon parameters (`%params%`: server paths and directories), set by the decorator (`decorators.neon`) like `setAppContext()`.

protected Config|null $paramsConfig = null

Write logic (asset directories) reads paths from here, not from getSettings() or the setting context, which may be filled by the client without server paths. The property cannot be named $config: it collides with the promoted $config in some managers.

$useTransactionForDelete

protected bool $useTransactionForDelete = true

$useTransactionForSave

protected bool $useTransactionForSave = true

$rejectedUploads

New uploaded items that the Api did not save during the `save()` that is running right now (type, dimensions, unreadable, …). Filled by {@see recordRejectedUpload()} and returned in `saveInfo.rejectedUploads`.

private array<int, array{name: string, reason: string}> $rejectedUploads = []

Methods

__construct()

public __construct(MessageThreadService $service, MessageManager $messageManager, MessageParticipantManager $participantManager, EmailManager $emailManager, UserManager $userManager, EntityManagerInterface $em[, MessageThreadReadListenerRegistry|null $readListeners = null ]) : mixed
Parameters
$service : MessageThreadService
$messageManager : MessageManager
$participantManager : MessageParticipantManager
$emailManager : EmailManager
$userManager : UserManager
$em : EntityManagerInterface
$readListeners : MessageThreadReadListenerRegistry|null = null

addMessage()

Adds a message to an existing thread and advances the derived state: the counter and time of the last message on the thread, and unread counts of the other participants.

public addMessage(int $threadId, int|null $senderParticipantId, string $text[, string|null $ip = null ]) : Result

$senderParticipantId === null is a system message (no sender is credited, everyone gets it unread).

Parameters
$threadId : int
$senderParticipantId : int|null
$text : string
$ip : string|null = null
Return values
Result

addParticipant()

An admin invites another participant (arbiter, other user) into the thread. Admin only: the Api key `:Api:Base:MessageThread:addParticipant` is granted to the `admin` role only and the action has no `action*()` (404 externally).

public addParticipant(int $threadId, int $userId) : Result

Rejects, each with a different code so the admin can write a clear message:

  • a missing or deleted thread NOT_AVAILABLE, a closed one OPERATION_CANCELLED,
  • an email_only thread INVALID_STATE (the counterparty is a guest and is talked to by e-mail),
  • a missing user USER_NOT_FOUND, an inactive one USER_INACTIVE, a banned one USER_BLOCKED,
  • a user who is already an active participant USER_ALREADY_EXISTS,
  • exceeding MessageParticipant::MAX_PER_THREAD INVALID_RANGE.

The new participant is a recipient (the thread shows in "Received") and sees the whole history (all existing messages are unread for them). A system message MessageSystemEvent::Joined is written to the thread and adds unread counts to all active participants, including the new one. A previously removed user is re-added by reactivating the same row (messages are not deleted because of participant_id, see removeParticipant()).

Everything runs in one transaction: a failed system message also rolls back adding the participant.

Parameters
$threadId : int
$userId : int
Return values
Result

countUnreadThreadsForUser()

Returns the number of threads with an unread message, for the header badge.

public countUnreadThreadsForUser(int $userId) : int

Threads are counted, not messages: the badge does not jump by five when one person writes five messages in a row, and it matches the list where one row is one thread.

IMPORTANT: the same visibility rules as findThreadsForUser() (the "unread only" filter in the mailbox) must apply: not "deleted for me", not archived, not a deleted thread, and the thread has an undeleted message (visibleThreadIds()). Otherwise the badge would show a thread that is not in the mailbox. Unread is participant state (unreadCount), so it does not depend on who started the thread.

Parameters
$userId : int
Return values
int

delete()

Deletes the entity or entities by id. Optional hooks (opt-in via `method_exists`, like {@see SaveTrait}) allow cleanup of derived or denormalized state during the delete:

public delete(int|array<string|int, mixed> $id[, array<string, mixed> $meta = [] ]) : Result
  • beforeDelete(array $entities, array $meta): ?Result runs before the delete (validation or capturing context while the rows exist). A failed Result means rollback and return. Its data flows into afterDelete under $meta['__beforeDelete'].
  • afterDelete(array $entities, Result $result, array $meta): ?Result runs after the delete, still in the transaction. It is only for recomputing derived state from the remaining rows (toped_until, member_until, county), idempotent and reversible. A failure rolls back the delete itself (atomicity). It is also the place for the mandatory delete of dependents through a foreign manager (comment, rating and message threads): an owner still managed in the unit of work references them, so they must go after its row, never before it in beforeDelete (collect their ids there). Additive or irreversible effects (credits, e-mails, invoices) do not belong here, only into completedDelete.
  • completedDelete(array $entities, Result $result, array $meta): void runs best effort after the commit, outside the transaction; an exception is only logged (the delete stays).
  • completedAssetCleanup(array $entities, Result $result, array $meta): void runs best effort after the commit and releases the Asset rows and files the deleted entity owned. The body is in AssetProcessingTrait; opt-in is ownedAssetIds(object $entity): array on the manager, without it the hook does not run.

IMPORTANT: the cleanup has its own name instead of completedDelete() because many Api managers define completedDelete() themselves and a class method overrides a trait method without a warning, so the cleanup would silently be missing in them.

IMPORTANT: a soft delete (deleted = 1) goes through save(), not here; assets must not be released there because the record can be restored.

$entities are the entities loaded before the delete (after the DELETE the row is gone, but the PHP object keeps the data, such as FKs for the recomputation).

IMPORTANT: the id is the exception. Doctrine nulls the id of a removed instance on flush(), so a hook must not rely on getId() once the delete ran (today only the read UoW::clear() in the service find() hands the preloaded entities over detached and keeps their id). Every hook therefore gets the ids captured before beforeDelete under the reserved key DELETED_IDS_META, keyed by the loaded entity object (an input id that loaded nothing has no entry); read it through getDeletedId(). The key is always set here, so a caller-supplied value never survives.

Without hooks it behaves exactly as before (no preloading, no transaction). NOTE: deleteAll() (bulk) and the tree deleteNode take another path and do not trigger these hooks.

Parameters
$id : int|array<string|int, mixed>
$meta : array<string, mixed> = []
Return values
Result

deleteAll()

public deleteAll([array<string|int, mixed> $filters = [] ]) : Result
Parameters
$filters : array<string|int, mixed> = []
Return values
Result

find()

public find(int $id[, string|array<string|int, mixed> $includes = [] ]) : T|null
Parameters
$id : int
$includes : string|array<string|int, mixed> = []
Return values
T|null

findAll()

public findAll([array<string|int, mixed>|null $includes = [] ][, array<string|int, mixed>|null $filters = [] ][, array<string|int, mixed>|null $orderBys = null ][, int|null $offset = null ][, int|null $limit = null ][, array<string|int, mixed>|null $columns = null ][, bool $computeTotalCount = false ]) : T> : list>)
Parameters
$includes : array<string|int, mixed>|null = []
$filters : array<string|int, mixed>|null = []
$orderBys : array<string|int, mixed>|null = null
$offset : int|null = null
$limit : int|null = null
$columns : array<string|int, mixed>|null = null
$computeTotalCount : bool = false
Return values
T> : list>) —

entities, or raw rows when $columns is set

findBy()

public findBy([array<string|int, mixed>|null $filters = [] ][, string|array<string|int, mixed> $includes = [] ]) : T|null
Parameters
$filters : array<string|int, mixed>|null = []
$includes : string|array<string|int, mixed> = []
Return values
T|null

findMessages()

Returns the thread messages in chronological order. Does not verify membership: the caller must do that first ({@see findParticipant()}); this is a building block, not an entry point.

public findMessages(int $threadId) : array<string|int, Message>
Parameters
$threadId : int
Return values
array<string|int, Message>

findParticipant()

Returns the thread participant for the given user; also the "may see the thread" test.

public findParticipant(int $threadId, int $userId[, bool $includeRemoved = false ]) : MessageParticipant|null

A removed participant (removedDatetime) is not a member unless $includeRemoved is passed explicitly (adding means reactivating the row).

Parameters
$threadId : int
$userId : int
$includeRemoved : bool = false
Return values
MessageParticipant|null

findParticipants()

Returns the thread participants; by default only active ones (no unread counts are added to others, no e-mail).

public findParticipants(int $threadId[, bool $includeRemoved = false ]) : array<string|int, MessageParticipant>
Parameters
$threadId : int
$includeRemoved : bool = false
Return values
array<string|int, MessageParticipant>

findParticipantViews()

Returns the thread participants in a shape sufficient to render the detail; members of the thread only.

public findParticipantViews(int $threadId, int $userId) : array<int, array{id: int, isMe: bool, isGuest: bool, isFormer: bool, name: string, email: ?string, phone: ?string, lastReadDatetime?: ?string}>

IMPORTANT: no participant entity leaves this method. A registered other side gets only a display name (first and last name as on the seller card, otherwise the username); neither the e-mail, phone nor userId of a registered user. Contact (email, phone) is carried only by a guest without an account, who entered it into the form precisely so the other side could reply, and an email_only thread depends on it. The other side's state (unread, archive) is not exposed.

Exception: lastReadDatetime, only when the operator enabled "Message read receipts" (readReceiptsEnabled()), only for other registered participants (not for me, not for a guest, who does not read in the system), as an ISO 8601 string, or null for not read yet. With the switch off the key is omitted entirely, because null would mean "not read" and be false information. This is decided here in the Api, so the front end never receives the field and cannot show it by mistake.

A non-member (including a removed participant) gets an empty array (same shape as "thread does not exist").

Returns all rows of the thread including removed ones (isFormer = true): messages of a removed participant stay signed with their name, so the detail must be able to name them. The front end builds the active "Participants" list from isFormer = false.

Parameters
$threadId : int
$userId : int
Return values
array<int, array{id: int, isMe: bool, isGuest: bool, isFormer: bool, name: string, email: ?string, phone: ?string, lastReadDatetime?: ?string}>

findRelatedIds()

Returns the ids of target entities that already belong to the record `$parentId` through the relation collection `$property` (for example `('assetRels', 'asset')`, `('fileRels', 'file')`). Reads straight from the database, not through the Api read path (no `UoW::clear()` in the middle of save hooks).

public findRelatedIds(int $parentId, string $property, string $targetAssociation) : array<int, int>|null
Parameters
$parentId : int
$property : string
$targetAssociation : string
Return values
array<int, int>|null —

null when it cannot be determined (collection mapping missing / database error)

findThreadsForUser()

Returns the threads the user participates in, with the user's own participant rows.

public findThreadsForUser(int $userId[, MessageBox|null $box = null ][, bool $unreadOnly = false ][, bool $includeArchived = false ][, int|null $offset = null ][, int|null $limit = null ][, int|null $threadId = null ]) : MessageParticipant[], totalCount: int}

IMPORTANT: ownership is indirect: the thread has no userId, its participants hold it. That is why this method exists: findAll() over threads with a caller-supplied filter would let anyone request someone else's conversation. Here the filtering is server-side by $userId and threads are loaded only by the participant rows found.

Parameters
$userId : int
$box : MessageBox|null = null

Received = where the user is the recipient, Sent = where the user started the thread, null = both

$unreadOnly : bool = false
$includeArchived : bool = false
$offset : int|null = null
$limit : int|null = null
$threadId : int|null = null
Return values
MessageParticipant[], totalCount: int}

getDefaultSkipFields()

Returns keys that the generic `service->save()` must not copy onto the managed entity.

public getDefaultSkipFields() : array<string|int, string>

The manager's own afterSave()/beforeSave() sync trait handles them through $meta, not through the object graph. Backed by an optional protected per-manager property. The write Api endpoint (SaveTrait::internalActionSave()) also uses this accessor to strip the same keys from $data before hydration, because defaultSkipFields alone does not protect hydration.

Return values
array<string|int, string>

getEntityClass()

Returns the root Doctrine entity class managed by this manager.

public getEntityClass() : TEntity>

The write Api endpoint (SaveTrait) takes it from here instead of the request, so a client cannot choose the instantiated PHP class.

Return values
TEntity>

getExternalMetaAllowlist()

Returns the meta keys allowed through the external HTTP path ({@see \App\Core\Traits\Api\Presenters\SaveTrait::actionSave()}).

public getExternalMetaAllowlist() : array<string|int, string>

The internal path (ApiBridge::internalCall() calling internalActionSave() directly, admin and front) is not limited by this allowlist and reads $meta unchanged. The default is an empty array, so external meta is dropped entirely; actionSave() is reachable through HTTP routing regardless of ApiBridge, hence the strip. Override per manager with the protected property $externalMetaAllowlist, analogous to self::getDefaultSkipFields().

Return values
array<string|int, string>

getExternalOwnerData()

Returns `data` keys that fill the owner from the server when an external save creates a record.

public getExternalOwnerData(int $userId) : array<string, mixed>

The default [] means the domain has no owner or must not be created through external save (Order uses the dedicated checkout action, User registers another way). On INSERT there is nothing to own, so the owner cannot be verified, only determined: the owner field is #[ExternalReadonly] (the strip removes it from the payload) and SaveTrait::applyExternalOwner() then fills it from the JWT, so the client value never reaches hydration.

IMPORTANT: return the key that matches the column mapping, otherwise the owner is silently not written:

  • Watchdog/Comment/Rating/Message: scalar column, ['userId' => $userId];
  • Advert (Bazaar/Comcat): mapped column, ['insertUserId' => $userId];
  • Bazaar Response/AdvertFollower: the scalar userId has an #[ORM\AttributeOverride] with insertable: false, updatable: false in the Api leaf and the column belongs to the association $user, so ['user' => ['id' => $userId]]; Comcat Response has no $user association, so there it is ['userId' => $userId];
  • Complaint: the owner is the association $user, ['user' => ['id' => $userId]].

['id' => X] is a reference that hydrateForPersist() looks up in the DB (OrmEntityHydrator::isReference()). Called only on the external path and only on INSERT; on UPDATE it would make whoever saves a foreign record its owner.

Parameters
$userId : int

identity from the JWT (never from the request)

Return values
array<string, mixed>

getExternalRemoteIpData()

Returns `data` keys that fill the caller's IP from the server when an external save creates a record; sibling of {@see getExternalOwnerData()}.

public getExternalRemoteIpData(string $ip) : array<string, mixed>

The IP field is #[ExternalReadonly], so the external strip removes it and the client cannot forge it. Without filling, an external insert would have no IP and the "anonymous by IP" deduplication and per-IP manager limits would silently stop working. SaveTrait::applyExternalRemoteIp() fills it from IRequest::getRemoteAddress(), never from the request. The default [] means the entity carries no IP; overrides return the entity field name (['insertIp' => $ip]).

Parameters
$ip : string
Return values
array<string, mixed>

getExternalUpdateColumnsAllowlist()

Returns the columns the external HTTP path may change through {@see \App\Core\Traits\Api\Presenters\SaveTrait::actionUpdateColumns()}.

public getExternalUpdateColumnsAllowlist() : array<string|int, string>

updateColumn on a to-many relation is DELETE-all plus INSERT, so without this filter an external POST with values={userRoles: [...]} would bypass the strip/guard of the save path (privilege escalation). The internal path (ApiBridge::internalCall() calling internalActionUpdateColumns() directly) is not limited. The default is an empty array, so external updateColumns is rejected entirely. Override with the protected property $externalUpdateColumnsAllowlist.

Return values
array<string|int, string>

getGraphDataKeys()

Returns the keys an external client may send nested in `data`.

public getGraphDataKeys() : array<string|int, string>

Before hydration the write Api endpoint (SaveTrait::internalActionSave()) moves them from $data to $meta, where the manager's own afterSave()/beforeSave() sync trait reads them (the same mechanism as internal meta). When the client also sends the same key explicitly in meta, meta wins (compatibility with admin, e.g. the categoryRels collision on Advert). Backed by an optional protected per-manager property, like self::getDefaultSkipFields().

IMPORTANT: promoting a key here does not make its content safe to write as received. When the key carries a sensitive field that the server otherwise dictates (typically a payment or approval flag), the manager must overwrite or verify it server-side on the external path in beforeSave()/afterSave() before its sync trait reads it; otherwise the client sets it itself (privilege escalation). The external path is recognized by $meta['__external'] === true, which only SaveTrait::actionSave() sets (never internalActionSave() alone), so internal calls never get the marker. Example: Bazaar Advert.categoryRels is persisted through the object graph (owned collection outside $graphDataKeys); a client could mark paid itself and get a paid advert without payment, so AdvertManager::beforeSave() overwrites it through CategoryRelsPaidSanitizationTrait::sanitizeExternalCategoryRelsPaid() before flush.

Return values
array<string|int, string>

getServiceClass()

Returns the fully qualified service class name, the identity for the per-service rollout allowlist of the wire-payload query cache ({@see \App\Core\Cache\Config::isServiceEnabled()}).

public getServiceClass() : class-string

The cache is controlled at the Api presenter boundary through this identity.

Return values
class-string

getSettings()

public getSettings() : array<int, array<string, mixed>>
Return values
array<int, array<string, mixed>>

getTotalCount()

public getTotalCount() : int|null
Return values
int|null

hideForUser()

Hides the thread in one participant's mailbox ("delete for me"); it stays for the others.

public hideForUser(int $threadId, int $userId) : Result
Parameters
$threadId : int
$userId : int
Return values
Result

markRead()

Marks the thread as read for the given user. Returns a failure when the user is not a participant: reading someone else's conversation must not pass even by mistake.

public markRead(int $threadId, int $userId[, bool $notifyListeners = true ]) : Result

The read is announced to listeners (MessageThreadReadListener) so that the linked advert response is marked too. The opposite direction (response to thread) passes $notifyListeners = false, otherwise the two directions would loop.

Parameters
$threadId : int
$userId : int
$notifyListeners : bool = true
Return values
Result

markReadIfOnlyFirstUnread()

Response to thread: the recipient marked the response as read, so mark the thread too, but only when the only unread message in the thread is the root one from the response (`unreadCount <= 1`). A thread with newer messages from the other side stays unread because the recipient has not seen them yet.

public markReadIfOnlyFirstUnread(int $threadId, int $userId) : Result

A user who is not in the thread (guest, another user) is skipped without error. Listeners are not notified (self::markRead()) to avoid a cycle.

Parameters
$threadId : int
$userId : int
Return values
Result

openThread()

Opens a conversation: thread, participants and the root message.

public openThread(MessageThreadType $type, string|null $subject, MessageParticipant $sender, array<string|int, MessageParticipant> $recipients, string $text[, string|null $ip = null ]) : Result

Opens its own transaction; called from the consumer's afterSave(), it nests inside the consumer's transaction. On failure it returns a failed Result and the caller answers with a rollback (same calling contract as CommentThreadCreatorTrait::createCommentThread()).

$sender and $recipients are participant entities without threadId and role; this method sets both. A participant with userId === null is a guest (contact in guest*).

Parameters
$type : MessageThreadType
$subject : string|null
$sender : MessageParticipant
$recipients : array<string|int, MessageParticipant>
$text : string
$ip : string|null = null
Return values
Result

registerEvent()

public registerEvent(array<string|int, int> $ids, class-string $entityClass, string $property) : Result
Parameters
$ids : array<string|int, int>
$entityClass : class-string

entity used to translate the property into a column in the expression

$property : string
Return values
Result

removeParticipant()

An admin removes a participant from the thread (admin only, key `:Api:Base:MessageThread:removeParticipant`).

public removeParticipant(int $threadId, int $participantId) : Result

The participant row is not deleted, only marked with removedDatetime: messages.participant_id has ON DELETE SET NULL, so deleting would turn their messages into "system" ones. A removed user does not see the thread (not a member, findParticipant()), their messages stay signed with their name (findParticipantViews() returns them as isFormer) and MessageSystemEvent::Left is written to the thread.

Rejects a missing, deleted or closed thread, a participant who is not active in the thread (ITEM_NOT_FOUND), and a removal that would leave fewer than MessageParticipant::MIN_ACTIVE_PER_THREAD active participants (INVALID_OPERATION).

Parameters
$threadId : int
$participantId : int

id of the message_participants row (not of the user)

Return values
Result

replyAsUser()

Adds a user's reply to a thread; the entry point for the front end.

public replyAsUser(int $threadId, int $userId, string $text[, string|null $ip = null ]) : Result

IMPORTANT: verifies three things, each for a different reason:

  1. the user is a participant (otherwise they write into someone else's conversation),
  2. the thread can be replied to in the system (email_only means the other side has no account),
  3. addMessage() itself guards closed/deleted.
Parameters
$threadId : int
$userId : int
$text : string
$ip : string|null = null
Return values
Result

resolveCallerRoles()

Resolves the caller's roles over the thread for RBAC (`owner` and `receiver`, see {@see \App\Core\Traits\Api\Presenters\MessageThreadRolesTrait}).

public resolveCallerRoles(int $threadId, int $userId) : array{member: bool, owner: bool, receiver: bool}

The owner is the author of each individual message, not the thread founder: owner means the caller is a participant and wrote at least one undeleted message. receiver means the caller is a participant and an undeleted message exists that they did not write, system messages included (participant_id = NULL). A fresh thread is thus only owner for the founder and only receiver for the other side; after the other side replies both have both roles. A non-member (including a guest without an account) has nothing, and it cannot be told from the result whether the thread exists.

Decided against the database through participants and messages; $userId must come from the JWT, never from the request.

Parameters
$threadId : int
$userId : int
Return values
array{member: bool, owner: bool, receiver: bool}

save()

public save(TEntity $entity[, array<string, mixed> $meta = [] ][, array<int, string> $skipFields = [] ]) : Result
Parameters
$entity : TEntity
$meta : array<string, mixed> = []
$skipFields : array<int, string> = []
Return values
Result

setParamsConfig()

public setParamsConfig(Config $paramsConfig) : static
Parameters
$paramsConfig : Config
Return values
static

updateAllRelations()

public updateAllRelations(int|array<string|int, mixed> $entityId, string $property, array<string, mixed> $values) : Result
Parameters
$entityId : int|array<string|int, mixed>

id, or a list of ids / composite-id arrays

$property : string
$values : array<string, mixed>

relation property name => value set on every row

Return values
Result

updateColumn()

public updateColumn(int|array<string|int, mixed> $entityId, string $property, mixed $value) : Result
Parameters
$entityId : int|array<string|int, mixed>

id, or a list of ids / composite-id arrays

$property : string
$value : mixed
Return values
Result

updateColumns()

public updateColumns(int|array<string|int, mixed> $entityId, array<string, mixed> $values) : Result
Parameters
$entityId : int|array<string|int, mixed>

id, or a list of ids / composite-id arrays

$values : array<string, mixed>

property name => new value

Return values
Result

updateRelations()

public updateRelations(int|array<string|int, mixed> $entityId, string $property, array<string|int, object> $relationEntities) : Result
Parameters
$entityId : int|array<string|int, mixed>

id, or a list of ids / composite-id arrays

$property : string
$relationEntities : array<string|int, object>

relation entities with updated values

Return values
Result

beginRejectedUploads()

Starts a save ({@see save()}): the list of rejected items belongs to this save only; the outer one (a nested save of the same manager) is returned to the caller and restored after the save through {@see finishRejectedUploads()}.

protected beginRejectedUploads() : array<int, array{name: string, reason: string}>
Return values
array<int, array{name: string, reason: string}> —

the outer save's list

bumpCacheTags()

Bumps cache tags for the entity after commit: its class, the owned-cascade classes from metadata and the optional `additionalCacheTags()` of the manager.

protected bumpCacheTags(object $entity) : void

Fail-open (the invalidator swallows exceptions). Called from the manager Save/Delete/Tree traits after the commit.

Parameters
$entity : object

bumpCacheTagsExplicit()

Bumps the given classes explicitly (raw-DBAL paths with a cross-entity effect).

protected bumpCacheTagsExplicit(array<string|int, string> $classes) : void
Parameters
$classes : array<string|int, string>

bumpCacheTagsForClass()

Bumps cache tags by class name when there is no instance (bulk delete, `updateColumns()`, raw DBAL, tree operations).

protected bumpCacheTagsForClass(class-string $rootClass[, array<string|int, string> $extraTags = [] ]) : void

Bumps the root class and its owned-cascade classes.

Parameters
$rootClass : class-string
$extraTags : array<string|int, string> = []

classCacheTags()

Returns extra tags for a bump that has no entity, typically tree operations ({@see \App\Core\Traits\Api\Models\Managers\TreeTrait::treeTransaction()}) that rewrite `lft`/`rgt` on many rows.

protected classCacheTags() : array<string|int, string>

IMPORTANT: additionalCacheTags(object) receives an entity, so a class-level bump cannot use it; without this hook a delete would lose the cross-entity tag (Comment to CommentThread). A manager whose cross-entity tag does not depend on a particular entity returns it here.

NOTE: unlike additionalCacheTags(), this hook is declared (with an empty default) instead of being an opt-in through method_exists(); method_exists() is always true in a manager that has the method, which phpstan reports as alreadyNarrowedType.

Return values
array<string|int, string>

finishRejectedUploads()

Ends a save: whatever {@see recordRejectedUpload()} wrote during it is added to the data of a successful result as `saveInfo.rejectedUploads` (the Api presenter passes it on to the caller, the admin builds a warning from it). A rejected item does not fail the save; the accepted files stay.

protected finishRejectedUploads(Result|null $result, array<int, array{name: string, reason: string}> $outer) : void
Parameters
$result : Result|null
$outer : array<int, array{name: string, reason: string}>

list from beginRejectedUploads()

getDeletedId()

Returns the id `$entity` had before the delete, from the snapshot in `$meta` that {@see delete()} hands to every hook. Null for an entity the snapshot does not know (not loaded by this delete, or without a simple id).

protected getDeletedId(array<string, mixed> $meta, object $entity) : int|null
Parameters
$meta : array<string, mixed>
$entity : object
Return values
int|null

getService()

Returns the service backing the manager traits.

protected getService() : Service<string|int, TEntity>
Return values
Service<string|int, TEntity>

inAtomicTransaction()

Runs `$work` in a transaction: a failed `Result` or an exception means rollback, success means commit.

protected inAtomicTransaction(Connection $connection, callable(): Result $work[, string $logChannel = 'api.transaction' ]) : Result

An exception is always logged (otherwise a nested run would lose it without a trace) and converted to Result::failure(DB_ERROR) so the caller gets a single Result.

Parameters
$connection : Connection
$work : callable(): Result
$logChannel : string = 'api.transaction'
Return values
Result

participantDisplayName()

Returns the display name: `guestName` for a guest, first and last name for a registered user, otherwise the username.

protected participantDisplayName(MessageParticipant $participant) : string
Parameters
$participant : MessageParticipant
Return values
string

readReceiptsEnabled()

Returns whether the operator enabled "Message read receipts".

protected readReceiptsEnabled() : bool

Read from the server settings layer (cache tagged Setting, bumped by saving in the admin, so a change applies immediately). Without a context or a row it is off.

Return values
bool

readUploadedItem()

Reads the content of an uploaded item (`{name, data}` base64 or `{name, path}` from the dropzone) through the single reader {@see UploadedItem}.

protected readUploadedItem(array<string|int, mixed> $item[, bool $strictBase64 = true ][, int|null $maxBytes = null ]) : string|null

Call it right before processing and release the result before the next item, so that files never meet in memory. A path is accepted only during an internal bridge call ($internalCallContext) and only inside the dropzone root. A rejected path is logged to the auth channel and null is returned; callers treat it like unreadable data (skip the item or refuse the save).

Parameters
$item : array<string|int, mixed>
$strictBase64 : bool = true

false for callers that decode loosely (base64_decode($x))

$maxBytes : int|null = null

size limit; for path it is checked before reading

Return values
string|null

recordRejectedUpload()

Records a new uploaded item that the save skipped ({@see UploadRejection} `REASON_*`).

protected recordRejectedUpload(mixed $name, string $reason) : void

Called by the places that drop an item (processOrderedAssets(), advert attachments, product set gallery).

Parameters
$name : mixed
$reason : string

visibleThreadIds()

Returns which of the given threads the user may see in the mailbox: the thread is not deleted and has at least one undeleted message.

protected visibleThreadIds(array<int, int> $threadIds) : array<int, int>

IMPORTANT: the single place of the thread visibility rule. The list (findThreadsForUser(), including the detail by threadId) and the badge (countUnreadThreadsForUser()) both use it, so they cannot diverge. A thread whose messages an admin all deleted has no owner or recipient (roles derive from undeleted messages) and could be neither opened nor hidden, so it is not shown at all; its detail behaves like for a non-member (404).

Decided by actual messages (EXISTS), not by message_count: that only grows in addMessage() and does not learn of a message deleted or restored by another path than the Api. One query over the thread_id index returning ids only, cheaper than hydrating messages.

Parameters
$threadIds : array<int, int>
Return values
array<int, int>

addMessageSteps()

Performs the writes of {@see addMessage()} in a transaction: message, thread counter, participants' unread counts.

private addMessageSteps(int $threadId, int|null $senderParticipantId, Message $message) : Result

IMPORTANT: counters are incremented atomically in SQL (message_count + 1), not by read-modify-write with a full save. Otherwise two concurrent messages lose one increment and a full thread save overwrites fields someone else changed meanwhile (close, delete).

Parameters
$threadId : int
$senderParticipantId : int|null
$message : Message
Return values
Result

addParticipantSteps()

Performs the steps of {@see addParticipant()} in a transaction: the participant row (new or reactivated) and a system message.

private addParticipantSteps(int $threadId, int $userId, MessageParticipant|null $existing) : Result
Parameters
$threadId : int
$userId : int
$existing : MessageParticipant|null
Return values
Result

bumpDeleteCacheTags()

Post-commit bump after a delete. With entities it goes through {@see \App\Core\Base\BaseManager::bumpCacheTags()}, which also collects `additionalCacheTags()` (Comment → CommentThread); without them it falls back to bumping the manager class itself, the original behavior.

private bumpDeleteCacheTags(array<int, object> $entities) : void
Parameters
$entities : array<int, object>

bumpParticipantsAfterMessage()

After a new message: adds one unread to the other participants and resets "archived" and "deleted for me" for everyone, because a new message returns the conversation to the mailbox.

private bumpParticipantsAfterMessage(int $threadId, int|null $senderParticipantId) : Result

unread_count + 1 runs atomically in SQL and touches only the affected columns. A full save of each participant from a stale copy would lose an increment on a concurrent message. Two updates per thread instead of one per participant.

Parameters
$threadId : int
$senderParticipantId : int|null
Return values
Result

captureDeletedIds()

Keys the ids by the loaded entity objects, not by the input ids: an input id that loaded nothing must not shift the pairing. A weak map, so the snapshot never keeps an entity alive.

private captureDeletedIds(array<int, object> $entities) : WeakMap<object, int>
Parameters
$entities : array<int, object>
Return values
WeakMap<object, int>

columnsInvalidateCache()

private columnsInvalidateCache(array<int, string> $columns) : bool
Parameters
$columns : array<int, string>
Return values
bool

deleteBatch()

Bulk delete of a list of ids as one transaction. `Service::deleteAll()` flushes per entity; in autocommit the rows deleted before a database failure would stay deleted while the caller got either an escaped `EntityManagerClosed` or an "OK" with an error text. Here the service stops at the technical failure, this wrapper rolls the whole batch back and returns the failure Result with `deletedCount = 0` and `rolledBack = true`: nothing of the batch is durable and nothing is attempted over the closed EntityManager. A domain partial success (missing ids) keeps the existing contract and is committed.

private deleteBatch(array<int, int|string|array<string|int, mixed>> $ids) : Result

Transaction ownership: the wrapper only opens its own level; inside a caller's transaction a rollback here marks the caller's transaction rollback-only, so its final commit rolls the whole scope back (TransactionManager counter contract, the same as the hooked delete() path).

Parameters
$ids : array<int, int|string|array<string|int, mixed>>
Return values
Result

finishTransaction()

Closes the transaction according to the result of the steps.

private finishTransaction(Result $result) : Result

Both commit and rollback are inside try: a failed commit (database error, or a transaction canceled by a nested rollback, RollbackOnlyException) is closed with a rollback and returned as DB_ERROR; a failed rollback does not override the original Result.

Parameters
$result : Result
Return values
Result

isAssetPathAllowed()

Checks only entities composing {@see AssetPathTrait}; the trait is the allowlist of "relative file path" columns, other `assetPath`-like fields are untouched.

private isAssetPathAllowed(BaseEntity $entity) : bool
Parameters
$entity : BaseEntity
Return values
bool

loadEntitiesForCacheTags()

Loads the entities being deleted only to find out their `additionalCacheTags()`. Fail-soft: what cannot be loaded is simply missing and the bump falls back to the class; it must never stop the delete.

private loadEntitiesForCacheTags(int|array<string|int, mixed> $id) : array<int, object>
Parameters
$id : int|array<string|int, mixed>
Return values
array<int, object>

notifyParticipants()

Sends a "you have a new message" e-mail to the other participants of the thread.

private notifyParticipants(MessageThread $thread, int|null $senderParticipantId, string $text) : void

IMPORTANT: best effort. The message is already saved at this point, so an e-mail failure (missing template, broken SMTP, CLI without context) must not fail addMessage(); everything is inside try, the error is logged and the method continues.

Not sent to: the sender, a participant with notify = 0, and one whose address is unknown (guest without guestEmail, deleted account).

Only a preview of the text goes into the e-mail: a private message may carry personal data and e-mail is an unencrypted channel, so the full text is available only after login.

Parameters
$thread : MessageThread
$senderParticipantId : int|null
$text : string

openThreadSteps()

Performs the steps of {@see openThread()}; split out only for the transaction wrapper.

private openThreadSteps(MessageThreadType $type, string|null $subject, MessageParticipant $sender, array<int, MessageParticipant> $recipients, string $text[, string|null $ip = null ]) : Result
Parameters
$type : MessageThreadType
$subject : string|null
$sender : MessageParticipant
$recipients : array<int, MessageParticipant>
$text : string
$ip : string|null = null
Return values
Result

participantEmail()

Returns the participant address: the account e-mail for a logged-in user, `guestEmail` for a guest; null when there is nowhere to write.

private participantEmail(MessageParticipant $participant) : string|null
Parameters
$participant : MessageParticipant
Return values
string|null

participantName()

Returns the name for the e-mail header: `guestName` for a guest, otherwise the account; empty string when unknown.

private participantName(MessageParticipant|null $participant) : string
Parameters
$participant : MessageParticipant|null
Return values
string

removeParticipantSteps()

Performs the steps of {@see removeParticipant()} in a transaction. The removed participant is marked before the message so no unread count is added to them.

private removeParticipantSteps(int $threadId, MessageParticipant $target) : Result
Parameters
$threadId : int
$target : MessageParticipant
Return values
Result

saveWithHooks()

Runs the save itself: transaction, `beforeSave` / `afterSave` / `completedSave`, tree, cache.

private saveWithHooks(TEntity $entity, array<string, mixed> $meta, array<int, string> $skipFields) : Result
Parameters
$entity : TEntity
$meta : array<string, mixed>
$skipFields : array<int, string>
Return values
Result

Returns the absolute link into the mailbox. Without a presenter (CLI, cron) it returns an empty string; the template then renders the e-mail without a link, which is still better than an unsent e-mail.

private threadLink(int $threadId) : string
Parameters
$threadId : int
Return values
string

usesAssetPathTrait()

private static usesAssetPathTrait(string $class) : bool
Parameters
$class : string
Return values
bool
On this page

Search results