RatingManager
extends Manager
in package
uses
SaveTrait, DuplicateVoterGuardTrait
Base class of Api managers whose traits delegate to a typed `Service<TEntity>`.
Tags
Table of Contents
Constants
- DELETED_IDS_META : string = '__deletedIds'
- Reserved `$meta` key with the ids of the entities being deleted, captured before the first hook.
- AGGREGATE_COLUMNS : mixed = ['approved', 'deleted', 'value', 'threadId']
- Columns the thread aggregate depends on; an `updateColumns()` touching one recalculates the threads.
Properties
- $service : RatingService
- $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
- $ratingThreadManager : RatingThreadManager
- $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
- canRespondTo() : bool
- Returns whether the user may respond to the rating: an ownership check on the shared data path for a review reply. Called by the front end through {@see \App\UI\Api\Base\Presenters\RatingPresenter::internalActionCanRespondTo()} (internal `ApiBridge::internalCall`) and by future external gates.
- clearUserEmailOfUser() : int
- Account cancellation: clears the reviewer e-mail (`user_email`, not mapped by the entity) on the user's ratings. Raw update outside the save hooks, so the `Rating` class is bumped when something changed.
- 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
- Bulk delete bypasses the delete hooks, so the affected threads are captured before it and recalculated after it.
- find() : T|null
-
findAll()
: T> : list
>) - findBy() : T|null
- 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).
- findRespondableOwner() : User|null
- Returns the owner of the rated object, derived only from the database: `rating`, `thread`, rated object, its owner. Never from the payload, never from the component parameter `setOwnerId()`.
- 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>
- Sets the author (the scalar `userId`; the Api leaf `Rating` remaps nothing) from the server on an external insert.
- getExternalRemoteIpData() : array<string, mixed>
- Sets the rater IP from the server on an external insert. `ip` is `#[ExternalReadonly]`, and without it {@see self::hasRated()} could not deduplicate a client without a user (X-API-Key). A logged-in JWT user is deduplicated by `userId`.
- 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
- initializeAllContexts() : void
- isOwnedBy() : bool
- Returns whether the rating `$ratingId` belongs to the user `$userId`.
- payloadChangesReply() : bool
- Returns whether the payload of the generic `save` action changes the reply compared to the database state.
- recalcAllThreads() : array{updated: int}
- Cron self-heal: recalculates the aggregate of all threads from `active` rows in a single set-based update. A safeguard against drift; normally `afterSave()` keeps the aggregate.
- recalcThread() : void
- Recalculates the thread aggregate (count, avg) from `active` rows. Called by `afterSave()`; also an entry point for the cron self-heal and admin actions.
- registerEvent() : Result
- save() : Result
- saveReply() : Result
- Saves the reply to a rating. The only path through which `response` and `responseDatetime` may be created; called exclusively by {@see \App\UI\Api\Base\Presenters\RatingPresenter::internalActionSaveReply()}, i.e. behind the guard of the key `:Api:Base:Rating:saveReply` (owner is the rated object).
- setAppContext() : $this
- setAssetFormatRegistry() : static
- setCacheTagInvalidator() : static
- setInternalCallContext() : static
- setLanguageContext() : $this
- setParamsConfig() : static
- setPresenterContext() : $this
- setSettingContext() : $this
- updateAllRelations() : Result
- updateColumn() : Result
- updateColumns() : Result
- A set-based column update bypasses the save hooks; when it touches a column the aggregate depends on (`approved`, `deleted` drive the generated `active`; `value`; `threadId`), the threads of the rows before and after the update are recalculated. Other columns (for example `userName` on account anonymization) skip the extra reads.
- updateRelations() : Result
- additionalCacheTags() : array<string|int, string>
- Adds the `RatingThread` cache tag.
- afterDelete() : Result|null
- Recalculates the aggregate of every thread a deleted rating belonged to.
- afterSave() : Result|null
- Recalculates the thread aggregate after every successful save.
- beforeSave() : Result|null
- Sets times and the approval state from two global flags (Base = settings[2]): - `rating.useAutoApprovalStars`: star-only rating - `rating.useAutoApprovalReviews`: written review (text, pros, cons)
- 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.
- guardThreadAndSelfRating() : Result|null
- Gate of a rating insert: the thread must exist and a logged-in author must not own the rated object (derivable today only for a user profile).
- 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_*`).
- 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.
- 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.
- findThreadIdsOfRatings() : array<int, int>
- Returns the thread ids of the ratings `$ids` (current database state, before an update).
- findThreadOwner() : User|null
- Returns the owner of the rated object by thread (from the database). Today only {@see ThreadType::SystemUser} (user profile), see {@see self::findRespondableOwner()}; other types return `null`.
- hasRated() : bool
- Returns whether this visitor has already rated the thread `$threadId`. Called by {@see self::beforeSave()} on insert. A logged-in user is recognized by account, an anonymous visitor only by IP (shared core {@see DuplicateVoterGuardTrait::voterFilters()}, same policy as {@see RatingVoteManager::hasVoted()}).
- 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.
- normalizeReplyText() : string|null
- Normalizes the reply text: an empty or non-string value means no reply (`null`), otherwise the trimmed text.
- recalcThreads() : void
- Recalculates the given threads outside the save/delete hooks and bumps the `RatingThread` class (the raw aggregate update is invisible to the generic bump).
- rollbackBatch() : void
- saveWithHooks() : Result
- Runs the save itself: transaction, `beforeSave` / `afterSave` / `completedSave`, tree, cache.
- threadIdsOf() : array<int, int>
- usesAssetPathTrait() : bool
- voterFilters() : array<int, Filter>|null
- Builds the filters for "does a record of this voter already exist within `$scopeFilters`?".
Constants
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().
AGGREGATE_COLUMNS
Columns the thread aggregate depends on; an `updateColumns()` touching one recalculates the threads.
private
mixed
AGGREGATE_COLUMNS
= ['approved', 'deleted', 'value', 'threadId']
Properties
$service
public
RatingService
$service
$appContext
protected
AppContext
$appContext
$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.
$languageContext
protected
LanguageContext|null
$languageContext
= NULL
$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.
$presenterContext
protected
PresenterContext
$presenterContext
$settingContext
protected
SettingContext
$settingContext
$totalCount
protected
int|null
$totalCount
= null
$useTransactionForDelete
protected
bool
$useTransactionForDelete
= true
$useTransactionForSave
protected
bool
$useTransactionForSave
= true
$ratingThreadManager read-only
private
RatingThreadManager
$ratingThreadManager
$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
= []
$userManager read-only
private
UserManager
$userManager
Methods
__construct()
public
__construct(RatingService $service, RatingThreadManager $ratingThreadManager, UserManager $userManager) : mixed
Parameters
- $service : RatingService
- $ratingThreadManager : RatingThreadManager
- $userManager : UserManager
canRespondTo()
Returns whether the user may respond to the rating: an ownership check on the shared data path for a review reply. Called by the front end through {@see \App\UI\Api\Base\Presenters\RatingPresenter::internalActionCanRespondTo()} (internal `ApiBridge::internalCall`) and by future external gates.
public
canRespondTo(int $ratingId, int $userId) : bool
IMPORTANT: there are two different owners. Rating::getUserId() is the author of the
review and is not relevant here. The owner is derived from the rated object (the thread),
not from the component parameter setOwnerId(), which the manager cannot trust: the same
method is called by the front end regardless of what the component set.
Today the only thread type with a derivable owner is ThreadType::SystemUser
(User::$ratingThreadId points to the profile's own thread). Other types (blog article,
eshop product set, comcat advert) have no owner defined, so the answer is deny, the same
as canRespond() on the front end (see
UserPresenter::createComponentFrontSystemUserRating()).
Parameters
- $ratingId : int
- $userId : int
Return values
boolclearUserEmailOfUser()
Account cancellation: clears the reviewer e-mail (`user_email`, not mapped by the entity) on the user's ratings. Raw update outside the save hooks, so the `Rating` class is bumped when something changed.
public
clearUserEmailOfUser(int $userId) : int
Parameters
- $userId : int
Return values
int —number of ratings whose e-mail was cleared
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): ?Resultruns before the delete (validation or capturing context while the rows exist). A failed Result means rollback and return. Its data flows intoafterDeleteunder$meta['__beforeDelete'].afterDelete(array $entities, Result $result, array $meta): ?Resultruns 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 inbeforeDelete(collect their ids there). Additive or irreversible effects (credits, e-mails, invoices) do not belong here, only intocompletedDelete.completedDelete(array $entities, Result $result, array $meta): voidruns best effort after the commit, outside the transaction; an exception is only logged (the delete stays).completedAssetCleanup(array $entities, Result $result, array $meta): voidruns best effort after the commit and releases theAssetrows and files the deleted entity owned. The body is in AssetProcessingTrait; opt-in isownedAssetIds(object $entity): arrayon 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
ResultdeleteAll()
Bulk delete bypasses the delete hooks, so the affected threads are captured before it and recalculated after it.
public
deleteAll([array<string|int, mixed> $filters = [] ]) : Result
Parameters
- $filters : array<string|int, mixed> = []
Return values
Resultfind()
public
find(int $id[, string|array<string|int, mixed> $includes = [] ]) : T|null
Parameters
- $id : int
- $includes : string|array<string|int, mixed> = []
Return values
T|nullfindAll()
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> : listentities, 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|nullfindRelatedIds()
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)
findRespondableOwner()
Returns the owner of the rated object, derived only from the database: `rating`, `thread`, rated object, its owner. Never from the payload, never from the component parameter `setOwnerId()`.
public
findRespondableOwner(int $ratingId) : User|null
This is the single place that walks this path: self::canRespondTo() and the $owned
closure of the saveReply action guard
(RatingPresenter::internalActionSaveReply()) both ask here, so
two copies of the rule cannot diverge.
NOTE: no denormalized owner column is needed on cms_mod_base_rating_threads. The thread
does not know its source object (only type and aggregates), but the link is reversed:
the rated object holds ratingThreadId (cms_system_users, cms_mod_blog_articles,
cms_mod_eshop_product_sets, ...), so the owner can be looked up by type. A
denormalization would add a column to fill and keep consistent without enabling anything.
Today the only type with a derivable owner is ThreadType::SystemUser
(User::$ratingThreadId is the profile thread). Other types have no owner, so the result
is null (deny). Whoever wires another type (for example an eshop seller) extends this
method, and the saveReply guard follows automatically.
Parameters
- $ratingId : int
Return values
User|nullgetAppContext()
public
getAppContext() : AppContext
Return values
AppContextgetDefaultSkipFields()
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()
Sets the author (the scalar `userId`; the Api leaf `Rating` remaps nothing) from the server on an external insert.
public
getExternalOwnerData(int $userId) : array<string, mixed>
Parameters
- $userId : int
-
identity from the JWT (never from the request)
Return values
array<string, mixed>getExternalRemoteIpData()
Sets the rater IP from the server on an external insert. `ip` is `#[ExternalReadonly]`, and without it {@see self::hasRated()} could not deduplicate a client without a user (X-API-Key). A logged-in JWT user is deduplicated by `userId`.
public
getExternalRemoteIpData(string $ip) : array<string, mixed>
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>getLanguageContext()
public
getLanguageContext() : LanguageContext|null
Return values
LanguageContext|nullgetPresenterContext()
public
getPresenterContext() : PresenterContext
Return values
PresenterContextgetServiceClass()
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-stringgetSettingContext()
public
getSettingContext() : SettingContext
Return values
SettingContextgetSettings()
public
getSettings() : array<int, array<string, mixed>>
Return values
array<int, array<string, mixed>>getTotalCount()
public
getTotalCount() : int|null
Return values
int|nullinitializeAllContexts()
public
initializeAllContexts() : void
isOwnedBy()
Returns whether the rating `$ratingId` belongs to the user `$userId`.
public
isOwnedBy(int $ratingId, int $userId) : bool
IMPORTANT: do not confuse with self::canRespondTo(), the easiest pair to mix up:
isOwnedBy()is the author of the review (Rating::$userId): whoever wrote it may edit it (thesaveaction, update branch);canRespondTo()is the owner of the rated object (profile throughUser::$ratingThreadId): whoever is rated may reply. A naiverating.userId === jwt.userIdfor a reply would give exactly the opposite result.
Rating::$userId is nullable (anonymous rating); null belongs to nobody and returns false.
Parameters
- $ratingId : int
- $userId : int
Return values
boolpayloadChangesReply()
Returns whether the payload of the generic `save` action changes the reply compared to the database state.
public
payloadChangesReply(int|null $ratingId, array<string|int, mixed> $data) : bool
Without this check the key saveReply would be decoration: :Api:Base:Rating:save has an
owner grant whose owner is the author of the review, so the author could write a "seller
reply" to themselves through save.
RatingPresenter::internalActionSave() therefore additionally
requires the guard of the key saveReply when this returns true.
The comparison is against the database, not another payload value:
- the
responsekey is missing in the payload:false; - insert (
$ratingId === null):trueonly for a non-empty reply. The front end sendsresponse => nullfor a new rating (extract of the whole entity), and an anonymous rating insert must not pay even one extra select; - update:
truewhen the normalized value differs from the stored one (this covers deleting a reply,texttonull, which is also an exercise of the owner's right); - unknown
id:true(fail-closed, let the guard decide, not this test).
NOTE: responseDatetime is deliberately not tested. It is #[ExternalReadonly], set by the
server (self::saveReply()), and without a change of the reply text nobody is
impersonated by it. Testing a DateTime from a free payload would also risk a TypeError
in the guard path, i.e. a fail-closed deny where ordinary saving is decided.
Parameters
- $ratingId : int|null
- $data : array<string|int, mixed>
-
datapayload of the genericsaveaction
Return values
boolrecalcAllThreads()
Cron self-heal: recalculates the aggregate of all threads from `active` rows in a single set-based update. A safeguard against drift; normally `afterSave()` keeps the aggregate.
public
recalcAllThreads() : array{updated: int}
Return values
array{updated: int} —number of affected threads (for sendSuccess)
recalcThread()
Recalculates the thread aggregate (count, avg) from `active` rows. Called by `afterSave()`; also an entry point for the cron self-heal and admin actions.
public
recalcThread(int $threadId) : void
Parameters
- $threadId : int
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
Resultsave()
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
ResultsaveReply()
Saves the reply to a rating. The only path through which `response` and `responseDatetime` may be created; called exclusively by {@see \App\UI\Api\Base\Presenters\RatingPresenter::internalActionSaveReply()}, i.e. behind the guard of the key `:Api:Base:Rating:saveReply` (owner is the rated object).
public
saveReply(int $ratingId, string|null $response) : Result
IMPORTANT: ownership is not verified again here. The guard in the presenter ($owned
closure and isReceiver()) decides it, because only it knows the identity from the JWT; the
manager receives an already permitted write. Calling this method from anywhere else than
the guarded action bypasses authorization.
responseDatetime is set by the server (never from the payload) and cleared together with
the reply when it is deleted (null); the same semantics as admin moderation
(RatingThread::onRatingFormSuccess()).
Parameters
- $ratingId : int
- $response : string|null
Return values
ResultsetAppContext()
public
setAppContext(AppContext $appContext) : $this
Parameters
- $appContext : AppContext
Return values
$thissetAssetFormatRegistry()
public
setAssetFormatRegistry(AssetFormatRegistry $assetFormatRegistry) : static
Parameters
- $assetFormatRegistry : AssetFormatRegistry
Return values
staticsetCacheTagInvalidator()
public
setCacheTagInvalidator(CacheTagInvalidator $cacheTagInvalidator) : static
Parameters
- $cacheTagInvalidator : CacheTagInvalidator
Return values
staticsetInternalCallContext()
public
setInternalCallContext(InternalCallContext $internalCallContext) : static
Parameters
- $internalCallContext : InternalCallContext
Return values
staticsetLanguageContext()
public
setLanguageContext(LanguageContext|null $languageContext) : $this
Parameters
- $languageContext : LanguageContext|null
Return values
$thissetParamsConfig()
public
setParamsConfig(Config $paramsConfig) : static
Parameters
- $paramsConfig : Config
Return values
staticsetPresenterContext()
public
setPresenterContext(PresenterContext $presenterContext) : $this
Parameters
- $presenterContext : PresenterContext
Return values
$thissetSettingContext()
public
setSettingContext(SettingContext $settingContext) : $this
Parameters
- $settingContext : SettingContext
Return values
$thisupdateAllRelations()
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
ResultupdateColumn()
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
ResultupdateColumns()
A set-based column update bypasses the save hooks; when it touches a column the aggregate depends on (`approved`, `deleted` drive the generated `active`; `value`; `threadId`), the threads of the rows before and after the update are recalculated. Other columns (for example `userName` on account anonymization) skip the extra reads.
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
- $values : array<string, mixed>
-
property name => new value
Return values
ResultupdateRelations()
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
ResultadditionalCacheTags()
Adds the `RatingThread` cache tag.
protected
additionalCacheTags(object $entity) : array<string|int, string>
Every save or delete of a Rating recalculates the RatingThread aggregate (afterSave()
and recalcThread(), raw SQL), but the generic post-commit bump knows only Rating.
The count and avg aggregate is a denormalization read through the ratingThread include
(Article/Advert). Same as Comment to CommentThread.
Parameters
- $entity : object
Return values
array<string|int, string>afterDelete()
Recalculates the aggregate of every thread a deleted rating belonged to.
protected
afterDelete(array<int, object> $entities, Result $result[, array<string, mixed> $meta = [] ]) : Result|null
Runs inside the delete transaction, so the summary is atomic with the delete. Without it a
hard delete (admin, Api delete) left the stale count and avg until the cron self-heal.
Parameters
- $entities : array<int, object>
-
ratings loaded before the delete
- $result : Result
- $meta : array<string, mixed> = []
Return values
Result|nullafterSave()
Recalculates the thread aggregate after every successful save.
protected
afterSave(bool $isInsert, Rating &$entity[, Result|null $result = null ][, array<string, mixed> $meta = [] ]) : Result|null
Runs inside the save transaction (the generated active is materialized by then), so it
is atomic with the insert. Also covers admin approve and soft delete (they save the row too); a hard delete goes
through afterDelete(), a column update through updateColumns().
Parameters
Return values
Result|nullbeforeSave()
Sets times and the approval state from two global flags (Base = settings[2]): - `rating.useAutoApprovalStars`: star-only rating - `rating.useAutoApprovalReviews`: written review (text, pros, cons)
protected
beforeSave(bool $isInsert, Rating &$entity[, array<string, mixed> $meta = [] ]) : Result|null
NOTE: the review key is ...Reviews (plural, like ...Stars); reading the singular would
never match the settings row and always fall back to 0.
A component may override both flags per instance through $meta:
['rating.useAutoApprovalStars' => true/false] or ['rating.useAutoApprovalReviews' => true/false].
A meta override takes precedence over the database setting.
IMPORTANT: duplicate gate. The database UNIQUE uq_user_thread (rating_thread_id, user_id)
protects only logged-in users: two NULL values are not equal in a unique index, so an
anonymous visitor could rate the same thread repeatedly. self::hasRated() (shared
core DuplicateVoterGuardTrait, same approach as RatingVoteManager::hasVoted())
adds an IP check for anonymous visitors. It runs here and not in the form or component
because beforeSave() is the only place every insert passes through (front end and future
external Api calls).
Parameters
- $isInsert : bool
- $entity : Rating
- $meta : array<string, mixed> = []
Return values
Result|nullbeginRejectedUploads()
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|nullgetService()
Returns the service backing the manager traits.
protected
getService() : Service<string|int, TEntity>
Return values
Service<string|int, TEntity>guardThreadAndSelfRating()
Gate of a rating insert: the thread must exist and a logged-in author must not own the rated object (derivable today only for a user profile).
protected
guardThreadAndSelfRating(Rating $entity) : Result|null
protected only for tests of the "once per thread" gate, which build the manager without DI.
Parameters
- $entity : Rating
Return values
Result|nullreadUploadedItem()
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
pathit is checked before reading
Return values
string|nullrecordRejectedUpload()
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
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>
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
booldeleteBatch()
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
ResultfindThreadIdsOfRatings()
Returns the thread ids of the ratings `$ids` (current database state, before an update).
private
findThreadIdsOfRatings(array<string|int, mixed> $ids) : array<int, int>
Parameters
- $ids : array<string|int, mixed>
Return values
array<int, int>findThreadOwner()
Returns the owner of the rated object by thread (from the database). Today only {@see ThreadType::SystemUser} (user profile), see {@see self::findRespondableOwner()}; other types return `null`.
private
findThreadOwner(RatingThread $thread) : User|null
Parameters
- $thread : RatingThread
Return values
User|nullhasRated()
Returns whether this visitor has already rated the thread `$threadId`. Called by {@see self::beforeSave()} on insert. A logged-in user is recognized by account, an anonymous visitor only by IP (shared core {@see DuplicateVoterGuardTrait::voterFilters()}, same policy as {@see RatingVoteManager::hasVoted()}).
private
hasRated(int $threadId, int|null $userId, string $ip) : bool
Parameters
- $threadId : int
- $userId : int|null
- $ip : string
Return values
boolisAssetPathAllowed()
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
boolloadEntitiesForCacheTags()
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>normalizeReplyText()
Normalizes the reply text: an empty or non-string value means no reply (`null`), otherwise the trimmed text.
private
static normalizeReplyText(mixed $value) : string|null
Parameters
- $value : mixed
Return values
string|nullrecalcThreads()
Recalculates the given threads outside the save/delete hooks and bumps the `RatingThread` class (the raw aggregate update is invisible to the generic bump).
private
recalcThreads(array<int, int> $threadIds) : void
Parameters
- $threadIds : array<int, int>
rollbackBatch()
private
rollbackBatch() : void
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
ResultthreadIdsOf()
private
static threadIdsOf(array<string|int, mixed> $entities) : array<int, int>
Parameters
- $entities : array<string|int, mixed>
Return values
array<int, int> —distinct positive thread ids
usesAssetPathTrait()
private
static usesAssetPathTrait(string $class) : bool
Parameters
- $class : string
Return values
boolvoterFilters()
Builds the filters for "does a record of this voter already exist within `$scopeFilters`?".
private
voterFilters(int|null $userId, string $ip, array<int, Filter> $scopeFilters) : array<int, Filter>|null
$scopeFilters identify the item voted on (threadId, ratingId + kind, …). A logged-in
voter ($userId > 0) matches by userId only; the IP is ignored because one account is
one vote from any device. An anonymous voter ($userId null or <= 0) matches by
userId IS NULL AND ip = X. An empty IP cannot identify a voter and returns null;
the caller must read that as "cannot verify", not as "has not voted".
Parameters
- $userId : int|null
- $ip : string
- $scopeFilters : array<int, Filter>