PHP code example of antevemus / aspecification

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\Helpers\StopWatch;
use Antevemus\ASpecification\Helpers\InstrumentationUtils;

$watch = StopWatch::createStarted();

$entities = $repo->findAll($complexSpec);

$watch->stop();
echo "Query executed in: " . $watch->formatElapsed() . "\n";
echo InstrumentationUtils::formatMemoryUsage() . "\n";

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\ASpecification\Spec;

$vipInNY = Spec::property('address.city', Spec::equalTo('New York'))
    ->and(Spec::property('profile.score', Spec::greaterThan(90)));

$user = (object)[
    'address' => (object)['city' => 'New York'],
    'profile' => ['score' => 95]
];

$result = $vipInNY->evaluate($user);
// $result->isSatisfied === true

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

foreach ($machine->nextEvents($quote, $state, null, $now) as $type => $answer) {
    echo $type, ': ', $answer->isAccepted() ? 'enabled' : 'disabled (' . $answer->getReason()->failures[0]->message . ')', PHP_EOL;
}
// APPROVE: disabled (Resolve the open pending item first.)
// CANCEL: enabled
// RESOLVE_PENDING: enabled

$machine->can($quote, $state, Event::external('APPROVE', $now))
    ->getRejections()['progress']->failures[0]->code; // 'QUOTE_PENDING_OPEN' (the reason per rejecting region)

use Antevemus\ASpecification\StateMachine\MachineDefinition;

$: [{
    "name": "lifecycle",
    "initial": "pending",
    "states": [
      {"name": "pending", "transitions": [
        {"event": "DELIVER", "target": "delivered", "guard": {"guard": "payloadHas", "params": {"field": "file"}}}
      ]},
      {"name": "delivered", "deadline": {"after": "P30D", "event": "EXPIRE"}, "transitions": [
        {"event": "VALIDATE", "target": "validated"},
        {"event": "EXPIRE", "target": "expired"}
      ]},
      {"name": "on_hold"},
      {"name": "validated", "final": true},
      {"name": "expired", "final": true}
    ]
  }]
}
JSON);

MachineDefinition::fromArray($

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