| name | ddd-service-specialist |
| description | Create and design DDD services, implement business logic, QueryBuilder patterns, rights protection, and entity access control in the mgamadeus/ddd framework. Use when creating services, writing custom queries, or implementing rights restrictions. |
| metadata | {"author":"mgamadeus","version":"1.0.0","framework":"mgamadeus/ddd"} |
DDD Service Specialist
Services, business logic, QueryBuilder patterns, and rights protection within the DDD Core framework (mgamadeus/ddd).
When to Use
- Creating entity-bound services (
EntitiesService) or custom services (Service)
- Writing custom QueryBuilder queries in services
- Implementing rights protection (
applyReadRightsQuery, etc.) in DB repositories
- Understanding service instantiation and DDD architecture conventions
Namespace
All code uses the DDD\ root namespace. Services live under DDD\Domain\{Domain}\Services\ or DDD\Infrastructure\Services\.
Service Template
Path: src/Domain/{DomainName}/Services/{EntityName}sService.php
<?php
declare(strict_types=1);
namespace DDD\Domain\{DomainName}\Services;
use DDD\Domain\{DomainName}\Entities\{Group}\{EntityName};
use DDD\Domain\{DomainName}\Entities\{Group}\{EntityName}s;
use DDD\Domain\{DomainName}\Repo\DB\{Group}\DB{EntityName};
use DDD\Domain\{DomainName}\Repo\DB\{Group}\DB{EntityName}s;
use DDD\Domain\Base\Services\EntitiesService;
class {EntityName}sService extends EntitiesService
{
public const DEFAULT_ENTITY_CLASS = {EntityName}::class;
}
Service Base Classes
| Base Class | Use For |
|---|
DDD\Domain\Base\Services\EntitiesService | Entity management services (find(), findAll(), repo access) |
DDD\Infrastructure\Services\Service | Custom services without entity management (orchestrators, API wrappers) |
EntitiesBaseService does NOT exist -- always use EntitiesService.
Rule: If a service manages a specific entity type with DB repo, use EntitiesService. For orchestration, API clients, or cross-cutting concerns, use Service.
Critical Rules
- NEVER use
private -- always protected. The private keyword destroys extensibility. This is a DDD framework -- every class may be extended. No exceptions.
- NEVER use
new ServiceClass() -- always resolve from the container
- NEVER cache services in class properties -- always resolve inline
EntitiesBaseService does NOT exist -- always use EntitiesService
Service Instantiation
ALWAYS use container-based resolution. NEVER use new ServiceClass(). NEVER cache services in class properties.
$service = {EntityName}s::getService();
${entityName}sService = AppService::instance()->getService({EntityName}sService::class);
Service variables must be named after their class: EntityModelGeneratorService -> $entityModelGeneratorService.
Calling find() -- caller-side vs in-service
Distinct paths depending on where the lookup happens:
| Where | What to call | Returns |
|---|
| Caller (controller, handler, other service, CLI) loading a known id | {EntityName}::getService()->find($id) | single entity, with rights restrictions, registry cache |
| Caller loading a known id via the entity-set | {EntityName}s::getService()->find($id) | single entity (set service forwards) |
| Inside this service, custom single-entity query | {EntityName}::getRepoClassInstance()->find($queryBuilder) | single entity, raw |
| Inside this service, custom set/scalar query | {EntityName}s::getRepoClassInstance()->find($queryBuilder) | entity set or scalar |
getRepoClassInstance() is for service-internal custom QueryBuilder queries only, never as a substitute for getService()->find($id) from outside. Going through the service applies applyReadRightsQuery, entity registry caching, lazy-loading defaults and other invariants -- skipping the service silently bypasses them.
getService() returning null — cross-namespace subclasses
If {Entity}::getService() returns null for an entity that clearly has a Service in the framework, the cause is almost always a missing App-side EntitySet: an App subclass extends a Framework entity from a different root namespace, and EntityTrait::getEntitySetClass() cannot resolve a same-namespace plural. The diagnosis is two checks:
- The entity class lives in
App\… and its parent lives in DDD\… (or another root namespace).
- There is no
App\…\{Entity}s class declared alongside it.
If the subclass adds only constants/type-aliases (no new persistent properties, no App service methods), tag it with #[ReuseParentEntitySet] from DDD\Domain\Base\Entities\Attributes — getService() will then resolve to the parent class's Service across the namespace boundary. See the "Constants-Only Subclasses Across Root Namespaces" section in ddd-entity-specialist.
If the subclass actually adds behavior, declare the parallel App\…\{Entity}s (EntitySet) and App\…\{Entity}sService explicitly — the attribute is not the right tool there.
DDD Architecture Conventions
Entity Logic Ownership
Entities handle ALL logic that is NOT repo-dependent. Domain logic, calculations, validations, parsing, formatting -- all live on the entity.
Services handle repo-dependent logic for their OWN entity type. A service must NOT implement query/finder methods for entities it doesn't own.
class SupportMessagesService extends EntitiesService {
public function findByGmailMessageId(string $gmailMessageId): ?SupportMessage { }
}
class SupportEmailsService extends Service {
public function findTicketByGmailThreadId(string $threadId): ?SupportTicket { }
}
Pass Objects, Not IDs
Always pass entity objects to service methods instead of raw integer IDs. The caller loads the entity first.
public function sendReply(SupportTicket $ticket, string $replyBody): SupportMessage
// WRONG
public function sendReply(int $ticketId, string $replyBody): SupportMessage
When to Write Service Methods
ONLY write service methods for scenarios lazy loading can't handle.
AVOID finder methods -- use lazy loading or QueryOptions instead:
findByParent() -> $parent->children (lazy load)
findByStatus() -> QueryOptions filtering in API layer
QueryBuilder Pattern -- ALWAYS Use Base Model Alias
Before writing a complex query, inspect the real table. A property name is NOT always the column you
query: #[Translatable] fields become JSON columns (searched via the generated virtual{Name}Search
column), #[DatabaseVirtualColumn] properties become virtualFoo / JSON-extraction columns, FK relations
become {name}Id columns, and only some columns are indexed. Run php bin/console app:entity:show-sql <Entity>
(read-only) to see the exact columns — including generated/virtual columns — the indexes, and the foreign
keys before hand-writing DQL/SQL, so you filter/order on real, indexed columns instead of guessing. See
ddd-cli-command-specialist; how those columns/indexes are generated is in ddd-entity-specialist.
All custom queries MUST use the base model alias as a column prefix. Queries without the alias fail at runtime.
Pattern: Single Entity
$repoClass = $this->getEntityRepoClassInstance();
$queryBuilder = $repoClass::createQueryBuilder();
$baseModelAlias = $repoClass::getBaseModelAlias();
$queryBuilder->andWhere("{$baseModelAlias}.columnName = :param");
$queryBuilder->setParameter('param', $value);
return $repoClass->find($queryBuilder);
Pattern: Collection or Scalar
$repoClass = $this->getEntitySetRepoClassInstance();
$queryBuilder = $repoClass::createQueryBuilder(true);
$baseModelAlias = $repoClass::getBaseModelAlias();
$queryBuilder->select("COUNT({$baseModelAlias}.id)")
->where("{$baseModelAlias}.name = :name")
->setParameter('name', $name);
CRITICAL: createQueryBuilder(true) -- when to pass true
createQueryBuilder(bool $includeModelSelectFromClause = false) -- the true parameter adds the SELECT ... FROM ... clause, which establishes the root alias. You MUST pass true when:
- Using JOINs (
leftJoin, innerJoin, join) -- joins require a root alias. Without true: RuntimeException: No alias was set before invoking getRootAlias()
- Using
->select() with scalar aggregates (COUNT, MAX, SUM) followed by ->getQuery()->getSingleScalarResult()
- Using
->groupBy()
When in doubt, always pass true -- it never hurts. The only case where omitting it is fine is simple filter queries where the framework's find() method adds its own SELECT/FROM clause internally.
$qb = $repoClass::createQueryBuilder();
$qb->andWhere("{$a}.status = :s")->setParameter('s', 'ACTIVE');
return $repoClass->find($qb);
$qb = $repoClass::createQueryBuilder(true);
$qb->leftJoin("{$a}.children", $a . '_children')
->andWhere("{$a}.worldId = :wid")->setParameter('wid', $worldId);
return $repoClass->find($qb);
$qb = $repoClass::createQueryBuilder(true);
$qb->select("COUNT({$a}.id)")->where("{$a}.parentId = :pid")->setParameter('pid', $id);
return (int) $qb->getQuery()->getSingleScalarResult();
Key Rules
createQueryBuilder() and getBaseModelAlias() are called statically on the repo class ($repoClass::)
- Column references MUST be
{$baseModelAlias}.columnName -- never bare columnName
- Use
getEntityRepoClassInstance() (single entity repo) for ->find() returning one entity
- Use
getEntitySetRepoClassInstance() (entity set repo) for ->find() returning a collection, or for scalar queries
Vector / semantic search — embed in the service, search in the repo
Semantic search is NOT a QueryOptions feature; it splits across two layers:
- Service owns embedding generation: turn the query text into an embedding via an embedding service, then
run the distance-ordered query. Never put embedding generation in the entity/repo.
- QueryBuilder owns the query:
ORDER BY a vector-distance DQL function over the VECTOR(n) column, the
query embedding bound as a parameter, setMaxResults($k) for top-k.
public function findSimilarByText(string $searchText, int $limit = 10): SupportTickets
{
$embedding = AppService::instance()->getService(AITextEmbeddingsService::class)
->generateEmbeddingForText($searchText, Vector::DIMENSION_OPENAI_EMBEDDING_LARGE);
if ($embedding->isEmpty()) {
return new SupportTickets();
}
$vectorString = '[' . implode(',', $embedding->vectorValues) . ']';
$zeroVector = '[' . implode(',', array_fill(0, Vector::DIMENSION_OPENAI_EMBEDDING_LARGE, '0')) . ']';
$repo = $this->getEntitySetRepoClassInstance();
$alias = $repo::getBaseModelAlias();
$qb = $repo::createQueryBuilder()
->andWhere("{$alias}.resolutionSummaryEmbedding != VEC_FROM_TEXT(:zeroVector)")
->addOrderBy("COSINE_DISTANCE({$alias}.resolutionSummaryEmbedding, VEC_FROM_TEXT(:searchVector))", 'ASC')
->setParameter('zeroVector', $zeroVector)
->setParameter('searchVector', $vectorString)
->setMaxResults($limit);
return $repo->find($qb);
}
- Zero-vector guard matters: an un-embedded row stores the default zero vector, which would otherwise sort
as a (meaningless) near match — exclude it with
!= VEC_FROM_TEXT(:zeroVector). Pair this with a backfill
method that finds rows still = VEC_FROM_TEXT(:zeroVector) and generates their embeddings.
- Optional: widen the pool (e.g. 3× the limit) and LLM-rerank the candidates when precision matters.
Full DQL function catalog (COSINE_DISTANCE / COSINE_SIMILARITY / EUCLIDEAN_DISTANCE / VEC_DISTANCE /
VEC_FROM_TEXT) + ANN/spatial worked examples → ddd-geometry-and-vector-specialist; the VECTOR column +
index and the TextEmbedding extends Vector shape → ddd-entity-specialist → "Spatial & vector columns".
Fulltext search over Translatable properties
A #[Translatable(fullTextIndex: true)] property (e.g. name) is backed by a generated, FULLTEXT-indexed
stored column virtual{Property}Search (e.g. virtualNameSearch) holding the joined translation values.
You normally do NOT touch that column name — fulltext search is exposed declaratively as QueryOptions
ft / fb operators on the logical property (filters=name ft 'pizza'), and the generated query rewrites to
virtualNameSearch automatically (see ddd-query-options-specialist). Only drop to a raw repo
MATCH(...) AGAINST(...) on virtualNameSearch when you need a relevance score outside the QueryOptions flow.
The column-generation side (name → virtualNameSearch) is documented in ddd-entity-specialist.
Concurrency-safe writes — atomic SQL/DQL, NEVER read-modify-write through update()
$entity->update() persists through DoctrineEntityManager::upsert() — an INSERT … ON DUPLICATE KEY UPDATE <col> = VALUES(<col>) over every initialized field of the model (a property that is unset and has no default is the only thing skipped). So update() is a last-writer-wins snapshot of the whole row.
Under concurrency this is a data race: two workers each load the entity, mutate one field in memory, and update() → the second write clobbers the first's other columns (and a counter read-modify-write loses increments). For any field on a row that more than one process can mutate — a counter, a status/flag, a timestamp, an exactly-once gate — do NOT go through update(). Issue a single atomic statement in the DB:
- counter →
SET col = col ± 1 in SQL (never $e->col++; $e->update()), guarded (… AND col > 0).
- state transition / exactly-once gate → compare-and-set
SET col = :to WHERE id = :id AND col = :from (or … AND col IS NULL); the affected-rows count tells you whether you won the race (exactly one winner).
- mirror the new value back onto the in-memory entity afterwards if the rest of the request needs it.
Two forms (both bypass the clobbering upsert)
$connection = EntityManagerFactory::getInstance()->getConnection();
$won = (int) $connection->executeStatement(
"UPDATE {$tableName} SET status = :to WHERE id = :id AND status = :from",
['to' => $toStatus, 'from' => $fromStatus, 'id' => $id]
);
$repoClass::createQueryBuilder()
->update($repoClass::BASE_ORM_MODEL, $alias)
->set("{$alias}.needsRegeneration", ':v')->setParameter('v', true)
->andWhere("{$alias}.someColumn IS NOT NULL")
->getQuery()->execute();
ALWAYS derive the table name from the model — never hardcode the literal
A raw-SQL string MUST get its table name from the ORM model, so the table name lives in ONE place (the model) and survives a rename:
$tableName = (self::BASE_ORM_MODEL)::getTableName();
$tableName = SomeDoctrineModel::getTableName();
(getTableName() is the static accessor on DoctrineModel; prefer it over reading the TABLE_NAME const directly.)
Multi-writer guarantees (when a single CAS isn't enough)
When the decision depends on a derived count or several rows (e.g. "settle a batch once N children are terminal"), wrap the read+write in a transaction with SET TRANSACTION ISOLATION LEVEL READ COMMITTED + a lock-first SELECT … FOR UPDATE on the coordinating row, then the conditional UPDATE under the held lock — so concurrent settlers serialize on that row and exactly one wins the … WHERE gate IS NULL flip. (Pattern reference: a fork-join "claim the wake exactly once" settle.)
Key rules
- A field mutated by >1 process → atomic SQL/DQL, never
entity->update().
- Counter →
SET col = col ± 1; state/flag → compare-and-set WHERE col = :from / WHERE col IS NULL; check affected-rows for the winner.
- Table name ALWAYS from
Model::getTableName() / (self::BASE_ORM_MODEL)::getTableName() — never a hardcoded string.
entity->update() stays correct for the single-writer case (creation, an aggregate only one process owns at a time).
Partial-entity write — updatePartialIgnoringRights(...$propertyNames) (VO/JSON/vector columns, no detach)
The atomic-SQL form above is right for a scalar/flag/counter. When the column you must persist is a VO/JSON column, a VECTOR column, or a Date/DateTime, hand-written SQL would have to re-implement the EntityManager's per-type encoding (JSON for VO, VEC_FromText(...) for vectors, the datetime format). And $entity->update() is wrong here: it writes the whole row (clobbers a column a concurrent writer just set) and, with DB_USE_READ_AFTER_WRITE=true, does EntityManager::clear() + find() afterwards — detaching your live entity (next lazy-load throws) and polluting the registry.
Use $entity->updatePartialIgnoringRights('colA', 'colB') (instance method on every entity; repo twin DBEntity::updatePartialIgnoringRights($entity, ...$names)). It maps ONLY id + the named properties via mapToRepository($entity, $names) (reusing the per-type / translation / change-history handling) and passes the same allow-list to upsert($model, null, $names), which writes strictly those columns — independent of the generated model's inline column defaults. No read-after-write, no detach, no rights applied (system write of a row the caller already loaded under rights). No-op if the entity has no id.
$conversation->updatePartialIgnoringRights('summary');
$message->updatePartialIgnoringRights('messageEmbedding');
$message->updatePartialIgnoringRights('toolCall', 'status');
Decision guide — pick by what you're writing:
| You must write… | Use |
|---|
| a whole NEW row, or an aggregate only one process owns at a time | $entity->update() |
| a scalar/flag/counter under concurrency, often compare-and-set | atomic raw SQL / DQL (prior section) |
| one/few VO/JSON/vector/datetime columns of an EXISTING row, under concurrency, without detaching the live entity | $entity->updatePartialIgnoringRights(...) |
NEVER hand-roll a "bare entity" partial write (newInstanceWithoutConstructor() + set one column + update(0)): newInstanceWithoutConstructor applies inline defaults so the "bare" model isn't bare → update() maps every initialized column and clobbers; and the read-after-write reload detaches your live entity. That anti-pattern is exactly what updatePartialIgnoringRights() replaces.
PHPDoc & @throws Convention
Every public service method MUST have complete PHPDoc with @param, @return, and @throws. The @throws declarations propagate upward — controllers that call service methods must declare the same exceptions.
Standard @throws for DB operations:
@throws BadRequestException
@throws InternalErrorException
@throws InvalidArgumentException
@throws ReflectionException
@throws ORMException
@throws OptimisticLockException
@throws ORMException
@throws NonUniqueResultException
@throws OptimisticLockException
Required imports:
use DDD\Infrastructure\Exceptions\BadRequestException;
use DDD\Infrastructure\Exceptions\InternalErrorException;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\OptimisticLockException;
use Psr\Cache\InvalidArgumentException;
use ReflectionException;
Example with complete PHPDoc:
public function findMembership(int $accountId, int $channelId): ?ChatChannelMember
Service Method Examples
Find Single Entity by Unique Field
public function findByLanguageCode(string $languageCode): ?Language
{
$repoClass = $this->getEntityRepoClassInstance();
$queryBuilder = $repoClass::createQueryBuilder();
$baseModelAlias = $repoClass::getBaseModelAlias();
$queryBuilder->andWhere("{$baseModelAlias}.languageCode = :languageCode");
$queryBuilder->setParameter('languageCode', $languageCode);
return $repoClass->find($queryBuilder);
}
Uniqueness Checks
public function isNameUnique(string $name, ?int $excludeId = null): bool
{
$repoClass = $this->getEntitySetRepoClassInstance();
$qb = $repoClass::createQueryBuilder(true);
$a = $repoClass::getBaseModelAlias();
$qb->select("COUNT({$a}.id)")->where("{$a}.name = :name")->setParameter('name', $name);
if ($excludeId !== null) {
$qb->andWhere("{$a}.id != :excludeId")->setParameter('excludeId', $excludeId);
}
return (int) $qb->getQuery()->getSingleScalarResult() === 0;
}
Computed Values
public function getNextDisplayOrder(int $parentId): int
{
$repoClass = $this->getEntitySetRepoClassInstance();
$qb = $repoClass::createQueryBuilder(true);
$a = $repoClass::getBaseModelAlias();
$qb->select("MAX({$a}.displayOrder)")->where("{$a}.parentId = :pid")->setParameter('pid', $parentId);
return ($qb->getQuery()->getSingleScalarResult() ?? -1) + 1;
}
ID Generation
public function generateUniqueAccessCode(): string
{
do { $code = Entity::generateAccessCode(); } while (!$this->isAccessCodeUnique($code));
return $code;
}
Display Order Sequencing
public function getNextDisplayOrder(int $parentId): int
{
$repoClass = $this->getEntitySetRepoClassInstance();
$qb = $repoClass::createQueryBuilder(true);
$a = $repoClass::getBaseModelAlias();
$qb->select("MAX({$a}.displayOrder)")->where("{$a}.parentId = :pid")->setParameter('pid', $parentId);
return ($qb->getQuery()->getSingleScalarResult() ?? -1) + 1;
}
Used by entities with displayOrder property (menus, sections, zones).
Scope-Aware Queries (Multi-Tenant)
For entities with Global/Business/Location scoping:
public function findAllAvailableForLocation(int $locationId, ?int $businessId): ?Ingredients
{
$repoClass = $this->getEntitySetRepoClassInstance();
$qb = $repoClass::createQueryBuilder(true);
$a = $repoClass::getBaseModelAlias();
$conditions = [
"({$a}.businessId IS NULL AND {$a}.locationId IS NULL)",
];
if ($businessId) {
$conditions[] = "({$a}.businessId = :businessId AND {$a}.locationId IS NULL)";
$qb->setParameter('businessId', $businessId);
}
$conditions[] = "({$a}.locationId = :locationId)";
$qb->setParameter('locationId', $locationId);
$qb->andWhere(implode(' OR ', $conditions));
return $repoClass->find($qb);
}
Uniqueness Validation Within Scope
public function isCodeUniqueWithinScope(
string $code,
?int $businessId,
?int $locationId,
?int $excludeId = null
): bool {
$repoClass = $this->getEntitySetRepoClassInstance();
$qb = $repoClass::createQueryBuilder(true);
$a = $repoClass::getBaseModelAlias();
$qb->select("COUNT({$a}.id)")->where("{$a}.code = :code")->setParameter('code', $code);
if ($businessId) {
$qb->andWhere("{$a}.businessId = :businessId")->setParameter('businessId', $businessId);
} else {
$qb->andWhere("{$a}.businessId IS NULL");
}
if ($locationId) {
$qb->andWhere("{$a}.locationId = :locationId")->setParameter('locationId', $locationId);
} else {
$qb->andWhere("{$a}.locationId IS NULL");
}
if ($excludeId !== null) {
$qb->andWhere("{$a}.id != :excludeId")->setParameter('excludeId', $excludeId);
}
return (int) $qb->getQuery()->getSingleScalarResult() === 0;
}
Advanced Service Patterns
Update Override with Side Effects
Override update() to handle related operations before/after persisting:
public function update(DefaultObject &$entity, int $depth = 1): ?Entity
{
if (isset($entity->id)) {
$repoClass = $this->getEntitySetRepoClassInstance();
$qb = $repoClass::createQueryBuilder(true);
}
return parent::update($entity, $depth);
}
Delete Override with Cleanup
Override to cascade-delete related entities or clean up external resources:
public function delete(DefaultObject &$entity): void
{
if ($entity->mediaItem) {
$entity->mediaItem->delete();
}
parent::delete($entity);
}
Junction Entity Management (M:N Links)
Creating many-to-many links via a junction entity service:
protected function linkAllergens(Ingredient $ingredient, array $allergenCodes, array $allergenIdByCode): void
{
$ingredientAllergensService = IngredientAllergens::getService();
foreach ($allergenCodes as $code) {
if (!isset($allergenIdByCode[$code])) {
continue;
}
$link = new IngredientAllergen();
$link->ingredientId = $ingredient->id;
$link->allergenId = $allergenIdByCode[$code];
$link->update();
}
}
Config-Driven Import/Seed
Loading seed data from configuration files (UPSERT pattern):
public function importFromConfig(): Entities
{
$configData = Config::get('Catalog.Allergens');
$entities = new Entities();
foreach ($configData as $item) {
$entity = $this->findByCode($item['code']);
if (!$entity) {
$entity = new Entity();
}
$entity->code = $item['code'];
$entity->name = $item['name'];
$entity->scope = Entity::SCOPE_GLOBAL;
$entity = $entity->update();
$entities->add($entity);
}
return $entities;
}
Cross-Service Orchestration
When one import depends on another, pass the service as a parameter:
public function importIngredientsFromConfig(AllergensService $allergensService): Ingredients
{
$allergens = $allergensService->findAll();
$allergenIdByCode = [];
foreach ($allergens->getElements() as $allergen) {
$allergenIdByCode[$allergen->code] = $allergen->id;
}
$configData = Config::get('Catalog.Ingredients');
foreach ($configData as $item) {
$ingredient = $this->findByName($item['name']) ?? new Ingredient();
$this->linkAllergens($ingredient, $item['allergenCodes'] ?? [], $allergenIdByCode);
}
}
Async Message Dispatching
Dispatch background tasks for expensive operations:
use Symfony\Component\Messenger\MessageBusInterface;
public function processGeoHashesForTracks(Tracks $tracks): void
{
$messageBus = AppService::instance()->getService(MessageBusInterface::class);
foreach ($tracks->getElements() as $track) {
$messageBus->dispatch(new TrackGeoHashesMessage($track->id));
}
}
Message handlers live in Domain/{Domain}/MessageHandlers/ and process dispatched messages asynchronously.
Infrastructure Utilities in Services
Config Access
use DDD\Infrastructure\Libs\Config;
$dbHost = Config::get('database.host');
$apiKey = Config::getEnv('API_KEY');
$seedData = Config::get('Catalog.Allergens');
Caching
use DDD\Infrastructure\Cache\Cache;
$cache = Cache::instance();
Encryption
use DDD\Infrastructure\Libs\Encrypt;
$encrypted = Encrypt::encrypt($sensitiveData, $password);
$decrypted = Encrypt::decrypt($encrypted, $password);
$hash = Encrypt::hashWithSalt($password);
JWT for Secure Tokens
use DDD\Infrastructure\Libs\JWTPayload;
$token = JWTPayload::createJWTFromParameters(['userId' => $id], validityInSeconds: 3600);
$params = JWTPayload::getParametersFromJWT($token);
Structured Error Logging (IssuesLogService)
For error tracking with fingerprinting and entry-point detection:
use DDD\Infrastructure\Services\IssuesLogService;
$issuesLogService = DDDService::instance()->getService(IssuesLogService::class);
$issuesLogService->logThrowable($exception, LogLevel::CRITICAL, [
'entityId' => $entity->id,
'operation' => 'recalculate',
]);
Features: error fingerprinting for deduplication, auto-detects entry point (HTTP/CLI/Messenger), filtered stack traces showing only application code.
Rights Protection System
Full documentation: See ddd-rights-specialist for the complete rights system: all 6 patterns (direct filter, leftJoin chain, subquery, custom update rights, property hiding, RolesRequiredForUpdate), the _rights alias convention, snapshot/restore mechanism, and real-world examples from 18+ repository implementations.
Cross-Reference
- Async dispatch from a service —
ddd-message-handler-specialist (the message + handler pair behind the $messageBus->dispatch(...) in the Async Message Dispatching example: auth-context propagation, workspace routing, and messenger.yaml transport config).
- CLI entry points that call services —
ddd-cli-command-specialist (console commands set up the admin auth context, then resolve and invoke these services for batch/maintenance work).