Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Auditor Laravel Package

damienharper/auditor

View on GitHub
Deep Wiki
Context7

id: custom-provider title: Building a Custom Provider

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:

Role Interface Responsibility
Auditing AuditingServiceInterface Hook into the ORM/framework to detect entity changes
Storage StorageServiceInterface Persist 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:

Key Type Description
schema_version int Row format version (2 for current)
type string Operation: 'insert', 'update', 'remove', 'associate', 'dissociate'
object_id string Stringified primary key of the entity
discriminator ?string Doctrine inheritance discriminator (or null)
transaction_id ?string ULID grouping all changes in a single flush
diffs string JSON-encoded field-level changes ({source, changes} envelope)
extra_data ?string Optional JSON metadata (enriched by event listeners)
blame_id int|string|null Authenticated user identifier
blame array|null Blame context: ['username', 'user_fqdn', 'user_firewall', 'ip']
created_at DateTimeImmutable Timestamp of the change

Providers built on Doctrine ORM also add:

Key Type Description
entity string FQCN of the audited entity
table string Resolved 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.

Recommended package structure

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('dh_auditor.provider');
    }
}

The dh_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([
            'schema_version' => 2,
            'type'           => TransactionType::INSERT,
            'object_id'      => '42',
            'discriminator'  => null,
            'transaction_id' => '01JXXXXXXXXXXXXXXXXXXXXXXXXX',
            'diffs'          => '{}',
            'extra_data'     => null,
            'blame_id'       => null,
            'blame'          => null,
            'created_at'     => new \DateTimeImmutable(),
        ]);

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

Quick reference: interfaces and classes

Class / Interface Namespace Purpose
ProviderInterface DH\Auditor\Provider Full provider contract
AbstractProvider DH\Auditor\Provider Boilerplate base — extend this
ConfigurationInterface DH\Auditor\Provider Marker for provider config classes
ServiceInterface DH\Auditor\Provider\Service Base service marker
AuditingServiceInterface DH\Auditor\Provider\Service Marks a service as change-detector
StorageServiceInterface DH\Auditor\Provider\Service Marks a service as storage writer
AbstractService DH\Auditor\Provider\Service Boilerplate base for services
LifecycleEvent DH\Auditor\Event Event dispatched per audit entry
TransactionType DH\Auditor\Model Backed enum of operation types
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
emuniq/filament-browser-notifications
syriable/filament-translator
hungnm28/livewire-form
wenprise/eloquent
crudly/encrypted
fadion/bouncy
cuci/prototurk-sdk
gos/pubsub-router-bundle
cuci/prototurk-sdk-symfony
clementtalleu/easyadmin-markdown-bundle
codeflextech/permission-manager
karnoweb/livewire-datepicker
sayedenam/sayed-dashboard
milito/query-filter
apiboxsym/user-bundle
apiboxsym/health-check-bundle
jayeshmepani/jpl-moshier-ephemeris-php
elnasnato/laraliveui
labrodev/rest-sdk
sampaui/sampaui