AuditTrailBundle supports multiple transports to dispatch audit logs. This allows you to store logs locally, publish them to external services, or fan them out to several destinations at once.
In v3, the transport pipeline is strongly typed. Custom transports receive a read-only AuditTransportContext instead of the older stringly array $context payload.
Note
Messenger Confusion Warning: The bundle utilizes Symfony Messenger for two different features.
database: { async: true }: Designed to save logs to your local database asynchronously via an internal worker.queue: { enabled: true }: Designed to publish logs to an external system (so other microservices or tools like ELK can consume them).
They can be used simultaneously because they target different Messenger message types and transport names.
[!NOTE]
symfony/messenger and symfony/http-client are optional integrations.
Install only what your chosen transport needs:
symfony/messengerfor queue delivery or async database persistencesymfony/http-clientfor HTTP delivery
If you enable a transport without its package installed, the bundle fails fast with a clear LogicException during container build.
You still need the corresponding runtime configuration:
Messenger routing/buses for queue or async database, and an endpoint for HTTP transport.
Use the base bundle by itself if you only need the synchronous database transport.
| Feature | Extra package | Extra Symfony config |
|---|---|---|
| Synchronous database transport | none | none |
| Async database transport | symfony/messenger |
audit_trail_database Messenger transport |
| Queue transport | symfony/messenger |
audit_trail Messenger transport/routing |
| HTTP transport | symfony/http-client |
HTTP endpoint config |
All transport implementations now use the same typed contract:
<?php
declare(strict_types=1);
use Rcsofttech\AuditTrailBundle\Contract\AuditTransportInterface;
use Rcsofttech\AuditTrailBundle\Transport\AuditTransportContext;
final class AppAuditTransport implements AuditTransportInterface
{
public function send(AuditTransportContext $context): void
{
// $context->phase
// $context->entityManager
// $context->unitOfWork
// $context->entity
// $context->audit
}
public function supports(AuditTransportContext $context): bool
{
return true;
}
}AuditTransportContext contains:
phase: the current AuditPhaseentityManager: the active Doctrine entity manager for this audit flowaudit: the current AuditLogunitOfWork: the activeUnitOfWorkwhen the phase has oneentity: the source entity when it is available
The AuditTransportContext instance passed to a transport is read-only and is
not mutated in place. Internally the dispatcher may create a new context with
withAudit() after listeners mutate or swap the AuditLog, but transport
implementations should treat the context they receive as read-only input.
By default, the database transport persists logs synchronously using phase-appropriate strategies:
- during
onFlush, ORM-safe audit rows are attached to the current UnitOfWork - during deferred phases such as
postFlush, database audit rows are written without re-entering Doctrineflush()
For high-traffic applications, you can offload this database write to a background worker.
Enable async mode in config/packages/audit_trail.yaml:
audit_trail:
transports:
database:
enabled: true
async: true # Offloads inserts to MessengerYou must explicitly define a transport named audit_trail_database in config/packages/messenger.yaml:
framework:
messenger:
transports:
# Internal bundle worker will consume from this transport
audit_trail_database: '%env(MESSENGER_TRANSPORT_DSN)%'If symfony/messenger is not installed and you enable database.async: true, the bundle throws:
To use async database transport, you must install the symfony/messenger package.
(The bundle auto-registers PersistAuditLogHandler to consume from this transport and insert the records into the database).
Important
Async database mode uses a per-message delivery identifier so worker retries can be handled idempotently.
If you enable or upgrade to this feature, generate and run a Doctrine migration so the audit_log.delivery_id
column and unique constraint exist before workers process messages.
The queue transport dispatches a strictly-typed DTO (AuditLogMessage) to a Symfony Messenger bus. You must define the Messenger transport routing yourself and provide the downstream consumer that ingests those messages.
If symfony/messenger is not installed and you enable queue, the bundle throws:
To use the Queue transport, you must install the symfony/messenger package.
Enable the queue transport in config/packages/audit_trail.yaml:
audit_trail:
transports:
queue:
enabled: true
bus: 'messenger.bus.default' # Optional: specify a custom busYou must define a transport named audit_trail in config/packages/messenger.yaml:
framework:
messenger:
transports:
# Your external service/worker will consume from this transport
audit_trail: '%env(MESSENGER_TRANSPORT_DSN)%'Use the AuditMessageStampEvent to add stamps to the message right before it is dispatched to the bus. This is the supported way to add transport-specific stamps such as DelayStamp or DispatchAfterCurrentBusStamp.
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use Rcsofttech\AuditTrailBundle\Event\AuditMessageStampEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Messenger\Stamp\DelayStamp;
class AuditMessengerSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
AuditMessageStampEvent::class => 'onAuditMessageStamp',
];
}
public function onAuditMessageStamp(AuditMessageStampEvent $event): void
{
// Add a 5-second delay to all audit logs sent via Queue
$event->addStamp(new DelayStamp(5000));
}
}When audit_trail.integrity.enabled is true, the bundle automatically signs the payload to ensure authenticity. The signature is added as a SignatureStamp to the Messenger envelope.
<?php
declare(strict_types=1);
use Rcsofttech\AuditTrailBundle\Message\Stamp\SignatureStamp;
// In your message handler
$signature = $envelope->last(SignatureStamp::class)?->signature;The HTTP transport streams audit logs to an external API endpoint (e.g., a logging service, ELK, or Splunk).
HTTP delivery is synchronous when it runs, but it is phase-limited: the built-in
HTTP transport only supports deferred phases such as postFlush and postLoad.
Setting defer_transport_until_commit: false does not move HTTP delivery into
Doctrine's onFlush transaction window.
If symfony/http-client is not installed and you enable http, the bundle throws:
To use the HTTP transport, you must install the symfony/http-client package.
audit_trail:
transports:
http:
enabled: true
endpoint: 'https://audit-api.example.com/v1/logs'
headers:
'Authorization': 'Bearer your-api-token'
'X-App-Name': 'MySymfonyApp'
timeout: 10 # secondsWhen audit_trail.integrity.enabled is true, the bundle adds an X-Signature header to the HTTP request. This header contains the HMAC signature of the JSON body.
POST /api/logs HTTP/1.1
X-Signature: a1b2c3d4...
Content-Type: application/json
{ ... }Different transports send slightly different JSON payloads based on their delivery target.
The HTTP transport sends a flat JSON object including the entity signature and the persisted audit context captured on the AuditLog.
{
"entity_class": "App\\Entity\\Product",
"entity_id": "123",
"action": "update",
"old_values": {"price": 100},
"new_values": {"price": 120},
"changed_fields": ["price"],
"user_id": "1",
"username": "admin",
"ip_address": "127.0.0.1",
"user_agent": "Mozilla/5.0...",
"transaction_hash": "a1b2c3d4...",
"signature": "hmac-signature-here",
"context": {
"impersonation": {
"impersonator_id": "99",
"impersonator_username": "admin"
},
"runtime_meta": "value"
},
"created_at": "2024-01-01T12:00:00+00:00"
}The Queue transport dispatches a strictly-typed DTO. It omits the entity signature from the body and carries transport signing separately through Messenger metadata (SignatureStamp).
{
"entity_class": "App\\Entity\\Product",
"entity_id": "123",
"action": "update",
"old_values": {"price": 100},
"new_values": {"price": 120},
"changed_fields": ["price"],
"user_id": "1",
"username": "admin",
"ip_address": "127.0.0.1",
"user_agent": "Mozilla/5.0...",
"transaction_hash": "a1b2c3d4...",
"created_at": "2024-01-01T12:00:00+00:00",
"context": {
"impersonation": {
"impersonator_id": "99",
"impersonator_username": "admin"
}
}
}The Database transport stores logs in your local database. It is enabled by default.
Logs are persisted directly during the Doctrine lifecycle using the current phase:
audit_trail:
transports:
database:
enabled: true
async: falseBehavior summary:
onFlush: the bundle usespersist()plusUnitOfWork::computeChangeSet()so the audit row joins the current flush safely- deferred phases such as
postFlushandpostLoad: the bundle resolves the final entity identifier if needed and inserts the audit row through a dedicated database writer
This avoids nested flush() calls from Doctrine event listeners while preserving immediate local persistence for database-backed audits.
Note
Deferred database writes are not ORM-managed AuditLog persistence operations.
If your application attaches Doctrine lifecycle listeners/subscribers specifically to AuditLog,
those hooks are only relevant to the in-UnitOfWork ORM path, not the deferred direct-write path.
Logs are dispatched via Symfony Messenger and persisted by a built-in handler (PersistAuditLogHandler).
This is useful for high-traffic applications where you want to offload DB writes to a worker.
audit_trail:
transports:
database:
enabled: true
async: trueYou must define a transport named audit_trail_database in config/packages/messenger.yaml:
framework:
messenger:
transports:
audit_trail_database: '%env(MESSENGER_TRANSPORT_DSN)%'Worker retries are handled safely: duplicate deliveries for the same internal message are ignored instead of creating duplicate audit rows.
You can enable multiple transports simultaneously. The bundle will automatically use a ChainAuditTransport to dispatch logs to all enabled transports.
audit_trail:
transports:
database:
enabled: true
queue:
enabled: trueChainAuditTransport is fail-fast by design. If one child transport throws,
later transports in the chain are not executed and the exception is allowed to
bubble back to the dispatcher. This keeps transport failure semantics explicit
and lets the bundle apply fail_on_transport_error and fallback rules in one
place instead of partially succeeding silently.
Fail-fast does not mean transactional rollback across transports. If an earlier transport has already produced a side effect, the chain does not undo that side effect when a later transport fails.
Audit deliveries now carry an internal deliveryId so the bundle's
database-backed writes are idempotent across fallback and retry paths. This
prevents duplicate local audit rows when an earlier database-capable transport
succeeds and a later transport fails in the same chain. It does not roll back
or deduplicate side effects performed by external systems.
Transport order is fixed by the bundle, not by YAML key order. When multiple transports are enabled, they are registered in this order:
- database
- http
- queue
That means:
- a database transport runs before HTTP or queue when it supports the current phase
- an HTTP transport failure prevents later queue delivery in the same chain execution
- swapping YAML key order does not change execution order
It is important to distinguish between the two types of signatures:
-
Entity Signature (
signaturefield in JSON):- Generated at the moment of creation.
- Signs the business data (Entity Class, ID, Changes).
- Purpose: Long-term data integrity and non-repudiation. Stored in the database.
-
Transport Signature (
X-Signatureheader orSignatureStamp):- Generated just before sending.
- Signs the outbound transport payload.
- Purpose: Transport security. Ensures the message wasn't tampered with during transit.