vanio/vanio-domain-bundle
A Symfony bundle from Vanio for domain-driven design, providing base classes and infrastructure to organize domain models, repositories, and services cleanly within Symfony applications.
Install via Composer and register the bundle the usual way for the Symfony version you are on:
composer require vanio/vanio-domain-bundle
// config/bundles.php
return [
// ...
Vanio\DomainBundle\VanioDomainBundle::class => ['all' => true],
];
There is no dedicated configuration block you have to fight with for a first run — the bundle is mostly a library of base classes, Doctrine mappings and a few services. Read the tests/ directory before the src/ directory. With a package this small (single-digit stars, no downstream dependents published), the test cases are the most reliable specification of intended behaviour; the README is thin and there is no hosted documentation.
A good first use case is replacing a primitive in an existing entity with a value object. Start with something trivial and self-contained, such as an Email or Money value object:
use Vanio\DomainBundle\Model\AbstractValueObject;
final class Email extends AbstractValueObject
{
/** @var string */
protected $email;
public function __construct(string $email)
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email '$email'.");
}
$this->email = mb_strtolower($email);
}
public function __toString(): string
{
return $this->email;
}
}
Map it as a Doctrine embeddable and wire it into one entity. If that round-trips through persistence, forms and json_encode correctly in your application, you have validated the whole stack the package depends on.
Value objects as embeddables. The dominant pattern in this bundle is "small immutable object owned by an entity", mapped with Doctrine @Embeddable/#[ORM\Embedded]. Extending AbstractValueObject gives you consistent equality semantics (comparison by state, not identity), which is exactly what you want when the same email address appears in a query result and in a loaded entity. Use a private/protected constructor plus named static constructors (Amount::fromCents(), Amount::fromDecimal()) to keep creation explicit and guard invariants at the boundary.
Typed collections instead of arrays. The bundle's collection abstraction is the other half of the value-object story. Use it where you would otherwise write array<OrderLine> docblocks and then hand-roll add/remove/contains logic in the entity. Give the collection a method that expresses the domain operation ($lines->addForProduct($product, $qty)) rather than exposing add() directly, so that invariants stay in one place.
Domain events dispatched after the transaction commits. The interesting part of a DDD bundle is always event ordering. The pattern to aim for is: an entity records events internally ($this->recordThat(new OrderPlaced($this->id))), an aggregate-root base class collects them, and a Doctrine post-flush listener dispatches them once the unit of work has actually succeeded. Verify in a test that a listener thrown during dispatch does not leave you with a committed entity and an undelivered event — that is the failure mode worth writing a regression test for, because it will silently duplicate work the first time flush succeeds and dispatch fails.
Aggregate boundaries. Keep the aggregate root as the only entry point. Repositories return aggregates, aggregates return value objects and collections, and services coordinate. When wiring controllers, inject the repository service and translate HTTP input into value objects before the domain call — do not let form data reach entities directly.
Integration with the wider Vanio stack. This package is designed to sit alongside vanio/vanio-data-bundle and its form/data-mapper helpers. If you find yourself writing a lot of form data transformers for value objects, check whether the sibling bundle already provides the transformer; reimplementing it locally is a common source of drift. Doctrine types registered by the bundle are only useful if the mapping in your entity matches exactly what the type expects — mismatches manifest as opaque ConversionExceptions deep in UnitOfWork.
Testing. Prefer unit tests for value objects (pure, no kernel) and integration tests for embeddable mapping and event dispatch. Value objects are cheap to test exhaustively; the collection and event plumbing is where you want a booted kernel against a real database.
AlwaysEmbeddable-style wrapper), no lazy loading, and no association cycles. Decide on null semantics before you write the mapping — retrofitting an "optional" value object into an embeddable is painful.columnPrefix / #[ORM\Embedded(columnPrefix: 'billing_')] from day one, even if you only have one embeddable today.json_encode on an entity will serialise the value object's private state, not what __toString returns. If value objects leak into API responses, add normalizers explicitly rather than relying on JsonSerializable guessing.dump() calls feel like they land in the wrong place. Symfony\Bridge\Doctrine\Middleware\Debug\... or simply tagging your dispatcher with LoggerInterface logging and running with -vvv will show ordering far more clearly than breakpoints in a flush.final class with an equals() method is less surface area. The bundle earns its keep once you have a family of value objects and events that need consistent semantics.composer.lock and read the diff on every update.How can I help you explore Laravel packages today?