Domain Generators
Scaffold bounded contexts, aggregate roots, child entities, value objects, domain events, and domain errors with the correct structure and imports from the start.
Domain Generators
These commands generate the building blocks of your domain layer — the pure, infrastructure-free core of each bounded context.
vortos:make:context
Scaffolds a complete, empty bounded context directory tree. Run this once when starting a new bounded context before running any other make:* commands.
php bin/console vortos:make:context <name>Argument
name | Bounded context name. PascalCase. Used as the top-level directory under src/. |
Example
php bin/console vortos:make:context OrderCreates
src/Order/
├── Domain/
│ └── Shared/
│ ├── ValueObject/ ← value objects shared across aggregates
│ ├── Error/ ← domain errors shared across aggregates
│ └── Service/ ← domain services used by more than one aggregate
├── Application/
│ ├── Command/
│ ├── EventHandler/
│ ├── Policy/
│ ├── Projection/
│ ├── Query/
│ └── ReadModel/
├── Infrastructure/
│ ├── Messaging/
│ ├── Persistence/
│ │ └── Mongo/
│ ├── Quota/
│ └── Repository/
└── Presentation/
├── Controller/
└── Request/Aggregate sub-folders (Domain/Order/, Domain/Buyer/, etc.) are created on demand by make:aggregate. No files are generated — just directories.
Multiple aggregates per context
A bounded context can contain multiple aggregate roots. Each aggregate gets its own sub-folder under Domain/. For example, an Order context might have both a Domain/Order/ and a Domain/Buyer/ aggregate boundary.
vortos:make:aggregate
Generates an aggregate root, its typed ID class, and the repository interface. These three files always travel together and are placed inside the aggregate's own boundary folder.
php bin/console vortos:make:aggregate <name> [--context=<context>] [--no-orm]Arguments & Options
name | Aggregate root class name. PascalCase. E.g., User, Order. |
--context / -c | Bounded context folder name. Defaults to name if omitted. |
--no-orm | Generate without Doctrine annotations even if vortos/persistence-orm is installed. |
Example
php bin/console vortos:make:aggregate TrainingSession --context=TrainingCreates
src/Training/
└── Domain/
└── TrainingSession/
├── TrainingSession.php
├── ValueObject/
│ └── TrainingSessionId.php
└── Repository/
└── TrainingSessionRepositoryInterface.php// TrainingSession.php
namespace App\Training\Domain\TrainingSession;
use Vortos\Domain\Aggregate\AggregateRoot;
final class TrainingSession extends AggregateRoot
{
private function __construct(private TrainingSessionId $id) {}
public static function create(TrainingSessionId $id): self
{
return new self($id);
}
public static function reconstruct(TrainingSessionId $id, int $version): self
{
$self = new self($id);
$self->restoreVersion($version);
return $self;
}
public function getId(): TrainingSessionId
{
return $this->id;
}
}// TrainingSession.php (with vortos/persistence-orm installed)
namespace App\Training\Domain\TrainingSession;
use Doctrine\ORM\Mapping as ORM;
use Vortos\PersistenceOrm\Aggregate\AggregateRoot;
#[ORM\Entity]
#[ORM\Table(name: 'training_sessions')]
final class TrainingSession extends AggregateRoot
{
#[ORM\Id]
#[ORM\Column(type: 'string', length: 36)]
private string $id;
private function __construct(TrainingSessionId $id)
{
$this->id = (string) $id;
}
public static function create(TrainingSessionId $id): self
{
$self = new self($id);
$self->incrementVersion();
return $self;
}
public function getId(): TrainingSessionId
{
return TrainingSessionId::fromString($this->id);
}
}When vortos/persistence-orm is installed, the generator prints an ORM mode notice and suggests --no-orm if you want the plain variant for this specific aggregate.
Next step after generating an aggregate:
php bin/console vortos:make:write-repository TrainingSession --context=Trainingvortos:make:entity
Generates a plain domain entity and its typed ID — for non-root entities that live inside an aggregate. Child entities have no repository and are always accessed and persisted through their aggregate root.
php bin/console vortos:make:entity <name> --context=<context> --aggregate=<aggregate> [--no-orm]Arguments & Options
name | Entity class name. PascalCase. E.g., ExerciseSet, OrderLine. |
--context / -c | Bounded context folder name. Required. |
--aggregate / -a | Aggregate root this entity belongs to. Required. |
--no-orm | Generate without Doctrine annotations even if vortos/persistence-orm is installed. |
Example
php bin/console vortos:make:entity ExerciseSet --context=Training --aggregate=TrainingSessionCreates
src/Training/
└── Domain/
└── TrainingSession/
├── Entity/
│ └── ExerciseSet.php
└── ValueObject/
└── ExerciseSetId.phpNo repository interface is generated — child entities are persisted through the aggregate root's repository.
// ExerciseSet.php
namespace App\Training\Domain\TrainingSession\Entity;
use App\Training\Domain\TrainingSession\ValueObject\ExerciseSetId;
final class ExerciseSet
{
public function __construct(
private readonly ExerciseSetId $id,
) {}
public function getId(): ExerciseSetId
{
return $this->id;
}
}// ExerciseSet.php (with vortos/persistence-orm installed)
namespace App\Training\Domain\TrainingSession\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
final class ExerciseSet
{
#[ORM\Id]
#[ORM\Column(type: 'string', length: 36)]
private string $id;
// Add the back-reference to your aggregate root:
// #[ORM\ManyToOne(targetEntity: TrainingSession::class, inversedBy: 'sets')]
// private TrainingSession $session;
public function __construct(ExerciseSetId $id)
{
$this->id = (string) $id;
}
public function getId(): ExerciseSetId
{
return ExerciseSetId::fromString($this->id);
}
}ORM wiring — one annotation on the aggregate root
After generating an ORM child entity, add a single mapping annotation on the aggregate root side:
// TrainingSession.php (aggregate root) — add this property
#[ORM\OneToMany(
targetEntity: ExerciseSet::class,
mappedBy: 'session',
cascade: ['persist', 'remove'],
orphanRemoval: true,
)]
private Collection $sets;With cascade: ['persist', 'remove'] and orphanRemoval: true, calling $repository->save($session) persists all exercise sets automatically. You never call persist on a child entity directly.
Aggregate root vs child entity
Use make:aggregate for the primary entity of an aggregate — the one that has a repository, is loaded by ID, and is the transaction boundary. Use make:entity for subordinate entities within an aggregate that have no independent lifecycle.
vortos:make:value-object
Generates an immutable value object with fromString, equals, and __toString.
php bin/console vortos:make:value-object <name> --context=<context> (--aggregate=<aggregate> | --shared) [--embeddable]Arguments & Options
name | Value object class name. PascalCase. E.g., Email, Duration. |
--context / -c | Bounded context folder name. Required. |
--aggregate / -a | Aggregate this value object belongs to. Use when only one aggregate needs it. |
--shared | Generate into Domain/Shared/ValueObject/. Use when multiple aggregates share it. |
--embeddable | Generate as a Doctrine embeddable. Requires vortos/persistence-orm. |
Exactly one of --aggregate or --shared is required. Providing neither (or both) is an error with an explanation.
# Duration is only used inside TrainingSession
php bin/console vortos:make:value-object Duration --context=Training --aggregate=TrainingSessionCreates: src/Training/Domain/TrainingSession/ValueObject/Duration.php
namespace App\Training\Domain\TrainingSession\ValueObject;
use Vortos\Domain\ValueObject\ValueObject;
final readonly class Duration extends ValueObject
{
private function __construct(private string $value) {}
public static function fromString(string $value): self
{
return new self($value);
}
public function equals(ValueObject $other): bool
{
return $other instanceof self && $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}php bin/console vortos:make:value-object Address --context=User --aggregate=User --embeddableCreates: src/User/Domain/User/ValueObject/Address.php
namespace App\User\Domain\User\ValueObject;
use Doctrine\ORM\Mapping as ORM;
use Vortos\Domain\ValueObject\ValueObject;
#[ORM\Embeddable]
final class Address extends ValueObject { ... }After generating, add #[ORM\Embedded] on the aggregate root property:
#[ORM\Embedded(class: Address::class)]
private Address $address;Add your validation logic to fromString. Throw a domain error (generated with make:domain-error) when invariants are violated.
vortos:make:domain-event
Generates a domain event — an immutable record of something that happened in the domain.
php bin/console vortos:make:domain-event <name> --context=<context> --aggregate=<aggregate>Arguments & Options
name | Event name. PascalCase. E.g., UserRegistered, SessionStarted. No suffix — the Event/ folder implies the type. |
--context / -c | Bounded context folder name. Required. |
--aggregate / -a | Aggregate root that emits this event. Required. |
Example
php bin/console vortos:make:domain-event SessionStarted --context=Training --aggregate=TrainingSessionCreates
src/Training/Domain/TrainingSession/Event/SessionStarted.phpGenerated class
namespace App\Training\Domain\TrainingSession\Event;
final readonly class SessionStarted
{
public function __construct(
// public string $property,
) {}
}Add your event properties as public readonly constructor-promoted parameters. No base class, no aggregateId — those live on EventEnvelope, which the framework injects automatically.
vortos:make:domain-error
Generates a structured domain error with a stable error code, typed context, and automatic HTTP status mapping.
php bin/console vortos:make:domain-error <name> --context=<context> (--aggregate=<aggregate> | --shared) [--status=<http-status>]Arguments & Options
name | Error name without the Error suffix. PascalCase. E.g., UserNotFound. |
--context / -c | Bounded context folder name. Required. |
--aggregate / -a | Aggregate root this error belongs to. Use when the error is specific to one aggregate. |
--shared | Generate into Domain/Shared/Error/. Use when the error is shared across aggregates in the context. |
--status / -s | HTTP status code the error maps to. Defaults to 422. |
Exactly one of --aggregate or --shared is required. Providing neither (or both) is an error.
Examples
# Aggregate-specific error
php bin/console vortos:make:domain-error SessionNotFound --context=Training --aggregate=TrainingSession --status=404
# Shared error (used by more than one aggregate in the context)
php bin/console vortos:make:domain-error Unauthorised --context=Training --shared --status=403Creates
# aggregate-specific
src/Training/Domain/TrainingSession/Error/SessionNotFoundError.php
# shared
src/Training/Domain/Shared/Error/UnauthorisedError.phpGenerated class
namespace App\Training\Domain\TrainingSession\Error;
use Vortos\Domain\Error\DomainError;
use Vortos\Domain\Error\HttpStatus;
#[HttpStatus(404)]
final class SessionNotFoundError extends DomainError
{
public static function because(string $reason): self
{
return new self($reason);
}
}After generating, replace the generic because() constructor with one that carries structured context:
public static function forId(string $id): self
{
return new self(
"Session '{$id}' was not found.",
context: ['sessionId' => $id],
);
}vortos:make:domain-service
Generates a stateless domain service decorated with #[AsDomainService] — for pure domain logic that doesn't belong on a single aggregate (e.g. a calculation, a policy check, a cross-aggregate rule).
php bin/console vortos:make:domain-service <name> --context=<context> (--aggregate=<aggregate> | --shared)Arguments & Options
name | Service class name. PascalCase. E.g., AgeGroupCalculator, PricingPolicy. |
--context / -c | Bounded context folder name. Required. |
--aggregate / -a | Aggregate this service is specific to. Use when only one aggregate uses it. |
--shared | Generate into Domain/Shared/Service/. Use when the service spans multiple aggregates. |
Exactly one of --aggregate or --shared is required.
Examples
# Aggregate-specific domain service
php bin/console vortos:make:domain-service PricingCalculator --context=Order --aggregate=Order
# Shared domain service (used by multiple aggregates)
php bin/console vortos:make:domain-service CurrencyConverter --context=Order --sharedCreates
# aggregate-specific
src/Order/Domain/Order/Service/PricingCalculator.php
# shared
src/Order/Domain/Shared/Service/CurrencyConverter.phpGenerated class
namespace App\Order\Domain\Order\Service;
use Vortos\Domain\Attribute\AsDomainService;
#[AsDomainService]
final class PricingCalculator
{
public function __construct(
// inject domain dependencies here
) {}
}The #[AsDomainService] attribute marks the class for auto-registration by the framework's DomainServiceCompilerPass. No manual DI wiring is needed.
No Infrastructure Dependencies
Domain services must be pure domain logic. They must not depend on repositories, HTTP clients, cache, the event bus, or any other infrastructure. If you need those, the logic belongs in an application service (command handler) instead.