PHPackages                             gsebastiao/laravel-auditable - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. gsebastiao/laravel-auditable

ActiveLibrary[Database &amp; ORM](/categories/database)

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.

v1.3.0(1w ago)05↓16.7%MITPHPPHP ^8.2

Since Jul 11Pushed 1w agoCompare

[ Source](https://github.com/gsebastiao/laravel-auditable)[ Packagist](https://packagist.org/packages/gsebastiao/laravel-auditable)[ RSS](/packages/gsebastiao-laravel-auditable/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (15)Versions (5)Used By (0)

Laravel Audit Table
===================

[](#laravel-audit-table)

**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](#-português) • [🇬🇧 English](#-english)

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

---

O problema que ele resolve
--------------------------

[](#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
============

[](#-português)

Instalação
----------

[](#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)
---------------------

[](#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)
-----------------------------------------

[](#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

[](#os-três-modos-de-tradução)

ModoQuando usarExemplo`direct`A FK aponta direto para uma tabela com o nome`status_id` → tabela `status``join`Precisa navegar por tabelas intermediárias`estado_id` → `estados` → `paises``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
------------------------

[](#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
----------------------------------------------

[](#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

[](#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

[](#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)
-------------------------------------------------

[](#ações-customizadas-além-de-createupdatedelete)

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
-----------------------------------------------

[](#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)
----------------------------------------------

[](#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)
-------------------------------------------------------

[](#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 é

[](#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 `` 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

[](#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:

WidgetPra quemO que mostra`GaAudit.full`Quem tem permissão de auditor/adminHistórico completo: busca, filtro por ação, paginação, alterações agrupadas por batch`GaAudit.simple`Qualquer usuário do sistemaLista direta e enxuta: o quê, quem, quando — sem filtros### Passo 1 — Publicar o arquivo

[](#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

[](#passo-2--incluir-na-página)

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

```

```

(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

[](#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

[](#passo-4--abrir-o-modal)

Duas formas, à sua escolha:

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

```

    Ver histórico completo

    Ver histórico

```

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

[](#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 `...` 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 `` esteja incluído.

### Customizando cores e ícones

[](#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:

```

    GaAudit.icons.close = '...';

```

### Sobre o idioma dos textos

[](#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
----------------------------------------------------

[](#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ê)
------------------------------------------

[](#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
------------------------------------

[](#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

[](#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`

[](#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 **lê** 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
-----------------------

[](#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`:

InterfaceO que fazPadrão`AuditRepository`Persiste a auditoriaGrava via Eloquent`BatchIdGenerator`Agrupa operações relacionadasULID`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
-----------------

[](#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
-------

[](#licença)

MIT. Use à vontade.

---

🇬🇧 English
==========

[](#-english)

Installation
------------

[](#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)
---------------------------

[](#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)
----------------------------------------

[](#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

[](#the-three-translation-modes)

ModeWhen to useExample`direct`The FK points straight to a table with the name`status_id` → `statuses` table`join`You need to walk through intermediate tables`state_id` → `states` → `countries``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
----------------------

[](#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
--------------------------------------------

[](#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

[](#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

[](#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)
--------------------------------------------

[](#custom-actions-beyond-createupdatedelete)

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
------------------------------------

[](#querying-a-specific-records-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)
--------------------------------------

[](#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 JOIN`s 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 `JOIN`s 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 JOIN`s (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)
-----------------------------------------------------

[](#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

[](#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 `` 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-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:

WidgetFor whomWhat it shows`GaAudit.full`Auditors/admins with elevated permissionsFull history: search, filter by action, pagination, changes grouped by batch`GaAudit.simple`Any regular userA short, direct list: what, who, when — no filters### Step 1 — Publish the file

[](#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

[](#step-2--include-it-on-the-page)

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

```

```

(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

[](#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

[](#step-4--open-the-modal)

Two ways, your choice:

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

```

    View full history

    View history

```

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

[](#the-modal-needs-no-html-on-the-page)

Unlike a traditional Bootstrap modal, you do **not** need to leave a hidden `...` 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 `` tag is included.

### Customizing colors and icons

[](#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:

```

    GaAudit.icons.close = '...';

```

### A note on the UI language

[](#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
---------------------------------------------

[](#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)
-----------------------------------------------

[](#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
-------------------------------

[](#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

[](#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
----------------------

[](#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`:

InterfaceWhat it doesDefault`AuditRepository`Persists the audit entryWrites via Eloquent`BatchIdGenerator`Groups related operationsULID`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
---------------

[](#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
-------

[](#license)

MIT. Use it freely.

###  Health Score

42

—

FairBetter than 88% of packages

Maintenance98

Actively maintained with recent releases

Popularity5

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity49

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~12 days

Total

4

Last Release

10d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/aab336e4460b96481b6b91cc9b0b101959b827f766ab3ac08b77496b9076b32f?d=identicon)[gsebastiao](/maintainers/gsebastiao)

---

Top Contributors

[![gsebastiao](https://avatars.githubusercontent.com/u/246223043?v=4)](https://github.com/gsebastiao "gsebastiao (4 commits)")

---

Tags

laraveleloquentAuditauditingsaasmultitenancyaudit-trailactivity-log

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/gsebastiao-laravel-auditable/health.svg)

```
[![Health](https://phpackages.com/badges/gsebastiao-laravel-auditable/health.svg)](https://phpackages.com/packages/gsebastiao-laravel-auditable)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.4M354](/packages/psalm-plugin-laravel)[laravel/ai

The official AI SDK for Laravel.

1.1k4.6M341](/packages/laravel-ai)[api-platform/laravel

API Platform support for Laravel

58190.1k21](/packages/api-platform-laravel)[pressbooks/pressbooks

Pressbooks is an open source book publishing tool built on a WordPress multisite platform. Pressbooks outputs books in multiple formats, including PDF, EPUB, web, and a variety of XML flavours, using a theming/templating system, driven by CSS.

45844.8k1](/packages/pressbooks-pressbooks)[simplestats-io/laravel-client

Server-side analytics for Laravel that follows the full funnel from visit to registration to payment, attributed to the channel that drove it. Revenue, MRR, churn and ad-spend profit (ROAS/CAC) per channel. GDPR compliant, ad-blocker proof.

5226.7k](/packages/simplestats-io-laravel-client)[romalytar/yammi-audit-log-laravel

Universal change history and audit log for Laravel. Tracks who changed what and when across Eloquent models, with rich actor attribution (user, job, command, scheduler), field-level diffs, human-readable relationship labels and a timeline dashboard.

631.6k](/packages/romalytar-yammi-audit-log-laravel)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
