Installation
Install vortos-audit (+ vortos-audit-admin), wire config/audit.php, declare the async consumer + worker, and run the Postgres extras installer.
Installation
1. Require the packages
composer require vortos/vortos-audit
# optional: the HTTP console API (platform + org read/verify/export endpoints)
composer require vortos/vortos-audit-adminvortos-audit self-registers its DI extension. It works with just a Doctrine DBAL connection; vortos-messaging, vortos-cache (Redis), vortos-object-store, vortos-authorization, and vortos-tenant unlock async ingestion, cross-process idempotency, cold archive, permission gating, and RLS respectively.
2. Declare the vocabulary
Actions are a controlled vocabulary. Ship a backed enum and a provider:
enum AuditAction: string {
case MemberInvited = 'member.invited';
case PaymentCaptured = 'payment.captured';
// …
}
final class AppAuditActionProvider implements AuditActionProviderInterface {
public function actions(): array {
return array_map(
fn (AuditAction $a) => new RegisteredAction($a->value, $a->description(), $a->sensitivity(), $a->scope()),
AuditAction::cases(),
);
}
}The provider is auto-discovered. With ->strict(false) an unknown action is still recorded (at Normal sensitivity) rather than rejected.
3. Configure
config/audit.php returns a closure taking the fluent config object (same convention as scheduler/messaging):
use Vortos\Audit\DependencyInjection\VortosAuditConfig;
return static function (VortosAuditConfig $config): void {
$config
->async(true)
->authEvents(unify: true)
->hmacKeyFromSecret('VORTOS_AUDIT_HMAC_KEY')
->rowLevelSecurity(true)
->retention(platform: 730, tenant: 365)
->coldArchive(bucket: 'my-audit-archive', prefix: 'audit-archive');
};See Configuration for every method.
4. Turn on the async pipeline
->async(true) only takes effect when the app declares the consumer and runs a worker.
#[MessagingConfig]
final class AuditMessagingConfig {
#[RegisterTransport]
public function transport(): KafkaTransportDefinition {
return KafkaTransportDefinition::create('vortos.audit')->topic('vortos.audit')->partitions(6);
}
#[RegisterProducer]
public function producer(): KafkaProducerDefinition {
return KafkaProducerDefinition::create('vortos.audit')->transport('vortos.audit')
->publish(\Vortos\Audit\Ingestion\AuditEventRecorded::class, as: 'vortos.audit.event_recorded')->outbox(true);
}
#[RegisterConsumer]
public function consumer(): KafkaConsumerDefinition {
return KafkaConsumerDefinition::create('vortos.audit')->groupId('my-audit-svc')->offsetReset('earliest');
}
}Then run a long-lived worker (e.g. a supervisor program): php bin/console vortos:consume vortos.audit.
The framework's AsyncAuditRecorder dispatches through the standalone event bus, because audit is recorded from places with no active business transaction (auth middleware after-hooks, read paths). You don't have to do anything — just be aware that's why it works outside a CommandBus transaction.
5. Migrations + Postgres extras
Run the module migrations (vortos:migrate:publish + vortos:migrate) to create audit_events, audit_checkpoints, audit_chain_heads, and audit_saved_views.
The full-text GIN index and row-level security can't be expressed by the portable Schema-diff migration seam, so they ship as an idempotent installer — run it on every deploy:
php bin/console vortos:audit:pg:install # FTS GIN index (+ RLS if ->rowLevelSecurity() is on)
php bin/console vortos:audit:pg:install --no-rls # FTS only6. Verify
php bin/console vortos:audit:doctorReports whether the store, HMAC signing, archive target, async ingestion, RLS, and search driver are wired. It only fails outright when no store is configured.
Audit
One append-only, hash-chained, per-tenant audit spine for the whole platform — controlled vocabulary, async ingestion, HMAC-signed tamper-evidence, RLS isolation, search, retention, and a signed export.
Configuration
Every knob on the fluent VortosAuditConfig, loaded from config/audit.php as a closure.