complex-heart/sdk
A Laravel/PHP SDK for the Complex Heart platform, offering a clean, expressive client with typed responses, authentication helpers, and framework-ready service providers and facades for quick integration.
composer require complex-heart/sdk
There is no service provider, no config file, and no publishable assets — it's a plain library of abstract classes, traits, and small value objects. Installation is genuinely just the require. Verify the PHP constraint in vendor/complex-heart/sdk/composer.json before you commit (it targets modern PHP; if your app is still on 8.0 you'll get a resolver conflict).
Where to look first. Don't hunt for docs — src/ is the documentation. Spend ten minutes on:
find vendor/complex-heart/sdk/src -type d
grep -rn "abstract class\|^trait " vendor/complex-heart/sdk/src
You'll find two functional areas worth caring about:
Domain/Model — base ValueObject, Entity, AggregateRoot and the traits that compose them (identity, attributes, timestamps, domain events).Domain/Criteria — filter/order/pagination objects for describing queries without leaking SQL into your domain.First use case: a value object, not a rewrite. The highest-value, lowest-risk entry point is wrapping one primitive in a ValueObject. Pick something with real invariants — an email, a money amount, a tenant ID — and leave the rest of your models alone for now:
use ComplexHeart\Domain\Model\ValueObject;
final class Email extends ValueObject
{
public function __construct(private readonly string $value)
{
if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException("Invalid email: {$value}");
}
}
public function __toString(): string
{
return $this->value;
}
}
Check which methods the base class actually leaves abstract (grep -n "abstract" src/Domain/Model/ValueObject.php) — depending on the release it may expect __toString() only, or a broader serialization contract. Getting this right on day one saves an afternoon.
Once a VO feels natural, move up one level: an AggregateRoot with a couple of recorded events, then a Criteria object passed to a repository. That's the package's whole arc, and it's a day's work.
Aggregate + events, flushed at the application boundary. The pattern the SDK nudges you toward is: mutate the aggregate, record events on it, and release them exactly once — in the command handler, after the transaction commits:
final class Order extends AggregateRoot
{
public static function place(OrderId $id, CustomerId $customer): self
{
$order = new self($id);
$order->record(new OrderWasPlaced($id));
return $order;
}
}
// command handler
DB::transaction(function () use ($command) {
$order = Order::place(OrderId::generate(), $command->customerId);
$this->orders->save($order);
$events = $order->releaseEvents();
});
foreach ($events as $event) {
event($event); // or dispatch explicitly
}
The important discipline: the domain records, the application dispatches. Don't let an Eloquent observer silently dispatch what the aggregate already recorded.
Criteria as the query contract. Criteria is the piece with the most day-to-day payoff and the most DIY work. It gives you a domain-safe way to express "filter by status, sort by created_at desc, page 2" that your controllers can build and your repositories can consume:
// controller / FormRequest
$criteria = Criteria::fromArray($request->validated());
// domain port
interface UserRepository
{
public function matching(Criteria $criteria): UserCollection;
}
Translate Criteria in the infrastructure layer, never in the domain. The package deliberately knows nothing about Eloquent/Doctrine. You write the adapter once:
final class EloquentUserRepository implements UserRepository
{
private const ALLOWED_FILTERS = ['status', 'role', 'created_at'];
public function matching(Criteria $criteria): UserCollection
{
$query = UserModel::query();
foreach ($criteria->filters() as $filter) {
abort_unless(in_array($filter->field(), self::ALLOWED_FILTERS, true), 422);
$query->where($filter->field(), $filter->operator(), $filter->value());
}
return new UserCollection($query->paginate($criteria->limit())->items());
}
}
Keep aggregate persistence hand-mapped. Resist extending your Eloquent models from Entity. The idiomatic shape is a thin UserModel for the database and a separate User aggregate plus a mapper. If that feels heavy for a small CRUD screen, that's a signal the SDK is the wrong tool for that screen — and that's fine, mixed codebases are normal.
Testing. The base classes make assertions cheap: self::assertTrue($a->equals($b)) for value objects, and self::assertContains(OrderWasPlaced::class, array_map('get_class', $order->releaseEvents())) for behaviour. Write those two test styles early; they're what keeps DDD refactors honest.
composer.json, commit composer.lock, and be prepared to vendor-fork. The code is small and MIT, so forking is realistic — but do the read-through before you build ten aggregates on top of it.CHANGELOG/commit history between releases; small libraries like this do ship breaking changes in minor versions.releaseEvents() is easy to forget, and forgetting it fails silently. Your aggregate records happily, the tests on the aggregate pass, and production just never sends the email. Add a unit test per aggregate asserting the expected event list.Criteria will hand you arbitrary field names. Always whitelist allowed filters and operators in the adapter — an unvalidated field from fromArray($request->all()) is a SQL injection surface, or at minimum a 500.equals() compares by property identity, reflection, or serialized form matters once a VO holds an array or another object. Two Money(100, 'USD') instances should compare equal; confirm the base does that rather than assuming.insteadof / as, and check the use blocks in the SDK's own abstract classes to see the intended combination.toArray()/jsonSerialize() shapes for persistence across versions; write your own mapper if you need durable representation.releaseEvents(), extend Criteria, compose the individual traits rather than inheriting the kitchen-sink aggregate. Everything else is designed to be left alone.tinker, dump($aggregate->releaseEvents()) before wiring dispatchers — it immediately shows whether the domain or the plumbing is broken.complex-heart/sdk is a reasonable low-ceremony choice. If you need full event sourcing, sagas, or projections, this is not that package — look at Broadway or a plain custom implementation instead of stretching these abstractions.How can I help you explore Laravel packages today?