Vortos
Make

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

nameBounded context name. PascalCase. Used as the top-level directory under src/.

Example

php bin/console vortos:make:context Order

Creates

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

nameAggregate root class name. PascalCase. E.g., User, Order.
--context / -cBounded context folder name. Defaults to name if omitted.
--no-ormGenerate without Doctrine annotations even if vortos/persistence-orm is installed.

Example

php bin/console vortos:make:aggregate TrainingSession --context=Training

Creates

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=Training

vortos: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

nameEntity class name. PascalCase. E.g., ExerciseSet, OrderLine.
--context / -cBounded context folder name. Required.
--aggregate / -aAggregate root this entity belongs to. Required.
--no-ormGenerate without Doctrine annotations even if vortos/persistence-orm is installed.

Example

php bin/console vortos:make:entity ExerciseSet --context=Training --aggregate=TrainingSession

Creates

src/Training/
└── Domain/
    └── TrainingSession/
        ├── Entity/
        │   └── ExerciseSet.php
        └── ValueObject/
            └── ExerciseSetId.php

No 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

nameValue object class name. PascalCase. E.g., Email, Duration.
--context / -cBounded context folder name. Required.
--aggregate / -aAggregate this value object belongs to. Use when only one aggregate needs it.
--sharedGenerate into Domain/Shared/ValueObject/. Use when multiple aggregates share it.
--embeddableGenerate 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=TrainingSession

Creates: 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;
    }
}
# Email is used by both Athlete and TrainingSession
php bin/console vortos:make:value-object Email --context=Training --shared

Creates: src/Training/Domain/Shared/ValueObject/Email.php

namespace App\Training\Domain\Shared\ValueObject;

use Vortos\Domain\ValueObject\ValueObject;

final readonly class Email extends ValueObject { ... }
php bin/console vortos:make:value-object Address --context=User --aggregate=User --embeddable

Creates: 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

nameEvent name. PascalCase. E.g., UserRegistered, SessionStarted. No suffix — the Event/ folder implies the type.
--context / -cBounded context folder name. Required.
--aggregate / -aAggregate root that emits this event. Required.

Example

php bin/console vortos:make:domain-event SessionStarted --context=Training --aggregate=TrainingSession

Creates

src/Training/Domain/TrainingSession/Event/SessionStarted.php

Generated 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

nameError name without the Error suffix. PascalCase. E.g., UserNotFound.
--context / -cBounded context folder name. Required.
--aggregate / -aAggregate root this error belongs to. Use when the error is specific to one aggregate.
--sharedGenerate into Domain/Shared/Error/. Use when the error is shared across aggregates in the context.
--status / -sHTTP 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=403

Creates

# aggregate-specific
src/Training/Domain/TrainingSession/Error/SessionNotFoundError.php

# shared
src/Training/Domain/Shared/Error/UnauthorisedError.php

Generated 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

nameService class name. PascalCase. E.g., AgeGroupCalculator, PricingPolicy.
--context / -cBounded context folder name. Required.
--aggregate / -aAggregate this service is specific to. Use when only one aggregate uses it.
--sharedGenerate 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 --shared

Creates

# aggregate-specific
src/Order/Domain/Order/Service/PricingCalculator.php

# shared
src/Order/Domain/Shared/Service/CurrencyConverter.php

Generated 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.

On this page