lava83 / laravel-ddd
A comprehensive toolkit providing foundational building blocks for implementing Domain-Driven Design (DDD) patterns in Laravel 13 applications. This package offers battle-tested base classes, contracts, and infrastructure components to help you build scalable, maintainable domain-driven applications
Fund package maintenance!
Requires
- php: ^8.4
- giggsey/libphonenumber-for-php: ^9.0
- illuminate/contracts: ^v13.29.0
- indexzer0/eloquent-filtering: ^2.2
- lava83/laravel-sqid: ^0.2.0
- spatie/laravel-data: ^4.18
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- dedoc/scramble: ^0.13.49
- deptrac/deptrac: ^4.6
- larastan/larastan: ^3.0
- laravel/pint: ^1.29
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^11.0.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-arch: ^5.0
- pestphp/pest-plugin-laravel: ^5.0
Suggests
- dedoc/scramble: Documents the list request blocks and meta.facets in the OpenAPI spec (^0.13.49)
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 2.x-dev
- v1.4.1
- v1.4.0
- v1.3.0
- v1.2.0
- v1.1.0
- v1.0.0
- v0.8.1
- v0.8.0
- v0.7.0
- v0.6.14
- v0.6.13
- v0.6.12
- v0.6.11
- v0.6.10
- v0.6.9
- v0.6.8
- v0.6.7
- v0.6.6
- v0.6.5
- v0.6.4
- v0.6.3
- v0.6.2
- v0.6.1
- v0.6.0
- v0.5.14
- v0.5.13
- v0.5.12
- v0.5.11
- v0.5.10
- v0.5.9
- v0.5.8
- v0.5.7
- v0.5.6
- v0.5.5
- v0.5.4
- v0.5.3
- v0.5.2
- v0.5.1
- v0.5.0
- v0.2.0
- v0.1.4.10
- v0.1.4.9
- v0.1.4.8
- v0.1.4.7
- v0.1.4.6
- v0.1.4.5
- v0.1.4.4
- v0.1.4.3
- v0.1.4.2
- v0.1.4.1
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
- dev-fix/scramble-spec-test
- dev-chore/prepare-1-0
This package is auto-updated.
Last update: 2026-11-07 14:02:34 UTC
README
Foundational building blocks for Domain-Driven Design in Laravel. The package ships battle-tested base classes and contracts — Entities, Aggregates, Value Objects, Repositories, Entity ↔ Model Mappers and Domain Events — so your applications can focus on the domain instead of the plumbing.
It enforces a strict layer separation (Domain, Application, Infrastructure) and gives you optimistic locking, automatic domain-event dispatching on save, and a set of ready-made value objects out of the box.
Requirements
- PHP 8.4+
- Laravel 13 (
illuminate/contracts^13.0)
Installation
composer require lava83/laravel-ddd
The service provider and the LaravelDdd facade are registered automatically through Laravel package discovery. There are no migrations to publish, and no configuration is required — you build your own domains on top of the provided base classes, as shown below. An optional config file tunes the make:aggregate scaffolder (see Scaffolding).
Quick start
The example models a single Article aggregate with a Title value object and persists it through a mapper and a repository. It is the smallest slice that still exercises every core building block: a value object, an aggregate, an Eloquent model, a mapper and a repository.
Suggested structure inside a consuming application:
app/
├── Domain/
│ └── Blog/
│ ├── Article.php
│ ├── Contracts/
│ │ └── ArticleRepository.php
│ └── ValueObjects/
│ ├── ArticleId.php
│ └── Title.php
├── Infrastructure/
│ ├── Mappers/
│ │ └── ArticleMapper.php
│ ├── Models/
│ │ └── ArticleModel.php
│ └── Repositories/
│ └── EloquentArticleRepository.php
└── Providers/
└── BlogServiceProvider.php
1. Value objects
An identity and a small, self-validating value object. Both are immutable.
<?php declare(strict_types=1); namespace App\Domain\Blog\ValueObjects; use Lava83\LaravelDdd\Domain\ValueObjects\Identity\Uuid; final class ArticleId extends Uuid {}
<?php declare(strict_types=1); namespace App\Domain\Blog\ValueObjects; use Lava83\LaravelDdd\Domain\Exceptions\ValidationException; use Lava83\LaravelDdd\Domain\ValueObjects\ValueObject; final class Title extends ValueObject { private function __construct(private readonly string $value) { parent::__construct(); } /** * @throws ValidationException */ public static function fromString(string $value): self { return new self(trim($value)); } public function value(): string { return $this->value; } public function equals(self $other): bool { return $this->value === $other->value; } public function jsonSerialize(): string { return $this->value; } public function __toString(): string { return $this->value; } protected function validate(): array { if ($this->value === '' || mb_strlen($this->value) > 255) { return ['value' => ['Title must be between 1 and 255 characters.']]; } return []; } }
Every value object implements validate() (error messages keyed by property name, [] when valid) and calls parent::__construct() in its constructor, which throws a ValidationException carrying those errors.
2. Aggregate
State changes go through updateAggregateRoot(), which tracks the change and bumps the version (and can record a domain event — see What else is in the box). Business rules live here, never in the application or infrastructure layer.
<?php declare(strict_types=1); namespace App\Domain\Blog; use App\Domain\Blog\ValueObjects\ArticleId; use App\Domain\Blog\ValueObjects\Title; use Lava83\LaravelDdd\Domain\Entities\Aggregate; use Lava83\LaravelDdd\Infrastructure\Models\Model; final class Article extends Aggregate { public function __construct( private readonly ArticleId $id, private Title $title, ) { parent::__construct(); } public static function create(ArticleId $id, Title $title): self { return new self($id, $title); } public function id(): ArticleId { return $this->id; } public function title(): Title { return $this->title; } public function rename(Title $title): void { $this->updateAggregateRoot(['title' => $title]); } /** * Rebuild the aggregate from its persisted state. * * This is the hook the mapper calls to hydrate the aggregate. It is * the one place where the Domain touches an Eloquent model; the * `InfraModel` layer in `deptrac.yaml` permits exactly that. */ public static function fromState(Model $state): static { /** @var \App\Infrastructure\Models\ArticleModel $state */ return new self( ArticleId::fromString((string) $state->id), Title::fromString((string) $state->title), ); } }
3. Eloquent model & migration
Extend the package Model — it provides a UUID primary key (via the HasUuids concern), version tracking, timestamp casts and a filtering layer. Point the model at its entity so toEntity() can resolve the mapper.
<?php declare(strict_types=1); namespace App\Infrastructure\Models; use App\Domain\Blog\Article; use Lava83\LaravelDdd\Infrastructure\Models\Concerns\HasUuids; use Lava83\LaravelDdd\Infrastructure\Models\Model; /** * @property string $id * @property string $title */ final class ArticleModel extends Model { use HasUuids; protected $table = 'articles'; /** @var class-string<Article> */ protected ?string $entityClassName = Article::class; /** @var list<string> */ protected $fillable = ['title']; }
The id, version, created_at and updated_at columns are handled by the base model, so the migration only adds them plus your own fields:
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('articles', function (Blueprint $table): void { $table->uuid('id')->primary(); $table->string('title'); $table->unsignedInteger('version')->default(1); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('articles'); } };
4. Mapper
The mapper is the single translation point between the domain and the database. findOrCreateModelFillData() (from the base mapper) loads or creates the row and fills the shared columns (id, version, timestamps) for you.
<?php declare(strict_types=1); namespace App\Infrastructure\Mappers; use App\Domain\Blog\Article; use App\Infrastructure\Models\ArticleModel; use Illuminate\Database\Eloquent\Model as EloquentModel; use Lava83\LaravelDdd\Domain\Entities\Entity; use Lava83\LaravelDdd\Infrastructure\Contracts\EntityMapper; use Lava83\LaravelDdd\Infrastructure\Mappers\EntityMapper as BaseMapper; /** * @implements EntityMapper<Article, ArticleModel> */ final class ArticleMapper extends BaseMapper implements EntityMapper { /** * @param ArticleModel $model */ public static function toEntity(EloquentModel $model, bool $deep = false): Article { /** @var ArticleModel $model */ $article = Article::fromState($model); // Restore version and timestamps from the persisted row. $article->hydrate($model); return $article; } /** * @param Article $entity */ public static function toModel(Entity $entity): ArticleModel { return self::findOrCreateModelFillData($entity, ArticleModel::class, [ 'title' => (string) $entity->title(), ]); } }
5. Repository
Keep the contract in the domain layer and the Eloquent implementation in the infrastructure layer. The base Repository provides saveEntity() / deleteEntity(), a version-guarded optimistic-locking check, a syncDependencies() hook for persisting dependent rows, and automatic domain-event dispatching; you add the read methods your application needs.
<?php declare(strict_types=1); namespace App\Domain\Blog\Contracts; use App\Domain\Blog\Article; use App\Domain\Blog\ValueObjects\ArticleId; use Lava83\LaravelDdd\Domain\Contracts\Repository; interface ArticleRepository extends Repository { public function nextId(): ArticleId; public function save(Article $article): void; public function find(ArticleId $id): ?Article; public function findOrFail(ArticleId $id): Article; }
<?php declare(strict_types=1); namespace App\Infrastructure\Repositories; use App\Domain\Blog\Article; use App\Domain\Blog\Contracts\ArticleRepository; use App\Domain\Blog\ValueObjects\ArticleId; use App\Infrastructure\Models\ArticleModel; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Lava83\LaravelDdd\Domain\ValueObjects\Identity\Uuid; use Lava83\LaravelDdd\Infrastructure\Repositories\Repository; /** * @extends Repository<ArticleModel, Article> */ final class EloquentArticleRepository extends Repository implements ArticleRepository { /** @var class-string<Article> */ protected ?string $entityClassName = Article::class; public function nextId(): ArticleId { return ArticleId::generate(); } public function save(Article $article): void { DB::transaction(fn () => $this->saveEntity($article)); } public function find(ArticleId $id): ?Article { return ArticleModel::query()->find($id->value())?->toEntity(); } public function findOrFail(ArticleId $id): Article { return ArticleModel::query()->findOrFail($id->value())->toEntity(); } public function exists(Uuid $id): bool { return ArticleModel::query()->whereKey($id->value())->exists(); } public function delete(Uuid $id): void { $model = ArticleModel::query()->find($id->value()); if ($model !== null) { $this->deleteEntity($model->toEntity()); } } /** * @return Collection<int, Article> */ public function all(): Collection { return ArticleModel::query() ->get() ->map(fn (ArticleModel $model): Article => $model->toEntity()); } public function count(): int { return ArticleModel::query()->count(); } }
Persisting dependent rows: the syncDependencies() hook
saveEntity() persists the aggregate's own row, then calls a protected syncDependencies() hook, and only after that dispatches the aggregate's domain events (which are deferred to the surrounding transaction's commit). Override the hook to persist child rows or pivot records that belong to the same write — they land before any event fires, and inside your DB::transaction():
use Lava83\LaravelDdd\Domain\Entities\Aggregate; use Lava83\LaravelDdd\Infrastructure\Models\Model; // in EloquentArticleRepository /** * @param Article $aggregate * @param ArticleModel $model */ protected function syncDependencies(Aggregate $aggregate, Model $model): void { // $model is the row that was just persisted; associate related records to // it here. The aggregate's domain events dispatch only after this returns. }
The base implementation is an empty no-op, so repositories with no dependent rows override nothing. Because dispatch is deferred to the transaction commit, the events for the aggregate and everything syncDependencies() writes are published together once the whole save() commits — and dropped together if it rolls back.
6. Wire it up
Register the mapper with the resolver and bind the repository contract to its implementation. A dedicated service provider keeps this in one place.
<?php declare(strict_types=1); namespace App\Providers; use App\Domain\Blog\Article; use App\Domain\Blog\Contracts\ArticleRepository; use App\Infrastructure\Mappers\ArticleMapper; use App\Infrastructure\Repositories\EloquentArticleRepository; use Illuminate\Support\ServiceProvider; final class BlogServiceProvider extends ServiceProvider { public function register(): void { $this->app->bind(ArticleRepository::class, EloquentArticleRepository::class); } public function boot(): void { entity_mapper_resolver()->registerMapper(Article::class, new ArticleMapper()); } }
Register the provider in bootstrap/providers.php:
return [ App\Providers\AppServiceProvider::class, App\Providers\BlogServiceProvider::class, ];
7. Use it
use App\Domain\Blog\Article; use App\Domain\Blog\Contracts\ArticleRepository; use App\Domain\Blog\ValueObjects\Title; $repository = app(ArticleRepository::class); // Create and persist a new article. $article = Article::create($repository->nextId(), Title::fromString('Hello, DDD')); $repository->save($article); // Load it, change it through the domain, persist again. $loaded = $repository->findOrFail($article->id()); $loaded->rename(Title::fromString('Hello, Domain-Driven Design')); $repository->save($loaded); // version is bumped; optimistic locking guards concurrent writes
Scaffolding
Rather than writing every class by hand (as the Quick start does), make:aggregate generates the building blocks for an aggregate in a bounded context: the identity value object, the aggregate root and the Eloquent model, and — optionally — a repository (contract plus Eloquent implementation) and an entity mapper.
Run it interactively and answer the prompts (aggregate name, bounded context, identity type, and whether to also create a repository and a mapper):
php artisan make:aggregate
Or pass everything up front:
php artisan make:aggregate Order OrderProcessing --with-repository --with-entity-mapper --id-type=uuid
Options
name— the aggregate name (e.g.Order); prompted when omitted.bounded-context— the context it lives in (e.g.OrderProcessing); prompted when omitted.--with-repository— also generate the repository contract and its Eloquent implementation.--with-entity-mapper— also generate the entity mapper.--id-type=— identity type,uuid(default) orinteger.--force— overwrite existing files instead of skipping them.
In non-interactive contexts (e.g. CI) the prompts are skipped: arguments and options drive everything, the identity type defaults to uuid, and the repository and mapper are generated only when their flags are present.
What it generates
Always:
{Name}Id— identity value object extending the packageUuidorIntegerbase.{Name}— the aggregate root, typedAggregate<{Name}Model, {Name}Id>, withcreate()/fromState()factory methods and avalidate()hook.{Name}Model— an Eloquent model extending the packageModel, with a#[Table]attribute derived from the snake-cased plural name (and theHasUuidsconcern for UUID identities).
On request:
--with-repository→{Name}RepositoryContract(findAll,findOrFail,save,remove) andEloquent{Name}Repository.--with-entity-mapper→{Name}MapperwithtoEntity()/toModel().
The generated files are skeletons: the model and mapper carry a name placeholder column, and the aggregate's validate() and the mapper's toModel() are left for you to complete. Existing files are reported as SKIPPED (exists) and left untouched unless you pass --force. After writing, the command prints the service-provider bindings to register — the mapper via entity_mapper_resolver()->registerMapper(...) and the repository via $this->app->bind(...) — and warns if the target root namespace isn't autoloaded yet.
Namespaces
Two keys in config/laravel-ddd.php decide where the classes land:
bounded_contexts_root_namespace— root namespace for generated code (defaultApp\BoundedContexts).bounded_contexts_without_own_layers— whether bounded contexts share the layer namespaces (defaulttrue).
With the defaults, make:aggregate Order OrderProcessing --with-repository --with-entity-mapper writes:
App\BoundedContexts\Domain\OrderProcessing\Aggregates\Order
App\BoundedContexts\Domain\OrderProcessing\ValueObjects\Identity\OrderId
App\BoundedContexts\Domain\OrderProcessing\Contracts\OrderRepositoryContract
App\BoundedContexts\Infrastructure\OrderProcessing\Models\OrderModel
App\BoundedContexts\Infrastructure\OrderProcessing\Mappers\OrderMapper
App\BoundedContexts\Infrastructure\OrderProcessing\Repositories\EloquentOrderRepository
Set bounded_contexts_without_own_layers to false and each context owns its layers instead — the context and layer segments swap, e.g. App\BoundedContexts\OrderProcessing\Domain\Aggregates\Order.
Target paths are resolved from your composer.json PSR-4 map; if the root namespace isn't mapped yet, the command prints the autoload entry to add and reminds you to run composer dump-autoload.
Publish the config to change these defaults:
php artisan vendor:publish --tag=ddd-config
What else is in the box
Beyond the slice above, the package provides an AggregateRoot contract with domain-event recording: events collected through updateAggregateRoot($changes, $eventClass) are dispatched automatically via Laravel's event system once the surrounding transaction commits — deferred with DB::afterCommit() — then cleared from the aggregate. With an Integer identity assigned on insert, events recorded before the first save (such as a …Created event in a create() factory) are moved onto the persisted key before they are dispatched. Every aggregate carries a version for optimistic locking and raises a ConcurrencyException on conflicting writes. You also get a growing catalogue of ready-made value objects — Uuid, Email, Phonenumber, Money, Link, Json, GeoAddress and more — plus a fluent Eloquent filtering layer on the base Model (see Filtering), with free-text search and facet counts for read-model lists (see Search and Facets), sorting (see Sorting) and the request side of a list endpoint (see List requests).
API-backed aggregates
Aggregates that live behind an HTTP API instead of a table use Infrastructure\Repositories\ApiRepository. It is opt-in: the package binds nothing, so pure database users never see it.
Reading wraps the data payload of GET {resource}/{id} in a RemoteModel (an attribute bag that refuses save(), delete() and queries). Writing is command-based, not diff-based: every recorded domain event is translated to the API call that realises it. Events without a call throw UnmappedDomainEvent before the first request. The calls run in recording order and are not atomic — if one fails, the earlier ones have already happened and the events stay on the aggregate. Events are published only after the last call succeeded.
final class ApiArticleRepository extends ApiRepository implements ArticleRepository { protected ?string $entityClassName = Article::class; protected string $modelClass = ArticleModel::class; // extends RemoteModel public function findOrFail(ArticleId $id): Article { /** @var Article */ return $this->fetchOne($id->value())->toEntity(); } public function save(Article $article): void { $this->persistCommands($article); } protected function resource(): string { return 'v1/articles'; } protected function makeRemoteModel(array $payload): RemoteModel { return ArticleModel::fromAttributes($payload); } protected function commands(): array { return [ ArticleCreated::class => fn (Article $a): Response => $this->api->post($this->resource(), [...]), ArticleRenamed::class => fn (Article $a): Response => $this->api->patch( "{$this->resource()}/{$a->id()->value()}/title", ['title' => (string) $a->title()], ), ]; } }
The ApiClient is built by the host in its own service provider:
$config = new ApiClientConfig(baseUrl: 'https://api.example.com', timeout: 30); $this->app->singleton(ApiClient::class, fn () => new ApiClient( $config, new ClientCredentialsAuthenticator($config, clientId: '…', clientSecret: '…', tokenPath: 'v1/oauth/token'), ));
- Authentication is an
ApiAuthenticator(authorize(PendingRequest)):ClientCredentialsAuthenticator(token cached as a plain string) orStaticBearerAuthenticator. Implement the interface for API keys or basic auth. An authenticator that also implementsRefreshableAuthenticatorgets one repeat after a 401; fixed credentials get none. - Only GETs are retried (connection errors and 5xx,
ApiClientConfig::$readAttemptstotal attempts). Writes never are — there are no idempotency keys. - Failures surface as
Infrastructure\Api\Exceptions\ApiExceptionsubclasses (Unauthorized,Forbidden,NotFound,ValidationFailed,Unavailable); callers never see Laravel'sRequestException. A 404 infetchOne()becomes aModelNotFoundException. - Several APIs: build one
ApiClient(or a thin subclass to type-hint it) per API.
Cursor pagination for read models
List endpoints that return read models should paginate with Infrastructure\Pagination\ReadModelCursorPaginator. Laravel reads the cursor values from the page items under the ORDER BY column name, so mapping a page with through() breaks as soon as the sort column (account_id) is not a property of the read model (id). ReadModelCursorPaginator keeps the source model of every read model and reads the cursor from there:
use Lava83\LaravelDdd\Infrastructure\Pagination\ReadModelCursorPaginator; $page = ReadModelCursorPaginator::fromPage( AccountModel::query()->orderBy('account_id')->cursorPaginate(200), fn (AccountModel $model) => AccountReadModel::fromModel($model), );
The result is a regular Illuminate\Pagination\CursorPaginator, so Resource::collection($page) produces data, links and meta as usual. The ordering must be unique (use the primary key), and no order column may hold NULL: the paginator throws Pagination\Exceptions\CursorValueIsNull rather than build a cursor that skips or repeats rows. For an order the client chooses, see Sorting.
Reading a paginated list from an API
ReadModelCursorPaginator serves the producing side: it starts from an Eloquent query. The consuming side has no query and no model, only the data and meta of an API answer and cursor tokens it must not interpret. For that, Infrastructure\Pagination\ApiCursorPageMapper reads the answer of a Laravel-style cursor page and returns a RelayedCursorPaginator, which hands the tokens on unchanged:
use Lava83\LaravelDdd\Infrastructure\Pagination\ApiCursorPageMapper; $pages = new ApiCursorPageMapper; // parameter names are configurable: per_page, cursor, filter $response = $api->get('v1/accounts', $pages->query( filter: ['status' => 'active'], perPage: 50, cursor: $request->query('cursor'), )); return $pages->fromResponse( $response, path: '/accounts', // your own endpoint, so the links never point at the API mapItem: fn (array $payload) => AccountReadModel::fromArray($payload), );
Resource::collection($page) then produces data, links and meta as usual. Nothing is derived from the items, so the read models need no sort column.
The mapper fails loudly with an ApiException if the answer is not a cursor page: no data list, no meta, no integer meta.per_page, no meta.next_cursor / meta.prev_cursor key, or a token that is not a Laravel cursor. A missing meta must never read as "last page, no results". An empty cursor string counts as no cursor.
The mapper expects the shape a Laravel resource collection produces. For an API with another shape (other keys, other token format), write a small mapper of your own and build a RelayedCursorPaginator from the tokens: the paginator knows no token format.
Filtering
The base Model ships with a filtering layer built on indexzer0/eloquent-filtering. Infrastructure\Models\Filter\Builder composes a set of filters fluently and serialises them — via toArray() — to the operator-array shape the model's filter() query scope consumes.
Building and applying filters
use Lava83\LaravelDdd\Infrastructure\Models\Filter\Builder; $builder = Builder::make() ->like('title', 'DDD') ->eq('status', 'published') ->in('category', ['laravel', 'php']) ->gte('reading_time', 5) ->isNull('archived_at'); // `filter()` is a query scope from the Filterable trait; each filter must be // permitted by the model's allowedFilters() list. $articles = ArticleModel::query()->filter($builder->toArray())->get();
toArray() produces one MongoDB-style operator entry per filter ($eq, $gte, $in, $null, …):
[
['type' => '$like', 'target' => 'title', 'value' => 'DDD'],
['type' => '$eq', 'target' => 'status', 'value' => 'published'],
['type' => '$in', 'target' => 'category', 'value' => ['laravel', 'php']],
['type' => '$gte', 'target' => 'reading_time', 'value' => 5],
['type' => '$null', 'target' => 'archived_at', 'value' => true],
]
Reconstructing filters from a request
Builder::fromArray() is the inverse of toArray() — it rebuilds a Builder from that array shape, for example from filters that arrive over HTTP. It validates strictly and throws Filter\Filters\Exceptions\FilterArrayNotValid on a missing key, an unknown operator, or a value whose type does not match the operator.
Because the check is strict, carry the filters as JSON rather than as bracket-notation query parameters. PHP parses every query-string value as a string, so ?filters[0][type]=$gte&filters[0][value]=18 yields the string "18" — which the numeric operators ($gt, $gte, $lt, $lte) and $null reject, while string-friendly operators like $eq and $in would still pass, making bracket notation deceptively half-working. A JSON payload preserves int and bool:
GET /api/articles?filters=[{"type":"$like","target":"title","value":"DDD"},{"type":"$eq","target":"status","value":"published"},{"type":"$in","target":"category","value":["laravel","php"]},{"type":"$gte","target":"reading_time","value":5},{"type":"$null","target":"archived_at","value":true}]
URL-encode the
filtersvalue in practice ([→%5B,"→%22,$→%24, …); it is shown decoded here for readability.
In a FormRequest, AcceptsFilters does this for JSON and bracket notation alike (see List requests). By hand, decode the JSON and hand the array to fromArray():
use Lava83\LaravelDdd\Infrastructure\Models\Filter\Builder; /** @var array<int, array<string, mixed>> $decoded */ $decoded = json_decode($request->query('filters', '[]'), true, flags: JSON_THROW_ON_ERROR); $articles = ArticleModel::query()->filter(Builder::fromArray($decoded)->toArray())->get();
The request above reconstructs exactly:
Builder::make() ->like('title', 'DDD') ->eq('status', 'published') ->in('category', ['laravel', 'php']) ->gte('reading_time', 5) ->isNull('archived_at');
Custom filters
Built-in operators cannot express every predicate — for example a tenant scope that is an OR over two columns, or that needs a subquery against another table or connection. Model such predicates as an indexzer0/eloquent-filtering custom filter: a virtual target implemented inside the model's infrastructure, addressed from the outside purely through the builder. This is the recommended way to express tenant/scope predicates that need cross-table or cross-connection subqueries.
1. Implement and register the FilterMethod. The constructor parameter is named after the request key (value):
use Illuminate\Database\Eloquent\Builder; use IndexZer0\EloquentFiltering\Filter\Contracts\FilterMethod; use IndexZer0\EloquentFiltering\Filter\Traits\FilterMethod\FilterContext\CustomFilter; final class BranchScopeFilter implements FilterMethod { use CustomFilter; public function __construct(private readonly int $value) {} public static function type(): string { return '$branchScope'; } public function apply(Builder $query): Builder { return $query->where(fn (Builder $q) => $q ->whereIn('b2b_user_id', fn ($sub) => $sub->select('id')->from('users')->where('branch_id', $this->value)) ->orWhereIn('b2c_user_id', fn ($sub) => $sub->select('id')->from('users')->where('branch_id', $this->value))); } }
// config/eloquent-filtering.php 'custom_filters' => [ BranchScopeFilter::class, ],
2. Allow it on the model. allowedFilters() remains the authoritative gate: a model that does not list the type rejects it according to the eloquent-filtering suppress config.
use IndexZer0\EloquentFiltering\Filter\Filterable\Filter; public function allowedFilters(): AllowedFilterList { return Filter::only(Filter::custom('$branchScope')); }
3. Build it. Builder::custom(string $type, mixed $value = true, ?string $target = null) accepts a scalar, or a (nested) array/Collection of scalars. target is optional and only serialised when given:
$filter = Builder::make()->custom('$branchScope', $branch->getKey()); // [['type' => '$branchScope', 'value' => 42]] AccountModel::query()->filter($filter->toArray())->get();
A custom type may not reuse a built-in operator name (CustomFilterNotValid), and an invalid value throws FilterValueNotValid.
Reconstructing from a request. fromArray() stays strict: an unknown type throws FilterArrayNotValid unless it is listed in the package config. Custom entries do not need a target; built-ins still do.
// config/laravel-ddd.php 'filters' => [ 'custom_types' => ['$branchScope'], ],
Merging. MergeStrategy::KeepExisting is unchanged. With MergeStrategy::Override, an incoming custom filter replaces existing ones with the same type string and target (no target equals no target).
Removing client filters (without())
without(string ...$targets) returns a new Builder without every filter whose target is one of $targets, built-in and custom alike. It never changes the builder it is called on, unlike eq(), custom() and the other operators, which add to the builder in place.
$requested = Builder::make() ->eq('status_id', 2) ->in('user_type_id', [1, 3]) ->custom('$b2cUserSearch', 'müller'); $requested->without('status_id')->toArray(); // [ // ['type' => '$in', 'target' => 'user_type_id', 'value' => [1, 3]], // ['type' => '$b2cUserSearch', 'value' => 'müller'], // ]
Custom filters without a target are never removed, an unknown target is a no-op, and without() with no arguments returns an equal copy.
Call
without()on client (request) filters only, never on a scope filter. A scope can use the same target as a facet (branch_id), and removing it widens what the caller sees.
Search
Filter\Methods\TokenSearchFilterMethod is an abstract custom FilterMethod for a free-text search over several columns. Extend it with the custom type and the columns:
use Lava83\LaravelDdd\Infrastructure\Models\Filter\Methods\TokenSearchFilterMethod; final class B2cUserSearchFilter extends TokenSearchFilterMethod { public static function type(): string { return '$b2cUserSearch'; } protected function columns(): array { return ['email', 'data.firstname', 'data.lastname', 'data.personalnr']; } }
- The phrase is split on Unicode whitespace. Duplicates are dropped case-insensitively, and only the first
maxTokens()tokens (default 5) are used. An empty phrase adds no constraint. - Every token must match (AND). A token matches when any column matches (OR).
emailis a column of the model.data.lastnameis a column of thedatarelation, resolved withwhereHas(): the part before the last dot is the relation, which must live on the same connection. Columns of one relation share oneEXISTSper token.mode()returnsSearchMode::Prefix(token%, the default) orSearchMode::Contains(%token%). Prefix can use a B-tree index on the column, Contains scans it.%,_and!in a token are literals. The filter escapes them and adds an explicitESCAPE '!', so it behaves the same on MySQL/MariaDB (with or withoutNO_BACKSLASH_ESCAPES), SQLite and PostgreSQL.- Case sensitivity follows the column collation. A
utf8mb4_*_cicollation on MariaDB matchesmüllertoMüller, and whetherßmatchesssdepends on the collation. SQLite folds ASCII only, and PostgreSQL'sLIKEis always case-sensitive. - The value must be a UTF-8 string or an integer. Anything else fails as a
MalformedFilterFormatException.
Register the type in eloquent-filtering.custom_filters and allow it on the model, like any custom filter. Do not list it in laravel-ddd.filters.custom_types. Let the request read a dedicated search parameter and add the filter with Builder::custom(), so a client cannot send it through filters. In a FormRequest, AcceptsSearch does this (see List requests). By hand:
// config/eloquent-filtering.php 'custom_filters' => [ B2cUserSearchFilter::class, ],
// the model public function allowedFilters(): AllowedFilterList { return Filter::only( Filter::field('status_id', [FilterType::EQUAL, FilterType::IN]), Filter::field('user_type_id', [FilterType::EQUAL, FilterType::IN]), Filter::custom('$b2cUserSearch'), ); }
// the request $requested = Builder::fromArray($decodedFilters); $search = $request->string('search')->trim()->value(); if ($search !== '') { $requested->custom(B2cUserSearchFilter::type(), $search); }
Facets
Facet counts are counts per value of one attribute of a list, for example how many users each status tab of an admin UI holds. They are counted on the database, with the same base query the list uses for its page.
Read models (Domain). Domain\ReadModels\Facets\FacetReadModel (key(), buckets(), toArray()) holds FacetBucketReadModels (value(), count(), toArray()):
$facet->toArray(); // [['value' => null, 'count' => 3], ['value' => 1, 'count' => 120], ['value' => 2, 'count' => 7]]
Buckets are ordered by value ascending, the null bucket first. A NULL column value is a bucket of its own. Values without rows have no bucket, because a GROUP BY cannot know them: a missing value means 0.
Definitions. Infrastructure\Models\Facets\FacetDefinition says what to count:
use Lava83\LaravelDdd\Infrastructure\Models\Facets\FacetDefinition; FacetDefinition::column('status', 'status_id'); // filter target: status_id FacetDefinition::column('type', 'user_type_id'); // filter target: user_type_id FacetDefinition::relation('services', 'services', 'id'); // filter target: services
column($key, $column, $filterTarget = null)counts the rows of the list model per value of one of its columns. The filter target defaults to$column.relation($key, $relation, $column, $filterTarget = null)counts the distinct rows of the list model per value of a column of aHasManyorBelongsToManyrelation: a user with two services counts once for each. The filter target defaults to$relation. The related model must live on the list model's connection, otherwiseFacetNotCountableis thrown. The list's keys go into a subquery, never through PHP memory.
Counting. FacetCounter::count(Closure $baseQuery, Builder $scope, Builder $requested, FacetDefinition ...$facets) returns a Collection<int, FacetReadModel> in the order of the definitions.
$baseQueryis aClosure(Filter\Builder): Eloquent\Builder: the base query of the list page, including->filter($filter->toArray()). Eager loads, ordering, limit and offset are dropped before grouping.- Each facet is counted with
$scope->merge($requested->without($facet->filterTarget())): the whole scope, plus every requested filter except the facet's own. The status tabs therefore show how many rows each status holds under the current search and type filter. - Keep the scope and the requested filters apart. The scope (tenant, branch: what the caller may see at all) is never reduced. Merge the two before calling the counter, and a facet on
branch_iddrops the branch scope along with the client's branch filter. - A column facet groups the list query with
count(*), so the base query must not join a to-many table, or a row counts once per joined row. A relation facet groups the related rows withcount(distinct …), and the soft-delete scopes of both models stay active.
Sorting
Infrastructure\Models\Sort\Sort is the order of a list: the fields a client asked for, then the key of the list as the tie-breaker. Cursor pagination needs a unique order, so the order ends with the key, or earlier with a field that is unique already.
use Lava83\LaravelDdd\Infrastructure\Models\Sort\Sort; $sort = Sort::fromArray(['-created_at'], allowed: ['created_at', 'email'], key: 'id'); // created_at DESC, id DESC $sort = Sort::fromArray(['email', '-created_at'], allowed: ['created_at', 'email'], key: 'id', unique: ['email']); // email $sort = Sort::byKey('id'); // no sort requested: the key ascending $page = ReadModelCursorPaginator::fromPage( $sort->apply(UserModel::query()->filter($filter->toArray()))->cursorPaginate($perPage), fn (UserModel $model) => UserReadModel::fromModel($model), );
fromArray()throwsSort\Exceptions\SortNotValidfor a field that is not allowed, a field given twice (emailand-emailcount as the same field) and a field that is not a non-empty string. In a request,AcceptsSortturns these into a 422 (see List requests).- The key follows the direction of the last field:
-created_atorders bycreated_at DESC, id DESC. Ties of a descending sort therefore come in descending key order. - The order ends at the first field that is unique: the key itself (
sort[]=-id&sort[]=emailorders byid DESConly) or a field inunique, such asemailwith a unique index. Fields after it never decide, and no key follows it. apply()replaces any order the query had and orders by the column names without the table. The cursor parameters are named after these columns, andcursorParameters()lists them:['created_at', 'id']for the first sort above,['email']for the second.- The cursor rule of
PaginatesByCursorexpects exactly these parameters, so a cursor issued for another sort is a 422 oncursor. A cursor of the same columns in the other direction passes, because Laravel's cursor does not record the direction. The page then starts at the cursor row in the new direction. ReadModelCursorPaginatorreads the cursor values from the source models, so a sort column does not have to be a property of the read model.FacetCounterdrops the order before grouping, so the facets do not change with the sort.- Only columns of the list model are sortable. Sorting by a column of a relation, such as a last name in a profile table, needs a join and the joined value in the select under a name of its own. That is not built. It would be a second kind of sort field next to the plain column, with that name as its cursor parameter.
Indexes. An index on the sort column serves the sort in both directions: the index carries the key ascending, and read backwards, descending, which is why the key follows the direction of the last field. A sort by several columns needs an index on them in that order. Declare a column with a unique index as unique: MariaDB does not serve ORDER BY email, id from a unique index on email and sorts the whole table instead, on every page.
Traps.
- No NULL in a sort column. Laravel's cursor pagination compares the cursor value with
>and<, which never matches NULL, so the next page would skip or repeat rows. Allow only columns that never hold NULL.ReadModelCursorPaginatorthrowsPagination\Exceptions\CursorValueIsNullinstead of putting NULL into a cursor, so the mistake fails loudly. - No fractions of a second in a datetime sort column. A
datetimecast puts the value into the cursor asY-m-d H:i:s. If the column stores fractions of a second, the next page repeats rows when sorting ascending and skips rows when sorting descending. Sort by such a column only if it stores whole seconds. - Only unique columns as unique fields. The order ends at a unique field, so the next page skips every row that has the same value as the cursor row.
List requests
The HTTP side of a list endpoint comes as traits for a FormRequest in Infrastructure\Http\Requests\Concerns, one per concern. A request uses the ones its list accepts and spreads their rules into rules():
| Trait | Parameters | Rules | Read with |
|---|---|---|---|
PaginatesByCursor |
per_page, cursor |
cursorPaginationRules() |
perPage() |
AcceptsFilters |
filters |
filterRules() |
filters() |
AcceptsSort |
sort[] |
sortRules() |
sort() |
AcceptsSearch |
search |
searchRules() |
filters(), searchPhrase() |
AcceptsFacets |
facets[] |
facetRules() |
facets() |
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Lava83\LaravelDdd\Infrastructure\Http\Requests\Concerns\AcceptsFacets; use Lava83\LaravelDdd\Infrastructure\Http\Requests\Concerns\AcceptsFilters; use Lava83\LaravelDdd\Infrastructure\Http\Requests\Concerns\AcceptsSearch; use Lava83\LaravelDdd\Infrastructure\Http\Requests\Concerns\AcceptsSort; use Lava83\LaravelDdd\Infrastructure\Http\Requests\Concerns\PaginatesByCursor; final class ListUsersRequest extends FormRequest { /** @use AcceptsFacets<UserFacet> */ use AcceptsFacets; use AcceptsFilters; use AcceptsSearch; use AcceptsSort; use PaginatesByCursor; public function rules(): array { return [ ...$this->cursorPaginationRules(), ...$this->filterRules(), ...$this->sortRules(), ...$this->searchRules(), ...$this->facetRules(), // A rule of its own: the tenant scope is a filter of the model too, but only the use case sets it. 'filters.*.target' => ['string', Rule::notIn(['$tenantScope'])], ]; } protected function listKey(): string { return 'id'; } protected function sortableFields(): array { return ['created_at', 'email']; } protected function uniqueSortableFields(): array { return ['email']; } protected function searchFilterType(): string { return UserSearchFilter::type(); } protected function facetEnum(): string { return UserFacet::class; } }
Every error is a 422 on its field: per_page, cursor, filters, sort (a field given twice) or sort.N (a field that is not allowed or blank), search, facets.N. Rules check everything, so no trait defines after(), withValidator() or prepareForValidation(), and a request has no validation hook to wire in. A blank parameter (sort=, facets=, per_page=) counts as a missing one, with or without Laravel's ConvertEmptyStringsToNull middleware.
OpenAPI. With Scramble installed, the package registers extensions for it, and the blocks document themselves: every parameter optional, with the doc comment of its rule as the description; sort[] as an array of the sortable fields; filters as a JSON string or a list of filter objects; facets[] as an array of the facet enum; and the response with meta.facets (see Facets in meta below). Scramble alone cannot get these right: it drops sort and filters with their descriptions because sort.* and filters.* have rules, and it does not see the facets that FacetsResource::addTo() adds. The extensions apply to every API: the default one and each one registered with Scramble::registerApi(), also in the boot() of a service provider. Nothing needs wiring by hand, and an API that adds the rule transformers itself still runs each once. The extensions are tested against Scramble 0.13.
Cursor pagination.
per_pageis an integer from 1 to 200, and 50 if it is missing. OverridedefaultPerPage()andmaxPerPage()to change that. They are methods, not constants, because a class cannot redefine a constant it got from a trait. Scramble shows the 50 of the trait, in the description and as@default 50, so a request with another default documents it in a rule of its own.cursormust decode and carry exactly the parameters of the order of the list: the key alone, or withAcceptsSortthe columns of the requested sort (Sort::cursorParameters()). A cursor of another order is a 422 oncursor, so the read query never throwsUnexpectedValueExceptionfor it. Do not catch that exception around the read query.
The key. listKey() is the column the list is ordered by last, unique per row. With AcceptsSort, sort() carries it to the read query, which calls $sort->apply() and names no key of its own. Without AcceptsSort, the read query orders by its own key, and listKey() must name the same column. If it does not, the cursor rule rejects every cursor from the second page on.
Client filters. filters comes as a JSON string or as a bracket-notation query array. Both give the same Filter\Builder. Prefer JSON: bracket notation sends every value as a string, which $gt, $gte, $lt, $lte and $null reject (see Reconstructing filters from a request).
GET /users?filters=[{"type":"$in","target":"status_id","value":[1,2]}]
GET /users?filters[0][type]=$in&filters[0][target]=status_id&filters[0][value][0]=1&filters[0][value][1]=2
AcceptsFiltersdecodes the JSON invalidationData(), before the rules run, so the rules of the request onfilters.*see both forms.- A request that overrides
validationData()itself must pass its data through$this->withDecodedFilters(...). If it does not, its rules onfilters.*never see JSON filters, andfilters()throws aLogicExceptionrather than hand out filters those rules did not check. - JSON wins. Within the query string
filtershas one form, but Laravel merges a JSON body and the query string into one input. If both carryfilters, a JSON string wins over an array, whichever of the two carries it. - Invalid JSON, JSON that is not a list, a malformed filter, an unknown operator and a value the operator rejects are a 422 on
filters.
Sort. sort[]=-created_at&sort[]=email: the first field sorts first, and a leading - sorts descending. Only the sortableFields() are allowed, and none of them may hold NULL. List the ones with a unique index in uniqueSortableFields() too (none by default), so the sort ends at them and the database can use that index. Without sort, sort() is the key ascending. See Sorting.
Search.
searchis a nullable string of at most 100 characters. OverridemaxSearchLength()to change that.- A trimmed, non-blank phrase ends up in
filters()ascustom(searchFilterType(), $phrase), with or withoutAcceptsFilters: a list can search without acceptingfilters. - Register the search type in
eloquent-filtering.custom_filtersonly, never inlaravel-ddd.filters.custom_types.filtersthen rejects it, and a client reaches the search only throughsearchand its length limit.filters()throws aLogicExceptionif the type is incustom_types.
Facets. facets[] takes distinct values of a string-backed enum (facetEnum()). facets() returns the enum cases in the order of the request.
Errors at read time. Validation cannot know whether the model allows a filter target. allowedFilters() decides that when the read query runs, and throws an eloquent-filtering FilterException. Wrap the read query in Infrastructure\Http\ClientFilterErrors::translate() to turn that into a 422 on filters. Every other exception passes through unchanged. Wrap reads only: a write that fails on a filter is not the client's error.
Facets in meta. Application\Resources\FacetsResource renders a Collection<int, FacetReadModel> as {<key>: [{value, count}, …]}. FacetsResource::addTo($collection, $facets, $count) adds the facets to meta.facets, next to the cursor meta and to any additional data the collection already has. It does so only if the client asked for facets. Without them, $count never runs and no facet query is made. In the OpenAPI spec, the package's Scramble extension for addTo() documents the response as the page without and the page with meta.facets, and the @return array shape of toArray() is the schema of the FacetsResource component.
A list endpoint
From the request to the response, with the scope and the client filters kept apart all the way down. The request is the ListUsersRequest above.
Use case. It builds the scope from who is asking, passes the client filters on untouched and returns the resource:
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Lava83\LaravelDdd\Application\Resources\FacetsResource; use Lava83\LaravelDdd\Infrastructure\Http\ClientFilterErrors; use Lava83\LaravelDdd\Infrastructure\Models\Filter\Builder; use Lava83\LaravelDdd\Infrastructure\Models\Sort\Sort; final readonly class ListUsersUseCase { public function __construct( private UserReadQuery $users, ) {} public function execute(int $branchId, int $perPage, Sort $sort, Builder $requested, UserFacet ...$facets): AnonymousResourceCollection { $scope = Builder::make()->eq('branch_id', $branchId); return ClientFilterErrors::translate(fn () => FacetsResource::addTo( UserResource::collection($this->users->page($scope->merge($requested), $sort, $perPage)), $facets, fn () => $this->users->facets($scope, $requested, ...$facets), )); } }
Read query. The page and the facets share one base query:
use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Lava83\LaravelDdd\Infrastructure\Models\Facets\FacetCounter; use Lava83\LaravelDdd\Infrastructure\Models\Facets\FacetDefinition; use Lava83\LaravelDdd\Infrastructure\Models\Filter\Builder; use Lava83\LaravelDdd\Infrastructure\Models\Sort\Sort; use Lava83\LaravelDdd\Infrastructure\Pagination\ReadModelCursorPaginator; final class EloquentUserReadQuery implements UserReadQuery { public function page(Builder $filter, Sort $sort, int $perPage): CursorPaginator { return ReadModelCursorPaginator::fromPage( $sort->apply($this->baseQuery($filter))->cursorPaginate($perPage), fn (UserModel $model) => UserReadModelMapper::fromModel($model), ); } public function facets(Builder $scope, Builder $requested, UserFacet ...$facets): Collection { return FacetCounter::count( fn (Builder $filter) => $this->baseQuery($filter), $scope, $requested, ...array_map(fn (UserFacet $facet): FacetDefinition => match ($facet) { UserFacet::Status => FacetDefinition::column('status', 'status_id'), UserFacet::Type => FacetDefinition::column('type', 'user_type_id'), }, $facets), ); } /** * @return EloquentBuilder<UserModel> */ private function baseQuery(Builder $filter): EloquentBuilder { return UserModel::query()->with('data')->filter($filter->toArray()); } }
Controller. One line:
public function __invoke(ListUsersRequest $request, ListUsersUseCase $useCase): AnonymousResourceCollection { return $useCase->execute($request->user()->branch_id, $request->perPage(), $request->sort(), $request->filters(), ...$request->facets()); }
The package does not enforce this split: a controller can do what the use case does here.
GET /users?sort[]=-created_at&search=anna&facets[]=status&facets[]=type&filters=[{"type":"$eq","target":"user_type_id","value":1}]
{
"data": ["…"],
"links": {"first": null, "last": null, "prev": null, "next": "…"},
"meta": {
"path": "…",
"per_page": 50,
"next_cursor": "…",
"prev_cursor": null,
"facets": {
"status": [{"value": null, "count": 3}, {"value": 1, "count": 120}, {"value": 2, "count": 7}],
"type": [{"value": 1, "count": 98}, {"value": 2, "count": 32}]
}
}
}
A status the list does not contain has no bucket. Fill the tabs from your own status enum, with 0 for a missing value.
Development
composer test # run the Pest test suite composer test-coverage # run tests with an HTML coverage report composer format # apply the Laravel Pint code style composer lint # Pint + PHPStan (Larastan) + Deptrac layering composer qa # lint + tests
License
The MIT License (MIT). See LICENSE.md for details.