DEV Community

rahul chavan
rahul chavan

Posted on

Dispatching Audit Logs Asynchronously for Maximum Performance

Audit logging is the backbone of any enterprise application. Whether you are building a healthcare portal, a fintech app, or an internal CRM where tracking "who did what and when" is legally mandated, audit logs are non-negotiable.

However, adding an audit trail often introduces a massive performance penalty. By default, every time you flush an entity in Doctrine, the audit system must compute change-sets, serialize old and new data, and execute additional INSERT statements to your database. In a high-traffic Symfony API, synchronous auditing can easily double your response times.

we're going to fix that. We will learn how to dispatch audit logs asynchronously using Symfony Messenger and the AuditTrailBundle.

If you are looking for the best Symfony audit log solution that doesn't sacrifice performance, this is for you.


The Synchronous Bottleneck

Before we optimize, let's understand the problem. In traditional audit logging, the lifecycle looks like this:

  1. You create or update a Product entity.
  2. You call $entityManager->flush().
  3. An onFlush or postFlush listener intercepts the operation.
  4. The listener builds an AuditLog entity (computes diffs, extracts user context, grabs IP addresses).
  5. The listener forces another synchronous INSERT into the database.

In our internal benchmarks using an in-memory SQLite database, a standard synchronous Doctrine flush with audit logging takes ~10.2ms. In a real-world scenario with a networked relational database (MySQL/PostgreSQL), network latency can easily push this to 30-50ms per request. And if you are doing bulk inserts? The latency stacks up quickly.

The Solution: Asynchronous Queue Transport

The AuditTrailBundle solves this elegantly using a Split-Phase Architecture and native support for Symfony Messenger.

Instead of waiting for the database to write the audit log, the bundle serializes the audit data into an AuditLogMessage and pushes it to a message queue (like RabbitMQ, Redis, or Doctrine). A background worker then processes the queue, validating the integrity of the log and inserting it into the database.

By offloading the I/O operations to a worker, your main user request finishes instantly.


Step-by-Step Guide: Implementing Async Symfony Audit Logs

Here is how you can set up asynchronous audit logging in your Symfony application in under 5 minutes.

Step 1: Install the Bundle

First, install the rcsofttech/audit-trail-bundle via Composer:

composer require rcsofttech/audit-trail-bundle
Enter fullscreen mode Exit fullscreen mode

Step 2: Configure Symfony Messenger

Before enabling the async transport, ensure you have a transport configured in Symfony Messenger. Let's create an audit_trail transport in config/packages/messenger.yaml:

framework:
    messenger:
        transports:
            # We use an async transport (e.g., AMQP, Redis, or Doctrine)
            audit_trail: '%env(MESSENGER_TRANSPORT_DSN)%'


Enter fullscreen mode Exit fullscreen mode

Step 3: Enable the Queue Transport

Now, tell the AuditTrailBundle to use the queue transport instead of the default synchronous Doctrine transport. Update your config/packages/audit_trail.yaml:

audit_trail:
    transports:
        # Disable the default synchronous transport
        doctrine: false 

        # Enable the async queue transport
        queue:
            enabled: true
            # Optional: Specify a custom bus if you use multiple message buses
            bus: 'messenger.bus.default' 
Enter fullscreen mode Exit fullscreen mode

Step 4: Run the Consumer

In your production or local environment, start the Messenger worker to consume the audit messages in the background:

php bin/console messenger:consume audit_trail -vv
Enter fullscreen mode Exit fullscreen mode

That's it! Your Symfony application is now dispatching audit logs asynchronously.


Under the Hood: How it Works

Let's look at the technical architecture of how AuditTrailBundle achieves this.

When you flush your EntityManager, the EntityProcessor captures the raw changes and delegates the dispatch to the AuditDispatcher. If the Queue transport is enabled, the QueueAuditTransport takes over.

1. Message Creation

The bundle prevents heavy ORM entity serialization by transforming the raw AuditLog into a lightweight, decoupled AuditLogMessage DTO:

$message = AuditLogMessage::createFromAuditLog($log, $entityId);
Enter fullscreen mode Exit fullscreen mode

2. Extensibility via Events

Before the message is dispatched to the bus, the bundle fires an AuditMessageStampEvent. This allows you to programmatically attach Messenger stamps (like a DelayStamp or custom routing rules) to the audit log without overriding core services.

// Example: Adding a 5-second delay to all audit logs
public function onAuditMessageStamp(AuditMessageStampEvent $event): void
{
    $event->addStamp(new DelayStamp(5000));
}
Enter fullscreen mode Exit fullscreen mode

3. Cryptographic Signatures (HMAC)

If you have data integrity enabled (audit_trail.integrity.enabled: true), the bundle signs the JSON payload of the message and attaches a SignatureStamp. When the worker consumes the message, it verifies the signature to ensure the audit log wasn't tampered with while sitting in the queue (e.g., Redis or RabbitMQ).


Hard Data: The Benchmarks

Does async logging actually make a difference? We ran rigorous automated benchmarks using PHPBench to measure the throughput of the QueueAuditTransport against the synchronous Doctrine approach.

Here are the results:

  1. Worker Throughput: The background Messenger worker that un-serializes the AuditLogMessage, verifies its HMAC signature, and flushes it to the database takes roughly ~1.45ms per message. That equals a staggering ~690 messages processed per second per single worker thread.
  2. Main Request Impact: In pure CPU time loops, serializing a message into the queue takes about ~12.2ms synchronously. While this sounds comparable to a pure in-memory SQLite flush (~10.2ms), the magic happens in a real networked environment. In production, a MySQL INSERT over the network introduces significant IO wait times. By pushing to Redis or RabbitMQ instead, your main user request bypasses database IO completely.
  3. Bulk Processing: At scale, the overhead of the bundle drops to just ~3ms per entity, making it perfect for highly intensive import scripts.

Conclusion

Audit logging shouldn't be a bottleneck for your application's growth. By switching to an asynchronous architecture using Symfony Messenger and the AuditTrailBundle, you decouple your core business logic from compliance requirements, leading to virtually zero perceived overhead for your users.

Ready to supercharge your Symfony audit logs? Check out the AuditTrailBundle on GitHub, give it a star, and drop any questions in the discussions!

Top comments (0)