Skip to main content
Version: 4.x

Building a Custom Provider

This guide covers everything you need to create a provider for auditor — from the minimal contract to a distributable Composer package.

What is a provider?

A provider is the bridge between auditor's change-detection pipeline and your storage backend. A single provider can implement one or both of these responsibilities:

RoleInterfaceResponsibility
AuditingAuditingServiceInterfaceHook into the ORM/framework to detect entity changes
StorageStorageServiceInterfacePersist the captured LifecycleEvent to your backend

Both roles are handled by registering named services inside the provider.


Provider contract

ProviderInterface

Every provider must implement DH\Auditor\Provider\ProviderInterface:

interface ProviderInterface
{
public function setAuditor(Auditor $auditor): self;
public function getAuditor(): Auditor;
public function getConfiguration(): ConfigurationInterface;
public function isRegistered(): bool;

public function registerStorageService(StorageServiceInterface $service): self;
public function registerAuditingService(AuditingServiceInterface $service): self;

public function getStorageServices(): array; // StorageServiceInterface[]
public function getAuditingServices(): array; // AuditingServiceInterface[]

public function supportsStorage(): bool;
public function supportsAuditing(): bool;

public function persist(LifecycleEvent $event): void;
}

AbstractProvider — use this instead of implementing from scratch

DH\Auditor\Provider\AbstractProvider already implements everything except three methods:

abstract class AbstractProvider implements ProviderInterface
{
// You must implement:
public function supportsStorage(): bool;
public function supportsAuditing(): bool;
public function persist(LifecycleEvent $event): void;
}

It handles setAuditor(), getAuditor(), isRegistered(), service registration/deduplication, and getConfiguration() (via the protected $configuration property).

ConfigurationInterface

Your provider's configuration class must implement DH\Auditor\Provider\ConfigurationInterface (it is an empty marker interface — add whatever options your provider needs):

use DH\Auditor\Provider\ConfigurationInterface;

final class Configuration implements ConfigurationInterface
{
public function __construct(
private readonly string $tableName = 'audit_log',
private readonly bool $enabled = true,
) {}

public function getTableName(): string { return $this->tableName; }
public function isEnabled(): bool { return $this->enabled; }
}

Service contract

Services are lightweight named objects that tell auditor what a provider is capable of. They only need to implement getName(): string (via ServiceInterface).

ServiceInterface
├─ AuditingServiceInterface (marker: provider can detect changes)
└─ StorageServiceInterface (marker: provider can store audit entries)

Extend AbstractService to avoid boilerplate:

use DH\Auditor\Provider\Service\AbstractService;
use DH\Auditor\Provider\Service\AuditingServiceInterface;
use DH\Auditor\Provider\Service\StorageServiceInterface;

// A service that hooks into your ORM
final class MyAuditingService extends AbstractService implements AuditingServiceInterface
{
public function __construct(string $name, private readonly MyOrmConnection $connection)
{
parent::__construct($name);
}
}

// A service that writes to your storage backend
final class MyStorageService extends AbstractService implements StorageServiceInterface
{
public function __construct(string $name, private readonly MyStorageBackend $backend)
{
parent::__construct($name);
}
}

NOTE

Service names must be unique within a provider. The name is just a human-readable identifier (e.g. 'default'). It is used as the array key in getStorageServices() / getAuditingServices().


The persist() method

persist() is called by AuditEventSubscriber for every LifecycleEvent dispatched by auditor. This is where you write the audit entry to your backend.

LifecycleEvent payload

public function persist(LifecycleEvent $event): void
{
$payload = $event->getPayload();
// $event->entity → the original entity object (may be null)
}

The $payload array always contains these keys:

KeyTypeDescription
typestringOperation: 'insert', 'update', 'remove', 'associate', 'dissociate'
object_idstringStringified primary key of the entity
discriminator?stringDoctrine inheritance discriminator (or null)
transaction_hash?stringGroups all changes in a single flush
diffsstringJSON-encoded field-level changes
extra_data?stringOptional JSON metadata (enriched by event listeners)
blame_idint|string|nullAuthenticated user identifier
blame_user?stringAuthenticated username
blame_user_fqdn?stringUser class FQCN
blame_user_firewall?stringSymfony firewall name
ip?stringClient IP address
created_atDateTimeImmutableTimestamp of the change

Providers built on Doctrine ORM also add:

KeyTypeDescription
entitystringFQCN of the audited entity
tablestringResolved audit table name

IMPORTANT

Use $payload['type'] (a plain string) to check the operation type, not $payload['action']. The TransactionType enum provides constants if you need comparisons: TransactionType::INSERT, TransactionType::UPDATE, etc.


Minimal provider example

namespace Acme\AuditProvider;

use DH\Auditor\Event\LifecycleEvent;
use DH\Auditor\Provider\AbstractProvider;

final class AcmeProvider extends AbstractProvider
{
public function __construct(private readonly Configuration $config)
{
$this->configuration = $config;

// Register your services in the constructor
$this->registerStorageService(new AcmeStorageService('default', $config));
}

public function supportsStorage(): bool
{
return true;
}

public function supportsAuditing(): bool
{
// This provider only handles storage, not auditing.
// Another provider (e.g. DoctrineProvider) handles change detection.
return false;
}

public function persist(LifecycleEvent $event): void
{
$payload = $event->getPayload();

// Write to your backend — file, remote API, time-series DB, etc.
$this->config->getBackend()->write([
'operation' => $payload['type'],
'entity_id' => $payload['object_id'],
'changes' => json_decode($payload['diffs'], true),
'created_at' => $payload['created_at']->format(\DateTimeInterface::ATOM),
]);
}
}

Register it with Auditor:

$auditor->registerProvider(new AcmeProvider(new Configuration($backend)));

Splitting auditing and storage across providers

You can mix providers freely. A common pattern is to use DoctrineProvider for auditing (change detection) and a custom provider for storage (e.g. writing to Elasticsearch):

// DoctrineProvider handles change detection
$doctrineProvider = new DoctrineProvider($doctrineConfig);
$doctrineProvider->registerAuditingService(new AuditingService('default', $entityManager));
$auditor->registerProvider($doctrineProvider);

// Your custom provider handles persistence only
$elasticProvider = new ElasticProvider(new ElasticConfiguration($client));
$auditor->registerProvider($elasticProvider);

auditor requires at least one provider that supports auditing and at least one that supports storage. The two roles can be fulfilled by the same provider or by separate ones.


Long-running processes (workers)

If your application runs in a long-lived process (Symfony Messenger workers, ReactPHP, etc.), implement Symfony's ResetInterface to clear any cached state between messages:

use Symfony\Contracts\Service\ResetInterface;

final class AcmeProvider extends AbstractProvider implements ResetInterface
{
public function reset(): void
{
// Clear prepared statements, connection references, internal caches, etc.
}
}

Packaging your provider

Publishing your provider as a standalone Composer package lets the community use it without modifying auditor's core.

acme/auditor-acme-provider/
├─ src/
│ ├─ AcmeProvider.php
│ ├─ Configuration.php
│ ├─ Service/
│ │ ├─ AuditingService.php (if applicable)
│ │ └─ StorageService.php
│ └─ DependencyInjection/ (Symfony bundle integration, optional)
│ ├─ AcmeExtension.php
│ └─ Configuration.php
├─ tests/
├─ composer.json
├─ README.md
└─ LICENSE

composer.json requirements

{
"name": "acme/auditor-acme-provider",
"description": "ACME storage provider for auditor",
"type": "library",
"license": "MIT",
"require": {
"php": ">=8.4",
"damienharper/auditor": "^4.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"Acme\\AuditProvider\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Acme\\AuditProvider\\Tests\\": "tests/"
}
}
}

NOTE

Do not list damienharper/auditor under "replace" or "conflict". Your package is a consumer of the core library, not a replacement for it.

Naming convention

Follow the pattern {vendor}/auditor-{technology}-provider (e.g. damienharper/auditor-doctrine-provider, acme/auditor-elasticsearch-provider). This makes the package discoverable and its purpose immediately obvious.

Packagist keywords

Add these keywords to composer.json to improve discoverability:

"keywords": ["audit", "audit-log", "auditor", "provider", "acme"]

Optional: Symfony bundle integration

If your provider targets Symfony applications, ship a bundle that wires everything into the container automatically.

namespace Acme\AuditProvider\DependencyInjection;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;

final class AcmeAuditExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);

$container->register(AcmeProvider::class)
->setArguments([new Reference('acme.audit.configuration')])
->addTag('auditor.provider');
}
}

The auditor.provider tag tells auditor-bundle to call $auditor->registerProvider($provider) automatically.


Testing your provider

Test your persist() implementation by dispatching a LifecycleEvent directly, without needing a real ORM flush:

use DH\Auditor\Event\LifecycleEvent;
use DH\Auditor\Model\TransactionType;
use PHPUnit\Framework\TestCase;

final class AcmeProviderTest extends TestCase
{
public function testPersistWritesToBackend(): void
{
$backend = $this->createMock(AcmeBackend::class);
$backend->expects($this->once())->method('write');

$provider = new AcmeProvider(new Configuration($backend));

$event = new LifecycleEvent([
'type' => TransactionType::INSERT,
'object_id' => '42',
'discriminator' => null,
'transaction_hash' => 'abc123',
'diffs' => '{}',
'extra_data' => null,
'blame_id' => null,
'blame_user' => null,
'blame_user_fqdn' => null,
'blame_user_firewall' => null,
'ip' => '127.0.0.1',
'created_at' => new \DateTimeImmutable(),
]);

$provider->persist($event);
}
}

Quick reference: interfaces and classes

Class / InterfaceNamespacePurpose
ProviderInterfaceDH\Auditor\ProviderFull provider contract
AbstractProviderDH\Auditor\ProviderBoilerplate base — extend this
ConfigurationInterfaceDH\Auditor\ProviderMarker for provider config classes
ServiceInterfaceDH\Auditor\Provider\ServiceBase service marker
AuditingServiceInterfaceDH\Auditor\Provider\ServiceMarks a service as change-detector
StorageServiceInterfaceDH\Auditor\Provider\ServiceMarks a service as storage writer
AbstractServiceDH\Auditor\Provider\ServiceBoilerplate base for services
LifecycleEventDH\Auditor\EventEvent dispatched per audit entry
TransactionTypeDH\Auditor\ModelBacked enum of operation types