gsebastiao/laravel-auditable

Auditoria Eloquent com resolução de labels legíveis (resolveMap), agrupamento de operações multi-tabela por batch, auditoria de falhas com debug e multitenancy opcional. Agnóstico à estratégia de tenancy.

Maintainers

Package info

github.com/gsebastiao/laravel-auditable

pkg:composer/gsebastiao/laravel-auditable

Transparency log

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.3.0 2026-08-16 08:43 UTC

This package is auto-updated.

Last update: 2026-08-16 08:45:29 UTC


README

Auditoria automática para Eloquent que grava labels legíveis, não IDs crus.

Toda vez que um model muda, o Auditable registra quem mudou, o quê, e quando — traduzindo chaves estrangeiras para nomes que um humano entende. Funciona por eventos do Eloquent, então você não muda uma linha da forma como já salva seus dados. Multitenancy é opcional e plugável.

🇵🇹 Português  •  🇬🇧 English

Requer PHP 8.2+ · Laravel 11, 12 ou 13 · Licença MIT

O problema que ele resolve

A maioria dos pacotes de auditoria grava isto quando um pedido muda de status:

{ "status_id": { "old": 2, "new": 5 } }

E aí alguém abre o log e pergunta: "o que é status 2? e 5?". Ninguém sabe sem ir ao banco.

O Auditable grava isto:

{ "Status": { "old": "Aguardando pagamento", "new": "Enviado" } }

O mesmo evento. A diferença é que o log se explica sozinho. É para isso que o pacote existe.

🇵🇹 Português

Instalação

composer require gsebastiao/laravel-auditable

Publique a configuração e a migration, depois rode a migration:

php artisan vendor:publish --tag=auditable-config
php artisan vendor:publish --tag=auditable-migrations
php artisan migrate

Pronto. Nada mais é obrigatório.

Começando (2 minutos)

Passo 1 — Adicione o trait Auditable a qualquer model:

use Illuminate\Database\Eloquent\Model;
use Gsebastiao\Auditable\Concerns\Auditable;

class Produto extends Model
{
    use Auditable;
}

É só isso para começar. A partir de agora, create, update e delete deste model são auditados automaticamente:

$produto = Produto::create(['nome' => 'Café', 'preco' => 20]);
$produto->update(['preco' => 25]);

Passo 2 — Consulte o histórico a qualquer momento:

$produto->audits;   // coleção com todo o histórico do registro

Cada entrada traz o evento (created/updated/deleted), o que mudou, quem fez, e quando. Sem configurar mais nada.

Traduzindo IDs para nomes (o diferencial)

Se o seu model tem chaves estrangeiras, diga ao Auditable como transformá-las em texto legível. Você faz isso adicionando um método ao model:

use Gsebastiao\Auditable\Support\AuditOptions;
use Gsebastiao\Auditable\Support\ResolveMap;

class Produto extends Model
{
    use Auditable;

    public function getAuditOptions(): AuditOptions
    {
        return AuditOptions::defaults()->resolveMap([

            // status_id: busca o nome na tabela "status"
            'status_id' => ResolveMap::direct(
                label:  'Status',   // como aparece no log
                table:  'status',   // onde buscar
                column: 'nome',     // qual coluna é o texto
            ),

        ]);
    }
}

Agora, em vez de status_id: 2 → 5, o log grava Status: "Ativo" → "Bloqueado".

Os três modos de tradução

Modo Quando usar Exemplo
direct A FK aponta direto para uma tabela com o nome status_id → tabela status
join Precisa navegar por tabelas intermediárias estado_idestadospaises
alias Não é FK, só quer renomear o campo no log ativo → "Situação"
Ver exemplos de join e alias
AuditOptions::defaults()->resolveMap([

    // JOIN: resolver o nome do país a partir de estado_id,
    // navegando estados → paises
    'estado_id' => ResolveMap::join([
        ['table' => 'estados', 'key' => 'id'],
        ['table' => 'paises',
         'on'     => ['paises.id', '=', 'estados.pais_id'],
         'column' => 'nome',
         'label'  => 'País'],
    ]),

    // ALIAS: campo booleano que só precisa de um nome bonito no log
    'ativo' => ResolveMap::alias('Situação'),

]);

Escolhendo o que auditar

O mesmo getAuditOptions() controla o resto. Tudo é opcional:

AuditOptions::defaults()
    ->except(['updated_at', 'senha'])   // nunca audita estes campos
    ->only(['preco', 'status_id'])      // OU: audita só estes
    ->events(['updated', 'deleted'])    // ignora o "created"
    ->onlyDirty()                       // só grava o que de fato mudou (padrão)
    ->logEmpty(false);                  // não grava se nada mudou (padrão)

Senhas e tokens (password, remember_token) já são ignorados por padrão.

Operações multi-tabela: um batch, uma história

Este é o cenário que dá sentido ao resto. Você cria um pedido — e junto com ele entram o cliente, os itens, uma baixa de estoque. São escritas em tabelas diferentes, mas fazem parte da mesma operação. Você quer poder olhar para qualquer uma delas depois e reconstruir a operação inteira.

Envolva a operação em Audit::transaction() (ou Audit::batch() se não quiser transação). Tudo que for auditado lá dentro — de qualquer model — recebe o mesmo batch:

use Gsebastiao\Auditable\Audit;

Audit::transaction(function () use ($dados) {
    $cliente = Cliente::create($dados['cliente']);
    $pedido  = Pedido::create(['cliente_id' => $cliente->id, ...]);

    foreach ($dados['itens'] as $item) {
        Item::create(['pedido_id' => $pedido->id, ...]);
    }
});

O cliente, o pedido e todos os itens ficam gravados sob um único batch. E como é uma transação, se qualquer parte falhar, tudo volta atrás — escritas e auditoria juntas.

Recuperando a operação inteira a partir de um registro

Agora a parte que você descreveu: você tem só o cliente e quer ver tudo que entrou junto com ele. Chame operation():

$cliente = Cliente::find($id);

$cliente->operation()->get();
// -> devolve as auditorias do cliente, do pedido E dos itens
//    (tudo o que compartilhou o batch)

Ou, se você só tem o id:

Cliente::operationFor($id)->get();

Como o resultado é um query builder normal, você agrupa por tabela para exibir:

$cliente->operation()->get()->groupBy('subject_type');
// [
//   'App\Models\Cliente' => [ ... ],
//   'App\Models\Pedido'  => [ ... ],
//   'App\Models\Item'    => [ ... ],
// ]

Precisa só do id do batch? $cliente->batchOf() devolve o identificador da última operação daquele registro — útil para logs ou para passar adiante.

Propagando o batch para filas

Se parte da operação roda numa job assíncrona e você quer que ela caia no mesmo batch, passe o id para a job e reabra lá dentro:

// Ao despachar:
ProcessarPedido::dispatch($pedido, Audit::currentBatch());

// Dentro da job:
public function handle(): void
{
    Audit::useBatch($this->batchId);
    // tudo auditado aqui entra no mesmo batch da operação original
}

Ações customizadas (além de create/update/delete)

Os três eventos automáticos cobrem escritas no banco. Mas nem tudo que você quer auditar é uma escrita — "aprovou o pedido", "reenviou o e-mail", "fez login", "exportou". Para esses, chame auditAction() com o nome que quiser:

$pedido->auditAction('aprovado');

$pedido->auditAction('email_reenviado', [
    'para' => $cliente->email,
    'via'  => 'ses',
]);

Fica no mesmo histórico dos eventos automáticos, com o nome que você deu.

Consultar o histórico de um registro específico

$produto->audits te dá o histórico do model que você já carregou. Quando você tem só o id, ou quer filtrar, use auditsFor() — que devolve um query builder:

// Tudo do registro 42, sem precisar carregar o Produto
Produto::auditsFor(42)->get();

// Só as aprovações
Produto::auditsFor(42)->action('aprovado')->get();

// A última alteração feita por um usuário
Produto::auditsFor(42)->byUser($userId)->latest()->first();

// Só as falhas deste registro
Produto::auditsFor(42)->failures()->get();

Filtros disponíveis: action(), byUser(), failures(), inBatch().

Colunas de auditoria numa listagem (DataTable)

As relações acima respondem "qual é o histórico deste registro?". Uma grelha faz outra pergunta, sobre muitos registros de uma vez: "para cada linha desta página, quem criou e quando? quem alterou por último e quando?". Resolver isso com a relação seria um N+1 — uma consulta de auditoria por linha exibida.

AuditColumnJoiner resolve de outro jeito: anexa audit_created_by, audit_created_at, audit_updated_by, audit_updated_at como colunas na própria query, via LEFT JOIN de subconsultas agregadas. Uma query só, sem N+1, pronta para o DataTable ordenar e paginar.

use Gsebastiao\Auditable\Support\AuditColumnJoiner;

// Na sua query de listagem:
$query = Produto::query()->where('ativo', 1);

AuditColumnJoiner::apply($query, Produto::class);
// agora cada linha traz: audit_created_by, audit_created_at, audit_updated_by, audit_updated_at

Por que o prefixo audit_? Porque created_at, updated_at e deleted_at são colunas nativas do Eloquent, com cast automático de datetime. Se emitíssemos uma coluna chamada created_at, ela colidiria com a nativa da própria tabela e o Eloquent tentaria dar cast na string já formatada (10/07/2026 14:30) — e quebraria. Prefixar todas as colunas na raiz elimina a colisão de vez e, de quebra, mantém o par _by/_at sempre consistente — sem exceções nem sufixos especiais. Toda ação sai igual: audit_restored_by/audit_restored_at, audit_aprovado_by/audit_aprovado_at. O prefixo é configurável (parâmetro prefix: ou config('auditable.column_prefix')).

Por que só created e updated por padrão? Numa grelha normal de um model com SoftDeletes, o global scope já esconde os apagados — então uma coluna audit_deleted_by ficaria sempre vazia, custando dois JOIN por linha à toa. Só inclua deleted/restored quando a própria grelha for uma lixeira:

// Grelha de lixeira: aí sim faz sentido "quem apagou / quando"
AuditColumnJoiner::apply(
    Produto::onlyTrashed(),
    Produto::class,
    actions: ['deleted', 'restored'],
);

// Exibir por email em vez de nome
AuditColumnJoiner::apply($query, Produto::class, userColumn: 'email');

// Ações de domínio também viram coluna (a última ocorrência)
AuditColumnJoiner::apply($query, Produto::class, actions: ['created', 'aprovado']);
// → audit_aprovado_by, audit_aprovado_at

Cada ação = dois LEFT JOIN (a subconsulta de auditoria + a tabela users). Peça só o que a grelha vai mostrar. Índice recomendado na tabela de auditoria: (subject_type, event, subject_id, id).

Quando usar o quê: MUITAS linhas, um resumo por linha → AuditColumnJoiner. UMA linha, o histórico todo → $model->audits / auditsFor().

Widget JS: um modal de histórico pronto (100% OPCIONAL)

Isto é totalmente opcional. Tudo que você leu até aqui — gravar auditoria, consultar $model->audits, montar colunas com AuditColumnJoiner — funciona 100% sem nada do que vem a seguir. Esta seção existe só para quem não quer escrever HTML/CSS/JS do zero para mostrar esse histórico numa tela. Se você prefere montar sua própria interface (ou já tem uma), pode pular esta seção inteira sem perder nenhuma funcionalidade do pacote.

O que é

Um único arquivo JavaScript (audit-table.init.js) que abre um modal ("popup") mostrando o histórico de um registro, quando você clica em algum botão da sua tela. Ele:

  • Não tem nenhuma dependência. Sem jQuery, sem Bootstrap, sem DataTables. Um <script> só, e pronto — o HTML do modal, o CSS e o comportamento (busca, filtro, paginação) são todos gerados pelo próprio arquivo, em tempo real, quando você abre o modal.
  • Não conflita com o visual do seu site. Todo o CSS injetado usa nomes de classe exclusivos, sempre começando com ga-audit- (ex.: ga-audit-modal, ga-audit-table). Nunca usa nomes genéricos como .modal ou .table, que são exatamente os nomes que frameworks como Bootstrap ou AdminLTE já usam — então não existe risco de o CSS do seu template "vazar" para dentro do modal, nem o contrário.
  • Funciona em qualquer tamanho de tela. Em celular, o modal ocupa a tela inteira (mais fácil de usar com o dedo); em telas maiores, aparece centralizado como um popup comum.

Os dois modais

O arquivo registra um objeto global chamado GaAudit, com dois widgets independentes. Você pode usar um, o outro, ou os dois — são pensados para públicos diferentes:

Widget Pra quem O que mostra
GaAudit.full Quem tem permissão de auditor/admin Histórico completo: busca, filtro por ação, paginação, alterações agrupadas por batch
GaAudit.simple Qualquer usuário do sistema Lista direta e enxuta: o quê, quem, quando — sem filtros

Passo 1 — Publicar o arquivo

O arquivo já vem dentro do pacote (em vendor/gsebastiao/laravel-auditable/src/plugin/audit-table.init.js), mas o navegador só consegue acessar arquivos que estão dentro da pasta public/ do seu projeto Laravel. Por isso existe um comando que copia o arquivo para lá:

php artisan auditable:publish-js

Por padrão, isso cria o arquivo em public/assets/js/audit-table.init.js.

Quer publicar em outro lugar? Duas formas:

# Só para esta execução (não muda nada permanentemente):
php artisan auditable:publish-js --path=js/vendor/auditoria

# Para sempre, editando o config publicado (config/auditable.php):
'js' => [
    'publish_path' => 'js/vendor/auditoria',
],

Atualizando o pacote e quer pegar uma versão nova do arquivo JS? Rode de novo com --force, para sobrescrever o que já está publicado:

php artisan auditable:publish-js --force

Alternativa: se seu projeto já usa um bundler (Vite, Mix, Webpack…) e você prefere que o audit-table.init.js passe pelo MESMO pipeline de build do resto do seu JS, ignore o comando acima e simplesmente copie o arquivo de dentro de vendor/gsebastiao/laravel-auditable/src/plugin/ para dentro da sua pasta de assets (ex.: resources/js/vendor/), e importe normalmente.

Passo 2 — Incluir na página

No seu layout Blade (ex.: resources/views/layouts/app.blade.php), antes do </body>:

<script src="{{ asset('assets/js/audit-table.init.js') }}"></script>

(Troque assets/js pelo caminho que você escolheu no Passo 1, se mudou o padrão.)

Passo 3 — Criar a rota que alimenta o modal

O widget JS não sabe nada sobre o seu banco de dados — ele só sabe fazer uma requisição POST para uma URL que você fornece, e espera um JSON de volta num formato específico. Quem monta essa resposta é uma rota Laravel comum, que você escreve, chamando os métodos do pacote que você já viu nas seções anteriores deste README.

Para o modal GaAudit.full (histórico completo, agrupado por batch):

// routes/web.php
use App\Models\Produto;
use Illuminate\Http\Request;

Route::post('/audit/readGrouped', function (Request $request) {
    $groups = Produto::operationFor($request->input('id'))
        ->get()
        ->groupBy('batch')
        ->map(fn ($actions, $batchId) => [
            'batch_id' => $batchId,
            'actions' => $actions->map(fn ($audit) => [
                'action' => $audit->event,
                'created_by' => User::find($audit->created_by, ['name'])->name,
                'type' => $audit->is_failure ? 'failed' : 'success',
                'created_at' => $audit->created_at->format('d/m/Y H:i'),
                'changes' => $audit->changes,
            ]),
        ])
        ->values();

    return response()->json([
        'record_id' => $request->input('id'),
        'groups' => $groups,
    ]);
})->middleware('auth'); // proteja com permissão de auditor

Para o modal GaAudit.simple (lista enxuta, sem agrupar):

// routes/web.php
use App\Models\Produto;
use Illuminate\Http\Request;

Route::post('/audit/read', function (Request $request) {
    $audits = Produto::auditsFor($request->input('id'))
        ->latest()
        ->limit(50)
        ->get()
        ->map(fn ($audit) => [
            'action' => $audit->event,
            'created_by' => User::find($audit->created_by, ['name'])->name,
            'created_at' => $audit->created_at->format('d/m/Y H:i'),
        ]);

    return response()->json(['audits' => $audits]);
})->middleware('auth');

Os exemplos acima usam nomes de rota (/audit/readGrouped, /audit/read) só como sugestão — use os nomes e o middleware que fizerem sentido no seu projeto. O que importa é o formato do JSON de resposta, não a URL em si. Se preferir, use um Controller normal em vez de uma Closure na rota.

Passo 4 — Abrir o modal

Duas formas, à sua escolha:

Forma A — atributos data-* (não precisa escrever JS nenhum):

<button
    data-ga-audit="full"
    data-ga-audit-id="{{ $produto->id }}"
    data-ga-audit-url="/audit/readGrouped">
    Ver histórico completo
</button>

<button
    data-ga-audit="simple"
    data-ga-audit-id="{{ $produto->id }}"
    data-ga-audit-url="/audit/read">
    Ver histórico
</button>

O widget já escuta cliques em qualquer elemento com data-ga-audit na página — não precisa registrar nada manualmente.

Forma B — chamando via JavaScript (mais controle):

document.getElementById('meuBotao').addEventListener('click', function () {
    GaAudit.full.open({
        endpoint: '/audit/readGrouped',
        id: 42,
        title: 'Histórico do Produto #42', // opcional
    });
});

title é opcional em ambas as formas. Sem ele, GaAudit.full usa "Histórico de Auditoria" e GaAudit.simple usa "Auditoria do Registro" como título padrão — passe title (ou data-ga-audit-title na Forma A) só quando quiser um texto diferente desse.

O modal não precisa de nenhum HTML na página

Ao contrário de um modal Bootstrap tradicional, você não precisa deixar um <div id="algumModal">...</div> escondido em algum lugar do layout. O widget cria todo o HTML do modal em memória quando você abre, e o remove por completo quando você fecha. Isso é o que a pergunta original sobre "integrar o modal dentro do plugin" resolve: zero HTML externo, zero configuração de layout, funciona em qualquer página onde o <script> esteja incluído.

Customizando cores e ícones

O visual usa variáveis CSS, então dá pra ajustar cor, raio de borda etc. sem tocar no arquivo do pacote — basta sobrescrever no CSS do seu próprio site:

:root {
    --ga-audit-accent: #7c3aed;   /* cor de destaque (botões, foco, paginação) */
    --ga-audit-radius: 4px;       /* cantos do modal */
}

Os ícones (lupa da busca, setas da paginação, "x" de fechar) são caracteres Unicode simples por padrão — leves e sem depender de nenhuma fonte de ícone externa. Se preferir usar SVG ou outra fonte de ícones, sobrescreva antes de abrir o primeiro modal:

<script src="{{ asset('assets/js/audit-table.init.js') }}"></script>
<script>
    GaAudit.icons.close = '<svg width="14" height="14">...</svg>';
</script>

Sobre o idioma dos textos

Os textos fixos da interface (rótulos "Ação:", "Linhas:", "Buscar:", cabeçalhos de coluna, mensagens como "Carregando…" ou "Nenhum resultado encontrado") estão em português, fixos no arquivo. Só o title do modal é customizável hoje (veja o Passo 4 acima). Se seu projeto precisa desses textos em outro idioma, por enquanto a forma de fazer isso é editar o arquivo publicado diretamente — ele é só JavaScript comum, sem etapa de build. Tornar esses textos configuráveis é algo que pode entrar em uma versão futura do pacote.

Sobrevivendo a um hard delete: o retrato de restauro

A tabela de auditoria é append-only e imutável — de propósito, ela não usa soft delete. Um registro de auditoria que pode ser apagado deixa de servir para auditar quem apaga coisas. O que precisa de proteção é o dado de negócio, e a proteção é outra.

No evento deleted, além do changes legível, o pacote grava em debug_info['restore'] um retrato integral e cru do registro — todos os campos e valores, ignorando as restrições de only()/except() do log legível. É esse retrato que permite reconstruir a linha mesmo depois de um hard delete (sem SoftDeletes), em que a linha some de verdade da tabela de origem.

// Alguém deu um hard delete num Produto. A linha sumiu — mas a auditoria guardou.
$audit = Produto::auditsFor($id)->action('deleted')->latest()->first();

$audit->isRestorable();   // true, se o retrato foi gravado
$produto = $audit->restore();   // a linha VOLTA à tabela original, com o id original

O retrato é cru (valores e FKs como eram), então o registro volta idêntico, incluindo o id. Duas garantias importantes:

  • Segredos não voltam. Campos em neverSnapshot (por padrão password, remember_token) nunca entram no retrato — nem para restaurar. Voltam nulos; trate-os no seu fluxo se preciso.
  • changes continua legível. O retrato de restauro é técnico e vai para debug_info (do dev). O changes do delete continua sendo o snapshot legível, para humanos. Uma preocupação para leitura, outra para reconstrução — separadas.
// Restaurar deixando o banco atribuir um id novo (evita conflito se o id foi reusado)
$produto = $audit->restore(withId: false);

Ligado por padrão. Se um model tiver campos volumosos que você não quer duplicar na auditoria, desligue por model:

public function getAuditOptions(): AuditOptions
{
    return AuditOptions::defaults()->fullSnapshotOnDelete(false);
}

Ou globalmente, em config/auditable.php, no bloco restore.

Auditando falhas (o debug que só o dev vê)

Quando uma operação pode falhar e você quer registrar por que falhou, use auditFailure() dentro do catch. Ele separa duas coisas:

  • changes — uma mensagem amigável, que o usuário pode ver.
  • debug_info — stack trace, SQL, request, ambiente. Só para o desenvolvedor.
try {
    $fatura->update($dados);
} catch (\Throwable $e) {
    $fatura->auditFailure('fatura_update', $e, [
        'payload' => $dados,   // contexto extra que ajuda a investigar
    ]);

    throw $e;   // relança — auditar não engole o erro
}

Depois, para investigar:

$falha = Fatura::auditsFor($id)->failures()->latest()->first();

$falha->changes;      // ['message' => 'A operação falhou.', 'error' => '...']
$falha->debug_info;   // trace, sql, request, ambiente — tudo o que você precisa

O debug_info traz driver e nome do banco, mas nunca host ou credenciais. Detalhes de servidor só aparecem fora de produção.

Usuário padrão para ações de sistema

Por padrão, quando uma auditoria é registrada, o campo created_by guarda o ID do usuário que está logado no momento. Mas o que acontece quando não tem ninguém logado?

Exemplos de situações sem usuário logado:

  • Comandos do Artisan rodando no terminal (php artisan db:seed)
  • Jobs na fila (Redis, SQS, etc.)
  • Agendamentos do Cron (php artisan schedule:run)
  • Webhooks recebendo requisições de sistemas externos

Nestes casos, o created_by ficaria vazio (NULL). Para resolver isso, o pacote permite definir um usuário padrão que será usado como fallback.

Como configurar

Passo 1 - No arquivo .env, defina o ID do usuário que será usado como padrão:

# .env
AUDITABLE_DEFAULT_created_by=1

**Passo 2** - Se preferir, defina diretamente no config/auditable.php:

```php
// // config/auditable.php
'default_created_by' => env('AUDITABLE_DEFAULT_created_by', 1),

Mas por padrão o pacote no config ja defini o null para o usuário padrão como fallback.

// config/auditable.php
'default_created_by' => env('AUDITABLE_DEFAULT_created_by', null), // ID NULL como fallback

## Multitenancy (opcional)

Se você tem um SaaS, há **dois cenários**. Escolha o seu:

### Cenário A — cada tenant tem seu próprio banco

Usa `stancl/tenancy`, `spatie/laravel-multitenancy` em modo multi-banco, ou
similar? **Você não precisa fazer nada.** Quando o seu pacote de tenancy troca a
conexão, a auditoria vai junto para o banco certo. Isolamento automático.

Se quiser forçar uma conexão específica para a auditoria:

```php
// config/auditable.php
'connection' => 'tenant',

Cenário B — um banco só, com coluna tenant_id

Todos os tenants no mesmo banco, separados por uma coluna? Ative o modo por coluna e diga ao pacote como descobrir o tenant atual:

// config/auditable.php
'tenant' => [
    'enabled'  => true,
    'column'   => 'tenant_id',
    'resolver' => fn () => auth()->user()?->tenant_id,   // ajuste à sua realidade
],

Depois, use o trait BelongsToTenant nos models que devem ser isolados:

use Gsebastiao\Auditable\Concerns\Auditable;
use Gsebastiao\Auditable\Concerns\BelongsToTenant;

class Produto extends Model
{
    use Auditable;
    use BelongsToTenant;   // filtra por tenant e preenche tenant_id sozinho
}

A partir daí, cada tenant só enxerga os próprios dados — e a auditoria de um tenant nunca vaza para outro.

Regra do pacote: ele qual é o tenant atual, nunca decide. Quem decide é a sua app ou o seu pacote de tenancy. Por isso o resolver é seu.

Personalização avançada

Trocar onde/como a auditoria é gravada (fila, serviço externo…)

Cada peça do pacote é uma interface com implementação padrão. Para trocar, religue no seu AppServiceProvider:

Interface O que faz Padrão
AuditRepository Persiste a auditoria Grava via Eloquent
BatchIdGenerator Agrupa operações relacionadas ULID
ContextResolver Descobre usuário e tenant atuais auth() + seu resolver
use Gsebastiao\Auditable\Contracts\AuditRepository;

public function register(): void
{
    $this->app->bind(AuditRepository::class, MinhaAuditoriaNaFila::class);
}
Usar seu próprio model de auditoria (outra tabela, relações extras…)
use Gsebastiao\Auditable\Models\Audit as BaseAudit;

class Audit extends BaseAudit
{
    // suas relações, scopes, accessors…
}
// config/auditable.php
'model' => App\Models\Audit::class,
Ligar/desligar auditoria globalmente (testes, seeders…)
// config/auditable.php
'enabled' => env('AUDITABLE_ENABLED', true),
# .env.testing
AUDITABLE_ENABLED=false

Referência rápida

// No model
use Gsebastiao\Auditable\Concerns\Auditable;          // torna auditável
use Gsebastiao\Auditable\Concerns\BelongsToTenant;    // isolamento por tenant (opcional)

// Nas opções (getAuditOptions)
AuditOptions::defaults()
    ->resolveMap([...])   // traduz FKs
    ->except([...])       // ignora campos
    ->only([...])         // ou: só estes campos
    ->events([...])       // quais eventos auditar
    ->onlyDirty()         // só o que mudou
    ->logEmpty(false);    // pular logs vazios

// Modos de tradução
ResolveMap::direct(label, table, column);   // FK → tabela
ResolveMap::join([...]);                    // por tabelas intermediárias
ResolveMap::alias(label);                   // só renomear

// Registrar (além dos eventos automáticos)
$model->auditAction('aprovado', [...]);            // ação de domínio nomeada
$model->auditFailure('op', $exception, [...]);     // falha com debug técnico

// Consultar histórico
$model->audits;                             // do model já carregado
Model::auditsFor($id);                      // por id (query builder)
    ->action('aprovado')                    // filtros encadeáveis:
    ->byUser($userId)
    ->failures()
    ->inBatch($batch);

// Colunas de auditoria numa listagem (DataTable) — sem N+1
use Gsebastiao\Auditable\Support\AuditColumnJoiner;
AuditColumnJoiner::apply($query, Model::class);                      // created_/updated_ by/at
AuditColumnJoiner::apply($query, Model::class, actions: ['deleted']); // p/ grelha de lixeira
AuditColumnJoiner::apply($query, Model::class, userColumn: 'email');  // "quem" por email

// Restaurar um registro após HARD delete (retrato em debug_info['restore'])
$audit = Model::auditsFor($id)->action('deleted')->latest()->first();
$audit->isRestorable();                     // tem retrato de restauro?
$audit->restore();                          // reconstrói com o id original
$audit->restore(withId: false);             // reconstrói com id novo

// Operações multi-tabela (um batch costura tudo)
use Gsebastiao\Auditable\Audit;
Audit::transaction(fn () => /* várias escritas */);   // transação + batch juntos
Audit::batch(fn () => /* várias escritas */);         // só o batch, sem transação
$model->operation()->get();                 // toda a operação, a partir de 1 registro
Model::operationFor($id)->get();            // idem, só com o id
$model->batchOf();                          // só o id do batch
Audit::currentBatch();                      // batch aberto (p/ propagar a filas)
Audit::useBatch($batchId);                  // reabrir batch (dentro de uma job)
# Widget JS opcional (modal de histórico pronto) — veja "Widget JS: um modal
# de histórico pronto" acima. Publica audit-table.init.js em public/:
php artisan auditable:publish-js
php artisan auditable:publish-js --path=outro/caminho   # só nesta execução
php artisan auditable:publish-js --force                # sobrescreve o já publicado

Licença

MIT. Use à vontade.

🇬🇧 English

Installation

composer require gsebastiao/laravel-auditable

Publish the config and migration, then run the migration:

php artisan vendor:publish --tag=auditable-config
php artisan vendor:publish --tag=auditable-migrations
php artisan migrate

That's it. Nothing else is required.

Getting started (2 minutes)

Step 1 — Add the Auditable trait to any model:

use Illuminate\Database\Eloquent\Model;
use Gsebastiao\Auditable\Concerns\Auditable;

class Product extends Model
{
    use Auditable;
}

That's all you need to start. From now on, create, update and delete on this model are audited automatically:

$product = Product::create(['name' => 'Coffee', 'price' => 20]);
$product->update(['price' => 25]);

Step 2 — Read the history whenever you want:

$product->audits;   // collection with the full history of the record

Each entry carries the event (created/updated/deleted), what changed, who did it, and when. No further setup.

Turning IDs into names (the whole point)

If your model has foreign keys, tell Auditable how to turn them into readable text. You do that by adding one method to the model:

use Gsebastiao\Auditable\Support\AuditOptions;
use Gsebastiao\Auditable\Support\ResolveMap;

class Product extends Model
{
    use Auditable;

    public function getAuditOptions(): AuditOptions
    {
        return AuditOptions::defaults()->resolveMap([

            // status_id: look up the name in the "statuses" table
            'status_id' => ResolveMap::direct(
                label:  'Status',     // how it shows in the log
                table:  'statuses',   // where to look
                column: 'name',       // which column is the text
            ),

        ]);
    }
}

Now, instead of status_id: 2 → 5, the log records Status: "Active" → "Blocked".

The three translation modes

Mode When to use Example
direct The FK points straight to a table with the name status_idstatuses table
join You need to walk through intermediate tables state_idstatescountries
alias Not an FK, you just want to rename the field active → "Status"
See join and alias examples
AuditOptions::defaults()->resolveMap([

    // JOIN: resolve the country name from state_id,
    // walking states → countries
    'state_id' => ResolveMap::join([
        ['table' => 'states', 'key' => 'id'],
        ['table' => 'countries',
         'on'     => ['countries.id', '=', 'states.country_id'],
         'column' => 'name',
         'label'  => 'Country'],
    ]),

    // ALIAS: a boolean field that just needs a nice label in the log
    'active' => ResolveMap::alias('Status'),

]);

Choosing what to audit

The same getAuditOptions() controls the rest. Everything is optional:

AuditOptions::defaults()
    ->except(['updated_at', 'secret'])   // never audit these fields
    ->only(['price', 'status_id'])       // OR: audit only these
    ->events(['updated', 'deleted'])     // skip "created"
    ->onlyDirty()                        // only record what actually changed (default)
    ->logEmpty(false);                   // don't record if nothing changed (default)

Passwords and tokens (password, remember_token) are ignored by default.

Multi-table operations: one batch, one story

This is the scenario that ties everything together. You create an order — and along with it come the customer, the line items, a stock decrement. These are writes across different tables, but they're part of the same operation. You want to look at any one of them later and reconstruct the whole thing.

Wrap the operation in Audit::transaction() (or Audit::batch() if you don't want a transaction). Everything audited inside — from any model — gets the same batch:

use Gsebastiao\Auditable\Audit;

Audit::transaction(function () use ($data) {
    $customer = Customer::create($data['customer']);
    $order    = Order::create(['customer_id' => $customer->id, ...]);

    foreach ($data['items'] as $item) {
        Item::create(['order_id' => $order->id, ...]);
    }
});

The customer, the order and all items are recorded under a single batch. And because it's a transaction, if any part fails, everything rolls back — writes and audit trail together.

Recovering the whole operation from a single record

Now the part you described: you have just the customer and want to see everything that came in with it. Call operation():

$customer = Customer::find($id);

$customer->operation()->get();
// -> returns the audits for the customer, the order AND the items
//    (everything that shared the batch)

Or, if you only have the id:

Customer::operationFor($id)->get();

Since the result is a normal query builder, group by table to display it:

$customer->operation()->get()->groupBy('subject_type');
// [
//   'App\Models\Customer' => [ ... ],
//   'App\Models\Order'    => [ ... ],
//   'App\Models\Item'     => [ ... ],
// ]

Just need the batch id? $customer->batchOf() returns the identifier of that record's latest operation — handy for logs or passing along.

Propagating the batch to queues

If part of the operation runs in an async job and you want it in the same batch, pass the id to the job and reopen it there:

// When dispatching:
ProcessOrder::dispatch($order, Audit::currentBatch());

// Inside the job:
public function handle(): void
{
    Audit::useBatch($this->batchId);
    // everything audited here joins the original operation's batch
}

Custom actions (beyond create/update/delete)

The three automatic events cover database writes. But not everything you want to audit is a write — "approved the order", "resent the email", "logged in", "exported". For those, call auditAction() with whatever name you want:

$order->auditAction('approved');

$order->auditAction('email_resent', [
    'to'  => $customer->email,
    'via' => 'ses',
]);

It lands in the same history as the automatic events, under the name you gave.

Querying a specific record's history

$product->audits gives you the history of a model you already loaded. When you only have the id, or want to filter, use auditsFor() — it returns a query builder:

// Everything for record 42, without loading the Product
Product::auditsFor(42)->get();

// Only approvals
Product::auditsFor(42)->action('approved')->get();

// The last change made by a user
Product::auditsFor(42)->byUser($userId)->latest()->first();

// Only failures for this record
Product::auditsFor(42)->failures()->get();

Available filters: action(), byUser(), failures(), inBatch().

Audit columns in a listing (DataTable)

The relations above answer "what is this record's history?". A grid asks a different question about many records at once: "for each row on this page, who created it and when? who last changed it and when?". Answering that with the relation would be an N+1 — one audit query per displayed row.

AuditColumnJoiner solves it differently: it attaches audit_created_by, audit_created_at, audit_updated_by, audit_updated_at as columns on the query itself, via LEFT JOINs of aggregated subqueries. One query, no N+1, ready for the DataTable to sort and paginate.

use Gsebastiao\Auditable\Support\AuditColumnJoiner;

// On your listing query:
$query = Product::query()->where('active', 1);

AuditColumnJoiner::apply($query, Product::class);
// each row now carries: audit_created_by, audit_created_at, audit_updated_by, audit_updated_at

Why the audit_ prefix? Because created_at, updated_at and deleted_at are native Eloquent columns with automatic datetime casting. If we emitted a column named created_at, it would collide with the table's native one and Eloquent would try to cast the already-formatted string (2026-07-10 14:30) — and break. Prefixing every column at the root removes the collision entirely and, as a bonus, keeps the _by/_at pair consistent across all actions — no exceptions, no special suffixes. Every action comes out the same: audit_restored_by/audit_restored_at, audit_approved_by/audit_approved_at. The prefix is configurable (prefix: param or config('auditable.column_prefix')).

Why only created and updated by default? In a normal grid of a model with SoftDeletes, the global scope already hides deleted rows — so an audit_deleted_by column would always be empty, costing two JOINs per row for nothing. Only include deleted/restored when the grid itself is a trash bin:

// Trash-bin grid: here "who deleted / when" makes sense
AuditColumnJoiner::apply(
    Product::onlyTrashed(),
    Product::class,
    actions: ['deleted', 'restored'],
);

// Show by email instead of name
AuditColumnJoiner::apply($query, Product::class, userColumn: 'email');

// Domain actions become columns too (the latest occurrence)
AuditColumnJoiner::apply($query, Product::class, actions: ['created', 'approved']);
// → audit_approved_by, audit_approved_at

Each action = two LEFT JOINs (the audit subquery + the users table). Ask only for what the grid will show. Recommended index on the audit table: (subject_type, event, subject_id, id).

Which to use: MANY rows, one summary per row → AuditColumnJoiner. ONE row, the whole history → $model->audits / auditsFor().

JS widget: a ready-made history modal (100% OPTIONAL)

This is entirely optional. Everything you've read so far — recording audits, querying $model->audits, building columns with AuditColumnJoiner — works 100% without anything below. This section exists only for people who don't want to write HTML/CSS/JS from scratch to show that history on a screen. If you'd rather build your own interface (or already have one), you can skip this entire section without losing any of the package's functionality.

What it is

A single JavaScript file (audit-table.init.js) that opens a modal (popup) showing a record's history when you click some button on your page. It:

  • Has zero dependencies. No jQuery, no Bootstrap, no DataTables. Just one <script> tag — the modal's HTML, CSS, and behavior (search, filter, pagination) are all generated by the file itself, in real time, when you open the modal.
  • Never clashes with your site's look. All injected CSS uses exclusive class names, always prefixed with ga-audit- (e.g. ga-audit-modal, ga-audit-table). It never uses generic names like .modal or .table — exactly the names frameworks like Bootstrap or AdminLTE already use — so there's no risk of your template's CSS leaking into the modal, or the other way around.
  • Works on any screen size. On mobile, the modal takes up the whole screen (easier to use with a finger); on larger screens, it shows up centered like a regular popup.

The two modals

The file registers a global object called GaAudit, with two independent widgets. Use one, the other, or both — they're built for different audiences:

Widget For whom What it shows
GaAudit.full Auditors/admins with elevated permissions Full history: search, filter by action, pagination, changes grouped by batch
GaAudit.simple Any regular user A short, direct list: what, who, when — no filters

Step 1 — Publish the file

The file already ships inside the package (at vendor/gsebastiao/laravel-auditable/src/plugin/audit-table.init.js), but the browser can only reach files that live inside your Laravel project's public/ folder. That's what this command is for — it copies the file there:

php artisan auditable:publish-js

By default, this creates the file at public/assets/js/audit-table.init.js.

Want to publish somewhere else? Two ways:

# Just for this one run (doesn't change anything permanently):
php artisan auditable:publish-js --path=js/vendor/audit

# Permanently, by editing the published config (config/auditable.php):
'js' => [
    'publish_path' => 'js/vendor/audit',
],

Updating the package and want the latest version of the JS file? Run it again with --force to overwrite what's already published:

php artisan auditable:publish-js --force

Alternative: if your project already uses a bundler (Vite, Mix, Webpack…) and you'd rather have audit-table.init.js go through the SAME build pipeline as the rest of your JS, skip the command above and just copy the file from vendor/gsebastiao/laravel-auditable/src/plugin/ into your assets folder (e.g. resources/js/vendor/), then import it normally.

Step 2 — Include it on the page

In your Blade layout (e.g. resources/views/layouts/app.blade.php), right before </body>:

<script src="{{ asset('assets/js/audit-table.init.js') }}"></script>

(Swap assets/js for whatever path you chose in Step 1, if you changed the default.)

Step 3 — Create the route that feeds the modal

The JS widget knows nothing about your database — it only knows how to make a POST request to a URL you give it, and expects a JSON response back in a specific shape. A regular Laravel route builds that response, calling the same package methods you've already seen throughout this README.

For the GaAudit.full modal (full history, grouped by batch):

// routes/web.php
use App\Models\Product;
use Illuminate\Http\Request;

Route::post('/audit/readGrouped', function (Request $request) {
    $groups = Product::operationFor($request->input('id'))
        ->get()
        ->groupBy('batch')
        ->map(fn ($actions, $batchId) => [
            'batch_id' => $batchId,
            'actions' => $actions->map(fn ($audit) => [
                'action' => $audit->event,
                'created_by' => User::find($audit->created_by, ['name'])->name,
                'type' => $audit->is_failure ? 'failed' : 'success',
                'created_at' => $audit->created_at->format('Y-m-d H:i'),
                'changes' => $audit->changes,
            ]),
        ])
        ->values();

    return response()->json([
        'record_id' => $request->input('id'),
        'groups' => $groups,
    ]);
})->middleware('auth'); // protect with auditor-level permissions

For the GaAudit.simple modal (short list, no grouping):

// routes/web.php
use App\Models\Product;
use Illuminate\Http\Request;

Route::post('/audit/read', function (Request $request) {
    $audits = Product::auditsFor($request->input('id'))
        ->latest()
        ->limit(50)
        ->get()
        ->map(fn ($audit) => [
            'action' => $audit->event,
            'created_by' => User::find($audit->created_by, ['name'])->name,
            'created_at' => $audit->created_at->format('Y-m-d H:i'),
        ]);

    return response()->json(['audits' => $audits]);
})->middleware('auth');

The route names above (/audit/readGrouped, /audit/read) are just suggestions — use whatever names and middleware make sense for your project. What matters is the JSON response shape, not the URL itself. Feel free to use a regular Controller instead of a route Closure.

Step 4 — Open the modal

Two ways, your choice:

Option A — data-* attributes (no JS to write at all):

<button
    data-ga-audit="full"
    data-ga-audit-id="{{ $product->id }}"
    data-ga-audit-url="/audit/readGrouped">
    View full history
</button>

<button
    data-ga-audit="simple"
    data-ga-audit-id="{{ $product->id }}"
    data-ga-audit-url="/audit/read">
    View history
</button>

The widget already listens for clicks on any element carrying data-ga-audit on the page — nothing to register manually.

Option B — calling it from JavaScript (more control):

document.getElementById('myButton').addEventListener('click', function () {
    GaAudit.full.open({
        endpoint: '/audit/readGrouped',
        id: 42,
        title: 'History for Product #42', // optional
    });
});

title is optional in both forms. Without it, GaAudit.full defaults to "Histórico de Auditoria" and GaAudit.simple defaults to "Auditoria do Registro" — pass title (or data-ga-audit-title in Option A) only when you want different text.

The modal needs no HTML on the page

Unlike a traditional Bootstrap modal, you do not need to leave a hidden <div id="someModal">...</div> somewhere in your layout. The widget builds the modal's entire HTML in memory when you open it, and removes it completely when you close it. That's what "integrating the modal into the plugin" means in practice: zero external HTML, zero layout setup, works on any page where the <script> tag is included.

Customizing colors and icons

The look uses CSS variables, so you can tweak accent color, border radius, etc. without touching the package file at all — just override them in your own site's CSS:

:root {
    --ga-audit-accent: #7c3aed;   /* accent color (buttons, focus ring, pagination) */
    --ga-audit-radius: 4px;       /* modal corners */
}

Icons (search magnifier, pagination arrows, close "x") are simple Unicode characters by default — lightweight, with no dependency on any external icon font. If you'd rather use SVGs or another icon set, override them before opening the first modal:

<script src="{{ asset('assets/js/audit-table.init.js') }}"></script>
<script>
    GaAudit.icons.close = '<svg width="14" height="14">...</svg>';
</script>

A note on the UI language

The interface's fixed text (labels like "Ação:", "Linhas:", "Buscar:", column headers, messages like "Carregando…" or "Nenhum resultado encontrado") is in Portuguese, hardcoded in the file. Only the modal's title is customizable today (see Step 4 above). If your project needs these strings in another language, for now the way to do it is to edit the published file directly — it's plain JavaScript, no build step involved. Making these strings configurable is something that may land in a future version of the package.

Surviving a hard delete: the restore snapshot

The audit table is append-only and immutable — by design, it does not use soft delete. An audit record that can be deleted stops being useful for auditing who deletes things. What needs protecting is the business data, and that protection is separate.

On the deleted event, besides the readable changes, the package writes a full raw snapshot of the record into debug_info['restore'] — every field and value, ignoring the only()/except() restrictions of the readable log. That snapshot is what lets you rebuild the row even after a hard delete (no SoftDeletes), where the row truly disappears from the source table.

// Someone hard-deleted a Product. The row is gone — but the audit kept it.
$audit = Product::auditsFor($id)->action('deleted')->latest()->first();

$audit->isRestorable();   // true, if the snapshot was written
$product = $audit->restore();   // the row COMES BACK, with its original id

The snapshot is raw (values and FKs as they were), so the record returns identical, id included. Two important guarantees:

  • Secrets don't come back. Fields in neverSnapshot (by default password, remember_token) never enter the snapshot — not even to restore. They return null; handle them in your flow if needed.
  • changes stays readable. The restore snapshot is technical and goes to debug_info (dev-facing). The delete's changes remains the readable snapshot, for humans. One concern for reading, another for rebuilding — kept apart.
// Restore letting the DB assign a fresh id (avoids conflict if the id was reused)
$product = $audit->restore(withId: false);

On by default. If a model has bulky fields you don't want duplicated into the audit, turn it off per model:

public function getAuditOptions(): AuditOptions
{
    return AuditOptions::defaults()->fullSnapshotOnDelete(false);
}

Or globally, in config/auditable.php, under the restore block.

Auditing failures (the debug only the dev sees)

When an operation can fail and you want to record why, use auditFailure() inside the catch. It separates two things:

  • changes — a friendly message, which the user can see.
  • debug_info — stack trace, SQL, request, environment. Developer only.
try {
    $invoice->update($data);
} catch (\Throwable $e) {
    $invoice->auditFailure('invoice_update', $e, [
        'payload' => $data,   // extra context that helps you investigate
    ]);

    throw $e;   // rethrow — auditing doesn't swallow the error
}

Then, to investigate:

$failure = Invoice::auditsFor($id)->failures()->latest()->first();

$failure->changes;      // ['message' => 'The operation failed.', 'error' => '...']
$failure->debug_info;   // trace, sql, request, environment — everything you need

debug_info includes the driver and database name, but never host or credentials. Server details only show outside production.

Default user for system actions

When an audit is triggered by a console command, a queued job, a seeder, or any other context without an authenticated user, created_by would stay empty. For these cases, you can set a default user that will be used as a fallback:

// config/auditable.php
'default_created_by' => env('AUDITABLE_DEFAULT_created_by', null),

## Multitenancy (optional)

If you run a SaaS, there are **two scenarios**. Pick yours:

### Scenario A — each tenant has its own database

Using `stancl/tenancy`, `spatie/laravel-multitenancy` in multi-database mode, or
similar? **You don't need to do anything.** When your tenancy package switches the
connection, auditing follows to the right database. Isolation is automatic.

To force a specific connection for auditing:

```php
// config/auditable.php
'connection' => 'tenant',

Scenario B — one database, with a tenant_id column

All tenants in the same database, separated by a column? Enable column mode and tell the package how to find the current tenant:

// config/auditable.php
'tenant' => [
    'enabled'  => true,
    'column'   => 'tenant_id',
    'resolver' => fn () => auth()->user()?->tenant_id,   // adjust to your setup
],

Then use the BelongsToTenant trait on the models that must be isolated:

use Gsebastiao\Auditable\Concerns\Auditable;
use Gsebastiao\Auditable\Concerns\BelongsToTenant;

class Product extends Model
{
    use Auditable;
    use BelongsToTenant;   // filters by tenant and fills tenant_id on its own
}

From then on, each tenant only sees its own data — and one tenant's audit trail never leaks into another's.

Package rule: it reads which tenant is current, it never decides. Your app or your tenancy package decides. That's why the resolver is yours.

Advanced customization

Change where/how audits are stored (queue, external service…)

Every piece of the package is an interface with a default implementation. To swap one, rebind it in your AppServiceProvider:

Interface What it does Default
AuditRepository Persists the audit entry Writes via Eloquent
BatchIdGenerator Groups related operations ULID
ContextResolver Finds current user and tenant auth() + your resolver
use Gsebastiao\Auditable\Contracts\AuditRepository;

public function register(): void
{
    $this->app->bind(AuditRepository::class, MyQueuedAudit::class);
}
Use your own audit model (other table, extra relations…)
use Gsebastiao\Auditable\Models\Audit as BaseAudit;

class Audit extends BaseAudit
{
    // your relations, scopes, accessors…
}
// config/auditable.php
'model' => App\Models\Audit::class,
Toggle auditing globally (tests, seeders…)
// config/auditable.php
'enabled' => env('AUDITABLE_ENABLED', true),
# .env.testing
AUDITABLE_ENABLED=false

Quick reference

// On the model
use Gsebastiao\Auditable\Concerns\Auditable;          // makes it auditable
use Gsebastiao\Auditable\Concerns\BelongsToTenant;    // per-tenant isolation (optional)

// In the options (getAuditOptions)
AuditOptions::defaults()
    ->resolveMap([...])   // translate FKs
    ->except([...])       // ignore fields
    ->only([...])         // or: only these fields
    ->events([...])       // which events to audit
    ->onlyDirty()         // only what changed
    ->logEmpty(false);    // skip empty logs

// Translation modes
ResolveMap::direct(label, table, column);   // FK → table
ResolveMap::join([...]);                    // through intermediate tables
ResolveMap::alias(label);                   // just rename

// Record (beyond the automatic events)
$model->auditAction('approved', [...]);            // named domain action
$model->auditFailure('op', $exception, [...]);     // failure with tech debug

// Read history
$model->audits;                             // of the already-loaded model
Model::auditsFor($id);                      // by id (query builder)
    ->action('approved')                    // chainable filters:
    ->byUser($userId)
    ->failures()
    ->inBatch($batch);

// Audit columns in a listing (DataTable) — no N+1
use Gsebastiao\Auditable\Support\AuditColumnJoiner;
AuditColumnJoiner::apply($query, Model::class);                      // created_/updated_ by/at
AuditColumnJoiner::apply($query, Model::class, actions: ['deleted']); // for a trash-bin grid
AuditColumnJoiner::apply($query, Model::class, userColumn: 'email');  // "who" by email

// Restore a record after a HARD delete (snapshot in debug_info['restore'])
$audit = Model::auditsFor($id)->action('deleted')->latest()->first();
$audit->isRestorable();                     // has a restore snapshot?
$audit->restore();                          // rebuild with the original id
$audit->restore(withId: false);             // rebuild with a fresh id

// Multi-table operations (one batch ties it all)
use Gsebastiao\Auditable\Audit;
Audit::transaction(fn () => /* several writes */);   // transaction + batch together
Audit::batch(fn () => /* several writes */);         // batch only, no transaction
$model->operation()->get();                 // the whole operation, from 1 record
Model::operationFor($id)->get();            // same, with just the id
$model->batchOf();                          // just the batch id
Audit::currentBatch();                      // open batch (to propagate to queues)
Audit::useBatch($batchId);                  // reopen batch (inside a job)
# Optional JS widget (ready-made history modal) — see "JS widget: a
# ready-made history modal" above. Publishes audit-table.init.js to public/:
php artisan auditable:publish-js
php artisan auditable:publish-js --path=some/other/path   # this run only
php artisan auditable:publish-js --force                  # overwrite what's published

License

MIT. Use it freely.