# Auditor Bundle Auditor Bundle integrates the auditor library into Symfony applications, providing automatic audit logging for all Doctrine ORM database changes. It automatically tracks and records entity insertions, updates, deletions, and relationship changes with full user attribution, IP address tracking, and transaction grouping. The bundle seamlessly integrates with Symfony's security system for user identification and access control. The bundle provides a complete solution for audit trail requirements including a built-in web viewer at `/audit`, console command tracking, role-based access control for viewing audit logs, and support for multi-database configurations. It includes PHP 8 attributes for entity configuration, customizable providers for user/security information, and extensibility through custom audit providers. Translations are available in 9 languages, and the viewer includes features like dark mode, activity graphs, and filtering by action type or user. ## Installation Install the bundle via Composer and register it in your Symfony application. ```bash # Install the bundle (includes auditor library automatically) composer require damienharper/auditor-bundle # Clear cache after installation bin/console cache:clear # Create audit tables using migrations (recommended) bin/console doctrine:migrations:diff bin/console doctrine:migrations:migrate # Or using schema tool directly bin/console doctrine:schema:update --force # Install assets for the viewer bin/console assets:install ``` ## Basic Configuration Configure audited entities in `config/packages/dh_auditor.yaml` with YAML configuration. ```yaml # config/packages/dh_auditor.yaml dh_auditor: enabled: true # Enable/disable auditing globally timezone: 'UTC' # Timezone for audit timestamps providers: doctrine: table_prefix: '' # Prefix for audit table names table_suffix: '_audit' # Suffix for audit table names # Properties to ignore globally across all entities ignored_columns: - createdAt - updatedAt - password entities: # Simple: all defaults App\Entity\User: ~ # With options App\Entity\Post: enabled: true ignored_columns: - viewCount roles: view: - ROLE_ADMIN App\Entity\Comment: ~ # Enable the web viewer viewer: enabled: true page_size: 50 activity_graph: enabled: true days: 30 layout: 'bottom' # 'bottom' or 'inline' cache: enabled: true pool: 'cache.app' ttl: 300 ``` ## Entity Attributes Configure auditing behavior directly on entity classes using PHP 8 attributes. ```php repository->find($value)?->getName(); } } // Entity using the resolver namespace App\Entity; use App\Audit\Resolver\CategoryResolver; use DH\Auditor\Provider\Doctrine\Auditing\Attribute as Audit; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] #[Audit\Auditable] class Product { #[ORM\Column(type: Types::INTEGER)] #[Audit\DiffLabel(resolver: CategoryResolver::class)] // Resolves categoryId to category name private int $categoryId; } // Result: {"categoryId": {"old": {"value": 1, "label": "Books"}, "new": {"value": 2, "label": "Electronics"}}} ``` ## Reading Audit Entries Query and read audit entries programmatically using the Reader service. ```php reader->createQuery(User::class, [ 'page_size' => 50, 'page' => 1, ]); // Execute and get entries $entries = $query->execute(); foreach ($entries as $entry) { echo sprintf( "ID: %s, Type: %s, User: %s, Date: %s\n", $entry->id, $entry->type, // insert, update, remove, associate, dissociate $entry->username, $entry->createdAt->format('Y-m-d H:i:s') ); // Access the diff data $diffs = $entry->getDiffs(); foreach ($diffs as $field => $change) { echo sprintf(" %s: %s -> %s\n", $field, $change['old'] ?? 'null', $change['new'] ?? 'null'); } // Access extra data if present if (null !== $entry->extraData) { echo sprintf(" Route: %s\n", $entry->extraData['route'] ?? 'N/A'); } } return $entries; } public function getEntityHistory(int $userId): array { // Get all changes for a specific entity instance $query = $this->reader->createQuery(User::class, [ 'object_id' => $userId, 'page_size' => null, // No pagination - get all ]); return $query->execute(); } public function getTransactionAudits(string $transactionHash): array { // Get all changes from a single database transaction return $this->reader->getAuditsByTransactionHash($transactionHash); } public function filterByExtraData(): array { // Filter entries by extra_data JSON content $query = $this->reader->createQuery(User::class, ['page_size' => null]); // Filter by exact value $query->addFilter(new JsonFilter('extra_data', 'route', 'app_user_edit')); // Filter with LIKE pattern $query->addFilter(new JsonFilter('extra_data', 'department', 'IT%', 'LIKE')); // Filter by multiple values (IN) $query->addFilter(new JsonFilter('extra_data', 'status', ['active', 'pending'], 'IN')); return $query->execute(); } } ``` ## Custom User Provider Customize how the current user is identified in audit entries for non-standard authentication systems. ```php tokenStorage->getToken(); if (null !== $token && null !== $token->getUser()) { $user = $token->getUser(); return new User( method_exists($user, 'getId') ? (string) $user->getId() : '', $user->getUserIdentifier() ); } // Fall back to API token $apiToken = $this->apiTokenManager->getCurrentToken(); if (null !== $apiToken) { return new User( $apiToken->getClientId(), 'API: ' . $apiToken->getClientName() ); } return null; // Anonymous user } } // config/packages/dh_auditor.yaml // dh_auditor: // user_provider: 'App\Audit\CompositeUserProvider' ``` ## Custom Security Provider Customize IP address and context detection for audit entries, especially when behind proxies. ```php requestStack->getCurrentRequest(); if (!$request instanceof Request) { return [null, null]; } // Cloudflare provides original IP in CF-Connecting-IP header $clientIp = $request->headers->get('CF-Connecting-IP') ?? $request->headers->get('X-Forwarded-For') ?? $request->getClientIp(); // Handle X-Forwarded-For with multiple IPs (take first) if (null !== $clientIp && str_contains($clientIp, ',')) { $clientIp = trim(explode(',', $clientIp)[0]); } $firewallConfig = $this->firewallMap->getFirewallConfig($request); return [$clientIp, $firewallConfig?->getName()]; } } // config/packages/dh_auditor.yaml // dh_auditor: // security_provider: 'App\Audit\CloudflareSecurityProvider' ``` ## Custom Role Checker Customize access control for the audit viewer with complex authorization logic. ```php ['ROLE_USER_ADMIN'], Order::class => ['ROLE_ORDER_MANAGER', 'ROLE_ACCOUNTANT'], Payment::class => ['ROLE_ACCOUNTANT'], ]; public function __construct( private readonly Security $security, ) {} public function __invoke(string $entity, string $scope): bool { // Super admin bypass if ($this->security->isGranted('ROLE_SUPER_ADMIN')) { return true; } $requiredRoles = self::ENTITY_ROLES[$entity] ?? []; // No restriction for unlisted entities if (empty($requiredRoles)) { return true; } // Check if user has any of the required roles (OR logic) foreach ($requiredRoles as $role) { if ($this->security->isGranted($role)) { return true; } } return false; } } // config/packages/dh_auditor.yaml // dh_auditor: // role_checker: 'App\Audit\EntityRoleChecker' ``` ## Extra Data Provider Attach additional contextual information to audit entries automatically. ```php requestStack->getCurrentRequest(); if (null === $request) { return null; // Console context - no extra data } return [ 'route' => $request->attributes->get('_route'), 'request_id' => $request->headers->get('X-Request-ID'), 'user_agent' => $request->headers->get('User-Agent'), 'tenant_id' => $request->attributes->get('tenant_id'), ]; } } // config/packages/dh_auditor.yaml // dh_auditor: // extra_data_provider: 'App\Audit\MyExtraDataProvider' ``` ## LifecycleEvent Listener for Extra Data Attach entity-specific extra data using Symfony event listeners. ```php getPayload(); // Only process Order entity if ($payload['entity'] !== Order::class) { return; } $request = $this->requestStack->getCurrentRequest(); // Decode existing extra_data if provider already set it $existing = null !== $payload['extra_data'] ? json_decode($payload['extra_data'], true, 512, JSON_THROW_ON_ERROR) : []; // Merge entity-specific data $merged = array_merge($existing, [ 'admin_user' => $this->security->getUser()?->getUserIdentifier(), 'order_status' => $event->entity?->getStatus(), 'audit_reason' => $request?->headers->get('X-Audit-Reason'), ]); // IMPORTANT: Must be JSON-encoded string, not array $payload['extra_data'] = json_encode($merged, JSON_THROW_ON_ERROR); $event->setPayload($payload); } } ``` ## Multi-Database Storage Configuration Store audit logs in a separate database from your entities. ```yaml # config/packages/doctrine.yaml doctrine: dbal: default_connection: default connections: default: url: '%env(DATABASE_URL)%' audit: url: '%env(AUDIT_DATABASE_URL)%' orm: default_entity_manager: default entity_managers: default: connection: default mappings: App: type: attribute dir: '%kernel.project_dir%/src/Entity' prefix: 'App\Entity' audit: connection: audit # No mappings needed - audit tables are created dynamically # config/packages/dh_auditor.yaml dh_auditor: providers: doctrine: # Store audits in separate database storage_services: - '@doctrine.orm.audit_entity_manager' # Monitor changes on default connection auditing_services: - '@doctrine.orm.default_entity_manager' # Route audits to storage (required for multi-storage) storage_mapper: 'App\Audit\StorageMapper' entities: App\Entity\User: ~ App\Entity\Order: ~ ``` ```php configuration = new ElasticsearchConfiguration($client); $this->registerStorageService(new ElasticsearchStorageService('default', $client)); } public function supportsStorage(): bool { return true; } public function supportsAuditing(): bool { // This provider only writes audit entries; DoctrineProvider handles change detection return false; } public function persist(LifecycleEvent $event): void { $payload = $event->getPayload(); $this->client->index([ 'index' => 'audit', 'body' => [ 'type' => $payload['type'], 'entity' => $payload['entity'], 'object_id' => $payload['object_id'], 'diffs' => json_decode($payload['diffs'], true), 'blame_user' => $payload['blame_user'], 'created_at' => $payload['created_at']->format(\DateTimeInterface::ATOM), ], ]); } public function reset(): void { $this->client->reset(); // Clear state for long-running processes } } // config/services.yaml // App\Audit\ElasticsearchProvider: // tags: [dh_auditor.provider] ``` ## Viewer Routes and URL Generation Generate URLs for the audit viewer programmatically. ```php urlGenerator->generate('dh_auditor_list_audits'); // Entity stream - /audit/App-Entity-User $entityUrl = $this->urlGenerator->generate('dh_auditor_show_entity_stream', [ 'entity' => UrlHelper::namespaceToParam('App\Entity\User'), // 'App-Entity-User' ]); // Specific entity instance - /audit/App-Entity-User/42 $instanceUrl = $this->urlGenerator->generate('dh_auditor_show_entity_stream', [ 'entity' => 'App-Entity-User', 'id' => 42, ]); // With filters - /audit/App-Entity-User?type=update&user=42 $filteredUrl = $this->urlGenerator->generate('dh_auditor_show_entity_stream', [ 'entity' => 'App-Entity-User', 'type' => 'update', 'user' => '42', ]); // Anonymous filter - /audit/App-Entity-User?user=__anonymous__ $anonymousUrl = $this->urlGenerator->generate('dh_auditor_show_entity_stream', [ 'entity' => 'App-Entity-User', 'user' => '__anonymous__', ]); // Transaction view - /audit/transaction/{hash} $transactionUrl = $this->urlGenerator->generate('dh_auditor_show_transaction_stream', [ 'hash' => 'abc123def456', ]); return [ 'list' => $listUrl, 'entity' => $entityUrl, 'instance' => $instanceUrl, 'filtered' => $filteredUrl, 'anonymous' => $anonymousUrl, 'transaction' => $transactionUrl, ]; } public function convertNamespace(): void { // Convert namespace to URL parameter $param = UrlHelper::namespaceToParam('App\Entity\User'); // Returns: 'App-Entity-User' // Convert URL parameter back to namespace $namespace = UrlHelper::paramToNamespace('App-Entity-User'); // Returns: 'App\Entity\User' } } ``` ## Console Commands Use the built-in console commands for schema management and cache control. ```bash # Update audit schema (for multi-database setups) bin/console audit:schema:update --dump-sql # Preview SQL bin/console audit:schema:update --force # Apply changes # Clean old audit logs (keep last 12 months) bin/console audit:clean --keep=12 # Clear activity graph cache bin/console audit:cache:clear # Clear all bin/console audit:cache:clear --entity="App\Entity\User" # Clear specific entity ``` ## Template Customization Override audit viewer templates to customize the appearance. ```twig {# templates/bundles/DHAuditorBundle/layout.html.twig #} {% extends 'base.html.twig' %} {% block stylesheets %} {{ parent() }} {% endblock %} {% block body %}
{% block dh_auditor_content %}{% endblock %}
{% endblock %} {% block javascripts %} {{ parent() }} {% endblock %} ``` ``` # Template override structure templates/bundles/DHAuditorBundle/ ├── Audit/ │ ├── audits.html.twig # Entity list │ ├── entity_stream.html.twig # Entity audit stream │ ├── transaction_stream.html.twig # Transaction view │ ├── entry.html.twig # Single entry │ └── helpers/ │ ├── helper.html.twig # Helper macros │ └── pager.html.twig # Pagination └── layout.html.twig # Base layout ``` ## Summary Auditor Bundle is ideal for applications requiring comprehensive audit trails for compliance, security monitoring, or debugging purposes. Common use cases include financial applications requiring transaction audit logs, healthcare systems needing HIPAA-compliant change tracking, e-commerce platforms tracking order modifications, and any system where understanding "who changed what and when" is critical. The bundle handles the complexity of capturing changes at the Doctrine ORM level, ensuring no modification goes unrecorded. Integration follows standard Symfony patterns with YAML configuration and PHP attributes for entity-level customization. The bundle automatically hooks into Symfony's security system for user attribution and provides extensibility points for custom user providers, security providers, and role checkers. For advanced use cases, custom audit providers can store entries in alternative backends (Elasticsearch, external APIs) while DoctrineProvider handles change detection. The built-in viewer provides immediate value for debugging and auditing, while the Reader API enables custom reporting and analytics integration.