Download the PHP package antevemus/aspecification without Composer
On this page you can find all versions of the php package antevemus/aspecification. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Download antevemus/aspecification
More information about antevemus/aspecification
Files in antevemus/aspecification
Package aspecification
Short Description Enterprise Specification Pattern Framework for PHP 8.2+ (DDD, Notification Pattern, SQL Query Visitor, Adianti TCriteria, DAG Repositories)
License MIT
Homepage https://github.com/antevemus-it/Antevemus.ASpecification
Informations about the package aspecification
Antevemus ASpecification
πΊπΈ English | π§π· PortuguΓͺs (Brasil)
Enterprise Specification Pattern Framework for PHP 8.2+ (PHP 8.4 only for the optional ALinq integration)
Independent, unofficial PHP reimplementation and evolution of the acclaimed Java Domian framework, grounded in the seminal paper Specifications by Eric Evans and Martin Fowler. Enhanced with natural-language fluent chaining, zero-exception Notification Pattern with rich diagnostic telemetry, relational database Dynamic Rule Engine, multi-DBMS SQL Query Visitor (15 drivers, 10 SQL dialects), Adianti Framework TCriteria Builder, Directed Acyclic Graph (DAG) partitioned repositories, hybrid persistence, high-concurrency IPC primitives, a specification-driven state machine, use cases with a result shared between both, concurrent resolution of the external data I/O-bound rules depend on, a repository over PDO whose queries are specifications compiled to SQL (with lifecycle columns, batch and set-based operations and relationships loaded in batch), and domain events emitted when an entity starts or stops satisfying a specification, on a write or by the mere passage of time.
ποΈ Origins & Theoretical Foundation
Antevemus ASpecification was engineered on solid software craftsmanship principles and Domain-Driven Design (DDD):
-
The Seminal Paper: Based on the classic paper The Specifications Pattern (PDF) by Eric Evans and Martin Fowler (2002), which formalized encapsulating business predicates and domain constraints into first-class composable objects for:
- Object Validation: Verify whether candidate entities satisfy domain constraints.
- Selection & Filtering: Query entities from repositories without leaking SQL or persistence concerns into the domain layer.
- Constraint Satisfaction: Specify preconditions required to instantiate or transition entities between domain lifecycle states.
- The Upstream Origin: Domian (Java): This library is an independent, unofficial PHP port and modern evolution of the Domian (Domain-Driven Design for Java) framework, originally authored by Eirik Torske and BjΓΈrn Nordlund under the Apache License, Version 2.0. It is not affiliated with, maintained by or endorsed by the Domian authors: the design, public API and semantics follow Domian's public sources, and the PHP code was written by Antevemus. Domian pioneered unifying Boolean algebra, Venn diagram set theory, collection-scoped specifications, Directed Acyclic Graph (DAG) partitioned repositories, and concurrent synchronization.
π Core Features
- π― Fluent Chaining & Natural Domain DSL: Compose highly readable domain rules as natural prose sentences (
Spec::specify(Customer::class)->where('gender', is('FEMALE'))->and('membershipDate', isBefore($oneYearAgo))). - π‘οΈ Zero-Exception Notification Pattern: Evaluate rules without throwing control-flow exceptions via
evaluate(), producing aSpecificationResultcontaining aggregatedSpecificationFailureinstances, custom business error codes (withCode()), human-readable rationales (because()), and contextual metadata. - β‘ Multi-DBMS SQL Query Visitor (Module 12): Direct compile-time translation of pure domain ASTs into secure parameterized
WHEREclauses (:p1,:p2) across 15 database drivers mapped onto 10 SQL dialects (PostgreSQL, MySQL, SQL Server, Oracle, Firebird, SQLite, IBM Db2, IBM Informix, DuckDB and ANSI-92), withpaginate()on every dialect. - π TCriteria Builder & Adianti Database Bridge (Module 13): Seamless compilation of domain specifications into native
Adianti\Database\TCriteriaandTFilterobjects, with automatic De Morgan logic inversion for negations, strict parenthesis precedence, and chained pagination (limit,offset), sorting (orderBy,direction) and grouping (groupBy). - π§© Dynamic Rule Engine & Document Matrix (Module 11): Dynamic runtime rule compilation driven by relational database catalogs (
RuleDefinition), operational severity triage (BLOCK,WARN,LOG), and document requirement Boolean algebra (ALL,ANY,ONE_OF_SET). - βοΈ Partial Satisfaction (
remainderUnsatisfiedBy): Isolate at runtime precisely which sub-clauses failed for a given candidate entity. - π Complete Boolean & Venn Set Algebra: Rigorous logical composition (
AND,OR,NOT,NOR / Joint Denial), Venn set subsumption calculus (isGeneralizationOf,isSpecialCaseOf,isDisjointWith) and structural tautology/contradiction detection (isTautology(),isContradiction()): it provesA β§ Β¬A,A β¨ Β¬A, conjunctions of disjoint operands,in([])and the absorbing identities (alwaysTrue()/alwaysFalse()); it is conservative (falsemeans "not proven", there is no SAT solving), and the rule engine reports degenerate catalog rules asRuleCompilationWarning;simplify()returns an equivalent, smaller tree (identity, double negation, idempotence, subsumption, proven contradictions and tautologies) without touching annotated nodes, with an opt-in in the rule engine (example 12). - ποΈ Repository Architecture & DAG Partitioning:
- $O(1)$ early branch pruning in query search trees through specification disjointness, over a materialised topological index of the DAG (
findAll45 % to 77 % faster than the recursive walk, seetests/Benchmark/REPORT-dag-index.md). - TTL and auto-prune on volatile repositories (
withTtl(),prune(), injectable clock), inherited across partitions. - In-memory (
InMemoryRepository), null (NullRepository), and test-double (FakePartitionRepository) implementations, and a database one,PdoRepository(Module 22). - Decoupled disk storage (
FilePerEntityRepository,SingleFileRepository) with pluggable entity serialization (JsonEntitySerializer) inside a JSON document owned by the repository, with the persistence metadata of each entity (reads, writes, first and last of each) written next to it and hydrated on load. Zerounserialize()since v2.0.0: the PHP-native serializer was removed, and a.binfile of 1.x is refused with the conversion recipe of UPGRADE-2.0.md, to run on 1.14.x. - Hybrid $L1$ (RAM) + $L2$ (Disk) tiering via
InMemoryAndFileRepository.
- $O(1)$ early branch pruning in query search trees through specification disjointness, over a materialised topological index of the DAG (
- π Concurrency & Atomic Locking:
ISynchronizerprimitives wired into every repository (shared readers, exclusive writer, reentrant per execution context): the in-processSemaphoreSynchronizer(permit counter, Fiber-aware) and atomic file locks (flock) across processes for the file repositories.SysVSemaphoreSynchronizer(ext-sysvsem) shares the same reader/writer lock between processes through SysV IPC semaphores. - β±οΈ Nanosecond Benchmarking & Diagnostics: High-resolution stopwatches (
StopWatchviahrtime) and hierarchical diagnostic utilities (InstrumentationUtils). - π¦ Spec-Driven State Machine (Modules 17 and 18): every guard is an
ISpecification, so a denied transition says why (code, message, property, severity); parallel regions, region-level transitions, named actions (exitβ transition βentry), persisted deadlines (a fixed duration, or a duration or an absolute instant read from the context, pausable with the balance kept while a condition holds); the definition is data (builder or JSON, versioned, fingerprinted, validated at load time); a puretransition()/can()/nextEvents()core and a single write port,MachineRunner(compare-and-set, idempotency key, append-only history, transactional outbox), over InMemory or PDO stores with a reference ANSI DDL (example 14). - π§ Use Cases (Module 19):
IUseCase+UseCaseRunner(middlewares,TransactionMiddleware, request validation where onlyERRORblocks),UseCaseResultwith a closed domain status and HTTP only at the edge (toHttpStatus()), no exception or stack trace in the result, andStateMachineEventUseCaseexposing a machine event as a use case through the sharedIOutcome(example 15). - β‘ Concurrent Data Resolution (Module 21): a rule that depends on external data (a bureau score, a fraud check, a balance) declares it with
Spec::external()instead of calling the service; aDataResolvercollects the declarations of the whole tree, fetches them concurrently throughcurl_multi(no dependency, works under PHP-FPM) and then evaluates the tree synchronously. A cost policy (concurrent(),tiered()with three-valued logic,sequential()) skips paid sources that can no longer change the verdict; requests are deduplicated; a failed fetch is an evaluation error of its leaf; parameters cannot change the host or path of a call (example 16). - ποΈ Repository Over PDO (Module 22):
PdoRepositoryimplementsIRepositoryover a table: a query is a specification compiled to parameterized SQL by the SQL visitor (any dialect), a write is a prepared statement in a transaction,iterate()streams the cursor in constant memory andcount()builds no entity. Mapping by attributes (#[Table],#[Column],#[Id],#[Version]) or by a builder; identifiersauto,ulid,uuid7,assignedandmax; optimistic locking; a non-translatable specification is refused instead of filtering the table in memory (withInMemoryFallback()is the explicit opt-in); only properties a specification may read by name can be queried. The transaction port (Persistence\ITransactionManager) is shared with the state machine stores (example 17). - π§± PDO Repository Follow-ups (Modules 25 and 26): lifecycle columns (
#[CreatedAt],#[UpdatedAt],#[SoftDelete]withwithTrashed()/onlyTrashed()/forceRemove()), hooks on the repository (beforePut,afterPut,beforeRemove,afterRemove), a SQL log without parameter values by default (IQueryLogger), booleans stored as 1/0 and default time zones per mapping and per repository;insertAll()with multi-rowINSERTs andputAll()with one existenceSELECTper batch;updateWhere(),select()projections,aggregate()->groupBy()->sum()/avg()/min()/max()/count()andSpec::inQuery()subqueries;#[ManyToOne]/#[OneToMany]relationships loaded explicitly in batch withwith()(one query per relation, never N+1), compositions saved and removed with their parent, and orphans removed only from collections that were loaded;#[ManyToMany]associations through a join table (only the links are written),markLoaded()for aggregates built by hand,iterate()with relations in pages that work on MySQL unbuffered queries, andCHAR(1)'Y'/'N'booleans read strictly (examples 17.1 to 17.3). - π‘ Domain Events From Specifications (Module 23):
SpecificationTriggerbinds a specification to an enter and an exit event;TriggeringRepositorydecorates any repository and publishes the crossings of every write to an outbox (in memory or PDO, reference DDLresources/sql/domain_events.ansi.sql) written in the same transaction, delivered after the commit byDomainEventDispatcherwith a deterministic idempotency key and a payload without personal data;MachineEventBridgesends an event to the state machine (example 18). - β° Domain Events From the Passage of Time (Module 24):
SpecificationTrigger::timed()builds its specification from "now";TriggerSweeper, run by a cron, finds the entities that crossed since their last recorded state (ITriggerStateStore, in memory or PDO, tabledomain_event_trigger_state) and publishes through the same outbox, one transaction and one compare-and-set per crossing;baseline()introduces a trigger over existing data without a flood of events;TriggeringRepositorykeeps the same state, so a write and a sweep never publish one crossing twice (example 18.1).
π― What Sets Us Apart
While most PHP specification libraries stop at primitive Boolean checks (isSatisfiedBy: bool) and the original Java Domian framework relied heavily on runtime reflection and memory-only collections, Antevemus ASpecification was designed for the rigorous demands of enterprise microservices, high-throughput APIs, and mission-critical legacy modernization:
| Capability / Feature | Domian (Original Java) | Typical PHP Specification Libs | Antevemus ASpecification |
|---|---|---|---|
| Notification Pattern (Zero Exceptions) | β Boolean only | β Boolean only or control-flow exceptions | β
evaluate(), aggregated SpecificationFailure, error codes & rationales |
| Operational Severity Triage | β None | β None | β
BLOCK (fatal), WARN (operational alert), LOG (audit trail) |
| Multi-DBMS SQL Query Visitor | β None | β Rare / single engine only | β 15 drivers, 10 dialects (PostgreSQL, MySQL, SQL Server, Oracle, Firebird, SQLite, Db2, Informix, DuckDB, ANSI) |
| Adianti Framework Database Bridge | β Not applicable | β None | β
Native TCriteria & TFilter, automatic De Morgan inversion, strict precedence |
| ALinq Synergy & Functional LINQ | β None | β None | β
Direct AST compiler for short-circuit LINQ predicates & ALinqBridge |
| Deep Nested Property Resolution | β οΈ Strict reflection | β οΈ Public getters only | β
Dot-notation (PropertyAccess, shared with ALinq) for deep objects and associative arrays |
| Database-Driven Dynamic Rule Engine | β None | β None | β
Dynamic compilation from relational catalog tables (RuleDefinition) |
| Document Matrix Requirement Algebra | β None | β None | β
ALL, ANY, ONE_OF_SET aggregation modes with rich verdict reports |
| Fluent Chaining & Natural Domain DSL | β οΈ Basic | β οΈ Partial | β
Natural English syntax (Spec::specify()->where()->and()->or()) + helpers (prop(), is(), not()) |
| Venn Set Subsumption & Remainder | β Complete | β Absent in almost all libs | β
isGeneralizationOf, isSpecialCaseOf, isDisjointWith, remainderUnsatisfiedBy |
| DAG Partitioning $O(1)$ | β Supported | β Extremely rare | β Partitioned repositories with early disjoint branch pruning |
| State Machine With Explained Denials | β None | β None | β Guards are specifications: a rejection carries code, message and severity; outbox, idempotency and persisted deadlines |
| Concurrency & Locking | β οΈ Java Threads / Locks | β Unsupported | β
Synchronized repositories (reentrant SemaphoreSynchronizer, Fiber-aware) SysVSemaphoreSynchronizer on SysV IPC between processes, and atomic flock file locks |
| Modern Architecture | β οΈ Java 6/7 Generics | β οΈ PHP 7.x legacy | β PHP 8.2+ native (Enums, First-class callables, Readonly, 8 segregated traits) |
π Requirements
- PHP:
^8.2(tested on PHP 8.2 and 8.4). PHP 8.4 is required only by the optional ALinq integration (Antevemus.AlinqCollection, Module 14), which the library detects at runtime. - PHP Extensions:
ext-json(for JSON entity serialization; required)ext-mbstring(required since v1.5.0: Unicode-aware case-insensitive string specifications,mb_strtolower)ext-sysvsem(optional:SysVSemaphoreSynchronizer, the reader/writer lock shared between processes through SysV IPC semaphores, Linux/Unix)ext-curl(optional:HttpDataSource, the concurrent HTTP fetching of theDataResolver, Module 21; without it an HTTP source is refused when registered andCallableDataSourcekeeps working)ext-pdo(optional, with the driver of your database:PdoRepository(Modules 22, 25 and 26), the PDO stores of the state machine,PdoRuleCatalogand the PDO domain event outbox and trigger state store (Modules 23 and 24))
- Dependency: one, from the same author, with no dependencies of its own:
antevemus/adeep-property-access^1.0(since v2.0.0), the property accessor shared with ALinq, so that both libraries read the same value from the same candidate (example 10.1). Composer installs it. - Optional package:
antevemus/alinq-collection^1.5.1 || ^2.0forALinqBridge(typedIALinqCollection/IALinqLazyCollectionreturns),ALinqSpecificationVisitorand O(1) lazy streaming overIRepository::iterate()(requires PHP 8.4; ALinq reads a non-public property only when it is marked#[Specifiable], the same rule as this package, and its 2.0 resolves properties through the same shared accessor).
Upgrading from 1.x? Read UPGRADE-2.0.md first:
.binfiles of the PHP-native serializer and rule catalogs with Portuguese values are converted on 1.14.x, before the upgrade.
π Installation
Install via Composer:
π‘ Practical Examples
1. Natural Language Fluent Chaining
Inspired by Domian Java's syntax, combine business rules fluently and expressively:
2. Zero-Exception Notification Pattern & Diagnostic Telemetry
In enterprise workflows, REST APIs, and UI forms, use evaluate() to accumulate all validation failures without interrupting execution:
Evaluation errors and what to show. When a rule cannot be evaluated (a specification, a handler or a guard throws), isError is true, the failure carries the generic message SpecificationResult::EVALUATION_ERROR_MESSAGE with the rule's code, and the exception stays on $result->exception, never in the failures. The library does not log for you. Recommended practice: send $result->exception to your logger, taking care not to log more than needed (exception messages can hold credentials, personal data or SQL); show users only $failure->message, which is either your business message or the generic one; and never show metadata, which is diagnostic and may hold the evaluated value (metadata['actual']).
3. Partial Satisfaction (remainderUnsatisfiedBy)
Pinpoint at runtime precisely which sub-rules failed for a given candidate:
4. Directed Acyclic Graph (DAG) Partitioned Repositories
Partition collections into logical branches indexed by specification predicates:
5. Decoupled Persistence & Hybrid L1/L2 Cache Tiering
The document is JSON whatever the serializer, and every entity carries its persistence metadata (__persistence_metadata: read and write counters, first and last read and write) next to its data, so getEntityMetaData() answers the same in a new process (since v2.0.0).
6. High-Precision Benchmarking & Telemetry
7. Dynamic Rule Engine & Relational Database Catalogs (Module 11)
Compile dynamic rules and mandatory document matrices directly from relational database tables (e.g. PostgreSQL, MySQL):
Vocabulary note. The rule engine is English end to end: getters (
getCode(),getIntValue(),getViolationMessage(),getLegalBasis()), named parameters (scope:,scenario:),fromArray()keys and catalog columns (code,rule_type,violation_action,product_code,valid_from...), context keys (product_code,plan_code,reference_date), failure metadata (action,rule_code,legal_basis...) and the action valuesblock/warn/log. The Portuguese vocabulary of v1.6, legacy from v1.7.0 to v1.14.x, is refused since v2.0.0 withLegacyVocabularyException, never ignored: a Portuguese key or action value in a catalog, a context key, a table role, a handler's metadata key. UPGRADE-2.0.md has the key table and the SQL that migrates a catalog;PdoRuleCatalogstill reads tables created with the old schema through two presets (below), which map names only.
The catalog can live in relational tables: PdoRuleCatalog reads the tables of the reference DDL resources/sql/rule_catalog.ansi.sql (business_rule, document_requirement_group, document_requirement) in portable, parameterized ANSI SQL and answers exactly like the in-memory catalog. Table and column names are configurable ($tables, $columns: canonical column => real column):
8. SQL Query Visitor & Multi-DBMS (WHERE Parameterization)
Translate pure domain specification trees directly into parameterized, injection-proof WHERE clauses with automatic identifier quoting and native dialect functions:
9. TCriteria Builder (Adianti Framework Database Bridge - Module 13)
Translate pure domain specifications into native Adianti\Database\TCriteria and TFilter instances, applying De Morgan's Laws for nested negations and maintaining strict Boolean precedence:
10. ALinq Synergy & Fluent LINQ Collections (Module 14)
Native high-performance interoperability with the Antevemus.AlinqCollection framework:
10.1 Deep Property Resolution with Dot-Notation (PropertyAccess)
Evaluate nested properties on objects (with getters or Boolean methods) and multidimensional associative arrays without reflection bottlenecks. Since v2.0.0 the resolution lives in antevemus/adeep-property-access, shared with ALinq (one accessor, one contract test); Helpers\PropertyAccessor remains as a deprecated facade until v3.0:
Each segment resolves in this order, first match wins: array or ArrayAccess key; public method getX(), x(), isX(), hasX() without required parameters (never __call(), never a name starting with __); public initialized property or __get guarded by __isset; public property; and, last, a private or protected property marked #[Specifiable] by the author of the class (since v1.6.1). The property name may come from a request (a filter or an ordering built by a screen), so private state is reachable by name only when its author opts in. An unmarked non-public property behaves as a property that does not exist: the property specification is an evaluation error, PropertyAccess::hasProperty() answers false, and a delta (updateWithDelta()) that names it throws InvalidArgumentException unless the entity has a public setter for it.
#[Specifiable] on a class covers every property declared by that class (not those of its parent nor of its subclasses). #[Antevemus\ALinq\Attributes\Specifiable] and #[Antevemus\ADeepPropertyAccess\Attributes\Specifiable] are accepted as the same marker, by name.
10.2 AST Compiler to LINQ Predicates (ALinqSpecificationVisitor)
Compile specification trees into a zero-overhead native Closure(mixed $candidate): bool using short-circuit logical operators (&&, ||, !):
10.3 Fluent In-Memory Repositories with ALinqBridge
Connect repositories and iterables directly to LINQ pipelines (sorting, pagination, grouping, and aggregations):
10.4 Generator-Based Streaming & Lazy Evaluation (ALinqLazyCollection) with O(1) Memory
Process massive, infinite, or file-backed data streams with constant memory footprint by pairing specifications with lazy generators:
11. Declarative Attributes Engine (#[AssertSpec], #[ValidateRule]) (Module 15)
In PHP 8.2+, annotate Data Transfer Objects (DTOs), Form Requests, Value Objects, and Domain Entities directly with specifications:
12. Tautology, Contradiction & Simplification
Every specification answers two structural questions, isTautology() and isContradiction(). A true is a proof from the shape of the tree; a false means "not proven" (there is no SAT solving, and evaluate() is untouched). The rule engine can report the catalog rules that can never pass or never fail:
simplify() (since 1.9.0) returns an equivalent, smaller specification. It applies, bottom-up until nothing changes: identity (A β§ true = A, A β¨ false = A), annihilation (A β§ false = false, A β¨ true = true), double negation, idempotence (A β§ A = A, by equals()), flattening of nested and/or, the contradictions and tautologies proven above (collapsed to alwaysFalse()/alwaysTrue()) and subsumption (A β§ B = A and A β¨ B = B when A is a special case of B). The input is never modified, and simplifying a simplified tree returns it unchanged. A node annotated with because()/withCode() is a unit: it is simplified inside but never removed, merged or replaced by a constant, so the verdict and the annotated messages stay the same.
Two limits, both stated in the docblocks. The equivalence holds for non-null candidates, the same premise as isTautology()/isContradiction(): AND/OR/NOT reject null whatever their operands, and the node left after simplification may not (alwaysTrue()->and(alwaysTrue()) rejects null, alwaysTrue() accepts it). And it is an equivalence of truth value only: removing a redundant branch may remove one that would have raised an exception, and the default messages of unannotated nodes may change; only annotated messages and codes are guaranteed. Nothing simplifies by itself: the SQL, TCriteria and ALinq visitors compile the tree as given, and the rule engine simplifies only when asked.
13. Declarative Method Calls (MethodParameterizedSpecification)
where() reads a property; whereMethod() / Spec::calling() call a public method of the candidate with arguments declared as data and apply a specification to the result. Unlike must(closure), the method name and the arguments are data: the specification has structural equals(), a rule name and can be described in a catalog. A missing method is an evaluation error, never a silent false.
The SQL and TCriteria visitors refuse a method call as a non-translatable leaf (like must()); the ALinq visitor compiles it.
14. Spec-Driven State Machine (Modules 17 and 18)
Antevemus\ASpecification\StateMachine is a state machine in which every guard is an ISpecification: when a transition is denied, the result says why, with the code, message, property and severity of the specification that blocked it. The API is event first (send(event), the target is a consequence), the definition is data (stable key and integer version, built fluently or loaded from JSON), transition() is pure (it computes the new snapshot, the transitions taken and the effects, and executes nothing), and MachineRunner is the single write port (snapshot, history and outbox in one transaction). The snippets of this section build on each other.
14.1 A Minimal Machine: Guards Are Specifications
An event that no transition of the current state answers is unhandled (no change, no exception). When several transitions answer the same event, they are tried in declaration order and the first whose guard passes wins; a guard that throws becomes an ERROR failure marked evaluation_error.
14.2 Parallel Regions: a Pending Flag That Never Loses the Progress
A machine has one or more orthogonal regions, each with its own current state. A pending item modelled as a region of its own (none β open) can be registered and resolved from any step without a "pending" state in the main flow, so resolving it never has to guess where the process was. inState('region.state') reads another region, and a guard expression can carry its own message and code:
Guards read the snapshot before the event (the order of the regions never changes the result), and an event is all or nothing: if any region rejects it, no region moves. The machine is done when every region that declares a final state is in one; a region without final states (the pending flag) does not hold the machine open, and a done machine answers every event as unhandled.
14.3 can() and nextEvents(): a Disabled Button With Its Reason
can() is transition() itself, with no side effect; nextEvents() tests every event the current states answer (as an external event with an empty payload), so an action panel can show a disabled button together with the reason. Continuing with the pending item still open:
14.4 The Definition Is Data: JSON and the Validator
Guards and actions are referenced by name and parameters, never by closures, so a definition round-trips through arrays and JSON without loss and has a deterministic fingerprint (SHA-256 of its canonical form) to detect a definition changed without a new version. The validator runs at load time: it refuses the definition (one exception listing every error) for a region without initial state, an unknown target or inState path, a guard without factory (or an action without handler, when the known action names are given), a final state with outgoing transitions; it warns, without blocking, about unreachable states, non-final states without exit, overlapping or shadowed guards and guards that always pass. The document requirement below (pending β delivered β validated, or expired after 30 days) is the RequirementState the 1.0.0 changelog announced, now expressed as a machine:
14.5 The Runner: One Transaction, Idempotency, Outbox and Persisted Deadlines
MachineRunner::send() loads the instance and the definition version it was started with, checks the idempotency key, computes the transition, and writes the new snapshot (compare-and-set on its version: a concurrent change throws ConcurrencyConflictException and is never retried silently), one history row per region moved and the effects in the outbox, in one transaction. Effects (the named actions: exit β transition β entry) are delivered after the commit by an EffectDispatcher, each with its own idempotency key, in write order per instance; a failing handler never undoes the transition. A deadline is persisted (due_at), never a timer in memory: a cron asks for the due ones and delivers them through the same runner, and a late or repeated delivery is harmless.
EffectHandlerRegistry::registerSync() declares the rare effect that must be able to veto: it runs inside the transaction, before the commit, and an exception rolls the whole step back. Each instance stays pinned to the definition version it started with; moving it to a new version is explicit (registerMigration() + migrate()).
14.6 PDO Persistence and the Reference DDL
MachinePersistence::pdo() gives the same ports over the tables of resources/sql/state_machine.ansi.sql: portable SQL, CREATE TABLE IF NOT EXISTS (accepted by PostgreSQL, MySQL/MariaDB and SQLite), a primary key on every table, UNIQUE (instance_id, idempotency_key), instants as fixed-width UTC text. The due deadlines are searched with a specification compiled to SQL by the SQL visitor. The InMemory and PDO stores run the same test battery (SQLite in memory in the suite).
JSON columns.
snapshot,payload,params,reasonandtransition_resultare JSON stored asVARCHAR(4000), the portable size that also fits Oracle. A larger event payload needs the column widened toTEXT/JSONB/CLOBin your copy of the DDL. If the PDO connection is already inside a transaction, the runner joins it and the owner of that transaction commits or rolls back.Upgrading from 1.8. Since 1.9.0
state_machine_regionhas a nullablepaused_remainingcolumn (the balance of a paused deadline, example 14.7).CREATE TABLE IF NOT EXISTSdoes not add a column to an existing table, so a database created with the 1.8 script needs, once, before the first deadline pause:ALTER TABLE state_machine_region ADD COLUMN paused_remaining INTEGER;. Until then, every definition that never pauses a deadline keeps working on the old table; the first pause fails and its step is rolled back.
14.7 Deadlines From the Context: an SLA That Pauses While a Pending Item Is Open
A deadline has exactly one source: a fixed duration (deadline('P3D', 'EXPIRE'), as in 14.5), a duration read from the context (afterFrom: 'context.path', an ISO-8601 string or a DateInterval), or the due instant itself read from the context (atFrom: 'context.path', a DateTimeInterface or an ISO-8601 text with date, time and offset). The path is read with the dot notation of the PropertyAccessor when the state is entered, from the context given to that call, so the computation stays pure. A missing or invalid source never becomes a silently empty deadline: the event that enters the state is rejected with SM_DEADLINE_SOURCE_INVALID (an ERROR that names the path, never the value), and so is a start, which then writes nothing. pausedWhile takes a guard expression (a 'region.state' string is a shorthand for inState), evaluated after every accepted step: when it starts to hold, the deadline pauses (no due instant, so no cron ever finds it, and the remaining whole seconds are kept); when it stops holding, the deadline resumes from that balance. Re-entering the state restarts the deadline and drops the balance. Below, the SLA is a region of its own (an absolute instant from the contract), paused while a pending flag (the parallel region of example 14.2) is open, and each stage lasts what the client's table says:
deliverDue() sends a null context unless given a contextProvider (it receives the due deadline and the loaded instance): a deadline transition entering a state whose deadline reads the context needs the instance's context, as here. A pause condition is any guard expression; one that throws rejects the step. The validator refuses a pausedWhile with an unknown guard or an inState path that does not exist, and warns when it references the deadline's own region (the pause would never end). The snapshot keeps the balance (pausedRemaining(), paused_remaining in its array form only while some region is paused, so a 1.8 snapshot loads unchanged), and migrate() refuses a migration that would leave a paused balance in a state whose deadline declares no pausedWhile, since it could never resume.
Deliberately out of scope (each can be modelled without it): actors, invoke/spawn (asynchronous work is an external job that sends an event back), eventless (always) transitions and raise (the caller sends the next event), nested states inside a region and history states (a parallel region, like the pending flag above, covers the real cases), and a machine-owned context (assign): the context is the caller's domain object. The history is a trail; the snapshot is the source of truth (no event sourcing).
15. Use Cases With a Result Shared With the Machine (Module 19)
A use case is one class with one method, execute(object $request): UseCaseResult. UseCaseRunner runs it through the middlewares, validates the request first (its #[ValidateRule]/#[AssertSpec] attributes plus any specification registered for its class) and only an ERROR failure blocks: WARNING and INFO come back with the success. The status is a closed domain enum (SUCCESS, CREATED, ACCEPTED, NO_CHANGE, INVALID, REJECTED, NOT_FOUND, CONFLICT, FORBIDDEN, FAILED); success is derived from it alone, and HTTP exists only in one edge method, toHttpStatus(). An unhandled exception becomes FAILED with a generic message and goes to an optional IUseCaseErrorReporter: no message, class, file, line or stack trace ever reaches the result. No mediator, container or handler discovery is embedded.
15.1 IUseCase, UseCaseRunner and TransactionMiddleware
TransactionMiddleware commits only a success and rolls back a failure status or an exception (which it rethrows for the runner to turn into FAILED). UseCaseResult and the machine's TransitionResult implement the same Results\IOutcome (isSuccess(), getReason()), and UseCaseResult::fromTransition() converts one into the other.
15.2 A Machine Event as a Use Case
StateMachineEventUseCase (or Spec::machineEventUseCase()) exposes one event of a machine as a use case: the request is a MachineEventRequest (instance id, occurredAt, payload, optional idempotency key, actor and expected version), and the outcome is the runner's result converted: accepted β SUCCESS (payload = the new snapshot), rejected β REJECTED with the guards' codes, unhandled β CONFLICT, a stale version β CONFLICT with SM_CONCURRENCY_CONFLICT, an unknown instance β NOT_FOUND. With the quote machine of 14.2:
16. Concurrent Data Resolution for I/O-Bound Specifications (Module 21)
A rule that depends on data held elsewhere (a credit bureau score, a fraud check, the balance in the core banking) does not call the service. Spec::external() declares the data it needs: a source name, parameters read from the candidate (dot paths), and an ordinary specification that decides on the value, with its own because()/withCode(). A DataResolver collects the declarations of the whole tree, fetches the data concurrently (curl_multi, no package dependency, works under PHP-FPM) and only then evaluates the tree, synchronously, as every specification is evaluated. Sources are registered by name: HttpDataSource (fixed base URL, a path template with {placeholders}, headers, its own time limit, a cost and a cache TTL) and CallableDataSource (any synchronous callable: a repository, an SDK, a gRPC client).
The policies: concurrent() (the default) fetches every request of the tree in one stage and reports every failure, as an evaluation outside the resolver does; tiered() (an explicit choice, when calls cost money) fetches by cost, cheapest first, and after each stage evaluates the tree in three-valued logic (true, false, unknown): once the verdict can no longer change (an and with a false operand, an or whose left operand is true) the more expensive sources are not called; sequential() fetches nothing beforehand and each leaf fetches when the evaluation reaches it. The verdict is the same under every policy and outside the resolver; under tiered(), a branch that was not fetched because it could not change the verdict reports no failure, so the list of failures can be shorter than with concurrent(). An or stops early only on its left operand: in the core an error on the left operand aborts the disjunction, so an unknown left operand cannot be skipped. Two leaves asking the same data (same source, same parameters) cause one call. A failed fetch is unknown, never a decision on its own.
Errors follow the 1.8.0 rule: a fetch that fails (an HTTP status outside 2xx, a transport error, the time limit of the source or of the whole resolution with ResolutionPolicy::tiered(timeout: 3.0), a parser that throws, an unknown source) is an evaluation error of the leaf: a failure with the generic message, evaluation_error = true, and a DataFetchException in $result->exception with the status code, never the response body. There is no retry. A parameter value is percent-encoded into the URL template, so /, ?, #, .. or a whole URL in a document number cannot change the host or the path of the call; redirects are not followed. A cache between resolutions is optional (new DataResolver($registry, new InMemoryDataCache()), with the TTL of each source; failures are never cached). ext-curl is a suggest: without it an HttpDataSource is refused when registered and a CallableDataSource keeps working. The SQL, TCriteria and ALinq visitors refuse the leaf with their "non translatable" exception. State machine guards stay pure: a use case resolves the data before sending the event. Concurrency for non-HTTP sources (Fibers/Revolt) is a planned satellite package.
17. Repository Over PDO (Module 22)
PdoRepository is the IRepository of a database table: a query is a specification compiled to SQL by the SQL visitor (any of its dialects), a write is a prepared INSERT/UPDATE/DELETE, and iterate() streams the cursor. The author of the entity maps it to the table, with attributes or with a builder; a mapped property is read and written by reflection even when it is private (the mapping is the author's opt-in). The library generates no DDL; relationships are declared on the entity and loaded explicitly, in batch (example 17.3).
Optimistic locking: with a #[Version] counter, the INSERT writes 1 and every UPDATE increments it and only matches the row while it still holds the version the entity was read with.
The rules that matter, and the builder for a class that cannot carry attributes:
- Queries. Every value is a bound parameter; table and column names come only from the mapping, validated as plain identifiers and quoted by the dialect. A specification that cannot be translated (a closure, a method call, external data) throws
NonTranslatableSpecificationException; nothing is filtered in memory unlesswithInMemoryFallback()was called, and then the whole table is read, so keep it for small tables. Only a property that is mapped and readable by name (public, a public getter or#[Specifiable]) can be queried, the rule the in-memory evaluation applies, so a private column is never a read oracle through SQL.entityIdalways means the identifier column. - Reads.
find*(),iterate*()andfindPage()order by the identifier (findPage()appends it to the given order, so pages never overlap);iterate()is a generator over the cursor (constant memory);count()builds no entity;findSingle()asks for at most two rows and throws when there is more than one;findById()reads one row. Entities are built without calling their constructor. MySQL streams only withPDO::MYSQL_ATTR_USE_BUFFERED_QUERYset tofalseon the connection; with the default buffered mode the driver holds the whole result before the first row. - Writes run in a transaction of the repository's
ITransactionManager(by default aPdoTransactionManageron the same connection, which joins a transaction already open):put()/update()insert or update by the presence of the id and of the row,putAll()is all or nothing,removeBy()is oneDELETE ... WHERE. A stale update or removal throwsOptimisticLockExceptionand writes nothing; a versioned copy whose row is gone is never re-inserted. - Identifiers:
auto(identity, serial, auto-increment; in PostgreSQLINSERT ... RETURNING, orlastInsertId()of the declared sequence),ulid,uuid7,assigned, andmax(SELECT COALESCE(MAX(id), 0) + 1inside the write transaction, compatible with theIDPOLICY = 'max'of Adianti'sTRecord).maxis subject to a race between concurrent writers: two transactions can read the same maximum and the primary key rejects the secondINSERT. Preferauto,ulidoruuid7for new tables. - Null values. A
NULLcolumn is a null value, and the database answers for it what the in-memory evaluation answers:property('note', isNull())matches the rows whosenoteisNULL; a comparison (greaterThan(),startsWith(),equalTo('x'),in('a', 'b'), ...) never matches them, and its negationnot(property('views', greaterThan(10)))does. Each comparison is compiled as(column IS NOT NULL AND ...), so it is false there, never SQL's unknown, and aNOTover it keeps the in-memory answer. Anand/or/notwritten inside a property rejects a null value, as it rejects any null candidate: "no note, or note n2" isanyOf(property('note', isNull()), property('note', equalTo('n2'))), notproperty('note', anyOf(isNull(), equalTo('n2'))).Spec::toSql()andSpec::toCriteria()follow the same rule (changed in 1.11.0, see the CHANGELOG). - Text operators follow memory; plain equality follows the column. Since v1.14.0,
startsWith/endsWith/contains, the wildcards,equalIgnoreCaseandregexselect in the database the rows the in-memory evaluation accepts, on every column collation: on SQLite the case-sensitive form usesGLOB(itsLIKEignores ASCII case), and MySQL/MariaDB compare throughutf8mb4_binand giveREGEXPthe case rule as an inline flag, sostartsWith('mo')does not matchMouseanywhere. Two limits remain. A case-insensitive comparison of non-ASCII letters (ΓandΓ©) follows the database where it folds ASCII only (SQLite, PostgreSQL columns with the"C"collation). Plain=(equals) is the database's own comparison, so MySQL's default_cicollations ignore case and accents there: declare the column with a binary collation (utf8mb4_bin) for exact equality. Write a regex with theumodifier when it matches non-ASCII text (in memory,.withoutuis one byte). SQLite gives aDECIMALcolumn numeric affinity ('59.90'comes back as'59.9'), so the example declares itTEXT; comparisons on that column are then textual. - The transaction port is shared (
Persistence\ITransactionManager): the state machine'sPdoTransactionManageris one, so one instance gives the repository, the domain event outbox (example 18) and the machine stores the same transaction. - Not in this version: joins (relationships load in batch by
IN, example 17.3), DDL generation, second-level cache, partitions (makePartition()throwsPartitioningNotSupportedException). Lifecycle columns, soft delete, hooks and the SQL log are in example 17.1, batch and set-based operations in 17.2.
17.1. Lifecycle: Timestamps, Soft Delete, Hooks and the SQL Log (Module 25)
The bookkeeping an application usually writes by hand around a table, as options of the mapping and of the repository: creation and modification timestamps, a removal that marks the row instead of deleting it, callables around each write, a log of every statement, booleans stored as integers or as text and the time zone of columns that hold local time without an offset (the Adianti tables, for one). Without them nothing changes.
Removal marks the row; every read leaves the marked rows out, and two views see them:
Legacy flags stored as text, such as the CHAR(1) 'Y'/'N' columns of the Adianti tables, are read strictly:
- Time zones. A temporal column is written and read in the zone it declares (
#[Column(timezone: ...)]), else in the default of the mapping (#[Table(timezone: ...)],EntityMapping::timezone()), else in the default of the repository (withTimezone()), else in UTC. - Timestamps.
#[CreatedAt]is filled by theINSERTwhen the entity holds no value (an imported value is kept) and never updated;#[UpdatedAt]is set by theINSERT, everyUPDATEand the soft removal. The instant comes from the repository's clock (withClock(), read once per call) and is truncated to the second, so the entity holds exactly what any timestamp column stores. - Soft delete.
#[SoftDelete('deleted_at')]on the class (the column alone; addproperty:to hydrate it) or on a nullable date property.remove()andremoveBy()mark only live rows and increment the#[Version]counter like any write;withTrashed()andonlyTrashed()return views (copies of the repository);forceRemove()deletes. A marked row is removed for every reader:TriggeringRepositorypublishes the exit andTriggerSweepersees it gone.put()of an entity whose row is marked behaves as after aDELETE: a versioned copy throwsOptimisticLockException, an unversioned one is written back and visible again.toWhereClause()does not show the soft delete condition. - Hooks belong to the repository (the entity stays a plain object) and run inside the write's transaction: an exception in a
before*or anafter*hook rolls the write back and propagates.beforePutruns before the identifier is generated and the timestamps are set. With a remove hook,removeBy()loads the matching entities and removes them one by one, so a hook is never bypassed; without one it stays one statement. - SQL log.
IQueryLogger::log(string $sql, int $parameterCount, float $milliseconds, ?array $parameters)receives every statement after it ran or failed; the values are passed only withwithQueryLogger($logger, logParameters: true). An exception thrown by a logger is ignored.InMemoryQueryLoggerkeeps at most 10 000 entries by default (entries(),statements(),clear()). - Booleans.
BooleanStorage::Integer, per column (#[Column(booleanAs: ...)]) or as the default of the mapping, writes 1/0 on every driver, PostgreSQL included (theSMALLINTflags of legacy schemas), and a specification compiles to"urgent" = 1; reading accepts both forms.BooleanStorage::Charwrites text,'Y'/'N'unlesstrueValue:/falseValue:say otherwise (on the column, or on#[Table]/EntityMapping::booleanAs()as the default; 1 to 16 letters or digits, different whatever the case). It reads the two texts in any case (and without the spaces aCHARcolumn pads with) andNULLasnull; any other stored value throwsDataIntegrityExceptionwith the column and the value. A comparison compiles to every case form of the text (IN ('Y', 'y');LOWER(col) = 'false'for a text of more than two letters), so the database finds exactly the rows a read accepts, and the null semantics of 1.11.0 still hold.Nativestays the default. extract()andhydrate().EntityMapping::extract($entity)gives the row of an entity (column => property value) andhydrate($row)builds an entity from one without calling its constructor, both without a database: thetoArray()/fromArray()of an active record.
17.2. Batch Writes and Set-Based Operations (Module 25)
Writes and reads over many rows that do not build one entity at a time: a multi-row INSERT, an UPDATE by specification, a projection into arrays, grouped aggregates computed by the database and a subquery over another repository.
insertAll()writes new entities in one transaction, in batches ofwithBatchSize()rows (1 000 by default) that never pass the driver's parameter limit; a row that already exists fails on its key and nothing of the call is kept. Identifiers generated in PHP go in the batch;autoidentifiers come back byRETURNINGon PostgreSQL and are inserted row by row on the other drivers (MySQL does not guarantee consecutive values for a multi-rowINSERT). Measured with 10 000 entities: about 3 times faster thanput()row by row in SQLite, 20 to 40 times in PostgreSQL and MySQL.putAll()keeps the upsert, but reads the existence of a whole batch with oneSELECT ... IN.updateWhere()converts each value by the type of its column and refuses the identifier, the version counter, the lifecycle columns and unmapped properties before any SQL. It stamps#[UpdatedAt], increments#[Version](a copy loaded before becomes stale) and touches only live rows. It runs no per-entity hook, andTriggeringRepositorydoes not see it (aTriggerSweepernotices the changes): useputAll()when hooks or triggers must run. On MySQL the returned count is the rows whose values changed (unless the connection setsPDO::MYSQL_ATTR_FOUND_ROWS), and MySQL refuses a subquery on the table being updated.select()is a generator of arrays keyed by property name, each value converted like a read value;aggregate()is immutable (groupBy(),count(),sum(),avg(),min(),max(), each with an optional alias) and follows SQL's null rules:sum/avg/min/maxignoreNULL, the sum of nothing isnull, not 0,count()counts rows andcount('prop')non-null values, theNULLgroup comes first. ADecimalcolumn gives canonical decimal text ('199.8', not'199.80'; SQLite has no decimal arithmetic and computes in floating point, rounded to 15 significant digits). Both apply the read scope (soft delete); an aggregate needs a translatable specification even with the in-memory fallback.Spec::inQuery($property, $other, $where, $otherProperty)compiles tocol IN (SELECT othercol FROM other WHERE ... AND othercol IS NOT NULL)when both repositories share the connection, with the read scope of$other; otherwise it cannot be translated. In memory the subquery runs once per instance of the specification and its values are kept: build a new one to see newer data.whereRelation()(example 17.3) builds it from a relationship.
17.3. Relationships: References, Compositions and Eager Loading (Module 26)
Relationships are declared on the entity and loaded explicitly, never lazily and without proxies. A reference (#[ManyToOne]) is an aggregation: it is never saved with the entity. A collection (#[OneToMany]) is read-only unless cascade: true makes it a composition, saved and removed with its parent. An association (#[ManyToMany]) goes through a join table: saving the owner writes only the links. The repositories of the related classes are given with withRelated().
Eager loading is a view, with(); each relation costs one query per batch of entities, never one per entity:
A collection that was not loaded is never taken as empty, and only a loaded one has orphans:
An association is never a composition: the targets live on their own, and a loaded collection only adds and removes links:
A collection built by hand can be declared loaded, and iterate() loads relations page by page:
- Eager loading.
with('lines', 'customer', 'lines.product')returns a view whose reads (find*(),iterate*(),findSingle(),findPage(),findById()) load those relations for each batch ofwithBatchSize()entities with oneSELECT ... WHERE fk IN (...)per relation (two for an association: its join table, then its targets; more only past the driver's parameter limit), in the read scope of the related repository (its soft-removed rows are not loaded). A dotted path goes as deep as the relations go, one more query per segment and batch. With 1 000 orders of 10 items each: 2 queries forwith('items')and 4 forwith('items.product', 'customer'), against 1 001 for a loop per order, in SQLite, PostgreSQL and MySQL. - Not loaded is not empty. A relation that was not loaded keeps its default (
[],null) and the repository knows it (isLoaded(); the state lives outside the entity).put()of a parent whose collection was not loaded removes no child, and a reference that was not loaded never clears the foreign key: only a reference loaded non-null and then set tonullclears it. - References are never cascaded:
put()writes the identifier of the referenced object into the foreign key property and refuses an object without one. - Compositions.
put(),putAll()andinsertAll()write the children after their parent, in its transaction (same connection required), setting the foreign key (and the child's back reference whenmappedBynames one). Orphans are removed only when the collection was loaded (or the parent was just inserted), and only the children loaded with it that still belong to it, each with its version checked: a child added by someone else in between is never removed. Moving a child between two loaded parents is safe within oneputAll(). - Removing a parent removes its children first. A parent with
#[SoftDelete]marks the children that have#[SoftDelete]and keeps the others (its row stays, so a revival finds them); a hard delete (forceRemove(), or a parent without soft delete) deletes every child row, marked or not, since no child row may outlive its parent's. - Domain events. Register a
TriggeringRepositorywithwithRelated()and the cascades and orphans go through it, publishing the children's entries and exits (it reads through itsPdoRepository); a barePdoRepositorypublishes nothing and aTriggerSweepernotices. ATriggeringRepositoryover the parent reads its "before" without relations: its triggers should read columns. whereRelation('customer', $spec)iscustomer_id IN (SELECT id FROM customer WHERE ...)andwhereRelation('lines', $spec)isid IN (SELECT order_id FROM order_line WHERE ...), with the related repository's read scope; the foreign key property must be readable by a specification. A relation property is not a column: a specification cannot read it.- Associations.
#[ManyToMany(Tag::class, 'article_tag', joinColumn: 'article_id', inverseJoinColumn: 'tag_id')](orEntityMapping::manyToMany()); the table and column names come only from the mapping, validated as identifiers, and the target repository must use the same connection.put(),putAll()andinsertAll()of an owner whose collection was loaded (or that was just inserted) read its stored links once per batch, insert the new ones (multi-row) and delete those of the targets that left the collection (only targets loaded with it), never writing or deleting a target; a target without an identifier is refused. A collection that was not loaded never touches the join table, even when it holds targets. A soft-removed target is left out of a loaded collection and its link stays. A hard removal of the owner deletes its links first; a soft removal keeps them.whereRelation('tags', $spec)isid IN (SELECT article_id FROM article_tag WHERE tag_id IN (SELECT id FROM tag WHERE ...)). With 1 000 owners of 5 targets each,with('tags')is 3 queries in SQLite, PostgreSQL and MySQL. markLoaded($entity, 'lines', 'tags')declares collections built by hand (from a form, a cache) as loaded and whole: the nextput()removes the orphans (#[OneToMany]composition) or the links (#[ManyToMany], soft-removed targets aside) that the collection lacks, relative to what it holds at thatput(). Declaring an incomplete collection deletes what it lacks, including a child or link someone else added: a collection loaded bywith()never removes those. After the write the collection is loaded as usual.markUnloaded()makes a collection say nothing again.iterate()with relations pages by key,WHERE ... AND id > :last ORDER BY id LIMIT batch: each page is read whole and its cursor closed before its relations load, so it works on MySQL unbuffered queries (PDO::MYSQL_ATTR_USE_BUFFERED_QUERY = false) and holds one page of entities at a time (10 000 rows withwith('tags')in pages of 500: about 1 MB at the peak, against 20 MB forfind(), in SQLite).find*()andfindPage()finish their read before loading. Without relationsiterate()streams one cursor, as before.
18. Domain Events From Specifications (Module 23)
A trigger binds a specification to domain events: when an entity starts to satisfy it, the trigger emits its enter event; when it stops, its exit event. TriggeringRepository decorates any IRepository: on put()/update()/updateWithDelta() it reads the stored state of the entity by its identity, writes, evaluates every trigger on the state before and after, and publishes the crossings; on remove()/removeBy() it emits the exits. The production publisher writes to an outbox in the same transaction as the write, and DomainEventDispatcher delivers it after the commit, as the effects of the state machine.
An event can move a state machine (example 14): MachineEventBridge is a ready-made handler that sends it to a MachineRunner as an internal event carrying the domain event's idempotency key, so a redelivery returns the original result and does not move the instance again.
Over the PdoRepository of example 17 the row and its events commit together when the repository and the outbox share the connection and the transaction manager. The decorator then reads the "before" with Spec::property('entityId', Spec::equalTo($id)), compiled to SQL, without configuration:
- The payload is minimal on purpose: the trigger name and the entity id, never the entity, so no personal data reaches a queue or a log; a handler that needs more reads the entity by its id. Each event carries a deterministic idempotency key (
de:+ SHA-256 of trigger, entity class, entity id, kind and instant): the outbox stores a crossing once, so re-applying the same write does not duplicate it. - Reading "before" costs one read per write (a scan in the in-memory repositories). Over a
PdoRepositorythe identity lookup is the SQL one shown above; for another repository that translates specifications to SQL, passidentitySpecification: TriggeringRepository::byIdProperty('id'). The in-memory repositories store the caller's object, so the decorator compares with the states it recorded at the last write of that object through it. Known limitation: on the in-memory repositories, an entity stored before the decorator existed (or written to the inner repository directly) and then mutated in place has no "before", and that write emits nothing: write through the decorator from the start. With a trigger state store (example 18.1) the "before" is the recorded state, and the limitation is gone for every entity that has one. - Delivery is at-least-once, in write order per entity: handlers deduplicate by the idempotency key they receive. A failing handler never undoes the write; the entry is marked failed (no stack trace) and retried, holding that entity's later events back until it succeeds or, with
maxAttempts, reaches the limit. An event without a handler is a failed attempt, not a silent drop. - Errors: a specification that throws while being evaluated emits no event; by default the write goes on and the error goes to an
IDomainEventErrorReporter, and withfailOnEvaluationError: truethe write throws instead. A failure of the publisher (the outbox) always propagates. - A crossing caused by time alone, with no write (a policy that becomes overdue at midnight while nobody writes it), is not seen by the decorator: that is the time-based sweeper of example 18.1. Event sourcing and a message bus client are out of scope: publish to Kafka or RabbitMQ from a handler.
18.1. Domain Events From the Passage of Time (Module 24)
"Unpaid and due more than 30 days ago" turns true at midnight while nobody writes the invoice: only the clock moved, so the write-time triggers of example 18 never see it. A time-based trigger builds its specification from "now"; a state store keeps, for every trigger and entity, the last known answer (satisfied, evaluated_at); a sweeper, run by your cron with the instant injected, compares the two, records each crossing and publishes it through the same publisher and outbox, with the same kind of idempotency key. TriggeringRepository keeps the same store on every write, so a write and a sweep never publish the same crossing twice.
β οΈ Introducing a trigger over existing data? Run
baseline()first. An entity without a recorded state counts as "not satisfied", so the first sweep publishes an enter for every invoice that is already overdue. That is the truth (they are inside and nobody was told), but rarely what a mailing wants.baseline($now)records the current state of every trigger for every entity and publishes nothing; the following sweeps publish only new crossings.
In production the store is a table, domain_event_trigger_state, in the same resources/sql/domain_events.ansi.sql (running the script again on a 1.11 database only adds it). Give the store, the outbox and the repository the same connection and transaction manager, so that the state of a crossing and its event commit together:
- How a sweep finds the crossings, and what it costs. Per time-based trigger, the states recorded as inside are read in pages of 500 (by entity id) and, per page, one
COUNT(*)ofspec(now) AND id IN (page)tells whether they are all still inside; only when they are not are those entities loaded (a removed one is an exit). Then, when the number confirmed inside equals theCOUNT(*)ofspec(now), nothing can enter and the entry pass is skipped; otherwise the entities satisfyingspec(now)are streamed (in SQL with aPdoRepository) and those not recorded inside are entries. A sweep that finds no crossing loads no entity: with 10 000 invoices in SQLite, 5 000 of them overdue, it is 22 statements, a few tens of milliseconds, and the same memory peak (one page) as with 1 000 invoices. The SQL only narrows: each crossing is confirmed in PHP on the loaded entity, as on a write. - One transaction per crossing, compare-and-set on the state. The state is written only if it is still at the version the sweep read, and the event is published only when that write won, so two sweepers (two cron hosts, an overlapping run) or a sweeper and a write racing on one crossing publish it once; the lost one is counted in
conflictCount(). The outbox's unique idempotency key is the second barrier. Two sweepers are correct together, not faster: the work is not partitioned between them, and there is no embedded scheduler (the cron is yours, as withdeliverDue()of the state machine). - The instant. An event found by a sweep is stamped with the sweep's
$now, unless the trigger declarescrossedAt(it receives the entity,$nowand the kind of crossing): its instant is used when it lies after the last recorded evaluation and not after$now, which keeps the idempotency key the same in every sweep and every process, while a re-entry after an exit still gets a key of its own. A crossing seen by a write carries the write's instant. limitbounds the crossings handled by one call: loop whilelimitReached().SweepReportgives the counts, the events, the lost compare-and-sets, the evaluation errors (also sent to theIDomainEventErrorReporter) and two cost counters (entitiesLoaded(),statesRead()).- Limits: one sweeper per concrete entity class (the states are keyed by it; an entity of another class found in the repository is reported, never recorded); on MySQL with unbuffered queries, give the sweeper a repository on its own connection (it reads states while the entity cursor is open); the plain triggers of the set are not swept (they only change on writes), but
baseline()records them too.
ποΈ Directory Structure
π§ͺ Code Quality & Automated Tests
The library features comprehensive unit and integration test coverage with zero regressions:
Report below as printed by the runner, with its Portuguese labels translated; the duration depends on the machine. Module 22 also prints its benchmark (10 000 rows in SQLite) and, without the ASPEC_PDO_* variables, that its PostgreSQL and MySQL runs were skipped, Module 24 its sweeps over 1 000 and 10 000 rows, Module 25 its batch, updateWhere(), null/subquery and null/Char parity runs and Module 26 its eager loading benchmarks (1 000 parents Γ 10 children, 1 000 owners Γ 5 targets) and the peak memory of iterate() with relations over 2 000 and 10 000 rows; those lines are omitted here:
- Domian Parity suite:
tests/Parity/transcribes the Domian (Java) tests for the specification algebra, the partition DAG, the synchronizers, entities and utilities (219 PHPUnit tests, 2 106 assertions;vendor/bin/phpunitruns 247 tests and 13 384 assertions in total). The audit behind it is indocs/PARIDADE-DOMIAN-2026-10-09.md. - Public API Documentation: 1,200+ methods documented via structured corporate PHPDoc blocks.
- Java (Domian) Traceability: conceptual and architectural parity with the upstream framework (public API and class names follow Domian; see the provenance table in NOTICE.md).
- Modular Factory Architecture:
SpecificationFactorydecomposed into 8 domain-specialized traits. - ALinq Synergy: Native integration with
Antevemus.AlinqCollection: theALinqSpecificationVisitorcompiler, the shared dot-notation accessor (antevemus/adeep-property-access) and theInMemoryRepositorybridge methods. - Multi-DBMS Compatibility: 15 drivers (10 SQL dialects) certified with proper identifier quoting and bound prepared statements.
PdoRepositoryruns its contract battery in SQLite in the suite and, throughtests/Fixtures/pdo_repository/run_portability.sh, against PostgreSQL 17 and MySQL 8.4 in disposable containers. - Adianti Database Bridge: Full translation to
TCriteria/TFilterwith De Morgan logic inversion and strict precedence.
πΊοΈ Roadmap & Next Steps
Antevemus ASpecification is actively maintained and continuously evolving. For the full strategic roadmap and timeline:
π View the complete ROADMAP.md
Key highlights & upcoming roadmap:
- English DocBlock Internationalization (Shipped in v1.1.0)
- PHP 8.4 Declarative Attributes (
#[AssertSpec],#[ValidateRule]) (Shipped in v1.1.0) - ALinq Lazy Streaming Pipeline & O(1) RAM Evaluation (Shipped in v1.1.0)
- Review-Driven Hardening: Error State, Strict Typing, Rule Binding & Document Matrix (Shipped in v1.2.0)
- README Promises I: Every Documented Example Runs (Shipped in v1.3.0) 5a. Domian Parity: Subsumption Algebra, Partition DAG, Synchronized Repositories, Entities & Utilities (Shipped in v1.4.4)
- README Promises II: Inter-Process, Unicode, Lazy Sources, Relational Catalog &
IN(Shipped in v1.5.0) - Announced-but-Unshipped Backlog I: ULID / UUID v7 Identities & Failure Severity (Shipped in v1.5.0)
- PHP-Native Serializer Retirement, Step 1: Deprecation (Shipped in v1.5.0)
MethodParameterizedSpecification: Declarative Method Calls (Shipped in v1.6.0)- Tautology & Contradiction Detection (Shipped in v1.6.0)
- Announced-but-Unshipped Backlog II: TTL, Partition DAG Index & Three SQL Dialects (Shipped in v1.6.0)
11a. Specification-Driven State Machine (the reserved
RequirementState, now expressible as a machine) (Shipped in v1.8.0) 11b. Use Cases With a Result Shared With the State Machine (Shipped in v1.8.0) - Specification
simplify()(Shipped in v1.9.0) - PHP-Native Serializer Retirement, Step 2: Removal & JSON-Only Envelope (persisted per-entity metadata) (Shipped in v2.0.0)
- Shared Property Accessor with ALinq (
antevemus/adeep-property-access) (Shipped in v2.0.0) - Concurrent Data Resolution for I/O-Bound Specifications (core shipped in v1.10.0)
15b. Fibers/Revolt Adapter for Non-HTTP Sources (satellite package
antevemus/aspecification-async) - Distributed Specification Cache (PSR-6 / PSR-16 / Redis)
- GraphQL AST & OpenAPI 3.1 Query Compilers
- Doctrine ORM & Laravel Eloquent Query Visitors
- Specification-Driven Domain Event Triggers (Shipped in v1.11.0) 19b. Time-Based Domain Event Triggers (a scheduled sweeper for crossings without a write) (Shipped in v1.12.0)
- AI-Assisted Specification Synthesizer
- PDO-Backed Repository (the Domian
HibernateRepository) (Shipped in v1.11.0) 21b.PdoRepositoryFollow-ups (lifecycle columns and soft delete, hooks, SQL log, batch insert, bulk update by specification, projections and aggregates, subqueries,ManyToOne/OneToManyrelationships with eager loading in batch) (Shipped in v1.13.0) 21c.PdoRepositoryFollow-ups II (ManyToMany, marking a hand-built collection as loaded,iterate()with relations over unbuffered MySQL,CHAR(1)booleans) (Shipped in v1.14.0) - Accent-Insensitive Text Comparison (an explicit option that ignores case and accents, with the same answer in memory and in SQL)
- Write-Behind Hybrid Repository (the Domian
INMEMORY_AND_FILEmode: memory now, disk onpersist())
βοΈ Upstream Attribution, Licensing & Acknowledgments
Antevemus ASpecification expresses its deepest respect and gratitude to the pioneer authors who established the theoretical and practical foundations of this pattern:
- Eirik Torske (Project Administrator & Developer) and BjΓΈrn Nordlund (Contributor), creators of the Domian (Java) framework, whose pioneering work in Boolean algebra, Venn set calculus (
isGeneralizationOf,isSpecialCaseOf,isDisjointWith), and partitioned repository architectures served as the inspiring foundation for this PHP 8.2+ implementation. - Eric Evans and Martin Fowler, for authoring the seminal paper Specifications (2002) and foundational works on Domain-Driven Design (DDD).
Apache License 2.0 Compliance
The upstream Domian framework is distributed under the terms of the Apache License, Version 2.0 (Copyright 2006-2010 the original author or authors, as stated in the Domian sources; developers Eirik Torske and BjΓΈrn Nordlund). In full compliance with Section 4 of said license:
- Original authorship and copyright notices are fully preserved.
- The THIRD_PARTY_NOTICES.md files contain the formal provenance and full third-party license texts.
- This independent PHP implementation is made available to the global open-source community under the MIT license. It is not a clean-room rewrite: it was written with Domian's public sources at hand, and the files that follow a specific Domian class or reuse a sentence of its documentation say so in their DocBlock, keeping the upstream attribution as Section 4 of the Apache License requires.
π References & Bibliography
- Domian Specification Framework: Original Java framework authored by Eirik Torske.
- The Specifications Pattern - Eric Evans & Martin Fowler: Seminal paper introducing the pattern.
- Domain-Driven Design: Tackling Complexity in the Heart of Software: Reference work by Eric Evans on rich domain modeling.
- Design Patterns: Elements of Reusable Object-Oriented Software: GoF reference patterns (Composite, Decorator, Visitor, Factory).
π License
Distributed under the MIT license. See THIRD_PARTY_NOTICES.md for more information.
π¨βπ» Authors & Corporate Maintenance
Heliton Junior (CTO)
- Email: [email protected]
- Website: antevemus.com.br
- Organization: Antevemus SoluΓ§Γ΅es Inovadoras em TI Ltda.
All versions of aspecification with dependencies
ext-json Version *
ext-mbstring Version *