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.
ReadModelCursorPaginator::fromPage() unblocks cursor pagination on pages whose items are read models (feeds, activity streams, transaction lists, audit views). Product consequence: infinite scroll and "load more" surfaces that stay correct while rows are inserted, without offset drift or a sort column leaking into the read model.ApiCursorPageMapper + RelayedCursorPaginator let us take a paginated answer from an external API and return it as a normal Laravel cursor page — data, links, meta — with our path in the links and the vendor's ordering and cursor tokens passed through untouched. Product consequence: an "Accounts", "Invoices", "Contacts" or "Tickets" screen can be built over a system we don't own, without reimplementing that system's sort semantics, and without leaking its URLs or token format to clients. This unblocks BFF / aggregation-layer / whitelabel-partner surfaces that would otherwise need bespoke per-vendor glue.ApiRepository, RemoteModel, ApiClient + ApiClientConfig) let a domain model a vendor system — billing, CRM, identity, logistics, tax — with the same aggregate/domain-event semantics as a local table. With v0.8.1 the same vendor can also own a list surface in our product, so a single vendor integration now covers detail views, mutations and index pages. That is a buy/extend decision: model the vendor behind a port, versus scattering SDK calls through services and controllers.ApiRepository writes are command-based (one API call per DomainEvent), run in recording order, and are not atomic — a mid-sequence failure leaves earlier calls done and events on the aggregate. Any roadmap item backed by a remote aggregate needs a compensating-action/saga story and a user-facing failure mode agreed before build. Note the asymmetry that v0.8.1 makes sharper: list reads through the relay are safe today; writes are the part that needs design.data, meta, an integer meta.per_page or the cursor keys — or carrying a token that isn't a Laravel cursor — throws ApiException. A broken upstream answer never renders as "last page, no results". Product consequence: we need an agreed empty/error state for every relayed list, and we should expect vendor-contract drift to surface as visible errors rather than silent truncation. That is the right trade, but it must be staffed.Entity::hydrate() no longer crashes when created_at/version are absent, and no longer coerces a missing version to 0. Product consequence: aggregates hydrated from API payloads or partial SELECTs are viable; the flip side is a documented rule — include version in partial selects or lose optimistic locking on save.ApiRepository, RemoteModel, HTTP exceptions and token DTOs — and v0.8.1 explicitly offers to replace hand-rolled "page value objects" for API lists with ApiCursorPageMapper. If a team already built either, "replace local layer with package classes" is a scheduled, low-risk refactor rather than a rewrite.ReadModelCursorPaginator unchanged. It can be adopted inside an existing sprint when a feature is blocked on a vendor-backed list, without a platform workstream or a bespoke internal library negotiation.Adopt when:
updateWithVersionGuard().->through() plus a hack, or a sort column leaking into the read model purely to satisfy the paginator.ApiCursorPageMapper + RelayedCursorPaginator is a direct fit when the vendor answer already looks like a Laravel resource collection (data, meta.per_page, next_cursor, prev_cursor) and its cursors are Laravel-decodable tokens.Unauthorized, Forbidden, NotFound, ValidationFailed, Unavailable, base ApiException) so error UX and retry policy are decided once — and a single, explicit "fail loud, never return a false empty page" rule for lists.RelayedCursor exists precisely because Laravel's Cursor::encode() re-encodes from decoded parameters and isn't guaranteed to round-trip — if your vendor is strict about token strings, this is the reason to use the relay rather than hand-rolling.Look elsewhere or wait when:
ApiCursorPageMapper is deliberately narrow; you'd write your own mapper feeding RelayedCursorPaginator (which is format-agnostic), but you'd also forgo cursor semantics the vendor doesn't offer.filter query key is a product requirement — the relay forwards filters, it does not normalise or reimplement them per vendor.hydrate() behaviour changes and a cache-key incompatibility, so expect API churn and budget upgrade time each release, even when a given release (like this one) is a clean no-op.ApiClient is deliberately thin — relayed lists multiply the number of upstream calls, so verify the operational story before putting a high-traffic screen behind it.RelayedCursorPaginator from items, page size and neighbouring tokens".For executives (one paragraph). This release rounds out an open-source Laravel package we already build on, and it finishes a story: we can now not only model third-party systems (billing, CRM, identity) as first-class parts of our domain, but also serve their paginated lists through our API as ordinary cursor pages — with our URLs, our error types and our auth story, and without reimplementing or exposing theirs. That removes a recurring bespoke build: every vendor-backed list screen ("Accounts", "Invoices", "Contacts") currently needs its own plumbing, and this makes it a few lines plus a mapper. It is additive and opt-in — nothing we run today has to change, no container bindings, and any hand-rolled page objects an application already has can be swapped out later. The strategic value is de-duplication: one maintained integration layer instead of one per vendor, with one failure vocabulary and a deliberate "fail loud, never show a false empty list" rule. Two decisions remain at product level: consistency — writes through a remote system are not atomic and are not retried, so any journey that writes to a vendor needs a compensation and failure-UX decision before we build; and proxying — relaying vendor lists through our backend is now cheap, but it means we absorb vendor latency and rate limits, and we inherit their page-size ceiling. Reads, lookups, enrichment and relayed lists are low risk today.
For engineering (bullets).
ReadModelCursorPaginator is unchanged. New surface: Infrastructure\Pagination\ApiCursorPageMapper, RelayedCursorPaginator (a CursorPaginator subclass), RelayedCursor.->through(...) on a cursor-paginated page with ReadModelCursorPaginator::fromPage($page, $mapper) — still an Illuminate\Pagination\CursorPaginator, so return types and Scramble keep working; cursors are read from each read model's tracked source model, so the domain stays free of persistence knowledge; filter()/reordering and JsonResource wrappers are handled. Remote: replace a hand-rolled API page value object with ApiCursorPageMapper and return Resource::collection($page) directly.path you pass, so vendor URLs never leak; the request query string (filter, per_page, cursor) is preserved in the links. Query key names are configurable and unset values are omitted.RelayedCursor keeps the vendor token byte-for-byte because Cursor::encode() re-encodes from decoded parameters and is not guaranteed to return the same string. An empty cursor string is normalised to "no cursor". Anything else that isn't a Laravel cursor throws.data, missing meta, missing an integer meta.per_page, missing either cursor key, or carrying a non-Laravel cursor throws ApiException. A malformed answer never reads as "last page, no results". Budget error states and vendor-contract-drift alerts accordingly.RelayedCursorPaginator — which is format-agnostic and knows no token format.hydrate() behaviour change from v0.8.0 — still the thing to read before merging a dependency bump. A model with no version used to yield persistedVersion() === 0 (guaranteed ConcurrencyException on update); it now keeps the entity default of 1. If you select columns partially, include version or you silently lose optimistic locking. Table-backed models with a version column are unaffected.commands() maps every DomainEvent to an API call; an unmapped event throws UnmappedDomainEvent before any call. Calls run in recording order, the aggregate is hydrated from the last response, events publish afterwards. On failure: earlier calls already happened, events stay on the aggregate — design compensation accordingly.RefreshableAuthenticator gets one retry after a 401; fixed credentials get none. Missing data payloads and missing access_token throw rather than reading as "no results" — fail loud, not empty. Relayed lists add upstream call volume, so per_page choices and any caching belong to the host application.Lava83\LaravelDdd\Infrastructure\Api\ApiClient (a thin subclass is fine) or the container can't resolve ApiRepository. Pass tokenPath explicitly (default oauth/token). Do not reuse an old cache key holding a serialized token DTO — tokens are cached as a plain string now; leave cacheKey unset or flush on deploy, and drop the cache.serializable_classes entry. Update catch blocks and message assertions (API … prefix).hydrate(); v0.8.1 is the counter-example, additive and no-op, but the policy stands.How can I help you explore Laravel packages today?