Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Domain Model Laravel Package

complex-heart/domain-model

Domain modeling toolkit for Laravel and PHP applications. Provides reusable base classes and utilities for entities, value objects, aggregates, and domain events, helping you keep business logic expressive, testable, and independent of framework concerns.

View on GitHub
Deep Wiki
Context7

Getting Started

Install

composer require complex-heart/domain-model

There is no config file and (almost certainly) no service provider to publish — this is a framework-agnostic PHP toolset that happens to be useful inside Laravel. Confirm by checking vendor/complex-heart/domain-model/composer.json for a laravel key in extra; if absent, nothing needs registering in config/app.php or bootstrap/providers.php.

First place to look

Read vendor/complex-heart/domain-model/src/ top to bottom before writing any code. The package is small enough to audit in ten minutes, and the class names in the README may lag behind the actual base classes. You are looking for four things:

  1. The base classes/interfaces for ValueObject, Entity, Aggregate, Service.
  2. What equality contract they impose (equals(), isEqualTo(), sameAs() — the exact name matters for every downstream comparison).
  3. Whether identity is modeled as a separate ID value object or just a scalar.
  4. Any collection/typed-list helper, since these packages usually ship one.

First use case

Do not start by rewriting your models. Start with the cheapest, highest-payoff conversion: one Value Object on the boundary of an existing feature.

// app/Domain/Shared/ValueObjects/Email.php
final class Email extends ValueObject
{
    private function __construct(private readonly string $value) {}

    public static function fromString(string $value): self
    {
        if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException("Invalid email: {$value}");
        }

        return new self($value);
    }

    public function toString(): string
    {
        return $this->value;
    }
}

Then use it in one place — a FormRequest's passedValidation(), an Action, or a Job — and see whether the "no invalid state" property actually buys you anything before rolling it out further.

Implementation Patterns

Value Objects at the edges, primitives in the middle

The highest-value pattern in Laravel is not "domain objects everywhere" — it is converting at the boundary and converting back at the exit.

// FormRequest
protected function prepareForValidation(): void
{
    // keep the raw input a string; convert in the controller/action
}

// Controller
public function store(StoreOrderRequest $request, PlaceOrder $placeOrder)
{
    $placeOrder->handle(
        CustomerId::fromString($request->string('customer_id')),
        Money::fromMinorUnits($request->integer('total_minor'), $request->string('currency')),
    );
}

Catch the package's validation exceptions in bootstrap/app.php (Laravel 11+) or app/Exceptions/Handler.php and convert them to ValidationException so the front end gets field-level errors instead of a 500.

Entities: identity-based equality, private mutation

Keep constructors private and expose named factories (register(), reconstitute()). The reconstitute() factory is the one you will call from repository implementations — it bypasses invariants that only apply on creation and rebuilds state from the database.

final class Order extends Entity
{
    /** @var OrderLine[] */
    private array $lines = [];

    public static function reconstitute(OrderId $id, CustomerId $customerId, OrderStatus $status): self
    {
        $order = new self($id, $customerId);
        $order->status = $status;

        return $order;
    }

    public function addLine(ProductId $productId, Quantity $qty, Money $unitPrice): void
    {
        if ($this->status !== OrderStatus::Draft) {
            throw new DomainException('Cannot modify a non-draft order.');
        }

        $this->lines[] = OrderLine::create($productId, $qty, $unitPrice);
    }
}

The aggregate root never exposes setters, never returns a mutable array, and returns copies or read-only views (array_map, ReadonlyCollection) from its getters.

Aggregates vs. Eloquent models — keep them separate

The single most important integration decision. Two viable shapes:

A. Separate persistence model (recommended for anything non-trivial).

app/Domain/Order/Order.php              // rich aggregate, no Eloquent
app/Models/OrderRecord.php             // Eloquent, $table = 'orders'
app/Infrastructure/OrderRepository.php // maps between the two

The repository hydrates the record, calls Order::reconstitute(...), and on save flattens back into order_records + order_lines inside a DB::transaction().

B. Aggregate extends Eloquent — tempting, but it forces public attributes and mutators onto your domain object, which is exactly what the package is designed to prevent. Only take this route for throwaway CRUD.

Domain events on aggregates

If the package's Aggregate base offers event recording, use it; otherwise add a releaseEvents(): array method yourself and dispatch from the application service:

DB::transaction(function () use ($order, $repository, $dispatcher) {
    $repository->save($order);

    foreach ($order->releaseEvents() as $event) {
        $dispatcher->dispatch($event);
    }
});

Dispatch after the transaction commits if the listeners touch external systems.

Services: stateless and interface-bound

Wrap the package's Service base (if it has one) around use cases, and bind the port in AppServiceProvider:

public function register(): void
{
    $this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
    $this->app->bind(PaymentGateway::class, StripePaymentGateway::class);
}

Keep services constructor-injected and free of app(), Auth::user(), or request() — pass those in as arguments so the service is testable without the container.

Test at the domain level, without the database

The payoff for all this ceremony is fast tests:

it('rejects adding lines to a confirmed order', function () {
    $order = Order::reconstitute(OrderId::generate(), CustomerId::generate(), OrderStatus::Confirmed);

    expect(fn () => $order->addLine(/* ... */))
        ->toThrow(DomainException::class);
});

No RefreshDatabase, no factories. Keep a small tests/Domain suite alongside tests/Feature.

Gotchas and Tips

Three stars, zero dependents — treat it as vendored code. Audit src/, pin an exact version in composer.json ("complex-heart/domain-model": "1.0.*" or a commit hash), and consider wrapping the base classes in your own thin abstractions (e.g. App\Domain\Shared\ValueObject) so a future replacement is a one-file change rather than a find-and-replace across the app. If the base classes are final or the constructors are private, you may need composition instead of extension — check before designing around inheritance.

The equality method name is load-bearing. Whatever the package calls it (equals, isEqualTo, sameAs), standardise on it everywhere and never mix with == or ===. In collections, use array_filter($items, fn ($i) => $i->equals($target)) rather than in_array($target, $items) — in_array will do an identity comparison and silently fail for value objects.

== on value objects works, === does not. PHP's loose comparison compares properties recursively, so two Money instances with the same amount and currency compare equal. That is convenient but masks the intent — prefer the explicit method so a future refactor to a class with object-typed properties doesn't quietly break it.

Eloquent and private properties don't mix. If you try to make an aggregate an Eloquent model, $model->fill() and attribute casting will bypass your constructor and invariants. Symptoms: Typed property must not be accessed before initialization, or worse, null sneaking into a non-nullable property. Use separate persistence models.

Hydration is where bugs live. Write reconstitute() once per aggregate and test it against a real row: a boolean 0/1 from MySQL becoming false in a constructor that expects bool, DECIMAL columns coming back as strings, dates arriving as string and needing DateTimeImmutable. Cast explicitly in the repository, never in the domain object.

Serialisation to JSON needs an escape hatch. Domain objects with private readonly props will serialise as {}. Add a toArray()/toNative()/jsonSerialize() method on each object and build Laravel API Resources on top of that — do not json_encode an aggregate directly.

Exceptions map to HTTP codes. Pick one convention: DomainException → 422, NotFoundException → 404, everything else → 500. Register it once in the exception handler and throw freely from inside the domain layer.

dd() on an entity is useless. Private properties won't show. Use dump($obj) (Symfony VarDumper reads private props via reflection), or add a __debugInfo() method, or serialise via your toArray().

Collections: hydrate eagerly, validate at the boundary. If the package ships a typed collection, filter with array_filter + array_values so keys don't become sparse — sparse arrays break json_encode into objects instead of arrays and will surprise your front-end.

Don't mock value objects. Construct real ones in tests; they're cheap and a mock hides the validation you're actually trying to verify. Mock repositories and gateways only.

Extension points to know. Look for protected methods on the base classes (comparison logic, ID generation) — those are the sanctioned overrides. If the base class uses an abstract id() or value() method, that is your seam. Avoid overriding __construct on a base class that expects specific setup.

Framework version churn. Check the package's PHP constraint against your Laravel version. A toolset written for PHP 8.1 won't install on a Laravel 10 app pinned to PHP 8.0, and a package with no releases in months may not have readonly-property support if you're on an older runtime.

Where this package earns its keep: money, email, phone, address, date ranges, order status transitions, and any aggregate with invariants that currently live only in FormRequests. Where it doesn't: simple CRUD resources. Convert one aggregate, measure the test-suite speed and bug count, then decide whether to continue.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin