Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .codacy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ exclude_paths: # Global static analysis ignores
- "src/Resources/**"
- "src/AuditTrailBundle.php"
- "src/Entity/AuditLog.php"
- "src/Message/AuditLogMessage.php"
- "src/Message/PersistAuditLogMessage.php"
- "vendor/**"
- "var/**"
- "bin/**"
Expand Down
17 changes: 15 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [2.1.0]

### 2.1.0 Breaking Changes

- **Transport Configuration**: The `doctrine` transport config key has been replaced with `database`. The new `database` key strictly requires an options array: `database: { enabled: true, async: false }` rather than a scalar boolean.

### 2.1.0 New Features

- **Asynchronous Database Transport**: Audit logs can now be dispatched to the database asynchronously via Symfony Messenger by configuring `database: { enabled: true, async: true }`. This utilizes a dedicated `audit_trail_database` route and a built-in `PersistAuditLogHandler` to insert the records, preventing conflict with the external `queue` transport.
- **Collection Serialization Tuning**: Introduced a tiered strategy for Doctrine collections (`lazy`, `ids_only`, `eager`) with configurable `max_collection_items` (default 100). This allows developers to balance audit detail against N+1 query safety and log bloat.

---

## [2.0.0]

This major release represents a complete architectural modernization of the bundle, leveraging **PHP 8.4** features and introducing a **Strict Contract Layer** for better extensibility and performance.

### Breaking Changes
### 2.0.0 Breaking Changes

- **PHP 8.4 Required**: The bundle now requires PHP 8.4+ for property hooks, asymmetric visibility, and typed class constants.
- **Symfony 7.4+ Required**: Updated to leverage modern Symfony DI attributes and framework features.
Expand All @@ -25,7 +38,7 @@ This major release represents a complete architectural modernization of the bund
- **Transport Interface**: `AuditTransportInterface::send()` now requires a mandatory `$context` array parameter for phase-aware dispatching (`on_flush` / `post_flush`). The `supports(string $phase, array $context)` method was added.
- **Configuration Defaults**: `audited_methods` defaults to `['GET']`. `defer_transport_until_commit` defaults to `true`. `fallback_to_database` defaults to `true`.

### New Features
### 2.0.0 New Features

- **PHP 8.4 Native Features**:
- **Property Hooks**: Used throughout `AuditLog` for real-time validation of IP addresses, action types, and entity class names directly on the entity. `AuditEntry` uses read-only property hooks for all accessors.
Expand Down
19 changes: 18 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,16 @@ audit_trail:
enable_soft_delete: true
soft_delete_field: 'deletedAt'

# Collection Serialization Performance
# -----------------------------------
collection_serialization_mode: 'lazy'
max_collection_items: 100

transports:
# Store logs in the local database
doctrine: true
database:
enabled: true
async: false # Set to true to persist via Messenger worker (requires 'audit_trail_database' transport)

# Send logs to an external API
http:
Expand Down Expand Up @@ -66,3 +73,13 @@ audit_trail:
# If false (default), transport errors are logged but execution continues.
fail_on_transport_error: false
```

## Collection Serialization Guide

Choose the mode that best fits your performance and audit requirements:

| Mode | Database Impact | Audit Detail | Recommended Use Case |
| :--- | :--- | :--- | :--- |
| **`lazy`** (Default) | **None** (Zero queries) | Low (shows placeholder) | High-traffic apps where performance is the #1 priority. |
| **`ids_only`** | **Low** (1 targeted query) | High (shows all item IDs) | **Best for most apps.** Relationship visibility with low memory. |
| **`eager`** | **Medium** (Full hydration) | High (Full entities) | Only if custom code needs to inspect full entity state. |
14 changes: 14 additions & 0 deletions docs/revert-feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ php bin/console audit:revert 123

# Revert with custom context (JSON)
php bin/console audit:revert 123 --context='{"reason": "Accidental deletion", "ticket": "T-101"}'

# Revert without silencing automatic audit logs (Keep technical logs)
php bin/console audit:revert 123 --noisy
```

## Custom Context on Revert
Expand All @@ -23,6 +26,17 @@ $auditReverter->revert($log, false, false, [

The bundle also **automatically** adds `reverted_log_id` to the context of the new audit log, linking it back to the original entry.

## Controlling Log Redundancy

By default, `AuditReverter` **silences** the standard `AuditSubscriber` during a revert. This prevents a duplicate `update` log from being created alongside the explicit `revert` log.

For scenarios requiring full technical transparency (e.g., strict forensic compliance), you can disable this silencing:

```php
// Pass 'false' as the 5th parameter to keep standard update logs enabled
$auditReverter->revert($log, false, false, [], false);
```

## Why it's "Safe"

- **Association Awareness**: Automatically handles entity relations and collections.
Expand Down
82 changes: 74 additions & 8 deletions docs/symfony-audit-transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,47 @@

AuditTrailBundle supports multiple transports to dispatch audit logs. This allows you to offload audit processing to external services or queues, keeping your main application fast.

## 1. Queue Transport (Symfony Messenger)
> [!NOTE]
> **Messenger Confusion Warning:** The bundle utilizes Symfony Messenger for **two different features**.
>
> 1. `database: { async: true }`: Designed to save logs to your *local database* asynchronously via an internal worker.
> 2. `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 without conflict because they route to different internal queues.

The Queue transport dispatches audit logs as messages via Symfony Messenger. This is the recommended way to handle audits in high-traffic applications.
## 1. Database Transport (Async)

By default, the `database` transport persists logs synchronously at the end of the Doctrine transaction. For high-traffic applications, you can offload this database write to a background worker.

### Configuration

Enable async mode in `config/packages/audit_trail.yaml`:

```yaml
audit_trail:
transports:
database:
enabled: true
async: true # Offloads inserts to Messenger
```

You must explicitly define a transport named `audit_trail_database` in `config/packages/messenger.yaml`:

```yaml
framework:
messenger:
transports:
# Internal bundle worker will consume from this transport
audit_trail_database: '%env(MESSENGER_TRANSPORT_DSN)%'
```

*(The bundle auto-registers `PersistAuditLogHandler` to consume from this transport and insert the records into the database).*

---

## 2. Queue Transport (External Delivery)

The `queue` transport acts as a webhook publisher. It dispatches a strictly-typed DTO (`AuditLogMessage`) to the bus. You must write your own external consumer to ingest these messages (e.g., Logstash, another microservice).

### Configuration for Queue transport

Expand All @@ -24,9 +62,8 @@ You must define a transport named `audit_trail` in `config/packages/messenger.ya
framework:
messenger:
transports:
# Your external service/worker will consume from this transport
audit_trail: '%env(MESSENGER_TRANSPORT_DSN)%'


```

### Advanced Usage: Messenger Stamps
Expand Down Expand Up @@ -145,14 +182,42 @@ The transport sends a `POST` request with a JSON body:

---

## 3. Doctrine Transport (Default)
## 3. Database Transport (Default)

The Database transport stores logs in your local database. It is enabled by default.

The Doctrine transport stores logs in your local database. It is enabled by default.
### Sync Mode (Default)

Logs are persisted directly during the Doctrine lifecycle:

```yaml
audit_trail:
transports:
doctrine: true
database:
enabled: true
async: false
```

### Async Mode

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.

```yaml
audit_trail:
transports:
database:
enabled: true
async: true
```

You must define a transport named `audit_trail_database` in `config/packages/messenger.yaml`:

```yaml
framework:
messenger:
transports:
audit_trail_database: '%env(MESSENGER_TRANSPORT_DSN)%'
```

---
Expand All @@ -164,7 +229,8 @@ You can enable multiple transports simultaneously. The bundle will automatically
```yaml
audit_trail:
transports:
doctrine: true
database:
enabled: true
queue:
enabled: true
```
Expand Down
9 changes: 8 additions & 1 deletion src/Command/AuditRevertCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ protected function configure(): void
InputOption::VALUE_REQUIRED,
'Custom context for the revert audit log (JSON string)',
'{}'
)
->addOption(
'noisy',
null,
InputOption::VALUE_NONE,
'Do not silence the AuditSubscriber (useful for strict compliance or debugging)'
);
}

Expand Down Expand Up @@ -111,7 +117,8 @@ private function performRevert(SymfonyStyle $io, InputInterface $input, AuditLog
}

try {
$changes = $this->auditReverter->revert($log, $dryRun, $force, $context);
$silence = !((bool) $input->getOption('noisy'));
$changes = $this->auditReverter->revert($log, $dryRun, $force, $context, $silence);
$this->displayRevertResult($io, $changes, $raw);

return Command::SUCCESS;
Expand Down
1 change: 1 addition & 0 deletions src/Contract/AuditReverterInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ public function revert(
bool $dryRun = false,
bool $force = false,
array $context = [],
bool $silenceSubscriber = true,
): array;
}
6 changes: 6 additions & 0 deletions src/Contract/ScheduledAuditManagerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,10 @@ public function schedule(object $entity, AuditLog $audit, bool $isInsert): void;
public function addPendingDeletion(object $entity, array $data, bool $isManaged): void;

public function clear(): void;

public function disable(): void;

public function enable(): void;

public function isEnabled(): bool;
}
59 changes: 53 additions & 6 deletions src/DependencyInjection/AuditTrailExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
use LogicException;
use Override;
use Rcsofttech\AuditTrailBundle\Contract\AuditTransportInterface;
use Rcsofttech\AuditTrailBundle\Factory\AuditLogMessageFactory;
use Rcsofttech\AuditTrailBundle\MessageHandler\PersistAuditLogHandler;
use Rcsofttech\AuditTrailBundle\Transport\AsyncDatabaseAuditTransport;
use Rcsofttech\AuditTrailBundle\Transport\ChainAuditTransport;
use Rcsofttech\AuditTrailBundle\Transport\DoctrineAuditTransport;
use Rcsofttech\AuditTrailBundle\Transport\HttpAuditTransport;
Expand Down Expand Up @@ -47,8 +50,10 @@ public function load(array $configs, ContainerBuilder $container): void
* integrity: array{enabled: bool, secret: ?string, algorithm: string},
* cache_pool: ?string,
* audited_methods: array<string>,
* collection_serialization_mode: string,
* max_collection_items: int,
* transports: array{
* doctrine: bool,
* database: array{enabled: bool, async: bool},
* http: array{enabled: bool, endpoint: string, headers: array<string, string>, timeout: int},
* queue: array{enabled: bool, api_key: ?string, bus: ?string}
* }
Expand All @@ -72,6 +77,8 @@ public function load(array $configs, ContainerBuilder $container): void
$container->setParameter('audit_trail.fallback_to_database', $config['fallback_to_database']);
$container->setParameter('audit_trail.cache_pool', $config['cache_pool']);
$container->setParameter('audit_trail.audited_methods', $config['audited_methods']);
$container->setParameter('audit_trail.collection_serialization_mode', $config['collection_serialization_mode']);
$container->setParameter('audit_trail.max_collection_items', $config['max_collection_items']);

if ($config['cache_pool'] !== null) {
$container->setAlias('rcsofttech_audit_trail.cache', $config['cache_pool']);
Expand Down Expand Up @@ -110,7 +117,7 @@ public function prepend(ContainerBuilder $container): void
/**
* @param array{
* transports: array{
* doctrine: bool,
* database: array{enabled: bool, async: bool},
* http: array{enabled: bool, endpoint: string, headers: array<string, string>, timeout: int},
* queue: array{enabled: bool, api_key: ?string, bus: ?string}
* }
Expand All @@ -120,8 +127,8 @@ private function configureTransports(array $config, ContainerBuilder $container)
{
$transports = [];

if ($config['transports']['doctrine'] === true) {
$transports[] = $this->registerDoctrineTransport($container);
if ($config['transports']['database']['enabled'] === true) {
$transports[] = $this->registerDatabaseTransport($container, $config['transports']['database']);
}

if ($config['transports']['http']['enabled'] === true) {
Expand All @@ -135,16 +142,54 @@ private function configureTransports(array $config, ContainerBuilder $container)
$this->registerMainTransport($container, $transports);
}

private function registerDoctrineTransport(ContainerBuilder $container): string
/**
* @param array{enabled: bool, async: bool} $config
*/
private function registerDatabaseTransport(ContainerBuilder $container, array $config): string
{
$id = 'rcsofttech_audit_trail.transport.doctrine';
if ($config['async']) {
return $this->registerAsyncDatabaseTransport($container);
}

$id = 'rcsofttech_audit_trail.transport.database';
$container->register($id, DoctrineAuditTransport::class)
->setAutowired(true)
->addTag('audit_trail.transport');

return $id;
}

private function registerAsyncDatabaseTransport(ContainerBuilder $container): string
{
if (!interface_exists(MessageBusInterface::class)) {
throw new LogicException('To use async database transport, you must install the symfony/messenger package.');
}

$this->registerMessageFactory($container);

$handlerId = 'rcsofttech_audit_trail.handler.persist_audit_log';
$container->register($handlerId, PersistAuditLogHandler::class)
->setAutowired(true)
->addTag('messenger.message_handler');

$id = 'rcsofttech_audit_trail.transport.async_database';
$container->register($id, AsyncDatabaseAuditTransport::class)
->setAutowired(true)
->addTag('audit_trail.transport');

return $id;
}

private function registerMessageFactory(ContainerBuilder $container): void
{
if ($container->has(AuditLogMessageFactory::class)) {
return;
}

$container->register(AuditLogMessageFactory::class, AuditLogMessageFactory::class)
->setAutowired(true);
}

/**
* @param array{enabled: bool, endpoint: string, headers: array<string, string>, timeout: int} $config
*/
Expand Down Expand Up @@ -174,6 +219,8 @@ private function registerQueueTransport(ContainerBuilder $container, array $conf
throw new LogicException('To use the Queue transport, you must install the symfony/messenger package.');
}

$this->registerMessageFactory($container);

$id = 'rcsofttech_audit_trail.transport.queue';
$definition = $container->register($id, QueueAuditTransport::class)
->setAutowired(true)
Expand Down
Loading