lin3s/shared-kernel
A lightweight PHP shared kernel offering reusable DDD building blocks such as value objects, identifiers, aggregates, domain events, and specifications for consistent modeling across bounded contexts.
This is a DDD shared kernel, not a utility library: it is meant to be the small set of building blocks (entities, value objects, aggregate roots, domain events, command/query bus contracts) that several bounded contexts of one product agree on. The current tag is v0.9.8 — still pre-1.0, with the last meaningful activity in the Symfony 4 era. Treat it as a reference implementation you vendor or copy, not a package you upgrade over time.
src/Domain/Model first — it defines the vocabulary you will use every day (ValueObject, Entity, AggregateRoot, DomainEvent, DomainEventStream, identifier/UUID types, possibly Enum/Collection/Specification). Then read src/Application/Command and src/Application/Query (interfaces only). Also look at any Resources/config directory — the v0.9.8 note about Tactician event subscriber definitions confirms the package ships Symfony-flavoured DI service definitions alongside the PHP contracts.composer.json constraints. A Symfony-4-era package typically pins php: ^7.x and old PHPUnit/Doctrine versions. If you are on PHP 8.1+, either install with --ignore-platform-reqs in a scratch project to evaluate it, or fork and bump constraints.Minimal smoke test:
composer require lin3s/shared-kernel
// src/Domain/Order/OrderId.php
final class OrderId extends /* package identifier base */ \LIN3S\SharedKernel\Domain\Model\Uuid
{
public static function generate(): self { return new self(/* uuid */); }
}
// src/Domain/Order/Money.php
final class Money extends \LIN3S\SharedKernel\Domain\Model\ValueObject
{
private $amount;
private $currency;
public function __construct(int $amount, string $currency) { /* guard */ }
public function equals(ValueObject $other): bool { /* compare */ }
}
Then confirm the package’s equals(), __toString() and identifier factory signatures against the source — they are the contract you will type against in every class.
Value objects everywhere. Extend the package ValueObject, keep properties private and immutable, validate in the constructor (throw domain exceptions), expose only getters and equals(). Do not add setters; create new instances for state change.
Aggregate roots own event recording. Subclass AggregateRoot, call the protected record() (or equivalent — check the exact method name) in named constructors and behaviour methods, and pull events out in the repository or command handler:
final class Order extends AggregateRoot
{
public static function place(OrderId $id, Money $total): self
{
$order = new self($id, $total, OrderStatus::placed());
$order->record(new OrderPlaced($id, $total));
return $order;
}
public function pay(PaymentId $paymentId): void
{
// guard invariants
$this->record(new OrderPaid($this->id, $paymentId));
}
}
Dispatch after persistence, not before. The classic workflow: handler invokes the aggregate → repository saves → $events = $order->releaseEvents() → publish through your event bus (or an outbox table) → clear. DomainEventStream from the package is a convenient immutable collection to iterate.
Buses are interfaces — bring your own. Implement CommandBus/QueryBus with a simple class-name→callable map for small apps, or bind them to Laravel’s container/dispatcher. Keep one handler per command class and let the bus do the lookup:
final class InMemoryCommandBus implements CommandBus
{
/** @var callable[] */
private $handlers = [];
public function register(string $commandClass, callable $handler): void
{ $this->handlers[$commandClass] = $handler; }
public function dispatch(Command $command): void
{ ($this->handlers[get_class($command)] ?? throw new \RuntimeException(...))($command); }
}
The package’s shipped Tactician wiring (the thing v0.9.8 touched) is a Symfony service-definition convenience and is not something a Laravel app will consume; on Laravel you either write the ~40-line in-memory bus above or expose the handler map as tagged bindings in a service provider.
Keep infrastructure out. Doctrine mappings, HTTP controllers, serializers, and framework services belong in your app’s Infrastructure layer, never in the shared kernel or your domain. The kernel is deliberately persistence-agnostic — map aggregates with XML/YAML Doctrine config or your own mapper, or with Eloquent models + a hand-written repository in Laravel.
Per-context use. Because a shared kernel is, by definition, shared by contexts, resist the urge to dump generic helpers (string utils, arrays) into it. Only add types that at least two bounded contexts genuinely share, and version it inside your monorepo rather than via Packagist.
Testing pattern. Assert recorded events directly in unit tests ($order->releaseEvents() after a behaviour call) instead of mocking an event bus. This keeps aggregate tests fast and the bus as a thin integration seam.
Serializable, implicit nullable parameters, missing return types on interfaces, and #[\ReturnTypeWillChange] warnings before adopting, or fork it (MIT licence permits this) and pin your own tag.config/*.php by hand.Resources/config, diff the two and apply the public: true change locally, otherwise a copied config will keep the original container-compilation error.new CommandBus(...) you will fail — the package ships contracts. Budget a day to write the in-memory/Laravel adapter and its DI wiring.equals() semantics. Value objects often compare with == or via get_class checks; verify strict vs loose comparison, especially with floats/Money. Write an explicit equality test per value object.Uuid/Id base classes, extend rather than duplicate; two UUID generators in one codebase produce inconsistent string formats in URLs and DB columns.releaseEvents() usually clears the internal buffer. If you need events twice (e.g. sync publisher + outbox), copy them into your own array before releasing, or wrap publishing in a transaction so a failed dispatch doesn’t silently drop events.serialize() aggregates.LIN3S\SharedKernel\... namespaces leak into every domain file. If you fork, consider keeping the namespace to avoid churn, and document the fork in your README so future devs don’t composer update into a conflict.src/Shared/Domain.How can I help you explore Laravel packages today?