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.
Architecture fit
ReadModelCursorPaginator handles query-backed lists; ApiCursorPageMapper + RelayedCursorPaginator handle API-backed lists. Both return Illuminate\Pagination\CursorPaginator, so controller signatures, Resource::collection(), response factories and Scramble docs are untouched. The abstraction seam stays at the paginator, which is the right place for it.Cursor::encode() reconstructs a token from decoded parameters and is not guaranteed to return the same string, and deriving a foreign API's ordering would couple the host to a sort it does not control. RelayedCursor makes the token genuinely opaque rather than nominally so.RelayedCursorPaginator deriving nothing from the items is consistent with the constraint that API read models have no sort column. Good separation: the paginator knows no token format, only ApiCursorPageMapper knows the Laravel-resource-collection shape. Third-party response shapes need a small mapper, not a fork of the paginator.data, meta, non-integer per_page, missing cursor keys, non-Laravel token) throws rather than silently rendering "last page, no results". Same philosophy as the 0.8.0 missing-data behaviour, and the right one.ReadModelCursorPaginator untouched. DB-only and existing-cursor consumers see no change.Integration feasibility
Entity::hydrate() version semantics) under a "Fixed" heading.ApiRepository — no ApiClient binding, no authenticator wiring, no FQCN aliasing. Integration effort is: pass a path (your own route, not the API's), supply mapItem, and handle ApiException on the list endpoint.data plus meta.{per_page,next_cursor,prev_cursor}, names configurable). A non-Laravel upstream needs a bespoke mapper feeding RelayedCursorPaginator, which is deliberately format-agnostic — reasonable, but it is host code, not package code.filter serialization is an integration decision that must be made up front: the mapper builds the query, but the upstream must accept the resulting shape (nested arrays vs repeated keys). Getting this wrong is a silent "no results" rather than an exception.cacheKey flushing, exception class/message churn, project-local API layer rename. 0.8.1 adds none of its own.Technical risk
next_cursor → next) into a hard failure on a page users browse. Version the upstream contract, and alert on ApiException volume on read endpoints.next/prev links is now the upstream's token. If that token is signed, expiring, or encodes more than a position (sort key, tenant, filter hash), your links inherit those properties: bookmarked or shared page URLs can stop working, and the token may carry more information than the ID it sorts on. Confirm what the upstream puts in its cursors before exposing them publicly.filter while keeping cursor, the mapper will forward both; nothing detects the inconsistency, and the upstream will return either an error or a page from a different ordering. Dropping the cursor on filter change, or hashing the filter into it, is host responsibility.path argument plus the request query. A wrong path (relative vs absolute, missing prefix) emits broken pagination links that tests asserting only on data will miss.commands() runs in recording order; if call N fails, calls 1..N-1 have landed, the aggregate still carries the events, and there is no compensation. No idempotency keys, no automatic write retry. Design a saga/compensation or a durability queue before using ApiRepository for any multi-command aggregate.Entity::hydrate() version semantics (unchanged, 0.8.0). A model without version previously yielded persistedVersion() === 0 (always ConcurrencyException); it now keeps the default of 1. Partial selects that omit version carry a wrong expected version and fail on save — loud, but only for some rows, which is harder to spot than the previous consistent failure.cacheKey holding a serialized DTO breaks deserialization. Deployment must flush the key or leave cacheKey unset.NotFound, Forbidden, Unauthorized, ValidationFailed, Unavailable, base ApiException) and changed messages. Host code branching on message text falls through to a generic handler — and now does so on read paths too.Key questions
ApiRepository participate in any unit-of-work / transaction boundary, or is the host expected to supply compensation for the non-atomic command sequence?ApiClient instance resolved per aggregate/repository, and what is the recommended binding pattern?ReadModelCursorPaginator::fromPage() handle the case where the mapper returns items that do not retain their source model (e.g. mapped to plain arrays or DTOs)?Infrastructure\Api and Infrastructure\Pagination?ApiCursorPageMapper limited to cursor field names and the filter key, or is there a documented extension point for an entirely different payload shape short of writing a mapper?filter array serialized into the query string, and is that encoding configurable per client?filter/sort, or is dropping it on filter change explicitly host responsibility?meta fields beyond per_page/next_cursor/prev_cursor (e.g. total, has_more) intentionally dropped by RelayedCursorPaginator, and can a host extend it to relay them?path validated or normalized (absolute vs relative, base URL), and are non-pagination query parameters preserved in the generated links?Stack fit
CursorPaginator, Resource::collection() and generated docs keep working, and the API read path needs no container binding or middleware assumptions.ApiRepository, a whole aggregate can now be served without local tables — list via relayed cursor pages, detail via RemoteModel. That is the notable architectural milestone in this release: the API path is no longer write-only, so "remote-backed aggregate" is a complete story rather than a half one.ReadModelCursorPaginator; endpoints can be migrated one at a time with no shared state.Migration path
cacheKey flush, exception mapping) — 0.8.1 does not change any of it.Resource::collection($page) from ApiCursorPageMapper::fromResponse(). This is the migration the release notes explicitly point at, and it removes bespoke link and token handling.filter changes (simplest and correct), or hash the filter into the cursor. Do not leave it implicit.path to your own route and add a test that asserts on links.next, not only on data — the one class of defect that unit tests on the mapper will not catch.ApiRepository, RemoteModel, exception classes; rebind the client; switch tokenPath; update catch blocks and message assertions together. Sequence the cacheKey flush with the code release.Compatibility
ReadModelCursorPaginator untouched. Lowest-risk item in either release.Sequencing
version where needed.ReadModelCursorPaginator on existing cursor-paginated read endpoints — immediate correctness win, independently deployable.ApiRepository. Define the compensation story for multi-command aggregates before writing commands().Maintenance
Support
ApiException from the mapper — missing data/meta, non-integer per_page, non-Laravel token — surfaces as a 500 on a page, not on a background write. Centralize the exception → HTTP status/payload mapping once so controllers do not each invent one.next/prev URLs contain an upstream-owned token that may expire; bookmarked and shared links are not guaranteed stable. Set expectations before users report it.Scaling
Failure modes
| Failure | Behaviour | Operational consequence |
|---|---|---|
Upstream response missing data/meta/cursor keys, or non-integer per_page |
ApiException thrown; page fails |
Upstream schema drift becomes a hard outage of the list view; needs monitoring and a versioned contract |
Cursor carried across a filter change |
Mapper forwards cursor and filter together | Wrong or inconsistent page; undetectable locally; drop the cursor on filter change |
| Upstream cursor expired or signed with short TTL | Upstream rejects the token | Shared/bookmarked page URLs stop working; document and expect it |
Wrong path passed to fromResponse() |
Links point at the wrong route or host | Broken "next" for API consumers; only caught by link assertions |
| Upstream 5xx or connection failure on page fetch | ApiException (Unavailable) |
User-facing error on a browsing page; consider cached fallback |
| Partial command failure (write path) | Earlier calls persisted, aggregate retains events, no rollback | Requires host compensation; possible duplicate or inconsistent remote state |
| Crash after last call, before publish | Events never published | Silent loss of integration events; no outbox to recover from |
| Stale token cache key on deploy | Token deserialization fails | Auth outage for API-backed aggregates until key is flushed |
| Unmapped domain event | Thrown before the first call | Safe by construction; surfaces as a loud runtime error in a path CI may not exercise |
| 401 on a fixed-credential authenticator | No retry (only RefreshableAuthenticator repeats) |
Hard failure; correct, but needs a clear error message |
| Write retried by caller or upstream | No idempotency key, no automatic write retry | Duplicate remote resources possible |
Partial-select entity without version |
persistedVersion() === 1 regardless of row state |
ConcurrencyException on save — loud but late |
| Empty cursor string in request | Treated as no cursor | Correct; avoids a spurious upstream error on first load |
Ramp-up
path, write mapItem, return Resource::collection(). The conceptual load is one idea — you do not own the cursor; do not decode, re-encode or reconstruct it.ReadModelCursorPaginator (you own the query and the sort) and ApiCursorPageMapper/RelayedCursorPaginator (you own neither). The names are similar and the wrong choice fails in a confusing way, usually as a plausible-looking empty page.How can I help you explore Laravel packages today?