1. Go to this page and download the library: Download antevemus/aspecification library. Choose the download type require.
2. Extract the ZIP file and open the index.php.
3. Add this code to the index.php.
<?php
require_once('vendor/autoload.php');
/* Start to develop here. Best regards https://php-download.com/ */
antevemus / aspecification example snippets
use function Antevemus\ASpecification\DSL\specify;
use function Antevemus\ASpecification\DSL\is;
use function Antevemus\ASpecification\DSL\not;
use function Antevemus\ASpecification\DSL\isBefore;
use function Antevemus\ASpecification\DSL\isAfterOrAt;
use function Antevemus\ASpecification\DSL\anyOf;
// Specification: Female customers who have been members for over a year,
// OR male customers older than 10 years of age.
$loyalFemale = specify(Customer::class)
->where('gender', is('FEMALE'))
->and('membershipDate', isBefore($oneYearAgo));
$seniorMale = specify(Customer::class)
->where('gender', is('MALE'))
->and('birthDate', not(isAfterOrAt($tenYearsAgo)));
$spec = anyOf($loyalFemale, $seniorMale);
// Standard Boolean evaluation
if ($spec->isSatisfiedBy($customer)) {
// Domain rule satisfied!
}
$adultSpec = Spec::property('age', Spec::greaterThanOrEqualTo(18))
->because('Customer must be of legal age.')
->withCode('CLI_001');
$activeSpec = Spec::property('status', Spec::equalTo('ACTIVE'))
->because('Only active accounts can receive a credit line.')
->withCode('CLI_002');
$creditRule = $adultSpec->and($activeSpec);
$result = $creditRule->evaluate($customer);
if (!$result->isSatisfied) {
echo "Validation failed (" . count($result->failures) . " errors found):\n";
foreach ($result->failures as $failure) {
echo sprintf(" - [%s] %s: %s\n", $failure->code, $failure->property, $failure->message);
}
}
$onboardingSpec = Spec::specify(User::class)
->where('emailVerified', Spec::isTrue())
->and('termsAccepted', Spec::isTrue())
->and('profileComplete', Spec::isTrue());
$remainder = $onboardingSpec->remainderUnsatisfiedBy($user);
if ($remainder !== null) {
// $remainder contains ONLY the specific clauses not yet satisfied!
echo "Pending onboarding
use Antevemus\ASpecification\Repositories\InMemoryRepository;
$rootRepo = new InMemoryRepository();
// Create an exclusive partition for VIP customers
$vipSpec = Spec::property('vip', Spec::equalTo(true));
$vipPartition = $rootRepo->makePartition($vipSpec);
// Insert into the partitioned repository
// ($vipCustomer implements IEntity, e.g. by extending Entities\AbstractUUIDEntity)
$vipPartition->put($vipCustomer);
// Queries benefit from O(1) early branch pruning of disjoint predicates
$results = $vipPartition->findAll(Spec::property('balance', Spec::greaterThan(1000)));
use Antevemus\ASpecification\Repositories\File\InMemoryAndFileRepository;
use Antevemus\ASpecification\Repositories\Serialization\JsonEntitySerializer;
// High-speed RAM read performance (L1) with durable disk persistence (L2)
$repo = InMemoryAndFileRepository::create(
storagePath: '/var/data/customers.json',
serializer: new JsonEntitySerializer(Customer::class)
);
$repo->put($newCustomer); // Stored in RAM and synchronized to disk atomically
use Antevemus\ASpecification\Spec;
// 1. Configure the Registry with pluggable rule handlers
$registry = Spec::ruleRegistry();
$registry->registerClosure('max_occurrences_per_contract', function ($rule) {
return Spec::specify(Contract::class)
->must(fn($c) => $c->getOccurrencesCount() <= $rule->getIntValue(), $rule->getCode(), $rule->getViolationMessage());
});
// 2. Instantiate the Engine connected to the relational catalog repository
$engine = Spec::engine($myCatalogRepository, $registry);
// 3. Validate target entity for a specific scope and scenario
$verdict = $engine->validate(
target: $contract,
scope: 'rental_contract',
scenario: 'activation'
);
if ($verdict->hasBlockingErrors()) {
return response()->json([
'status' => 403,
'errors' => $verdict->getBlockingFailures(),
'legal_bases' => $verdict->getLegalBases(),
], 403);
}
if ($verdict->hasWarnings()) {
NotificationService::dispatch($verdict->getWarningFailures());
}
use Antevemus\ASpecification\Engine\PdoRuleCatalog;
// Optional: read only the rules in force on a date (validity window filtered in SQL)
$catalog = (new PdoRuleCatalog($pdo))->withAsOf(new DateTimeImmutable('2026-10-09'));
// Tables created with the Portuguese schema of v1.5/v1.6 (regra_negocio...) are read by name; their action values must be English since v2.0 (UPGRADE-2.0.md)
$legacyCatalog = new PdoRuleCatalog(
$pdo,
'rental_guarantee',
PdoRuleCatalog::legacyPortugueseTables(),
PdoRuleCatalog::legacyPortugueseColumns()
);
$verdict = Spec::engine($catalog, $registry)->validate(
target: $contract,
scope: 'rental_contract',
context: ['product_code' => 'PROD_A']
);
use Antevemus\ASpecification\Spec;
use Antevemus\ASpecification\Sql\SqlDialect;
// 1. Build pure domain specification
$spec = Spec::property('active', Spec::equalTo(true))
->and(
Spec::property('salary', Spec::greaterThan(5000))
->or(Spec::property('city', Spec::wildcard('New*')))
);
// 2. Compile to PostgreSQL with domain-to-column mapping
$whereClause = Spec::toSql(
specification: $spec,
dialect: SqlDialect::POSTGRESQL,
fieldMapper: [
'active' => 'is_active',
'salary' => 'val_salary',
'city' => 'txt_city'
]
);
echo $whereClause->getSql();
// '(("is_active" IS NOT NULL AND "is_active" = TRUE) AND (("val_salary" IS NOT NULL AND "val_salary" > :p1) OR ("txt_city" IS NOT NULL AND "txt_city" LIKE :p2)))'
// (booleans are rendered by the dialect; the remaining values are bound; each comparison carries an
// IS NOT NULL guard, so a NOT over a NULL column gives the in-memory answer)
print_r($whereClause->getBindings());
// [':p1' => 5000, ':p2' => 'New%']
// 3. First-class support for 15 database drivers across 10 SQL dialects (SQL Server, Oracle, Firebird, MySQL, SQLite, Db2, Informix, DuckDB, etc.)
$whereSqlServer = Spec::toSql($spec, SqlDialect::SQLSRV);
// '(([active] IS NOT NULL AND [active] = 1) AND (([salary] IS NOT NULL AND [salary] > :p1) OR ([city] IS NOT NULL AND [city] LIKE :p2)))'
$whereMySql = Spec::toSql($spec, SqlDialect::MYSQL);
// '((`active` IS NOT NULL AND `active` = 1) AND ((`salary` IS NOT NULL AND `salary` > :p1) OR (`city` IS NOT NULL AND BINARY `city` LIKE :p2)))'
use Antevemus\ASpecification\Spec;
// 1. Pure domain specification with conjunction, disjunction, and negation
$spec = Spec::property('active', Spec::equalTo(true))
->and(
Spec::property('salary', Spec::greaterThan(5000))
->or(Spec::property('city', Spec::wildcard('New*')))
)
->and(Spec::property('status', Spec::not(Spec::equalTo('CANCELLED'))));
// 2. Direct compilation with column mapping and query properties
$criteria = Spec::toCriteria(
specification: $spec,
fieldMap: [
'active' => 'st_active',
'salary' => 'vl_salary',
'city' => 'ds_city',
'status' => 'tp_status'
],
properties: [
'order' => 'vl_salary',
'direction' => 'desc',
'limit' => 50,
'offset' => 0
]
);
echo $criteria->dump();
// '((st_active = TRUE AND (vl_salary > 5000 OR ds_city LIKE 'New%')) AND tp_status <> 'CANCELLED')'
// 3. Direct invocation from any ISpecification instance
$criteriaFromInstance = $spec->toCriteria();
use Antevemus\ADeepPropertyAccess\PropertyAccess;
use Antevemus\ASpecification\Attributes\Specifiable;
use Antevemus\ASpecification\Spec;
final class Account
{
#[Specifiable]
private string $status = 'active'; // opted in: readable by name, writable by a delta
private string $passwordHash = 'h4sh'; // not marked: invisible to a name
}
$account = new Account();
Spec::property('status', Spec::equalTo('active'))->isSatisfiedBy($account); // true
PropertyAccess::hasProperty($account, 'passwordHash'); // false
Spec::property('passwordHash', Spec::startsWith('h'))->evaluate($account)->isError; // true, as a missing property
use Antevemus\ASpecification\Linq\ALinqSpecificationVisitor;
$predicate = ALinqSpecificationVisitor::createPredicate($vipInNY);
// Use directly in ALinq collections or native array_filter
$approved = $myAlinqCollection->where($predicate);
use Antevemus\ASpecification\Linq\ALinqBridge;
// 1. Fluent filtering on any iterable
$techItems = ALinqBridge::filter($products, $specTech)
->orderByDescending(fn($p) => $p->price)
->take(10)
->toArray();
// 2. Strongly typed query on InMemoryRepository returning an ALinqCollection
$topCustomers = $inMemoryRepo->findAsLinqCollection($specApproved)
->orderBy(fn($c) => $c->getPoints())
->take(5)
->toArray();
// 3. Aggregate metrics directly
$avgPoints = $inMemoryRepo->asLinqCollection()->average(fn($c) => $c->getPoints());
use Antevemus\ASpecification\Linq\ALinqBridge;
use Antevemus\ASpecification\Spec;
// 1. Stream massive dataset through specification filter with constant O(1) RAM
$largeLogStream = static function(): \Generator {
$handle = fopen('system_events.log', 'rb');
while (($line = fgets($handle)) !== false) {
yield json_decode($line, true);
}
fclose($handle);
};
// Elements are pulled and evaluated lazily on demand
$criticalEvents = Spec::filterLazy($largeLogStream, $specHighSeverity)
->take(100)
->toArray();
// 2. Stream directly from InMemoryRepository with findAsLazyCollection()
$activeCustomers = $inMemoryRepo->findAsLazyCollection($specActive)
->select(fn($c) => $c->getEmail())
->take(50)
->toArray();
use Antevemus\ASpecification\Attributes\AssertSpec;
use Antevemus\ASpecification\Attributes\ValidateRule;
use Antevemus\ASpecification\Spec;
#[AssertSpec(CustomerMustBeActiveSpec::class, message: 'Customer account is suspended', code: 'CUST_SUSPENDED')]
class RegisterCustomerDto
{
#[ValidateRule('not_blank', message: 'Name cannot be empty')]
public string $name;
#[ValidateRule('>=', value: 18, message: 'Customer must be at least 18 years old', code: 'UNDERAGE')]
public int $age;
#[ValidateRule('email', message: 'Invalid corporate email format')]
public string $email;
public function __construct(string $name, int $age, string $email)
{
$this->name = $name;
$this->age = $age;
$this->email = $email;
}
}
$dto = new RegisterCustomerDto('Alice Smith', 16, '[email protected]');
// 1. Non-throwing Notification Pattern validation
$result = Spec::validateAttributes($dto);
if (!$result->isSatisfied) {
foreach ($result->failures as $failure) {
echo "Violation [{$failure->code}]: {$failure->message} (at {$failure->ruleName})\n";
}
}
// 2. Strict throwing assertion
try {
Spec::assertAttributes($dto);
} catch (\Antevemus\ASpecification\Attributes\Exceptions\AttributeValidationException $e) {
// Thrown automatically on validation failure with structured details
$failures = $e->getResult()->failures;
}
use Antevemus\ASpecification\Engine\InMemoryRuleCatalog;
use Antevemus\ASpecification\Engine\RuleDefinition;
use Antevemus\ASpecification\Spec;
$active = Spec::property('status', Spec::equalTo('ACTIVE'));
$inactive = Spec::property('status', Spec::equalTo('INACTIVE'));
Spec::isContradiction($active->and($active->not())); // true (A ∧ ¬A)
Spec::isTautology($active->or($active->not())); // true (A ∨ ¬A)
$active->and($inactive)->isContradiction(); // true (disjoint leaves, same property)
Spec::in()->isContradiction(); // true (the empty set)
Spec::greaterThan(5)->or(Spec::lessThan(10))->isTautology(); // false: not proven (no SAT solving)
// Rule engine: report the rules that can never pass (or never fail), without blocking
$catalog = new InMemoryRuleCatalog();
$catalog->addRule(new RuleDefinition(code: 'R-12', name: 'Active and inactive', ruleType: 'status_both', scope: 'rental_contract'));
$registry = Spec::ruleRegistry()->registerClosure('status_both', fn() => $active->and($inactive));
$engine = Spec::engine($catalog, $registry)->withCatalogValidation();
$engine->compileSpecification('rental_contract');
foreach ($engine->getCompilationWarnings() as $warning) {
echo $warning->ruleCode . ': ' . $warning->kind, PHP_EOL; // "R-12: contradiction"
}
use Antevemus\ASpecification\Engine\InMemoryRuleCatalog;
use Antevemus\ASpecification\Engine\RuleDefinition;
use Antevemus\ASpecification\Spec;
$active = Spec::property('status', Spec::equalTo('ACTIVE'));
$adult = Spec::property('age', Spec::greaterThanOrEqualTo(18))->because('The tenant must be an adult.')->withCode('TENANT_MINOR');
// A redundant tree, as composed rules often end up: ¬¬A ∧ true ∧ B ∧ (status in ACTIVE, PENDING)
$tree = Spec::not(Spec::not($active))
->and(Spec::alwaysTrue())
->and($adult)
->and(Spec::property('status', Spec::in('ACTIVE', 'PENDING'))); // implied by status = ACTIVE
$simple = $tree->simplify(); // same as Spec::simplify($tree)
$simple->equals($active->and($adult)); // true: double negation, identity and implied leaf removed
Spec::simplify($simple) === $simple; // true: nothing left to simplify
// Same truth value, and the annotated failure is still reported
$minor = (object) ['status' => 'ACTIVE', 'age' => 16];
$simple->isSatisfiedBy($minor) === $tree->isSatisfiedBy($minor); // true
$simple->evaluate($minor)->failures[0]->code; // 'TENANT_MINOR'
// Proven contradictions and tautologies become constants, but an annotated node is never dropped
Spec::simplify($active->and($active->not())); // alwaysFalse()
Spec::simplify($active->or($active->not())); // alwaysTrue()
Spec::simplify($adult->and(Spec::alwaysFalse()))->evaluate($minor)->getCodes(); // ['TENANT_MINOR']
// Rule engine: opt-in (off by default); every rule binding is kept and simplified inside
$catalog = new InMemoryRuleCatalog();
$catalog->addRule(new RuleDefinition(code: 'R-30', name: 'Active tenant', ruleType: 'active_tenant', scope: 'rental_contract'));
$registry = Spec::ruleRegistry()->registerClosure('active_tenant', fn() => Spec::not(Spec::not($active))->and(Spec::alwaysTrue()));
$engine = Spec::engine($catalog, $registry)->withSimplification();
(string) $engine->compileSpecification('rental_contract'); // 'Rule[R-30]((AlwaysTrueSpecification WHERE status: EqualSpecification))'
$engine->validate((object) ['status' => 'CLOSED'], 'rental_contract')->getFailureCodes(); // ['R-30']
use Antevemus\ASpecification\Spec;
final class Contract
{
public function __construct(private DateTimeImmutable $endsAt, private int $installments) {}
public function isEligibleFor(DateTimeInterface $date): bool { return $date <= $this->endsAt; }
public function installmentsLeft(int $paid): int { return $this->installments - $paid; }
}
$eligible = Spec::specify(Contract::class)
->whereMethod('isEligibleFor', [new DateTimeImmutable('2026-12-01')], Spec::isTrue())
->and(Spec::calling('installmentsLeft', [3], Spec::greaterThan(0)));
$eligible->isSatisfiedBy(new Contract(new DateTimeImmutable('2027-06-30'), 12)); // true
$eligible->isSatisfiedBy(new Contract(new DateTimeImmutable('2026-06-30'), 12)); // false (not eligible)
$eligible->isSatisfiedBy(new Contract(new DateTimeImmutable('2027-06-30'), 3)); // false (nothing left)
// Declarative: method name and arguments are data, so equality is structural
Spec::calling('installmentsLeft', [3], Spec::greaterThan(0))
->equals(Spec::calling('installmentsLeft', [3], Spec::greaterThan(0))); // true
// A missing method is an evaluation error, never a silent false
Spec::calling('isEligibleFor', [new DateTimeImmutable('2026-12-01')], Spec::isTrue())
->evaluate(new stdClass())->isError; // true
use Antevemus\ASpecification\Spec;
use Antevemus\ASpecification\StateMachine\Event;
// Named guards: name => factory(array $params): ISpecification, evaluated against a TransitionCandidate
// (context = your domain object or array, event = type + payload + occurredAt, snapshot = current states)
$guards = Spec::guardRegistry()->register('hasRole', fn(array $params) =>
Spec::property('context.role', Spec::in(...$params['roles']))
->because('Only a manager can approve.')
->withCode('ROLE_NOT_ALLOWED'));
$order = Spec::machine('purchase_order', 1)
->region('lifecycle', 'draft')
->state('draft')
->on('SUBMIT', 'submitted')
->state('submitted')
->on('APPROVE', 'approved')->guard('hasRole', ['roles' => ['manager']])
->on('SEND_BACK', 'draft')->guard('payloadHas', ['field' => 'motive']) // built-in guard
->finalState('approved')
->build();
Spec::validateMachine($order, $guards); // [] (throws on errors, returns the warnings)
$machine = Spec::stateMachine($guards);
$now = new DateTimeImmutable('2026-10-10 09:00'); // the machine never reads the clock
$snapshot = $machine->initialSnapshot($order, null, $now)->getSnapshot();
$result = $machine->transition($order, $snapshot, Event::external('SUBMIT', $now));
$result->isAccepted(); // true
$snapshot = $result->getSnapshot();
$snapshot->stateOf('lifecycle'); // 'submitted'
$denied = $machine->transition($order, $snapshot, Event::external('APPROVE', $now), ['role' => 'clerk']);
$denied->isRejected(); // true: a denial is a result, never an exception
$denied->getSnapshot() === $snapshot; // true: nothing changed
$denied->getReason()->failures[0]->code; // 'ROLE_NOT_ALLOWED'
$denied->getReason()->failures[0]->message; // 'Only a manager can approve.'
$machine->transition($order, $snapshot, Event::external('APPROVE', $now), ['role' => 'manager'])
->getSnapshot()->isDone(); // true: the region is in a final state
use Antevemus\ASpecification\StateMachine\GuardExpression;
$quote = Spec::machine('quote', 1)
->region('progress', 'registering')
->state('registering')
->on('SEND_PROPOSAL', 'proposal_sent')
->state('proposal_sent')
->on('APPROVE', 'approved')
->guard(GuardExpression::not(GuardExpression::ref('inState', ['state' => 'pending.open']))
->because('Resolve the open pending item first.')
->withCode('QUOTE_PENDING_OPEN'))
->on('CANCEL', 'cancelled')
->finalState('approved')
->finalState('cancelled')
->region('pending', 'none')
->state('none')
->on('REGISTER_PENDING', 'open')->guard('payloadHas', ['field' => 'motive'])
->state('open')
->on('RESOLVE_PENDING', 'none')
->build();
$machine = Spec::stateMachine();
$state = $machine->initialSnapshot($quote, null, $now)->getSnapshot();
$state = $machine->transition($quote, $state, Event::external('SEND_PROPOSAL', $now))->getSnapshot();
$state = $machine->transition($quote, $state, Event::external('REGISTER_PENDING', $now, ['motive' => 'Missing ID']))->getSnapshot();
$state->stateOf('progress'); // 'proposal_sent': registering a pending item moved only its region
$state->isIn('pending.open'); // true
$machine->transition($quote, $state, Event::external('APPROVE', $now))
->getReason()->failures[0]->code; // 'QUOTE_PENDING_OPEN'
$machine->transition($quote, $state, Event::external('CANCEL', $now))->isAccepted(); // true: CANCEL is not blocked
$state = $machine->transition($quote, $state, Event::external('RESOLVE_PENDING', $now))->getSnapshot();
$state->stateOf('progress'); // 'proposal_sent': back where it was, nothing to remember
$machine->transition($quote, $state, Event::external('APPROVE', $now))->isAccepted(); // true
use Antevemus\ASpecification\StateMachine\Effect;
use Antevemus\ASpecification\StateMachine\Persistence\InMemory\InMemoryMachineDefinitionRepository;
use Antevemus\ASpecification\StateMachine\Persistence\MachinePersistence;
use Antevemus\ASpecification\StateMachine\Runner\EffectDispatcher;
use Antevemus\ASpecification\StateMachine\Runner\EffectHandlerRegistry;
use Antevemus\ASpecification\StateMachine\Runner\MachineRunner;
$document = Spec::machine('document', 1)
->region('lifecycle', 'pending')
->state('pending')
->on('DELIVER', 'delivered')->guard('payloadHas', ['field' => 'file'])->action('notify_reviewer')
->state('delivered')->deadline('P3D', 'EXPIRE')
->on('VALIDATE', 'validated')
->on('EXPIRE', 'expired')->action('notify_owner')
->finalState('validated')
->finalState('expired')
->build();
$sent = [];
$handlers = (new EffectHandlerRegistry())
->register('notify_reviewer', function (Effect $effect, string $idempotencyKey) use (&$sent): void { $sent[] = $effect->action; })
->register('notify_owner', function (Effect $effect, string $idempotencyKey) use (&$sent): void { $sent[] = $effect->action; });
$persistence = MachinePersistence::inMemory();
$runner = new MachineRunner(Spec::stateMachine(), new InMemoryMachineDefinitionRepository([$document]), $persistence, $handlers);
$runner->start('doc-42', 'document', 1, null, new DateTimeImmutable('2026-10-10 09:00'));
$deliver = Event::external('DELIVER', new DateTimeImmutable('2026-10-10 10:00'), ['file' => 'id.pdf'], 'upload-7f3a');
$runner->send('doc-42', $deliver, actor: 'alice')->isAccepted(); // true
$runner->send('doc-42', $deliver, actor: 'alice')->isAccepted(); // true: same key, the original result, nothing written twice
$runner->load('doc-42')->version; // 2 (start + one accepted event)
count($persistence->history->findByInstance('doc-42')); // 2 (append-only: start + DELIVER)
// After the commit (a worker or a cron): deliver the effects
(new EffectDispatcher($persistence->outbox, $handlers))->dispatchPending()->deliveredCount(); // 1
$sent; // ['notify_reviewer']
// Deadlines: find and deliver the due ones (the EXPIRE event is internal: it cannot be sent from outside)
$runner->findDue(new DateTimeImmutable('2026-10-12 10:00')); // [] (due at 2026-10-13 10:00)
$deliveries = $runner->deliverDue(new DateTimeImmutable('2026-10-13 10:00'), actor: 'scheduler');
$deliveries[0]->isDelivered(); // true
$runner->load('doc-42')->snapshot->stateOf('lifecycle'); // 'expired'
// Once, with your migration tool: resources/sql/state_machine.ansi.sql
$runner = new MachineRunner(
Spec::stateMachine(),
new InMemoryMachineDefinitionRepository([$document]), // definitions ship with the application
MachinePersistence::pdo($pdo), // same contract as inMemory()
$handlers
);
// Tables with other names: table names by role (instance, region, history, outbox, idempotency,
// rejection), an optional schema and, when the columns differ, canonical column => real column
$persistence = MachinePersistence::pdo($pdo, tables: ['instance' => 'quote_state', 'history' => 'quote_state_history']);
use Antevemus\ASpecification\StateMachine\Persistence\DueDeadline;
use Antevemus\ASpecification\StateMachine\Persistence\MachineInstance;
$claim = Spec::machine('claim', 1)
->region('progress', 'triage')
->state('triage')->deadline(event: 'TRIAGE_CLOSED', afterFrom: 'context.deadlines.triage')
->on('START_ANALYSIS', 'analysis')
->on('TRIAGE_CLOSED', 'analysis')
->state('analysis')->deadline(event: 'ANALYSIS_OVERDUE', afterFrom: 'context.deadlines.analysis')
->on('APPROVE', 'approved')
->on('ANALYSIS_OVERDUE', 'late')
->state('late')
->on('APPROVE', 'approved')
->finalState('approved')
->region('pending', 'none')
->state('none')->on('REGISTER_PENDING', 'open')
->state('open')->on('RESOLVE_PENDING', 'none')
->region('sla', 'running')
->state('running')->deadline(event: 'SLA_BREACHED', atFrom: 'context.sla_due_at', pausedWhile: 'pending.open')
->on('SLA_BREACHED', 'breached')
->finalState('breached')
->build();
// The context of each claim: the SLA instant of the contract and the stage durations of the client
$contexts = ['claim-7' => ['sla_due_at' => '2026-11-09T18:00:00-03:00', 'deadlines' => ['triage' => 'P2D', 'analysis' => 'P5D']]];
$runner = new MachineRunner(Spec::stateMachine(), new InMemoryMachineDefinitionRepository([$claim]), MachinePersistence::inMemory());
$runner->start('claim-7', 'claim', 1, $contexts['claim-7'], new DateTimeImmutable('2026-10-10T09:00:00-03:00'))->isAccepted(); // true
$runner->load('claim-7')->snapshot->dueAt('sla')->format(DATE_ATOM); // '2026-11-09T18:00:00-03:00' (atFrom)
$runner->load('claim-7')->snapshot->dueAt('progress')->format(DATE_ATOM); // '2026-10-12T09:00:00-03:00' (start + P2D, afterFrom)
// A missing source is a rejection, never a silently empty SLA: the start writes nothing
$missing = $runner->start('claim-8', 'claim', 1, ['deadlines' => ['triage' => 'P2D']], new DateTimeImmutable('2026-10-10T09:00:00-03:00'));
$missing->isRejected(); // true
$missing->getReason()->failures[0]->code; // 'SM_DEADLINE_SOURCE_INVALID'
$missing->getReason()->failures[0]->property; // 'context.sla_due_at'
// The cron: TRIAGE_CLOSED enters 'analysis', whose deadline reads the context, so the cron provides it
$deliveries = $runner->deliverDue(
new DateTimeImmutable('2026-10-12T09:00:00-03:00'),
contextProvider: fn(DueDeadline $due, MachineInstance $instance) => $contexts[$instance->instanceId]
);
$deliveries[0]->isDelivered(); // true (without the provider: rejected, retried on the next run)
$runner->load('claim-7')->snapshot->dueAt('progress')->format(DATE_ATOM); // '2026-10-17T09:00:00-03:00'
// A pending item opened 20 days before the SLA: the SLA pauses and keeps the balance
$runner->send('claim-7', Event::external('REGISTER_PENDING', new DateTimeImmutable('2026-10-20T18:00:00-03:00')), $contexts['claim-7']);
$snapshot = $runner->load('claim-7')->snapshot;
$snapshot->isPaused('sla'); // true
$snapshot->dueAt('sla'); // null: a paused deadline is never due
$snapshot->pausedRemaining('sla'); // 1728000 (20 days, in whole seconds)
array_map(fn(DueDeadline $due) => $due->region, $runner->findDue(new DateTimeImmutable('2027-01-01T00:00:00-03:00'))); // ['progress']
// Resolved 5 days later: the SLA resumes with the balance, 5 days after the contract instant
$runner->send('claim-7', Event::external('RESOLVE_PENDING', new DateTimeImmutable('2026-10-25T18:00:00-03:00')), $contexts['claim-7']);
$runner->load('claim-7')->snapshot->dueAt('sla')->format(DATE_ATOM); // '2026-11-14T18:00:00-03:00'
use Antevemus\ASpecification\Attributes\ValidateRule;
use Antevemus\ASpecification\Spec;
use Antevemus\ASpecification\UseCase\IUseCase;
use Antevemus\ASpecification\UseCase\TransactionMiddleware;
use Antevemus\ASpecification\UseCase\UseCaseResult;
use Antevemus\ASpecification\UseCase\UseCaseStatus;
final class RegisterTenantRequest
{
public function __construct(
#[ValidateRule('not_blank', code: 'NAME_REQUIRED', message: 'The tenant name is his->pdo->prepare('INSERT INTO tenant (name, rent) VALUES (?, ?)')->execute([$request->name, $request->rent]);
return UseCaseResult::created((int) $this->pdo->lastInsertId());
}
}
$useCases = Spec::useCaseRunner(middlewares: [
new TransactionMiddleware($pdo->beginTransaction(...), $pdo->commit(...), $pdo->rollBack(...)),
]);
$created = $useCases->run(new RegisterTenant($pdo), new RegisterTenantRequest('Alice', 6200));
$created->isSuccess(); // true: a WARNING never blocks (committed)
$created->getStatus(); // UseCaseStatus::CREATED
$created->getWarnings()[0]->code; // 'RENT_REVIEW'
$invalid = $useCases->run(new RegisterTenant($pdo), new RegisterTenantRequest('', 1200));
$invalid->getStatus(); // UseCaseStatus::INVALID: execute() was not called (rolled back)
$invalid->getCodes(); // ['NAME_REQUIRED']
// HTTP only at the edge of the application
$invalid->getStatus()->toHttpStatus(); // 400 (CREATED → 201, REJECTED → 422, CONFLICT → 409 ...)
use Antevemus\ASpecification\UseCase\MachineEventRequest;
$runner = new MachineRunner(Spec::stateMachine(), new InMemoryMachineDefinitionRepository([$quote]), MachinePersistence::inMemory());
$runner->start('quote-7', 'quote', 1, null, $now);
$useCases = Spec::useCaseRunner();
$send = fn(string $event, array $payload = []): UseCaseResult => $useCases->run(
Spec::machineEventUseCase($runner, $event),
new MachineEventRequest('quote-7', $now, $payload, actor: 'alice')
);
$send('SEND_PROPOSAL')->getStatus(); // UseCaseStatus::SUCCESS
$send('REGISTER_PENDING', ['motive' => 'Missing ID'])->isSuccess(); // true
$send('APPROVE')->getCodes(); // ['QUOTE_PENDING_OPEN'] (status REJECTED, HTTP 422)
$send('SEND_PROPOSAL')->getStatus(); // UseCaseStatus::CONFLICT: the event does not fit the current state
$send('RESOLVE_PENDING')->getPayload()->stateOf('progress'); // 'proposal_sent'
$useCases->run(Spec::machineEventUseCase($runner, 'CANCEL'), new MachineEventRequest('quote-99', $now))
->getStatus(); // UseCaseStatus::NOT_FOUND
use Antevemus\ASpecification\Resolution\DataResolver;
use Antevemus\ASpecification\Resolution\DataSourceRegistry;
use Antevemus\ASpecification\Resolution\HttpDataSource;
use Antevemus\ASpecification\Resolution\ResolutionPolicy;
use Antevemus\ASpecification\Spec;
$serasaUrl = 'https://bureau.example.com'; // owned by the source, never by the candidate
$fraudUrl = 'https://fraud.example.com';
$bankUrl = 'https://core-banking.example.com';
$registry = DataSourceRegistry::default()
->register('serasa.score', new HttpDataSource(
baseUrl: $serasaUrl,
path: '/serasa/score/{document}',
parse: fn(int $status, array $headers, string $body): int => json_decode($body, true)['score'],
headers: ['Authorization' => 'Bearer bureau-token'],
timeout: 2.0,
cost: 10, // a paid bureau query
))
->register('fraud.flag', new HttpDataSource(
baseUrl: $fraudUrl,
path: '/fraud/check/{document}',
parse: fn(int $status, array $headers, string $body): bool => json_decode($body, true)['flagged'],
cost: 5,
))
->register('bank.balance', new HttpDataSource(
baseUrl: $bankUrl,
path: '/bank/balance/{account}',
parse: fn(int $status, array $headers, string $body): float => json_decode($body, true)['balance'],
cost: 1, // our own core banking: cheap
));
// The rule declares the data it needs; it calls nothing
$credit = Spec::external('bank.balance', ['account' => 'account.number'],
Spec::greaterThanOrEqualTo(1000)->because('Balance below 1,000.')->withCode('LOW_BALANCE'))
->and(Spec::external('fraud.flag', ['document' => 'cpf'], Spec::isFalse())->withCode('FRAUD_SUSPECTED'))
->and(Spec::external('serasa.score', ['document' => 'cpf'], Spec::greaterThanOrEqualTo(700))
->because('Credit score below 700.')->withCode('SCORE_LOW'));
$customer = (object) ['cpf' => '111.444.777-35', 'account' => (object) ['number' => 'A-1']];
$resolver = new DataResolver($registry);
// concurrent() (the default): the three calls at once, about the time of the slowest one
$resolver->evaluate($credit, $customer, ResolutionPolicy::concurrent())->isSatisfied; // true
// tiered() (an explicit choice): cheapest first, stopping as soon as the verdict is decided
$poor = (object) ['cpf' => '222.333.444-05', 'account' => (object) ['number' => 'A-2']];
$data = $resolver->resolve($credit, $poor, ResolutionPolicy::tiered()); // balance 10: the AND is already false
count($data->getSkippedRequests()); // 2: the fraud check and the bureau were not called
$data->evaluate($credit, $poor)->getCodes(); // ['LOW_BALANCE']
// Same verdict under every policy; concurrent() paid every source and reports every failure
$resolver->evaluate($credit, $poor, ResolutionPolicy::concurrent())->getCodes(); // ['LOW_BALANCE', 'SCORE_LOW']
$resolver->evaluate($credit, $poor, ResolutionPolicy::sequential())->isSatisfied; // false
use Antevemus\ASpecification\Resolution\HttpDataSource;
use Antevemus\ASpecification\Spec;
use Antevemus\ASpecification\Sql\Exceptions\NonTranslatableSpecificationException;
// Outside the resolver the same rule still works: each leaf fetches its own data through the
// default registry, synchronously (one call after the other, no deduplication)
$credit->evaluate($customer)->isSatisfied; // true
// A source that fails is an evaluation error of its leaf, never a silent "false"
$limitsUrl = 'https://limits.example.com';
$registry->register('credit.limit', new HttpDataSource(baseUrl: $limitsUrl, path: '/limits/{document}', cost: 2));
$limit = Spec::external('credit.limit', ['document' => 'cpf'], Spec::greaterThan(0));
$result = $resolver->evaluate($limit, $customer); // the service answers 503
$result->isError; // true
$result->failures[0]->message; // 'The rule could not be evaluated.'
$result->exception->getStatusCode(); // 503, for the log (the body is never kept)
// The visitors refuse the leaf: the data only exists when the tree is evaluated in PHP
try {
$credit->toSql('pgsql');
} catch (NonTranslatableSpecificationException $e) {
// filter in SQL with the other rules, evaluate the external part in PHP
}
use Antevemus\ASpecification\Entities\AbstractEntity;
use Antevemus\ASpecification\Repositories\Pdo\Mapping\Column;
use Antevemus\ASpecification\Repositories\Pdo\Mapping\ColumnType;
use Antevemus\ASpecification\Repositories\Pdo\Mapping\Id;
use Antevemus\ASpecification\Repositories\Pdo\Mapping\IdStrategy;
use Antevemus\ASpecification\Repositories\Pdo\Mapping\Table;
use Antevemus\ASpecification\Repositories\Pdo\Mapping\Version;
use Antevemus\ASpecification\Repositories\Pdo\PdoRepository;
use Antevemus\ASpecification\Spec;
#[Table('product')]
#[Version(property: 'version')] // optimistic locking on the counter AbstractEntity declares
class Product extends AbstractEntity
{
#[Id(strategy: IdStrategy::Auto)] // identity/auto-increment, read back after the INSERT
private ?int $id = null;
public function __construct(
#[Column] public string $name,
#[Column('unit_price', type: ColumnType::Decimal)] public string $price, // exact decimal, as a string
#[Column] public bool $active = true,
#[Column] private string $cost = '0.00', // mapped, but private without a getter: never queryable by name
) {
parent::__construct();
}
public function getEntityId(): ?int
{
return $this->id;
}
}
$pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec('CREATE TABLE product (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(80) NOT NULL,
unit_price TEXT NOT NULL, active INTEGER NOT NULL, cost TEXT NOT NULL, version INTEGER NOT NULL)');
$products = PdoRepository::forEntity($pdo, Product::class); // the dialect comes from the PDO driver
$keyboard = new Product('Keyboard', '149.90', cost: '90.00');
$products->put($keyboard); // INSERT; the generated id comes back
$keyboard->getEntityId(); // 1
$keyboard->getVersion(); // 1
$products->putAll([new Product('Mouse', '59.90'), new Product('Cable', '19.90', active: false)]); // one transaction
$active = Spec::property('active', Spec::isTrue());
$products->count($active); // 2: SELECT COUNT(*), no entity is built
$products->findSingle(Spec::property('name', Spec::equalTo('Mouse')))->price; // '59.90'
foreach ($products->iterate($active) as $product) {
echo $product->name, PHP_EOL; // Keyboard, Mouse: one row at a time from the cursor
}
array_map(fn(Product $p): string => $p->name,
$products->findPage($active, limit: 1, offset: 1, orderBy: ['name' => 'DESC'])); // ['Keyboard']
$products->toWhereClause($active)->toSql(); // '("active" IS NOT NULL AND "active" = 1)'
use Antevemus\ASpecification\Repositories\Pdo\Exceptions\OptimisticLockException;
$mine = $products->findById(1); // both copies carry version 1
$theirs = $products->findById(1);
$theirs->price = '139.90';
$products->update($theirs); // UPDATE ... WHERE id = 1 AND version = 1
$theirs->getVersion(); // 2
$mine->price = '129.90';
try {
$products->update($mine); // still version 1: the UPDATE matches no row
} catch (OptimisticLockException $e) {
// nothing was written: reload, apply the change again
}
$products->findById(1)->price; // '139.90'
use Antevemus\ASpecification\Repositories\Pdo\Mapping\EntityMapping;
use Antevemus\ASpecification\Specifications\PredicateSpecification;
use Antevemus\ASpecification\Sql\Exceptions\NonTranslatableSpecificationException;
// A specification the SQL visitor cannot translate is refused: never a silent full-table scan
$cheap = new PredicateSpecification(fn(Product $p): bool => (float) $p->price < 100);
try {
$products->find($cheap);
} catch (NonTranslatableSpecificationException $e) {
// express the rule with property specifications, or opt in below
}
count(PdoRepository::forEntity($pdo, Product::class)->withInMemoryFallback()->find($cheap)); // 2: reads the whole table
// Only a mapped property a specification may read by name (public, public getter or #[Specifiable])
try {
$products->find(Spec::property('cost', Spec::greaterThan('50')));
} catch (NonTranslatableSpecificationException $e) {
// "cost" is mapped but private without a getter: refused in SQL as it is in memory
}
// The same mapping, built explicitly
$mapping = EntityMapping::for(Product::class)->table('product')->id('id', IdStrategy::Auto)->version('version')
->column('name')->column('price', 'unit_price', ColumnType::Decimal)->column('active')->column('cost');
$mapping->equals(EntityMapping::fromAttributes(Product::class)); // true
$products = new PdoRepository($pdo, $mapping);
use Antevemus\ASpecification\Persistence\InMemoryQueryLogger;
use Antevemus\ASpecification\Repositories\Pdo\Mapping\BooleanStorage;
use Antevemus\ASpecification\Repositories\Pdo\Mapping\CreatedAt;
use Antevemus\ASpecification\Repositories\Pdo\Mapping\SoftDelete;
use Antevemus\ASpecification\Repositories\Pdo\Mapping\UpdatedAt;
#[Table('ticket', timezone: 'America/Sao_Paulo', booleanAs: BooleanStorage::Integer)] // local time without offset, flags as 1/0
#[SoftDelete('deleted_at')] // remove() marks the row instead of deleting it
class Ticket extends AbstractEntity
{
#[Id(strategy: IdStrategy::Auto)]
private ?int $id = null;
#[CreatedAt] public ?DateTimeImmutable $createdAt = null; // filled by the INSERT, never updated
#[UpdatedAt] public ?DateTimeImmutable $updatedAt = null; // set by every write of the row
public function __construct(
#[Column] public string $subject,
#[Column] public bool $urgent = false,
) {
parent::__construct();
}
public function getEntityId(): ?int
{
return $this->id;
}
}
// $pdo of example 17
$pdo->exec('CREATE TABLE ticket (id INTEGER PRIMARY KEY AUTOINCREMENT, subject VARCHAR(80) NOT NULL,
urgent SMALLINT NOT NULL, created_at TEXT, updated_at TEXT, deleted_at TEXT)');
$now = new DateTimeImmutable('2026-03-10 14:30:00.750 UTC');
$log = new InMemoryQueryLogger();
$tickets = PdoRepository::forEntity($pdo, Ticket::class)
->withClock(function () use (&$now): DateTimeImmutable { return $now; }) // the system clock by default
->withQueryLogger($log) // parameter values stay out unless logParameters: true
->beforePut(function (Ticket $ticket): void { // also afterPut(), beforeRemove(), afterRemove()
if (trim($ticket->subject) === '') {
throw new InvalidArgumentException('A ticket needs a subject.');
}
});
$ticket = new Ticket('Printer on fire', urgent: true);
$tickets->put($ticket);
$ticket->createdAt->format('Y-m-d H:i:s P'); // '2026-03-10 11:30:00 -03:00': in the mapping's zone, to the second
$pdo->query('SELECT created_at, urgent FROM ticket')->fetch(PDO::FETCH_NUM); // ['2026-03-10 11:30:00', 1]
$log->entries()[0]['parameterCount']; // 5
$log->entries()[0]['parameters']; // null: values can be personal data
$now = $now->modify('+2 hours');
$ticket->subject = 'Printer on fire, 3rd floor';
$tickets->put($ticket);
$ticket->updatedAt->format('H:i'); // '13:30' (createdAt keeps '11:30')
try {
$tickets->put(new Ticket(' '));
} catch (InvalidArgumentException $e) {
// the beforePut hook refused it inside the transaction: nothing was written
}
$tickets->remove($ticket); // UPDATE ... SET deleted_at: the row stays
$tickets->count(Spec::alwaysTrue()); // 0: find, iterate, count, findById, select, aggregate skip it
$tickets->withTrashed()->count(Spec::alwaysTrue()); // 1: a view that also sees the marked rows
$tickets->onlyTrashed()->findById(1)->subject; // 'Printer on fire, 3rd floor'
$tickets->forceRemove($ticket); // DELETE: gone for real
$tickets->withTrashed()->count(Spec::alwaysTrue()); // 0
use Antevemus\ASpecification\Repositories\Pdo\Exceptions\DataIntegrityException;
#[Table('member')]
class Member extends AbstractEntity
{
public function __construct(
#[Id(strategy: IdStrategy::Assigned)] private int $id,
#[Column(booleanAs: BooleanStorage::Char)] public bool $active = true, // 'Y' / 'N'
#[Column(booleanAs: BooleanStorage::Char, trueValue: 'S', falseValue: 'N')] public ?bool $admin = null,
) {
parent::__construct();
}
public function getEntityId(): int
{
return $this->id;
}
}
$pdo->exec('CREATE TABLE member (id INTEGER PRIMARY KEY, active CHAR(1) NOT NULL, admin CHAR(1))');
$members = PdoRepository::forEntity($pdo, Member::class);
$members->putAll([new Member(1), new Member(2, active: false, admin: true)]);
$pdo->query('SELECT active, admin FROM member WHERE id = 2')->fetch(PDO::FETCH_NUM); // ['N', 'S']
$members->toWhereClause(Spec::property('active', Spec::isTrue()))->toSql();
// ("active" IS NOT NULL AND "active" IN ('Y', 'y')): every case form a read accepts
$pdo->exec("UPDATE member SET active = 'X' WHERE id = 1"); // a value the mapping does not declare
try {
$members->findById(1);
} catch (DataIntegrityException $e) {
$e->getStoredValue(); // 'X': an integrity error, never read as false
}
use Antevemus\ASpecification\Entities\AbstractUlidEntity;
#[Table('sale')]
#[Id(strategy: IdStrategy::Ulid, column: 'id', property: 'entityId')] // generated in PHP: the rows go in one INSERT
class Sale extends AbstractUlidEntity
{
public function __construct(
#[Column('product_id')] public int $productId,
#[Column] public ?string $region,
#[Column(type: ColumnType::Decimal)] public string $amount,
#[Column] public int $quantity = 1,
) {
parent::__construct();
}
}
$pdo->exec('CREATE TABLE sale (id CHAR(26) PRIMARY KEY, product_id INTEGER NOT NULL, region VARCHAR(10),
amount TEXT NOT NULL, quantity INTEGER NOT NULL)');
$log->clear();
$sales = PdoRepository::forEntity($pdo, Sale::class)->withQueryLogger($log);
$sales->insertAll([ // new entities, without the existence SELECT of put()
new Sale(1, 'north', '139.90'),
new Sale(1, 'south', '279.80', quantity: 2),
new Sale(2, 'north', '59.90'),
new Sale(3, null, '19.90'),
]); // 4
count($log->statements()); // 1: one multi-row INSERT
$sales->updateWhere(Spec::property('region', Spec::isNull()), ['region' => 'west']); // 1: one UPDATE, no entity loaded
iterator_to_array($sales->select(Spec::property('region', Spec::equalTo('north')), ['productId', 'amount']), false);
// [['productId' => 1, 'amount' => '139.90'], ['productId' => 2, 'amount' => '59.90']]: arrays, no entity built
$sales->aggregate(Spec::alwaysTrue())->groupBy('region')->sum('amount')->sum('quantity')->count()->get();
// [['region' => 'north', 'sum_amount' => '199.8', 'sum_quantity' => 2, 'count' => 2],
// ['region' => 'south', 'sum_amount' => '279.8', 'sum_quantity' => 2, 'count' => 1],
// ['region' => 'west', 'sum_amount' => '19.9', 'sum_quantity' => 1, 'count' => 1]]
// The sales of active products ($products of example 17): product_id IN (SELECT id FROM product WHERE ...)
$sales->count(Spec::inQuery('productId', $products, Spec::property('active', Spec::isTrue()), 'entityId')); // 3
use Antevemus\ASpecification\Repositories\Pdo\Mapping\ManyToOne;
use Antevemus\ASpecification\Repositories\Pdo\Mapping\OneToMany;
#[Table('customer')]
class Customer extends AbstractEntity
{
public function __construct(
#[Id(strategy: IdStrategy::Assigned)] private int $id,
#[Column] public string $name,
) {
parent::__construct();
}
public function getEntityId(): int
{
return $this->id;
}
}
#[Table('orders')]
#[SoftDelete('deleted_at')]
class Order extends AbstractEntity
{
#[Id(strategy: IdStrategy::Auto)]
private ?int $id = null;
#[Column('customer_id')] public ?int $customerId = null; // the stored truth of the reference
#[ManyToOne(Customer::class, 'customer_id')] public ?Customer $customer = null; // aggregation: never cascaded
/** @var list<OrderLine> */
#[OneToMany(OrderLine::class, mappedBy: 'orderId', cascade: true)] public array $lines = []; // composition
public function __construct(#[Column] public string $number)
{
parent::__construct();
}
public function getEntityId(): ?int
{
return $this->id;
}
}
#[Table('order_line')]
class OrderLine extends AbstractEntity
{
#[Id(strategy: IdStrategy::Auto)]
private ?int $id = null;
#[Column('order_id')] public ?int $orderId = null; // set by the cascade
public function __construct(
#[Column] public string $sku,
#[Column] public int $quantity,
) {
parent::__construct();
}
public function getEntityId(): ?int
{
return $this->id;
}
}
$pdo->exec('CREATE TABLE customer (id INTEGER PRIMARY KEY, name VARCHAR(80) NOT NULL)');
$pdo->exec('CREATE TABLE orders (id INTEGER PRIMARY KEY AUTOINCREMENT, number VARCHAR(20) NOT NULL,
customer_id INTEGER REFERENCES customer (id), deleted_at TEXT)');
$pdo->exec('CREATE TABLE order_line (id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL REFERENCES orders (id),
sku VARCHAR(20) NOT NULL, quantity INTEGER NOT NULL)');
$customers = PdoRepository::forEntity($pdo, Customer::class);
$lines = PdoRepository::forEntity($pdo, OrderLine::class);
$orders = PdoRepository::forEntity($pdo, Order::class)->withRelated($customers, $lines);
$customers->putAll([new Customer(1, 'Ana'), new Customer(2, 'Bruno')]);
foreach (range(1, 100) as $n) {
$order = new Order("PO-{$n}");
$order->customer = $customers->findById($n % 2 + 1); // a reference needs an identifier: put it first
$order->lines = [new OrderLine('KB-1', 1), new OrderLine('MS-2', $n)];
$orders->put($order); // the order, then its lines (order_id set), in one transaction
}
$log->clear();
foreach ([$orders, $customers, $lines] as $repository) {
$repository->withQueryLogger($log);
}
$all = $orders->with('lines', 'customer')->find(Spec::alwaysTrue());
count($all); // 100
count($log->statements()); // 3: the orders, then their lines and their customers by IN (...)
$all[0]->customer->name; // 'Bruno'
$all[0]->customer === $all[2]->customer; // true: one instance per customer
count($all[0]->lines); // 2
// The filter through a relationship is a subquery on the foreign key
$orders->count($orders->whereRelation('customer', Spec::property('name', Spec::equalTo('Ana')))); // 50
$orders->count($orders->whereRelation('lines', Spec::property('quantity', Spec::greaterThan(95)))); // 5
$first = $orders->with('lines')->findById(1);
array_pop($first->lines); // a loaded collection: the line that left it is an orphan
$orders->put($first); // ...and is deleted
$lines->count(Spec::property('orderId', Spec::equalTo(1))); // 1
$bare = $orders->findById(1); // no with(): lines and customer not loaded
$orders->isLoaded($bare, 'lines'); // false: [] here means "unknown", not "none"
$orders->put($bare); // removes no line, keeps customer_id
$lines->count(Spec::property('orderId', Spec::equalTo(1))); // 1
$orders->remove($first); // Order has #[SoftDelete]: marked; OrderLine has none, its rows stay
$lines->count(Spec::property('orderId', Spec::equalTo(1))); // 1
$orders->forceRemove($first); // a hard delete removes every line row first
$lines->count(Spec::property('orderId', Spec::equalTo(1))); // 0
use Antevemus\ASpecification\Repositories\Pdo\Mapping\ManyToMany;
#[Table('tag')]
class Tag extends AbstractEntity
{
public function __construct(
#[Id(strategy: IdStrategy::Assigned)] private int $id,
#[Column] public string $name,
) {
parent::__construct();
}
public function getEntityId(): int
{
return $this->id;
}
}
#[Table('article')]
class Article extends AbstractEntity
{
#[Id(strategy: IdStrategy::Auto)]
private ?int $id = null;
/** @var list<Tag> */
#[ManyToMany(Tag::class, 'article_tag', joinColumn: 'article_id', inverseJoinColumn: 'tag_id')]
public array $tags = []; // association: only the links are written
public function __construct(#[Column] public string $title)
{
parent::__construct();
}
public function getEntityId(): ?int
{
return $this->id;
}
}
$pdo->exec('CREATE TABLE tag (id INTEGER PRIMARY KEY, name VARCHAR(30) NOT NULL)');
$pdo->exec('CREATE TABLE article (id INTEGER PRIMARY KEY AUTOINCREMENT, title VARCHAR(80) NOT NULL)');
$pdo->exec('CREATE TABLE article_tag (article_id INTEGER NOT NULL REFERENCES article (id),
tag_id INTEGER NOT NULL REFERENCES tag (id), PRIMARY KEY (article_id, tag_id))');
$tags = PdoRepository::forEntity($pdo, Tag::class);
$articles = PdoRepository::forEntity($pdo, Article::class)->withRelated($tags);
[$php, $sql, $orm] = [new Tag(1, 'php'), new Tag(2, 'sql'), new Tag(3, 'orm')];
$tags->putAll([$php, $sql, $orm]); // the targets are written by their own repository
$article = new Article('Specifications over PDO');
$article->tags = [$php, $sql];
$articles->put($article); // the article, then two rows of article_tag
$log->clear();
$tags->withQueryLogger($log);
$articles->withQueryLogger($log);
$loaded = $articles->with('tags')->findById(1);
count($log->statements()); // 3: the article, its links, its tags
$loaded->tags = [$sql, $orm];
$articles->put($loaded); // unlinks php, links orm: no tag row is written
$articles->count($articles->whereRelation('tags', Spec::property('name', Spec::equalTo('orm')))); // 1
$edited = $articles->findById(1); // no with(): tags not loaded, put() would not touch them
$edited->tags = [$php];
$articles->markLoaded($edited, 'tags'); // DANGER: declared WHOLE, what it lacks is removed
$articles->put($edited); // links php, unlinks sql and orm
count($articles->with('tags')->findById(1)->tags); // 1
$view = $articles->with('tags')->withBatchSize(500);
foreach ($view->iterate(Spec::alwaysTrue()) as $one) {
$one->tags[0]->name; // 'php': pages of 500 by id, each read before its tags load
}
use Antevemus\ASpecification\DomainEvents\DomainEvent;
use Antevemus\ASpecification\DomainEvents\DomainEventDispatcher;
use Antevemus\ASpecification\DomainEvents\DomainEventHandlerRegistry;
use Antevemus\ASpecification\DomainEvents\Outbox\InMemoryDomainEventOutbox;
use Antevemus\ASpecification\DomainEvents\OutboxDomainEventPublisher;
use Antevemus\ASpecification\DomainEvents\SpecificationTrigger;
use Antevemus\ASpecification\DomainEvents\TriggeringRepository;
use Antevemus\ASpecification\DomainEvents\TriggerSet;
use Antevemus\ASpecification\Entities\AbstractEntity;
use Antevemus\ASpecification\Repositories\InMemoryRepository;
use Antevemus\ASpecification\Spec;
class Policy extends AbstractEntity
{
public int $daysOverdue = 0;
public bool $documentsValidated = false;
public string $holderName = 'Maria Silva'; // personal data: never copied into an event
public function __construct(private string $number)
{
parent::__construct();
}
public function getEntityId(): string
{
return $this->number;
}
}
$triggers = new TriggerSet(
new SpecificationTrigger('policy.delinquent', Spec::property('daysOverdue', Spec::greaterThan(30)),
onEnter: 'delinquency.started', onExit: 'delinquency.ended'),
new SpecificationTrigger('policy.documents', Spec::property('documentsValidated', Spec::isTrue()),
onEnter: 'documents.validated'),
);
$outbox = new InMemoryDomainEventOutbox(); // PdoDomainEventOutbox in production
$policies = new TriggeringRepository(new InMemoryRepository(), $triggers, new OutboxDomainEventPublisher($outbox));
$policy = new Policy('P-1');
$policies->put($policy); // satisfies no trigger: no event
$policy->daysOverdue = 45;
$policies->update($policy); // starts to satisfy policy.delinquent
$policies->update($policy); // no crossing, no event
$policy->daysOverdue = 0;
$policies->update($policy); // stops satisfying it
$outbox->count(); // 2
$event = $outbox->findByEntity(Policy::class, 'P-1')[0]->event;
$event->name; // 'delinquency.started'
$event->getPayload(); // ['trigger' => 'policy.delinquent', 'entity_id' => 'P-1']
// After the commit (a worker or a cron): every event to its handlers, in write order per entity
$seen = [];
$handlers = (new DomainEventHandlerRegistry())
->register('delinquency.started', function (DomainEvent $event, string $idempotencyKey) use (&$seen): void { $seen[] = $event->name; })
->register('delinquency.ended', function (DomainEvent $event, string $idempotencyKey) use (&$seen): void { $seen[] = $event->name; });
(new DomainEventDispatcher($outbox, $handlers))->dispatchPending()->deliveredCount(); // 2
$seen; // ['delinquency.started', 'delinquency.ended']
use Antevemus\ASpecification\DomainEvents\MachineEventBridge;
use Antevemus\ASpecification\StateMachine\Persistence\InMemory\InMemoryMachineDefinitionRepository;
use Antevemus\ASpecification\StateMachine\Persistence\MachinePersistence;
use Antevemus\ASpecification\StateMachine\Runner\MachineRunner;
// "Every document validated" moves the review without a click
$review = Spec::machine('policy_review', 1)
->region('lifecycle', 'waiting_documents')
->state('waiting_documents')
->on('VALIDATE', 'under_review')
->finalState('under_review')
->build();
$runner = new MachineRunner(Spec::stateMachine(), new InMemoryMachineDefinitionRepository([$review]), MachinePersistence::inMemory());
$runner->start('review-P-1', 'policy_review', 1, null, new DateTimeImmutable('2026-10-11 09:00'));
(new MachineEventBridge($runner, ['documents.validated' => 'VALIDATE'], fn(DomainEvent $e): ?string => 'review-' . $e->entityId))
->registerIn($handlers);
$policy->documentsValidated = true;
$policies->update($policy); // documents.validated goes to the outbox
(new DomainEventDispatcher($outbox, $handlers))->dispatchPending()->deliveredCount(); // 1
$runner->load('review-P-1')->snapshot->stateOf('lifecycle'); // 'under_review'
use Antevemus\ASpecification\DomainEvents\Outbox\PdoDomainEventOutbox;
use Antevemus\ASpecification\Persistence\PdoTransactionManager;
// Once, with your migration tool: resources/sql/domain_events.ansi.sql
$transactions = new PdoTransactionManager($pdo); // one unit of work for the row and its events
$catalog = new TriggeringRepository(
PdoRepository::forEntity($pdo, Product::class, $transactions),
new TriggerSet(new SpecificationTrigger('product.withdrawn', Spec::property('active', Spec::isFalse()), onEnter: 'product.withdrawn')),
new OutboxDomainEventPublisher($events = new PdoDomainEventOutbox($pdo)),
$transactions,
);
$mouse = $catalog->findSingle(Spec::property('name', Spec::equalTo('Mouse')));
$mouse->active = false;
$catalog->update($mouse); // UPDATE product and INSERT INTO domain_event_outbox, one commit
$events->count(); // 1
use Antevemus\ASpecification\DomainEvents\State\InMemoryTriggerStateStore;
use Antevemus\ASpecification\DomainEvents\TriggerSweeper;
use Antevemus\ASpecification\Repositories\Pdo\Mapping\Column;
use Antevemus\ASpecification\Repositories\Pdo\Mapping\Id;
use Antevemus\ASpecification\Repositories\Pdo\Mapping\IdStrategy;
use Antevemus\ASpecification\Repositories\Pdo\Mapping\Table;
#[Table('invoice')] // mapped for the PDO block below; used as is in memory
class Invoice extends AbstractEntity
{
#[Column] public bool $paid = false;
public function __construct(
#[Id(strategy: IdStrategy::Assigned)] private string $number,
#[Column('due_date')] public DateTimeImmutable $dueDate,
) {
parent::__construct();
}
public function getEntityId(): string
{
return $this->number;
}
}
$overdue = new TriggerSet(SpecificationTrigger::timed(
'invoice.overdue',
fn(DateTimeImmutable $now) => Spec::property('paid', Spec::isFalse())
->and(Spec::property('dueDate', Spec::lessThan($now->modify('-30 days')))),
onEnter: 'collection.started',
onExit: 'collection.ended',
crossedAt: fn(Invoice $invoice) => $invoice->dueDate->modify('+30 days'), // the real instant of the crossing
));
$today = new DateTimeImmutable('2026-01-01');
$clock = function () use (&$today): DateTimeImmutable { return $today; }; // the system clock by default
$states = new InMemoryTriggerStateStore(); // PdoTriggerStateStore in production
$outbox = new InMemoryDomainEventOutbox();
$repository = new InMemoryRepository();
$invoices = new TriggeringRepository($repository, $overdue, new OutboxDomainEventPublisher($outbox), clock: $clock, states: $states);
$sweeper = new TriggerSweeper($repository, Invoice::class, $overdue, $states, new OutboxDomainEventPublisher($outbox));
$invoice = new Invoice('INV-1', new DateTimeImmutable('2026-01-05'));
$invoices->put($invoice); // due in 4 days: no event; its state is recorded
$sweeper->sweep(new DateTimeImmutable('2026-01-20'))->enteredCount(); // 0
$sweeper->sweep(new DateTimeImmutable('2026-02-05'))->enteredCount(); // 1: nobody wrote it, time did
$sweeper->sweep(new DateTimeImmutable('2026-02-06'))->enteredCount(); // 0: already announced
$outbox->findByEntity(Invoice::class, 'INV-1')[0]->event->occurredAt == $invoice->dueDate->modify('+30 days'); // true
$today = new DateTimeImmutable('2026-02-09');
$invoice->paid = true;
$invoices->update($invoice); // collection.ended, published by the write
$sweeper->sweep(new DateTimeImmutable('2026-02-10'))->exitedCount(); // 0: the write already did
$outbox->count(); // 2
// A trigger introduced over existing data: baseline() first, or the first sweep announces everything already inside
$archive = new InMemoryRepository([
new Invoice('INV-2', new DateTimeImmutable('2025-11-03')), // overdue since December
new Invoice('INV-3', new DateTimeImmutable('2026-03-02')),
]);
$catchUp = new TriggerSweeper($archive, Invoice::class, $overdue, new InMemoryTriggerStateStore(), new OutboxDomainEventPublisher($outbox));
$catchUp->baseline(new DateTimeImmutable('2026-02-10'))->recordedCount(); // 2: one state per invoice, no event
$catchUp->sweep(new DateTimeImmutable('2026-02-11'))->enteredCount(); // 0: INV-2 was already inside
$catchUp->sweep(new DateTimeImmutable('2026-04-02'))->eventNames(); // ['collection.started']: INV-3, a new crossing
// The cron (every hour, every night): the instant is the cron's, the limit slices a large backlog
do {
$report = $catchUp->sweep(new DateTimeImmutable(), limit: 500);
} while ($report->limitReached());
use Antevemus\ASpecification\DomainEvents\State\PdoTriggerStateStore;
// $pdo of example 17; once, with your migration tool: the invoice table and resources/sql/domain_events.ansi.sql
$transactions = new PdoTransactionManager($pdo);
$repository = PdoRepository::forEntity($pdo, Invoice::class, $transactions);
$states = new PdoTriggerStateStore($pdo); // table domain_event_trigger_state
$publisher = new OutboxDomainEventPublisher($events = new PdoDomainEventOutbox($pdo));
$invoices = new TriggeringRepository($repository, $overdue, $publisher, $transactions, states: $states);
$sweeper = new TriggerSweeper($repository, Invoice::class, $overdue, $states, $publisher, $transactions);
$invoices->put(new Invoice('INV-4', new DateTimeImmutable('2026-01-05'))); // overdue already: the write publishes the enter
$sweeper->sweep(new DateTimeImmutable())->enteredCount(); // 0: its state says inside
count($events->findByEntity(Invoice::class, 'INV-4')); // 1
bash
php tests/run_all.php
Loading please wait ...
Before you can download the PHP files, the dependencies should be resolved. This can take some minutes. Please be patient.