mgamadeus / ddd
A DDD Entity Framework on top of symfony and doctrine
Requires
- php: >=8.3
- ext-ctype: *
- ext-dom: *
- ext-gd: *
- ext-iconv: *
- ext-imagick: *
- ext-libxml: *
- ext-openssl: *
- ext-zlib: *
- beberlei/doctrineextensions: ^1.3
- brick/geo-doctrine: ^0.3.1
- doctrine/doctrine-bundle: ^2.8
- doctrine/inflector: 2.*
- doctrine/orm: ^2.13
- dompdf/dompdf: ^2.0
- dragonmantank/cron-expression: ^3.3
- ezyang/htmlpurifier: ^4.19
- firebase/php-jwt: ^6.10
- giggsey/libphonenumber-for-php: ^8.13
- guzzlehttp/guzzle: ^7.5
- jms/parser-lib: ^1.0
- kamermans/guzzle-oauth2-subscriber: ^1.1
- mockery/mockery: ^1.5
- phpoffice/phpspreadsheet: ^4.2
- phpunit/phpunit: ^9.5
- predis/predis: 2.0.0
- symfony/amqp-messenger: 7.3.*
- symfony/console: 7.3.*
- symfony/dependency-injection: 7.3.*
- symfony/dotenv: 7.3.*
- symfony/expression-language: 7.3.*
- symfony/framework-bundle: 7.3.*
- symfony/http-client: 7.3.*
- symfony/messenger: 7.3.*
- symfony/mime: >=7.3 <9.0
- symfony/monolog-bridge: 7.3.*
- symfony/monolog-bundle: ^4.0
- symfony/process: 7.3.*
- symfony/proxy-manager-bridge: >=6.4 <7.0
- symfony/redis-messenger: 7.3.*
- symfony/routing: 7.3.*
- symfony/runtime: 7.3.*
- symfony/security-bundle: 7.3.*
- symfony/translation: 7.3.*
- symfony/validator: 7.3.*
- symfony/var-exporter: 7.3.*
- symfony/yaml: 7.3.*
- twig/twig: ^3.0
- dev-main
- 2.10.31
- 2.10.30
- 2.10.29
- 2.10.28
- 2.10.27
- 2.10.26
- 2.10.25
- 2.10.24
- 2.10.23
- 2.10.22
- 2.10.21
- 2.10.20
- 2.10.19
- 2.10.18
- 2.10.17
- 2.10.16
- 2.10.15
- 2.10.14
- 2.10.13
- 2.10.12
- 2.10.11
- 2.10.10
- 2.10.9
- 2.10.7
- 2.10.6
- 2.10.5
- 2.10.4
- 2.10.3
- 2.10.2
- 2.10.1
- 2.10.0
- 2.9.36
- 2.9.35
- 2.9.34
- 2.9.33
- 2.9.32
- 2.9.31
- 2.9.30
- 2.9.29
- 2.9.28
- 2.9.27
- 2.9.26
- 2.9.25
- 2.9.24
- 2.9.23
- 2.9.22
- 2.9.21
- 2.9.20
- 2.9.19
- 2.9.18
- 2.9.17
- 2.9.16
- 2.9.15
- 2.9.14
- 2.9.13
- 2.9.12
- 2.9.11
- 2.9.10
- 2.9.8
- 2.9.6
- 2.9.5
- 2.9.4
- 2.9.3
- 2.9.2
- 2.9.1
- 2.8.47
- 2.8.46
- 2.8.45
- 2.8.44
- 2.8.43
- 2.8.42
- 2.8.41
- 2.8.40
- 2.8.39
- 2.8.38
- 2.8.37
- 2.8.36
- 2.8.35
- 2.8.34
- 2.8.33
- 2.8.32
- 2.8.31
- 2.8.30
- 2.8.29
- 2.8.28
- 2.8.27
- 2.8.26
- 2.8.25
- 2.8.24
- 2.8.23
- 2.8.22
- 2.8.20
- 2.8.19
- 2.8.18
- 2.8.17
- 2.8.15
- 2.8.14
- 2.8.13
- 2.8.11
- 2.8.10
- 2.8.9
- 2.8.8
- 2.8.7
- 2.8.6
- 2.8.5
- 2.8.4
- 2.8.3
- 2.8.2
- 2.8.1
- 2.8.0
- 2.7.15
- 2.7.14
- 2.7.13
- 2.7.12
- 2.7.10
- 2.7.9
- 2.7.8
- 2.7.7
- 2.7.6
- 2.7.5
- 2.7.4
- 2.7.3
- 2.7.2
- 2.7.1
- 2.7.0
- 2.6.7
- 2.6.6
- 2.6.5
- 2.6.4
- 2.6.3
- 2.6.2
- 2.6.1
- 2.6.0
- 2.5.26
- 2.5.25
- 2.5.24
- 2.5.23
- 2.5.22
- 2.5.21
- 2.5.20
- 2.5.19
- 2.5.18
- 2.5.17
- 2.5.16
- 2.5.15
- 2.5.14
- 2.5.13
- 2.5.12
- 2.5.11
- 2.5.10
- 2.5.9
- 2.5.8
- 2.5.7
- 2.5.6
- 2.5.5
- 2.5.4
- 2.5.3
- 2.5.2
- 2.5.1
- 2.5.0
- 2.4.30
- 2.4.26
- 2.4.25
- 2.4.24
- 2.4.23
- 2.4.22
- 2.4.21
- 2.4.20
- 2.4.19
- 2.4.18
- 2.4.17
- 2.4.16
- 2.4.15
- 2.4.14
- 2.4.13
- 2.4.12
- 2.4.11
- 2.4.10
- 2.4.9
- 2.4.8
- 2.4.7
- 2.4.6
- 2.4.5
- 2.4.4
- 2.4.3
- 2.4.2
- 2.4.1
- 2.4.0
- 2.3.9
- 2.3.8
- 2.3.7
- 2.3.6
- 2.3.5
- 2.3.4
- 2.3.3
- 2.3.2
- 2.3.1
- 2.3.0
- 2.2.8
- 2.2.7
- 2.2.6
- 2.2.5
- 2.2.4
- 2.2.3
- 2.2.2
- 2.2.1
- 2.2.0
- 2.1.9
- 2.1.7
- 2.1.6
- 2.1.5
- 2.1.4
- 2.1.3
- 2.1.2
- 2.1.1
- 2.1.0
- 2.0.9
- 2.0.8
- 2.0.7
- 2.0.6
- 2.0.5
- 2.0.4
- 2.0.3
- 2.0.2
- 2.0.1
- 2.0.0
- 1.9.9
- 1.9.8
- 1.9.7
- 1.9.6
- 1.9.5
- 1.9.4
- 1.9.3
- 1.9.2
- 1.9.1
- 1.9.0
- 1.8.9
- 1.8.8
- 1.8.7
- 1.8.6
- 1.8.5
- 1.8.4
- 1.8.3
- 1.8.2
- 1.8.1
- 1.8.0
- 1.7.9
- 1.7.7
- 1.7.6
- 1.7.5
- 1.7.4
- 1.7.3
- 1.7.2
- 1.7.1
- 1.7.0
- 1.6.9
- 1.6.8
- 1.6.7
- 1.6.6
- 1.6.5
- 1.6.4
- 1.6.3
- 1.6.2
- 1.6.1
- 1.6.0
- 1.5.9
- 1.5.8
- 1.5.7
- 1.5.6
- 1.5.5
- 1.5.4
- 1.5.3
- 1.5.2
- 1.5.1
- 1.5.0
- 1.4.8
- 1.4.7
- 1.4.6
- 1.4.5
- 1.4.4
- 1.4.3
- 1.4.2
- 1.4.1
- 1.4.0
- 1.3.9
- 1.3.8
- 1.3.7
- 1.3.6
- 1.3.5
- 1.3.4
- 1.3.3
- 1.3.2
- 1.3.1
- 1.3.0
- 1.2.9
- 1.2.8
- 1.2.7
- 1.2.6
- 1.2.5
- 1.2.4
- 1.2.3
- 1.2.2
- 1.2.1
- 1.2.0
- 1.1.9
- 1.1.8
- 1.1.7
- 1.1.6
- 1.1.5
- 1.1.4
- 1.1.3
- 1.1.2
- 1.1.1
- 1.1.0
- 1.0.9
- 1.0.8
- 1.0.7
- 1.0.6
- 1.0.5
- 1.0.4
- 1.0.3
- 1.0.2
- 1.0.1
- 1.0.0
- dev-fix-filters-options-null-object
- dev-feature/enum-support
- dev-feature/defaultObject-clone-improvements
This package is auto-updated.
Last update: 2026-04-30 03:03:12 UTC
README
A Domain-Driven Design entity framework built on top of Symfony 7.3 and Doctrine ORM for PHP 8.3+.
Overview
mgamadeus/ddd provides a complete DDD stack for building PHP applications:
- Entities & Value Objects with identity, validation, serialization, parent-child relationships, and single table inheritance
- Lazy Loading via
#[LazyLoad]attributes -- relationships load on-demand from DB, Virtual, or Class Method repositories - Repository Pattern with DB (Doctrine), Virtual, and Class Method repository types, plus auto-generated ORM model classes
- Service Layer with
EntitiesServicefor entity management andServicefor cross-cutting concerns - OData-Style QueryOptions --
$filter,$select,$expand,$orderBy,$top,$skipwith 11 filter operators (eq, ne, gt, ge, lt, le, in, ni, bw, ft, fb) - Rights Protection -- query-level access control via
applyReadRightsQuery(),applyUpdateRightsQuery(),applyDeleteRightsQuery() - Multi-Language Support via
#[Translatable]with JSON storage, fulltext search indexes, and language/country/writing-style context - Change History -- automatic
created/updatedtimestamps viaChangeHistoryTrait - REST Presentation Layer --
HttpController, typed DTOs (Request/Response), OpenAPI 3.0 documentation, request caching, specialized response types (Excel, PDF, ZIP, Image, HTML, Redirect) - Async Processing -- Symfony Messenger integration with
AppMessage/AppMessageHandlerbase classes, auth context propagation, and workspace routing - Module System -- Composer packages self-register as DDD modules with automatic service discovery and entity inclusion
- CLI Commands -- Console command infrastructure with admin auth context, batch processing, progress tracking, and cron job management
- Infrastructure Utilities -- Config management, caching (APC/Redis/PhpFiles), encryption, JWT, text processing, input filtering, internationalized domain names
Requirements
- PHP >= 8.3
- Symfony 7.3
- Doctrine ORM ^2.13
- Extensions:
ctype,dom,gd,iconv,libxml,openssl,zlib,imagick
Installation
composer require mgamadeus/ddd
Register the bundle in your Symfony application:
// config/bundles.php return [ // ... DDD\DDDBundle::class => ['all' => true], ];
Quick Start
1. Entity
<?php declare(strict_types=1); namespace App\Domain\Common\Entities\Products; use DDD\Domain\Base\Entities\Entity; use DDD\Domain\Base\Entities\ChangeHistory\ChangeHistoryTrait; use DDD\Domain\Base\Entities\LazyLoad\LazyLoad; use DDD\Domain\Base\Entities\LazyLoad\LazyLoadRepo; use DDD\Infrastructure\Validation\Constraints\Length; use App\Domain\Common\Repo\DB\Products\DBProduct; /** * @method static ProductsService getService() * @method static DBProduct getRepoClassInstance(string $repoType = null) */ #[LazyLoadRepo(LazyLoadRepo::DB, DBProduct::class)] class Product extends Entity { use ChangeHistoryTrait; public ?int $id = null; #[Length(max: 255)] public string $name; public ?string $description = null; public ?int $categoryId = null; #[LazyLoad] public ?Category $category; public function uniqueKey(): string { return parent::uniqueKeyStatic($this->id ?? spl_object_id($this)); } }
2. EntitySet (Collection)
<?php declare(strict_types=1); namespace App\Domain\Common\Entities\Products; use DDD\Domain\Base\Entities\EntitySet; use DDD\Domain\Base\Entities\LazyLoad\LazyLoadRepo; use DDD\Domain\Base\Entities\QueryOptions\QueryOptionsTrait; use App\Domain\Common\Repo\DB\Products\DBProducts; use App\Domain\Common\Services\ProductsService; /** * @property Product[] $elements * @method Product first() * @method static ProductsService getService() */ #[LazyLoadRepo(LazyLoadRepo::DB, DBProducts::class)] class Products extends EntitySet { use QueryOptionsTrait; public const string SERVICE_NAME = ProductsService::class; }
3. Repositories
// DB Repo -- Single Entity class DBProduct extends DBEntity { public const BASE_ENTITY_CLASS = Product::class; public const BASE_ORM_MODEL = DBProductModel::class; // Auto-generated, never edit } // DB Repo -- Entity Set class DBProducts extends DBEntitySet { public const BASE_REPO_CLASS = DBProduct::class; public const BASE_ENTITY_SET_CLASS = Products::class; }
4. Service
<?php declare(strict_types=1); namespace App\Domain\Common\Services; use App\Domain\Common\Entities\Products\Product; use DDD\Domain\Base\Services\EntitiesService; /** * @method Product find(int|string|null $entityId, bool $useEntityRegistrCache = true) * @method Products findAll(?int $offset = null, $limit = null, bool $useEntityRegistrCache = true) */ class ProductsService extends EntitiesService { public const DEFAULT_ENTITY_CLASS = Product::class; }
5. Use It
// CRUD $product = Product::byId(42); echo $product->category->name; // Lazy-loaded automatically $product->name = 'Updated'; $product->update(); // Validates and persists $product->delete(); // Service access $service = Products::getService(); $allProducts = $service->findAll(); // Programmatic QueryOptions $originalQO = clone Products::getDefaultQueryOptions(); $filters = FiltersOptions::fromString("status eq 'ACTIVE'"); Products::getDefaultQueryOptions()->setFilters($filters)->setTop(100); $activeProducts = $service->findAll(); Products::setDefaultQueryOptions($originalQO); // Always restore
Key Features
Lazy Loading
Relationships load automatically on property access -- no manual finder methods needed:
#[LazyLoad] // DB (default) public ?Category $category; #[LazyLoad(repoType: LazyLoadRepo::CLASS_METHOD, loadMethod: 'computeTotal')] public ?Money $total; // Custom method #[LazyLoad(repoType: LazyLoadRepo::VIRTUAL, loadMethod: 'lazyloadRankings')] public Rankings $rankings; // Virtual/computed #[LazyLoad(addAsParent: true)] public ?ParentEntity $parent; // Parent-child #[LazyLoad(loadThrough: IntermediaryEntities::class)] public ?RelatedEntities $related; // N-N via junction
QueryOptions (OData-Style Filtering)
Applied at the database level with 11 filter operators:
GET /api/products?$filter=status eq 'ACTIVE' and price gt '10'&$select=id,name&$expand=category(select=id,name)&$orderBy=name asc&$top=20
| Operator | Meaning | Example |
|---|---|---|
eq / ne |
Equals / Not equals | status eq 'ACTIVE' |
gt / ge / lt / le |
Comparison | price gt '100' |
in / ni |
In / Not in list | status in ['ACTIVE','PENDING'] |
bw |
Between | date bw ['2026-01-01','2026-12-31'] |
ft |
Fulltext (natural language) | name ft 'search terms' |
fb |
Fulltext (boolean, prefix matching) | name fb 'alm*' |
Expand supports nested clauses: $expand=zones(filters=isActive eq 'true';orderBy=name asc;top=50;expand=tables)
Rights Protection
Query-level access control via overridable methods in DB repositories:
class DBProduct extends DBEntity { public static function applyReadRightsQuery(DoctrineQueryBuilder &$queryBuilder): bool { if (!self::$applyRightsRestrictions) return false; $authAccount = AuthService::instance()->getAccount(); if (!$authAccount) { $alias = static::getBaseModelAlias(); $queryBuilder->andWhere("{$alias}.id is null"); return true; } if ($authAccount?->roles?->isAdmin()) return true; // Non-admin restrictions... return parent::applyReadRightsQuery($queryBuilder); } }
Multi-Language (Translatable)
class Product extends Entity { use TranslatableTrait; #[Translatable] public string $name; #[Translatable(fullTextIndex: true)] // Enables ft/fb search operators public ?string $description = null; }
JSON storage with language/country/writing-style context. Fulltext indexes auto-generate virtual search columns.
REST Controllers & DTOs
#[Route('/api/products')] #[Tag(group: 'Catalog', name: 'Products')] class ProductsController extends HttpController { #[Get('/list')] #[Summary('Products List')] public function list( ProductsGetRequestDto &$requestDto, ProductsService $productsService ): ProductsGetResponseDto { Products::getDefaultQueryOptions()->setQueryOptionsFromRequestDto($requestDto); $productsService->throwErrors = true; $responseDto = new ProductsGetResponseDto(); $responseDto->products = $productsService->findAll(); $responseDto->products->expand(); return $responseDto; } }
Response types beyond JSON: ExcelResponseDto, PDFResponseDto, ImageResponseDto, ZipResponseDto, FileResponseDto, HtmlResponseDto, RedirectResponseDto.
Async Processing (Symfony Messenger)
// Message class ProcessItemMessage extends AppMessage { public static string $messageHandler = ProcessItemHandler::class; public ?int $itemId = null; public function __construct(?int $itemId = null) { parent::__construct(); $this->itemId = $itemId; } } // Handler #[AsMessageHandler(fromTransport: 'process_item')] class ProcessItemHandler extends AppMessageHandler { public function __invoke(ProcessItemMessage $message): void { $this->setAuthAccountFromMessage($message); if ($message->processOnWorkspaceIfNecessary()) return; // Process... } } // Dispatch from service public function processItem(int $itemId, bool $async = false): void { if ($async) { (new ProcessItemMessage($itemId))->dispatch(); return; } // Synchronous work... }
Module System
Composer packages self-register as DDD modules:
{ "extra": { "ddd-module": "Vendor\\MyModule\\MyDDDModule" } }
class MyDDDModule extends DDDModule { public static function getSourcePath(): string { return __DIR__ . '/../src'; } public static function getConfigPath(): ?string { return __DIR__ . '/../config/app'; } }
Modules are discovered automatically. Services auto-registered. Entities included in model generation. Config directories integrated with app > module > framework priority.
CLI Commands
#[AsCommand(name: 'app:recalculate', description: 'Recalculates data')] class RecalculateCommand extends Command { protected function execute(InputInterface $input, OutputInterface $output): int { ini_set('memory_limit', '1024M'); $defaultAccount = DDDService::instance()->getDefaultAccountForCliOperations(); AuthService::instance()->setAccount($defaultAccount); $service = MyEntities::getService(); $service->recalculate(new SymfonyStyle($input, $output)); return Command::SUCCESS; } }
Framework-provided commands: app:generate-doctrine-models-for-entities, app:process-cli-message, app:crons:execute, app:crons:list.
Entity Attributes
| Attribute | Purpose |
|---|---|
#[LazyLoadRepo] |
Bind entity to repository class |
#[LazyLoad] |
Deferred relationship loading |
#[ChangeHistory] |
Automatic created/updated timestamps |
#[Translatable] |
Multi-language properties with fulltext search |
#[QueryOptions] |
Custom OData-style query support |
#[DatabaseColumn] |
Column mapping and SQL type control |
#[DatabaseVirtualColumn] |
Computed/extracted database columns |
#[DatabaseIndex] |
Index definitions (unique, composite) |
#[DatabaseForeignKey] |
Foreign key relationships |
#[DatabaseTrigger] |
SQL trigger integration |
#[SubclassIndicator] |
Single Table Inheritance (discriminator) |
#[HideProperty] |
Exclude from API serialization |
#[HidePropertyOnSystemSerialization] |
Exclude from DB persistence |
#[DontPersistProperty] |
Exclude from persistence (visible in API) |
#[OverwritePropertyName] |
Rename property in serialized output |
#[Aliases] |
Backward-compatible property aliases |
#[NoRecursiveUpdate] |
Prevent cascade updates from parent |
#[RolesRequiredForUpdate] |
Role-based write authorization |
#[RequestCache] |
GET endpoint response caching |
#[Choice] |
Enum-like validation with dynamic choices |
#[Length] |
String length validation |
#[UniqueProperty] |
Database uniqueness validation |
Infrastructure Utilities
Config::get('database.host'); // Hierarchical config (dot-notation) Config::getEnv('DATABASE_URL'); // Env with type coercion Cache::instance(); // Auto-selects APC/Redis/PhpFiles Encrypt::encrypt($data, $password); // AES-256-CBC JWTPayload::createJWTFromParameters($params, 3600); // JWT tokens StringFuncs::generateAlias('Hello World!'); // URL slugs Datafilter::validEmail($email); // Input validation __('key', 'de', 'DE', 'FORMAL', ['%name%' => 'Max']); // Translation
Project Structure (Consuming Application)
your-app/
+-- src/
| +-- Domain/ # Business logic (DDD)
| | +-- {DomainName}/
| | +-- Entities/{Group}/ # Entity.php, Entities.php
| | +-- Repo/DB/{Group}/ # DBEntity.php, DBEntities.php (+ auto-generated Model)
| | +-- Services/ # EntitiesService.php
| | +-- MessageHandlers/ # AppMessage.php, AppMessageHandler.php
| +-- Infrastructure/ # Cross-cutting: AuthService, AppService
| +-- Presentation/Api/ # Controllers & DTOs
| | +-- Admin/ # Admin endpoints (ROLE_ADMIN)
| | +-- Client/ # Client endpoints (JWT)
| | +-- Public/ # Public endpoints (no auth)
| | +-- Batch/ # Integration endpoints
| +-- Symfony/Commands/ # Console commands
+-- config/
| +-- app/ # App-specific config
| +-- symfony/default/ # services.yaml, routes.yaml, messenger.yaml
+-- vendor/mgamadeus/ddd/src/ # This framework
Domain Directory Pattern
Domain/{DomainName}/
+-- Entities/{Group}/{Entity}.php, {Entity}s.php
+-- Repo/DB/{Group}/DB{Entity}.php, DB{Entity}s.php, DB{Entity}Model.php (auto-generated)
+-- Services/{Entity}sService.php
+-- MessageHandlers/{Action}Message.php, {Action}Handler.php
Presentation/Api/{Audience}/{DomainName}/
+-- Controller/{Entity}Controller.php
+-- Dtos/{Entity}*Dto.php
Conventions
- Always
declare(strict_types=1)in every file - Never use
private-- alwaysprotected(the framework is built for extensibility) - Never manually edit
DB*Model.phpfiles (auto-generated from entity attributes) - Never cache services in class properties -- always resolve from the container
- Never use PHP's
\DateTime-- useDDD\Infrastructure\Base\DateTime\DateTimeorDate - Always pass entity objects to functions, not raw IDs
- Traits are comma-separated on a single line:
use TraitA, TraitB; - Constants over magic values:
self::STATUS_ACTIVEnot'ACTIVE' - Services via container:
Products::getService()orAppService::instance()->getService(ProductsService::class)
Object Hierarchy
BaseObject (abstract)
+- DefaultObject -- SerializerTrait, ValidatorTrait, ParentChildrenTrait, LazyLoadTrait, ReflectorTrait
+- Entity -- domain entities with identity
+- ValueObject -- immutable objects
| +- ObjectSet -> EntitySet -- typed collections
+- Other domain objects
License
MIT