lava83/laravel-ddd
A Laravel package for structuring applications with domain-driven design, offering building blocks such as domain layers, entities, value objects, and repositories to help separate business logic from framework code.
This assessment covers lava83/laravel-ddd as of v0.8.1. Both recent releases are additive: v0.8.0 changed database-backed projects only if they paginate read models with cursors or hydrate entities from partial sources, and v0.8.1 adds the consuming side of cursor pagination with nothing bound in the container. Existing code upgrades with no changes.
Minimal setup
composer require lava83/laravel-ddd and publish whatever config the package ships, then check the README sections for the domain/application/infrastructure layering it expects.Entity (identity, hydrate(), persistedVersion(), updateWithVersionGuard())Model and how aggregates map to rowsDomainEvent and how events are collected on the aggregate and published after persistenceAccount with a handful of invariants — model it on the base Entity, give it a repository backed by Eloquent, and run a create → mutate → save → update-with-version-guard cycle in a feature test. That exercises the whole spine of the package in an afternoon.AccountReadModelMapper::fromAccountModel()), then wrap the cursor page with ReadModelCursorPaginator::fromPage() (see below). This is the lowest-risk way to see the CQRS split working end to end.ApiCursorPageMapper to build the request and turn the response into a RelayedCursorPaginator, then return Resource::collection($page) — same JSON shape as any Laravel list. This is the natural pairing to step 4: producing cursor pages locally, consuming them remotely.Cursor-paginating read models (producing side)
The core problem the paginator solves: Laravel's CursorPaginator reads next_cursor / prev_cursor off the page items, keyed by the ORDER BY column. Once you map rows to read models, a sort on account_id blows up because the read model only exposes id. A SELECT alias doesn't help — the cursor WHERE clause can't use it.
use Lava83\LaravelDdd\Infrastructure\Pagination\ReadModelCursorPaginator;
return ReadModelCursorPaginator::fromPage(
$this->baseQuery($filter)->orderBy('account_id')->cursorPaginate($perPage),
fn (AccountModel $m) => AccountReadModelMapper::fromAccountModel($m),
);
Patterns that fall out of it:
filter() and collection reordering, and works when items are wrapped in JsonResource.Illuminate\Pagination\CursorPaginator, so controller return types and Scramble/OpenAPI generation keep working. Declare the parent type, not the subclass, in signatures that face Laravel.fromPage() is deliberately not map() — a static map() would shadow the collection forwarding Laravel uses when serialising resource collections. If you have a local copy with map(), swap it out and rename call sites.Cursor-paginating a remote list (consuming side, new in 0.8.1)
When the list lives behind another API, you have no query and no model — only data, meta and opaque cursor tokens. Rebuilding that API's ordering and token format buys you a breakage the moment it changes its sort. ApiCursorPageMapper relays the tokens instead:
use Lava83\LaravelDdd\Infrastructure\Pagination\ApiCursorPageMapper;
$pages = new ApiCursorPageMapper;
$response = $api->get('v1/accounts', $pages->query(
filter: ['status' => 'active'],
perPage: 50,
cursor: $request->query('cursor'),
));
return $pages->fromResponse(
$response,
path: '/accounts',
mapItem: fn (array $payload) => AccountReadModel::fromArray($payload),
);
ApiCursorPageMapper owns request shaping and response parsing; RelayedCursorPaginator owns the page. RelayedCursor keeps each token byte-for-byte, because Laravel's Cursor::encode() rebuilds a token from decoded parameters and is not guaranteed to return the same string.RelayedCursorPaginator is built from items, page size and the neighbour tokens, so your read models need no sort column — the constraint that forced ReadModelCursorPaginator to retain source models simply doesn't exist here.path to fromResponse() and the generated links point at your endpoint. The incoming request query (including filter) is preserved in the links, so frontends keep their parameters across pages.per_page, cursor and filter names are configurable on the mapper; unset values are omitted rather than sent empty.?cursor= from a frontend reset behaves like a first-page request rather than an error — handle that in your controller, not with a special case in the mapper.data + meta.{per_page,next_cursor,prev_cursor}). For anything else, write a small mapper that extracts items/page size/tokens and hand them to RelayedCursorPaginator, which knows nothing about token format or payload shape.Resource::collection($page) directly — the serialisation contract is unchanged.Wiring an API-backed aggregate
The host owns construction — the package binds nothing — which is what lets several APIs coexist:
// AppServiceProvider::register()
$this->app->singleton(ApiClient::class, function ($app) {
return new ApiClient(
ApiClientConfig::for(baseUrl: config('services.billing.url'), timeout: 5),
new ClientCredentialsAuthenticator(
// tokenPath defaults to 'oauth/token'
clientId: config('services.billing.client_id'),
clientSecret: config('services.billing.client_secret'),
),
);
});
// Required: repositories type-hint the package class
$this->app->alias(ApiClient::class, \Lava83\LaravelDdd\Infrastructure\Api\ApiClient::class);
ApiRepository type-hints ApiClient; without the alias the container can't resolve repositories. A thin project subclass is fine for your own type hints, but the alias must exist.ApiCursorPageMapper reads — a read-only list does not require the alias if you resolve the client yourself.RemoteModel. It's a plain attribute bag for fromState() / hydrate(); save(), delete() and newQuery() throw LogicException by design. Treat it as a DTO, not a model.commands() mapping each DomainEvent to the API call that realises it. Treat the map as exhaustive — the repository validates it up front (see Gotchas). Calls run in recording order; the aggregate is rehydrated from the last response; events publish afterwards.ClientCredentialsAuthenticator caches the token as a plain string (no cache.serializable_classes entry needed); StaticBearerAuthenticator for fixed credentials. Implement RefreshableAuthenticator on your own authenticator if you want the single 401-repeat; fixed credentials never get one.Unauthorized, Forbidden, NotFound, ValidationFailed, Unavailable — and convert them into your application's own failure semantics rather than letting transport exceptions bleed into domain/application code.ApiRepository / RemoteModel / UnmappedDomainEvent / HTTP exceptions / token DTO / API page value object with the package classes, pass tokenPath explicitly, and update catch blocks and any tests asserting on messages (they now read API …).Optimistic locking
Keep passing version through your read/write cycle and use updateWithVersionGuard(); catch ConcurrencyException at the application layer and surface a retry or conflict response. This behaviour is unchanged across 0.8.x — but see the hydrate change below, which can silently alter the starting version.
Upgrade hazards
ReadModelCursorPaginator is untouched. The only work is intentional — deleting a local page value object in favour of ApiCursorPageMapper.Entity::hydrate() version semantics changed in 0.8.0. Previously a model carrying no version produced persistedVersion() === 0, which guaranteed a ConcurrencyException on the next save. It now keeps the entity default of 1. If you ever select columns partially before saving, include version or you'll be persisting against a guessed version. Table-backed models with a real version column are unaffected.cacheKey unset (it's derived from base URL + client id) or flush the old key as part of the deploy. The old cache.serializable_classes entry can be deleted.->through(...) for fromPage(...) on any cursor-paginated read-model page, and delete local copies of the paginator. Return types don't change, so this is a mechanical, low-risk replacement.Remote list consumption — the sharp edges
RelayedCursor exists precisely because re-encoding changes the string. No validation, no decoding, no "normalising" on the way through — pass $request->query('cursor') straight in and meta.next_cursor straight on.order_by parameters to make the tokens line up with your own sort, you've re-implemented the thing this release removes. Sort client-side only if the contract allows it, and never assume the remote list is sorted the way yours would be.data, meta, an integer meta.per_page, or either cursor key — or carrying a token that isn't a Laravel cursor — throws ApiException. That's deliberate: a broken answer must never render as "last page, no results". Make sure your exception handler doesn't blanket-convert ApiException into an empty 200.path is required for correct links. Omit or fudge it and you'll leak the upstream API's URLs into your own responses. Verify links in a test the first time, including that filter survives.next_cursor in meta is an error, not the same thing.RelayedCursorPaginator, not the mapper. Don't bend the mapper's parsing with subclasses; a tiny extractor is clearer and keeps the paginator free of format assumptions.API aggregates — the sharp edges
UnmappedDomainEvent is thrown before the first HTTP call, which is the good case — but only if you have a test asserting the map is exhaustive. Add a unit test that iterates your aggregate's event types against commands(); otherwise a newly added domain event fails at runtime, in production, on the happy path.data payload throws rather than reading as "zero results"; a token response without access_token throws. If you have error handling that swallows exceptions and returns empty collections, it will mask these — check that your repository decorators don't.RemoteModel is a DTO. Any generic code that calls save(), delete() or newQuery() on an aggregate's model will now throw LogicException. Audit shared base repositories and test helpers before pointing them at a remote aggregate.RefreshableAuthenticator implementations get a single repeat. If your provider rotates tokens aggressively, handle the second failure yourself.Pagination and debugging
UnexpectedValueException — unchanged Laravel behaviour, and it's the most common symptom of a stale client-side cursor after a sort change on the local producing side. Don't try to "fix" it in the paginator; on the consuming side the equivalent is the remote API returning a 400 on a stale token, which you should surface rather than swallow.links. Both paginators preserve the incoming query. If a frontend relies on custom params surviving pagination, verify it explicitly after switching to fromPage() / fromResponse().ApiClient boundary (method, path, status). Because missing data throws, an empty page and a malformed payload are distinguishable — lean on that rather than guessing.ReadModelCursorPaginator, ApiCursorPageMapper and RelayedCursorPaginator are infrastructure; return the parent CursorPaginator type upward and let the transport layer deal with it.How can I help you explore Laravel packages today?