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

Laravel Ddd Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. 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.
  2. Skim, in this order, to understand the contract surface you'll build on:
    • the base Entity (identity, hydrate(), persistedVersion(), updateWithVersionGuard())
    • the base Eloquent Model and how aggregates map to rows
    • the repository interfaces plus their Eloquent implementations
    • DomainEvent and how events are collected on the aggregate and published after persistence
  3. First use case (database path): take one aggregate you already have — an Account 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.
  4. First use case (read path, local): a paginated listing over your own tables. Build a read model + mapper (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.
  5. First use case (read path, remote): a listing that lives behind an API you don't own. Use 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.
  6. Only then consider the API-backed aggregate support — it is opt-in and binds nothing, so it can wait until you actually have a remote aggregate to write to. Consuming a remote list (step 5) does not require it.

Implementation Patterns

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:

  • Keep the mapper as the single translation point. The paginator retains each read model's source model internally, so the cursor values come from the persistence model and the domain stays persistence-agnostic. Don't leak source attributes into the read model just to satisfy pagination.
  • Filter/reorder after mapping is fine. The paginator tolerates filter() and collection reordering, and works when items are wrapped in JsonResource.
  • Type-hint the parent. It's still an 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.
  • One factory, consistently named. 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),
);
  • Two objects, two jobs. 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.
  • Nothing is derived from the items. 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.
  • Your URLs, not theirs. Pass 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.
  • Configurable query names. per_page, cursor and filter names are configurable on the mapper; unset values are omitted rather than sent empty.
  • Empty cursor means no cursor. ?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.
  • Custom API shapes: the mapper expects what a Laravel resource collection emits (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.
  • Replacing a hand-rolled page value object: delete it, construct the mapper, and return 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);
  • Naming the binding matters. 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.
  • Multiple APIs: register one configured client per API and inject it into that API's repositories explicitly; don't rely on a single global binding if you have more than one remote. The same client works for both aggregate writes and ApiCursorPageMapper reads — a read-only list does not require the alias if you resolve the client yourself.
  • Model remote aggregates on 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.
  • Writes are command-based, not diff-based. Implement 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.
  • Authentication: 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.
  • Translate at the edge. Catch the specific subclasses — 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.
  • Migrating a project-local layer: replace local 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.

Gotchas and Tips

Upgrade hazards

  • v0.8.1 is a no-op upgrade unless you maintain API-list pages by hand: nothing is bound in the container and 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.
  • Never reuse an old token cache key. Release 0.7-era caches hold a serialized token DTO; the package now stores a plain string and will fail on the stale entry. Either leave 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.
  • Swap ->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

  • Do not reinterpret the tokens. 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.
  • Don't rebuild the remote ordering. If you find yourself adding 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.
  • Loud failures are the point. A reply missing 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.
  • Test an empty page explicitly. Empty cursor string → first page is the intended contract; a missing next_cursor in meta is an error, not the same thing.
  • Custom shapes go through 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

  • Writes are not atomic. If the third call fails, the first two have already happened and the events stay on the aggregate. Design compensations or an outbox/retry story deliberately; don't assume the repository rolls back remote state.
  • Writes are never retried, and there are no idempotency keys. Only GETs get retries (connection errors and 5xx). Don't wrap write calls in your own blind retry loop — that's how you double-charge someone.
  • Map every event. 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.
  • Loud failures are intentional. A response without a 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.
  • 401 refresh is one-shot. Only RefreshableAuthenticator implementations get a single repeat. If your provider rotates tokens aggressively, handle the second failure yourself.

Pagination and debugging

  • A cursor that doesn't match the ordering still throws 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.
  • Query parameters stay in links. Both paginators preserve the incoming query. If a frontend relies on custom params surviving pagination, verify it explicitly after switching to fromPage() / fromResponse().
  • Debugging an empty API read: log at the ApiClient boundary (method, path, status). Because missing data throws, an empty page and a malformed payload are distinguishable — lean on that rather than guessing.
  • Keep the paginators out of your domain layer. ReadModelCursorPaginator, ApiCursorPageMapper and RelayedCursorPaginator are infrastructure; return the parent CursorPaginator type upward and let the transport layer deal with it.
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.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core
nodisrupt/laravel-agent
sierratecnologia/media-manager