laravel-auditable maintained by gsebastiao
Laravel Auditable
Guarde automaticamente quem mudou o quê e quando nos seus models
Eloquent — com nomes legíveis (Status: Ativo → Bloqueado) em vez de ids
soltos (status_id: 1 → 3).
Você adiciona uma linha ao model e, a partir daí, cada create(), update()
e delete() fica registrado. Com o histórico gravado, dá para montar telas
como esta:
| Quando | Quem | O quê | Alterações |
|---|---|---|---|
| 11/07/2026 09:00 | João Santos | updated | Preço: 20 → 25 · Status: Ativo → Bloqueado |
| 10/07/2026 14:30 | Maria Pereira | created | Nome: Café · Preço: 20 · Status: Ativo |
O que o pacote faz por você
- Registra criação, alteração e exclusão sozinho (e restauração, com SoftDeletes).
- Traduz chaves estrangeiras em nomes legíveis (
resolveMap). - Agrupa várias gravações de uma mesma ação do usuário numa só operação.
- Registra falhas com detalhes técnicos que só o desenvolvedor vê.
- Recria registros apagados a partir da auditoria.
- Mostra "criado por / alterado por" numa listagem com uma única consulta.
- Traz um modal de histórico pronto em JavaScript (opcional).
- Funciona com multitenancy (opcional).
Sumário
- Requisitos
- Instalação
- Primeiros passos
- O que é (e o que não é) auditado automaticamente
- Nomes legíveis com
resolveMap - Escolher o que auditar
- Quem fez a alteração
- Agrupar uma operação (batch)
- Registrar ações próprias:
auditAction() - Personalizar a entrada automática:
audit() - Auditar sem model:
Audit::for() - Consultar o histórico
- Mostrar o histórico numa tela Blade
- Colunas "criado por / alterado por" numa listagem
- Modal de histórico pronto (JavaScript, opcional)
- Restaurar um registro apagado
- Registrar falhas
- Multitenancy (opcional)
- Desligar a auditoria
- Configuração completa
- Personalização avançada
- Problemas comuns
- Referência rápida
Requisitos
- PHP 8.2 ou mais recente
- Laravel 11.4+, 12 ou 13
- Um banco suportado pelo Laravel (MySQL, MariaDB, PostgreSQL, SQLite ou SQL Server)
Instalação
Rode os quatro comandos abaixo na pasta do seu projeto:
composer require gsebastiao/laravel-auditable
php artisan vendor:publish --tag=auditable-config
php artisan vendor:publish --tag=auditable-migrations
php artisan migrate
O que cada um faz:
composer requireinstala o pacote. O Laravel o encontra sozinho; não precisa registrar nada.--tag=auditable-configcriaconfig/auditable.php, com todas as opções comentadas. Você só mexe nele se quiser mudar algum padrão.--tag=auditable-migrationscria emdatabase/migrations/o arquivo que monta a tabela de auditoria.migratecria a tabela (chamadaaudit_table, por padrão).
Os ids dos seus models são UUID ou ULID? Antes do passo 4, abra a migration criada no passo 3 e troque
'integer'por'uuid'ou'ulid'nas três linhas do topo do arquivo. Veja Meus ids são UUID.
Primeiros passos
1. Adicione o trait Auditable ao model
<?php
namespace App\Models;
use Gsebastiao\Auditable\Concerns\Auditable;
use Illuminate\Database\Eloquent\Model;
class Produto extends Model
{
use Auditable;
protected $fillable = ['nome', 'preco', 'status_id'];
}
Pronto. Não há mais nada a configurar.
2. Use o model como sempre usou
$produto = Produto::create(['nome' => 'Café', 'preco' => 20, 'status_id' => 1]);
$produto->update(['preco' => 25]);
$produto->delete();
Cada uma dessas três linhas gravou uma linha na tabela de auditoria.
3. Veja o histórico
$produto->audits()->latest()->get(); // as auditorias deste produto, das mais novas para as mais antigas
Para experimentar rapidamente sem criar tela nenhuma, use o Tinker:
php artisan tinker
App\Models\Produto::auditsFor(1)->get(['event', 'changes']);
O que fica gravado
Depois do exemplo acima, a tabela de auditoria tem estas três linhas (simplificando):
| event | changes |
|---|---|
created |
{"nome": "Café", "preco": 20, "status_id": 1, "id": 1} |
updated |
{"preco": {"old": 20, "new": 25}} |
deleted |
{"nome": "Café", "preco": 25, "status_id": 1, "id": 1} |
Repare:
- No
createde nodeletedfica um retrato do registro (não há "antes" e "depois"). - No
updatedfica só o que mudou, no formato{"old": ..., "new": ...}. password,remember_token,created_ateupdated_atdo model não entram emchanges(a própria auditoria já tem a sua data).
E estas são todas as colunas da tabela:
| Coluna | O que guarda |
|---|---|
id |
Número da auditoria |
batch |
Id da operação — as auditorias de uma mesma ação do usuário têm o mesmo batch (saiba mais) |
subject_type |
Qual model foi alterado (ex.: App\Models\Produto) |
subject_id |
O id do registro alterado |
event |
created, updated, deleted, restored ou um nome seu (ex.: aprovado) |
changes |
O que mudou, pronto para ler |
debug_info |
Detalhes técnicos: dados de falhas e o retrato usado para restaurar um registro apagado |
created_by |
Id do usuário que estava logado (vazio = ação do sistema) |
created_at / updated_at |
Quando aconteceu |
O que é (e o que não é) auditado automaticamente
A auditoria automática funciona pelos eventos do Eloquent. Por isso:
| Isto é auditado | Isto não é auditado |
|---|---|
Produto::create([...]) |
Produto::where(...)->update([...]) (update em massa) |
$produto->update([...]) |
Produto::where(...)->delete() (delete em massa) |
$produto->save() |
DB::table('produtos')->insert/update/delete(...) |
$produto->delete() |
Produto::insert([...]) e Produto::upsert(...) |
$produto->increment('estoque') |
$produto->saveQuietly() e código dentro de Model::withoutEvents() |
$produto->restore() (SoftDeletes) |
Se você precisa auditar uma escrita da coluna da direita, há dois caminhos:
// 1) Percorrer os models (mais lento, mas cada um gera a sua auditoria)
Produto::where('categoria_id', 3)->each(fn (Produto $p) => $p->update(['ativo' => false]));
// 2) Fazer a escrita em massa e registrar você mesmo com Audit::for()
// (veja "Auditar sem model")
Nomes legíveis com resolveMap
Sem ajuda, uma chave estrangeira aparece assim no histórico:
{"status_id": {"old": 1, "new": 3}}
Ninguém sabe o que é o status 1 ou o 3. Com o resolveMap, você diz ao pacote
onde buscar o nome:
<?php
namespace App\Models;
use Gsebastiao\Auditable\Concerns\Auditable;
use Gsebastiao\Auditable\Support\AuditOptions;
use Gsebastiao\Auditable\Support\ResolveMap;
use Illuminate\Database\Eloquent\Model;
class Produto extends Model
{
use Auditable;
public function getAuditOptions(): AuditOptions
{
return AuditOptions::defaults()
->resolveMap([
'status_id' => ResolveMap::direct('Status', 'status', 'nome'),
]);
}
}
Leia a linha do resolveMap assim: "o campo status_id aparece com o nome
Status; o texto está na tabela status, coluna nome". O resultado:
{"Status": {"old": {"id": 1, "label": "Ativo"}, "new": {"id": 3, "label": "Bloqueado"}}}
O id original continua guardado (id) ao lado do texto (label).
As três formas
ResolveMap::direct() — o caso mais comum: o nome está na tabela para
onde a chave aponta.
'status_id' => ResolveMap::direct('Status', 'status', 'nome'),
'categoria_id' => ResolveMap::direct('Categoria', 'categorias', 'titulo'),
// Parâmetros: (nome no log, tabela, coluna com o texto = 'nome', coluna procurada = 'id', filtros extra = [])
'tipo_id' => ResolveMap::direct('Tipo', 'tabelas_gerais', 'descricao', scope: ['grupo' => 'tipo_cliente']),
ResolveMap::join() — o nome está noutra tabela, a mais de uma ligação
de distância. Exemplo: o campo guarda estado_id, mas você quer mostrar o
país do estado:
'estado_id' => ResolveMap::join([
// 1º item: a tabela onde está o valor gravado no campo
['table' => 'estados', 'key' => 'id'],
// itens seguintes: cada ligação (LEFT JOIN); no último, o que mostrar
['table' => 'paises',
'on' => ['paises.id', '=', 'estados.pais_id'],
'column' => 'nome',
'label' => 'País'],
]),
ResolveMap::alias() — não consulta nada, só troca o nome do campo:
'preco_venda' => ResolveMap::alias('Preço de venda'),
Bom saber
- As consultas são feitas no mesmo banco do model, e cada valor é consultado no máximo uma vez por requisição.
- Se a linha procurada não existir (ex.: o status foi apagado), o
labelficanulle oidcontinua lá.
Escolher o que auditar
Tudo é configurado no método getAuditOptions() do model. Os métodos podem ser
encadeados:
public function getAuditOptions(): AuditOptions
{
return AuditOptions::defaults()
->events(['created', 'updated']) // não registrar exclusões
->except(['observacao_interna']) // nunca mostrar este campo
->resolveMap([
'status_id' => ResolveMap::direct('Status', 'status', 'nome'),
]);
}
| Método | Padrão | Para que serve |
|---|---|---|
events([...]) |
created, updated, deleted |
Quais eventos automáticos registrar. Acrescente restored se o model usa SoftDeletes. |
only([...]) |
todos os campos | Registrar só estes campos. |
except([...]) |
password, remember_token |
Não mostrar estes campos em changes. O que você passar soma-se ao padrão. |
neverSnapshot([...]) |
password, remember_token |
Nunca guardar estes campos em lugar nenhum (nem em changes, nem no retrato de restauro). Use para segredos. |
onlyDirty(false) |
ligado | No updated, mostrar também os campos que não mudaram. |
logEmpty() |
desligado | Gravar a auditoria mesmo quando nada mudou. |
logTimestamps() |
desligado | Mostrar created_at e updated_at do model em changes. |
fullSnapshotOnDelete(false) |
ligado | Não guardar o retrato que permite restaurar um registro apagado. |
resolveMap([...]) |
vazio | Nomes legíveis. |
except() ou neverSnapshot()?
except()é para campos que só poluem o histórico. Eles continuam no retrato de restauro, para que o registro volte inteiro se for apagado.neverSnapshot()é para segredos (tokens, chaves de API): não ficam guardados em lugar nenhum da auditoria.
Campos criptografados (cast encrypted) aparecem como ******** em
changes: você vê que o campo mudou, mas o valor nunca vai para a auditoria
em texto claro.
Quem fez a alteração
O id do usuário logado vai sozinho para a coluna created_by. Para ler:
$audit->user?->name; // null quando a ação foi do sistema
Quando não há ninguém logado (comandos artisan, filas, agendamentos,
webhooks), created_by fica vazio. Se preferir gravar sempre um usuário (por
exemplo, um usuário chamado "Sistema" com id 1), coloque no .env:
AUDITABLE_DEFAULT_CREATED_BY=1
Usa outro guard de login (ex.: admin)? Ajuste em config/auditable.php:
'auth_guard' => 'admin',
Numa job da fila ninguém está logado. Para registrar o usuário que pediu a
tarefa, passe o id dele para a job e use audit()
logo depois da gravação:
public function handle(): void
{
$pedido = Pedido::create($this->dados)->audit(createdBy: $this->userId);
}
Agrupar uma operação (batch)
Uma única ação do usuário costuma mexer em várias tabelas. Por exemplo, "finalizar pedido" cria o pedido, cria os itens e baixa o estoque. Cada gravação tem a sua auditoria, mas é útil saber que todas fazem parte da mesma operação.
É para isso que serve a coluna batch. Tudo o que for gravado dentro de
Audit::transaction() recebe o mesmo batch:
use Gsebastiao\Auditable\Audit;
$pedido = Audit::transaction(function () use ($dados) {
$pedido = Pedido::create(['cliente_id' => $dados['cliente_id']]);
foreach ($dados['itens'] as $item) {
$pedido->itens()->create($item);
Produto::find($item['produto_id'])->decrement('estoque', $item['quantidade']);
}
return $pedido;
});
Audit::transaction() faz duas coisas ao mesmo tempo:
- Abre uma transação de banco: se qualquer linha lançar um erro, nada é gravado — nem os dados, nem as auditorias.
- Dá o mesmo batch a todas as auditorias lá de dentro.
Se você só quer agrupar, sem transação, use Audit::batch(fn () => ...).
E se o seu código já usa DB::transaction(), pode combinar os dois, em
qualquer ordem:
DB::transaction(fn () => Audit::batch(function () {
// ...
}));
Ver a operação inteira
$pedido->operation()->get(); // a ÚLTIMA operação em que o pedido participou (todas as tabelas)
$pedido->operations()->get(); // TODAS as operações do pedido, com todas as linhas de cada uma
$pedido->audits()->get(); // só as linhas do próprio pedido
Pedido::operationFor(42)->get(); // o mesmo, sem carregar o pedido
Pedido::operationsFor(42)->get();
Audit::inBatch($batch)->get(); // tudo de um batch
Continuar a operação numa fila
Se a operação despacha uma job, passe o batch para ela:
// Dentro do Audit::transaction():
EnviarNotaFiscal::dispatch($pedido->id, Audit::currentBatch());
// Na job:
public function __construct(public int $pedidoId, public ?string $batch) {}
public function handle(): void
{
Audit::useBatch($this->batch, function () {
// o que for auditado aqui entra na mesma operação
});
}
Audit::currentBatch() devolve null fora de uma operação — sem problema:
nesse caso, useBatch() abre uma operação nova.
Registrar ações próprias: auditAction()
Nem tudo o que importa é um create, update ou delete. Para registrar
qualquer outro acontecimento, dê-lhe um nome:
$pedido->auditAction('aprovado');
$pedido->auditAction('email_reenviado', ['para' => $cliente->email]);
$relatorio->auditAction('exportado', ['formato' => 'PDF']);
O segundo parâmetro (opcional) vai para changes. auditAction() sempre
cria uma linha nova.
Personalizar a entrada automática: audit()
Às vezes você quer que a auditoria automática saia diferente: com outro
autor, outro nome de evento ou informações extra. É para isso que existe
audit():
// Outro autor (ex.: numa job da fila)
$pedido = Pedido::create($dados)->audit(createdBy: $this->userId);
// Outro nome de evento: em vez de "created", grava "importado"
$produto = Produto::create($linha)->audit(event: 'importado');
// Informação técnica extra
$produto->update($dados);
$produto->audit(debugInfo: ['origem' => 'api-parceiro']);
As regras são simples:
audit()substitui a entrada automática que este objeto acabou de gravar. Não cria uma segunda linha.- Se o objeto ainda não gravou nada (por exemplo, acabou de ser carregado com
find()),audit()cria uma entrada nova. - O que você não passar continua automático.
- É seguro com muitos usuários ao mesmo tempo:
audit()só mexe na entrada do objeto em que foi chamado, nunca na de outra pessoa ou de outro registro.
audit() ou auditAction()?
audit() |
auditAction() |
|
|---|---|---|
| Cria uma linha nova? | Não: ajusta a entrada automática que o objeto acabou de gravar | Sempre |
| Para quê | Mudar autor, data, dados ou nome do evento da entrada automática | Registrar um acontecimento a mais |
Todos os parâmetros de audit() (todos opcionais, use por nome):
| Parâmetro | O que muda |
|---|---|
event |
O nome do evento gravado |
changes |
O conteúdo de changes (array) |
debugInfo |
O conteúdo de debug_info (array) |
createdBy |
O autor |
createdAt / updatedAt |
As datas ('Y-m-d H:i:s') |
batch |
A operação |
subjectType / subjectId |
Grava a entrada em nome de outro registro |
tenantId |
O tenant (só com multitenancy ligado) |
Auditar sem model: Audit::for()
Use quando a gravação não passa pelo Eloquent: DB::table(), updates em
massa ou tabelas que não têm model.
use Gsebastiao\Auditable\Audit;
use Gsebastiao\Auditable\Support\ResolveMap;
use Illuminate\Support\Facades\DB;
DB::table('pessoas')->where('id', $id)->update(['estado_id' => 7]);
Audit::for('pessoa', $id, 'updated')
->changes(['estado_id' => ['old' => 3, 'new' => 7]])
->resolveMap(['estado_id' => ResolveMap::direct('Estado', 'estados')])
->save();
- Os três parâmetros são: o tipo (um nome livre, como
'pessoa', ou a classe de um model), o id e o evento. - Nada é gravado antes do
->save(). changes()aceita o formato de alteração (['campo' => ['old' => ..., 'new' => ...]]) ou um retrato (['campo' => valor]). O pacote reconhece qual é.- Se o tipo for a classe de um model (
Produto::class), as opções dogetAuditOptions()dele (como oresolveMap) são aproveitadas. - Também pode encadear:
except(),only(),events(),onlyDirty(),logEmpty(),resolveMap(),debugInfo(),batch(),createdBy(),tenantId(),createdAt()eupdatedAt().
Exemplo com um update em massa, tudo numa operação:
$ids = Produto::where('categoria_id', 3)->pluck('id');
Audit::transaction(function () use ($ids) {
Produto::whereIn('id', $ids)->update(['ativo' => false]);
foreach ($ids as $id) {
Audit::for(Produto::class, $id, 'updated')
->changes(['ativo' => ['old' => true, 'new' => false]])
->save();
}
});
Versões anteriores usavam
DB::table('x')->audit(...). Continua a funcionar e faz o mesmo queAudit::for(...)(a tabela doDB::table()não é usada).
Consultar o histórico
use Gsebastiao\Auditable\Audit;
// De um registro
$produto->audits()->latest()->get();
Produto::auditsFor(42)->get(); // sem carregar o produto (já vem do mais novo para o mais antigo)
Produto::auditsFor(42)->action('updated')->get();
// Da aplicação inteira
Audit::byUser(auth()->id())->latest()->limit(20)->get();
Audit::action(['aprovado', 'reprovado'])->get();
Audit::failures()->whereDate('created_at', today())->get();
Audit::forRecord(Produto::class, 42)->get();
Filtros disponíveis (todos encadeáveis, e ainda funcionam where(),
latest(), paginate() etc.):
| Filtro | O que traz |
|---|---|
action('updated') ou action([...]) |
Pelo nome do evento |
byUser($id) |
Feitas por um usuário |
inBatch($batch) |
De uma operação |
failures() |
Só as falhas |
forRecord(Produto::class, $id) |
De um registro |
Em cada auditoria:
$audit->user; // o usuário (ou null)
$audit->subject; // o registro auditado (null se foi apagado) — só em auditorias de models
$audit->changes; // array com o que mudou
$audit->changeLines(); // o mesmo, em texto: ['Preco: 20 → 25', 'Status: Ativo → Bloqueado']
$audit->isFailure(); // foi uma falha?
$audit->subjectsó funciona quandosubject_typeé um model. Numa auditoria gravada com um nome livre (Audit::for('pessoa', ...)), usesubject_typeesubject_iddiretamente.
Audit::(deGsebastiao\Auditable\Audit) aceita qualquer consulta do model de auditoria. Se preferir usar o model diretamente, ele éGsebastiao\Auditable\Models\Audit.
Evite consultas repetidas: ao listar auditorias com o nome do usuário,
carregue os usuários de uma vez com with('user').
Mostrar o histórico numa tela Blade
No controller:
public function show(Produto $produto)
{
$historico = $produto->audits()->with('user')->latest()->get();
return view('produtos.show', compact('produto', 'historico'));
}
Na view:
<table>
<thead>
<tr><th>Quando</th><th>Quem</th><th>O quê</th><th>Alterações</th></tr>
</thead>
<tbody>
@foreach ($historico as $audit)
<tr>
<td>{{ $audit->created_at->format('d/m/Y H:i') }}</td>
<td>{{ $audit->user?->name ?? 'Sistema' }}</td>
<td>{{ $audit->event }}</td>
<td>
@foreach ($audit->changeLines() as $linha)
{{ $linha }}<br>
@endforeach
</td>
</tr>
@endforeach
</tbody>
</table>
Quer um nome mais bonito para um campo (ex.: "Preço" em vez de "Preco")? Use
ResolveMap::alias('Preço') no resolveMap.
Colunas "criado por / alterado por" numa listagem
Numa listagem com muitos registros, buscar a auditoria de cada linha seria
lento. O AuditColumnJoiner acrescenta essas colunas à mesma consulta da
listagem:
use App\Models\Produto;
use Gsebastiao\Auditable\Support\AuditColumnJoiner;
$query = Produto::query()->where('ativo', true);
AuditColumnJoiner::apply($query, Produto::class);
$produtos = $query->paginate(20);
Cada produto ganha quatro colunas:
@foreach ($produtos as $produto)
{{ $produto->nome }}
— criado por {{ $produto->audit_created_by }} em {{ $produto->audit_created_at }}
— alterado por {{ $produto->audit_updated_by ?? '-' }} em {{ $produto->audit_updated_at ?? '-' }}
@endforeach
audit_created_*vem da primeira auditoriacreated;audit_updated_*vem da mais recenteupdated. Registros nunca alterados ficam comnull.- O nome do usuário é encurtado para "Primeiro Último" ("Maria da Silva
Pereira" → "Maria Pereira") e a data sai como
10/07/2026 14:30:00. - Funciona também com
DB::table('produtos').
Opções (todas por nome):
AuditColumnJoiner::apply(
$query,
Produto::class,
actions: ['created', 'updated', 'aprovado'], // cada ação vira audit_<acao>_by e audit_<acao>_at
userColumn: 'email', // coluna da tabela de usuários a mostrar
dateFormat: 'DD/MM/YYYY', // ou null para a data crua
);
Requisito: a tabela de auditoria precisa estar no mesmo banco da listagem. Não funciona se
AUDITABLE_CONNECTIONapontar para outro banco.
Modal de histórico pronto (JavaScript, opcional)
Esta seção é opcional. Tudo o que foi explicado até aqui funciona sem ela.
O pacote traz um arquivo JavaScript que abre um modal (janela por cima da página) com o histórico de um registro. Não depende de jQuery, Bootstrap nem de nenhuma outra biblioteca, e não precisa de HTML nenhum na página.
Há dois modais:
| Modal | Para quem | O que mostra |
|---|---|---|
GaAudit.full |
Administradores/auditores | Histórico completo, agrupado por operação, com busca, filtro e paginação |
GaAudit.simple |
Qualquer usuário | Lista simples: o quê, quem e quando |
Passo 1 — Copie o arquivo para public/
php artisan auditable:publish-js
O arquivo vai para public/assets/js/audit-table.init.js. Para outra pasta,
use --path=js/auditoria. Depois de atualizar o pacote, rode de novo com
--force para pegar a versão nova.
Passo 2 — Inclua no layout
No layout Blade (ex.: resources/views/layouts/app.blade.php):
<head>
{{-- ... --}}
<meta name="csrf-token" content="{{ csrf_token() }}">
</head>
<body>
{{-- ... --}}
<script src="{{ asset('assets/js/audit-table.init.js') }}"></script>
</body>
A tag csrf-token é obrigatória: sem ela o Laravel recusa o pedido do
modal (erro 419).
Passo 3 — Crie as rotas
Em routes/web.php. O AuditWidget já devolve o JSON no formato que o modal
espera:
use App\Models\Produto;
use Gsebastiao\Auditable\Support\AuditWidget;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::middleware('auth')->group(function () {
// Para o GaAudit.full
Route::post('/auditoria/produtos/completo', function (Request $request) {
$id = $request->integer('id');
return AuditWidget::full(Produto::operationsFor($id), recordId: $id);
});
// Para o GaAudit.simple
Route::post('/auditoria/produtos/resumo', function (Request $request) {
return AuditWidget::simple(Produto::auditsFor($request->integer('id')));
});
});
Quem pode ver o histórico completo? Proteja a rota com uma permissão. Por exemplo, acrescente
->can('ver-auditoria')depois doRoute::post(...)e defina a regra noboot()doAppServiceProvider:use Illuminate\Support\Facades\Gate; Gate::define('ver-auditoria', fn ($user) => $user->is_admin);
Passo 4 — Abra o modal
Sem escrever JavaScript, com atributos data-*:
<button type="button"
data-ga-audit="full"
data-ga-audit-id="{{ $produto->id }}"
data-ga-audit-url="/auditoria/produtos/completo"
data-ga-audit-title="Histórico do produto">
Ver histórico
</button>
Troque full por simple (e a URL) para o modal simples. O título é
opcional. Se preferir chamar pelo seu próprio JavaScript:
GaAudit.full.open({
endpoint: '/auditoria/produtos/completo',
id: 42,
title: 'Histórico do produto #42', // opcional
});
Personalizar o visual
As cores usam variáveis CSS. Sobrescreva no CSS do seu site:
:root {
--ga-audit-accent: #7c3aed; /* botões, foco e paginação */
--ga-audit-radius: 4px; /* cantos do modal */
}
Outras variáveis: --ga-audit-bg, --ga-audit-text, --ga-audit-border,
--ga-audit-success, --ga-audit-danger e --ga-audit-font.
Para trocar os ícones, defina-os depois de incluir o script:
<script>
GaAudit.icons.close = '<svg width="14" height="14">...</svg>';
</script>
Os textos da interface estão em português. Para outro idioma, edite o arquivo
publicado em public/ (é JavaScript comum, sem compilação).
Restaurar um registro apagado
Quando um registro é apagado, a auditoria deleted guarda um retrato completo
dele. Com esse retrato, o registro pode ser recriado:
$audit = Produto::auditsFor($id)->action('deleted')->first();
$produto = $audit->restore();
O registro volta exatamente como estava no banco: o mesmo id, as mesmas datas e os mesmos valores (inclusive JSON e campos criptografados).
Variações:
$audit->restore(withId: false); // recria com um id novo
// Campos de neverSnapshot() (como password) não foram guardados.
// Se a coluna for obrigatória, informe um valor:
$audit->restore(attributes: ['password' => Hash::make(Str::random(16))]);
$audit->isRestorable(); // esta auditoria pode ser restaurada?
Bom saber
- Só auditorias
deletedpodem ser restauradas. - Se já existir um registro com o mesmo id,
restore()lança um erro que explica o que fazer. - Em models com SoftDeletes, o registro apagado continua na tabela (na
"lixeira"): use o
$produto->restore()do próprio Laravel. O restauro pela auditoria serve para exclusões definitivas (forceDelete()ou models sem SoftDeletes). - O registro recriado gera uma auditoria
creatednova, então o histórico mostra "apagado" e depois "criado".
Registrar falhas
Quando algo dá errado, você pode registrar a falha. O usuário vê uma mensagem simples; os detalhes técnicos ficam guardados só para o desenvolvedor.
try {
$gateway->cobrar($fatura);
} catch (\Throwable $e) {
$fatura->auditFailure('cobranca', $e, ['gateway' => 'mpesa'], 'Não foi possível concluir o pagamento.');
throw $e;
}
Parâmetros: o nome da ação, a exceção, dados extra para investigar (opcional) e a mensagem para o usuário (opcional; o padrão é "A operação falhou.").
O que é gravado:
changes— só a mensagem:{"message": "Não foi possível concluir o pagamento."}. Pode ser mostrada ao usuário.debug_info— para o desenvolvedor: classe e mensagem da exceção, arquivo e linha, as primeiras linhas do stack trace, os dados extra, a rota e o IP da requisição, e o nome do banco. Numa falha de SQL, a query é guardada com?no lugar dos valores. Senhas e endereço do banco nunca são gravados.
Para consultar:
Audit::failures()->latest()->get();
$audit->isFailure();
Cuidado com transações. Se você registrar a falha dentro de um
Audit::transaction()e relançar o erro, a própria auditoria da falha é desfeita junto com a transação. Registre-a fora:try { Audit::transaction(function () use ($fatura) { // ... }); } catch (\Throwable $e) { $fatura->auditFailure('cobranca', $e); // fora da transação: fica gravada throw $e; }
Multitenancy (opcional)
Multitenancy é quando um único sistema atende vários clientes (empresas, escolas, lojas), e cada um só pode ver os seus dados. Se o seu sistema não é assim, pule esta seção.
Caso A — cada cliente tem o seu próprio banco
Não é preciso configurar nada no pacote. A auditoria de cada cliente vai para
o banco dele, porque usa a mesma conexão da aplicação. É o caso típico de
pacotes como stancl/tenancy e spatie/laravel-multitenancy no modo
multi-banco.
Só não se esqueça de que a tabela de auditoria precisa existir em cada
banco de cliente: coloque a migration publicada junto das migrations dos
tenants (no stancl/tenancy, a pasta database/migrations/tenant).
Caso B — um banco só, com uma coluna tenant_id em cada tabela
1. Ligue o modo tenant antes de rodar a migration (é ele que cria a
coluna tenant_id na tabela de auditoria):
AUDITABLE_TENANT_ENABLED=true
Se a tabela de auditoria já existe, crie uma migration para acrescentar a coluna:
Schema::table('audit_table', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable()->index();
});
2. Diga ao pacote quem é o tenant atual, no boot() do
app/Providers/AppServiceProvider.php:
use Gsebastiao\Auditable\Audit;
public function boot(): void
{
Audit::resolveTenantUsing(fn () => auth()->user()?->empresa_id);
}
O pacote só pergunta qual é o tenant; quem decide é a sua aplicação.
3. Nos seus models com tenant_id, acrescente BelongsToTenant:
use Gsebastiao\Auditable\Concerns\Auditable;
use Gsebastiao\Auditable\Concerns\BelongsToTenant;
class Produto extends Model
{
use Auditable, BelongsToTenant;
}
Com isso, Produto::all() só traz os produtos do tenant atual e
Produto::create() preenche o tenant_id sozinho. As auditorias também
passam a ser gravadas e filtradas por tenant.
Ver todos os tenants (relatórios internos, tarefas de manutenção):
Produto::withoutTenantScope()->get();
Audit::withoutTenantScope()->get();
Bom saber
-
Sem tenant identificado (ex.: um comando no terminal), as consultas não são filtradas. Para que não mostrem nada nesse caso, ligue
'strict' => trueemconfig/auditable.php(seçãotenant). -
A coluna tem outro nome? Mude
'column'emconfig/auditable.php. Se só um model for diferente, defina nelepublic function tenantColumn(): string { return 'empresa_id'; }. -
Não coloque
BelongsToTenantno modelUserse o seu resolver usaauth()->user(): para descobrir o tenant, o Laravel precisaria carregar o usuário, que por sua vez precisaria do tenant — um ciclo sem fim. -
Prefere configurar tudo em
config/auditable.php? Crie uma classe com o método__invoke()e indique o nome dela em'resolver':// app/Support/TenantAtual.php namespace App\Support; class TenantAtual { public function __invoke(): ?int { return auth()->user()?->empresa_id; } }// config/auditable.php 'resolver' => App\Support\TenantAtual::class,Não coloque uma função anônima (
fn () => ...) direto na config: isso impede ophp artisan config:cache.
Desligar a auditoria
use Gsebastiao\Auditable\Audit;
// Só um trecho de código (seeders, importações, correções em massa)
Audit::withoutAuditing(function () {
Produto::factory()->count(500)->create();
});
# No sistema inteiro — por exemplo, no .env.testing
AUDITABLE_ENABLED=false
Para um model específico, deixe a lista de eventos vazia:
AuditOptions::defaults()->events([]).
Configuração completa
Todas as opções de config/auditable.php. Os padrões funcionam; mude só o
que precisar.
| Opção | Padrão | O que faz |
|---|---|---|
enabled |
true (AUDITABLE_ENABLED) |
Liga/desliga a auditoria no sistema inteiro |
table |
audit_table |
Nome da tabela de auditoria (mude antes da migration) |
connection |
null (AUDITABLE_CONNECTION) |
Conexão de banco da auditoria; null = a da aplicação |
model |
Gsebastiao\Auditable\Models\Audit |
Model da tabela de auditoria (estender) |
auth_guard |
null |
Guard de onde vem o usuário logado; null = o padrão |
default_created_by |
null (AUDITABLE_DEFAULT_CREATED_BY) |
Usuário gravado quando ninguém está logado |
user_model |
null |
Model dos usuários para $audit->user; null = o do config/auth.php |
users_table |
users |
Tabela de usuários usada pelo AuditColumnJoiner |
column_prefix |
audit_ |
Prefixo das colunas do AuditColumnJoiner |
tenant.enabled |
false (AUDITABLE_TENANT_ENABLED) |
Liga o multitenancy por coluna |
tenant.column |
tenant_id |
Nome da coluna de tenant |
tenant.resolver |
null |
Classe invocável que devolve o tenant atual |
tenant.strict |
false |
Sem tenant identificado: false vê tudo, true não vê nada |
js.publish_path |
assets/js |
Pasta (dentro de public/) do widget JS |
debug.include_database |
true (AUDITABLE_DEBUG_DB) |
Grava conexão, driver e nome do banco nas falhas |
Guardar a auditoria noutro banco. Crie a conexão em config/database.php
e indique-a no .env antes de rodar a migration:
AUDITABLE_CONNECTION=auditoria
Nesse caso, Audit::transaction() desfaz os dois bancos juntos em caso de
erro, mas o AuditColumnJoiner deixa de funcionar (ele precisa do mesmo
banco).
Personalização avançada
Nada disto é necessário para o uso normal.
O seu próprio model de auditoria
Para acrescentar relações ou métodos às auditorias:
namespace App\Models;
use Gsebastiao\Auditable\Models\Audit as BaseAudit;
class Auditoria extends BaseAudit
{
// opcional: outra tabela (senão usa config('auditable.table'))
// protected $table = 'historico';
}
// config/auditable.php
'model' => App\Models\Auditoria::class,
Dentro da sua classe, leia a coluna com
$this->getAttribute('changes'):$this->changesé uma propriedade interna do Eloquent com o mesmo nome.
Gravar em outro lugar (fila, serviço externo)
Implemente Gsebastiao\Auditable\Contracts\AuditRepository (métodos
persist, replace e forget — a documentação de cada um está na
interface) e registre no AppServiceProvider:
use Gsebastiao\Auditable\Contracts\AuditRepository;
$this->app->bind(AuditRepository::class, \App\Auditoria\GravarNaFila::class);
Se o destino não tiver ids, persist() pode devolver null; nesse caso,
audit() passa a criar sempre uma linha nova.
Outras peças substituíveis
| Interface | Responsável por | Padrão |
|---|---|---|
Contracts\ContextResolver |
Descobrir o usuário e o tenant atuais | Lê o auth() e o resolver de tenant |
Contracts\BatchIdGenerator |
Gerar o id das operações | ULID (26 caracteres) |
Troque do mesmo jeito: $this->app->bind(Interface::class, SuaClasse::class);.
Problemas comuns
Não foi gravada nenhuma auditoria
Confira, nesta ordem:
- O model tem
use Auditable;? - A gravação passa pelo Eloquent? Updates e deletes em massa,
DB::table(),insert(),upsert()esaveQuietly()não geram auditoria (veja o que é auditado). - Algum valor mudou de verdade? Um
update()com os mesmos valores não grava nada — o Laravel nem chega a salvar. E se só mudaram campos ignorados (updated_atou os deexcept()), não sobra nada para registrar: uselogEmpty()para gravar mesmo assim. - O evento está em
events()? E a auditoria não está desligada (AUDITABLE_ENABLED,Audit::withoutAuditing())? - A gravação aconteceu dentro de uma transação que foi desfeita?
- Usa
php artisan config:cache? Depois de mudar o.env, rode-o de novo.
created_by está vazio
Ninguém estava logado (terminal, fila, agendamento). Defina
AUDITABLE_DEFAULT_CREATED_BY ou use audit(createdBy: ...). Veja
Quem fez a alteração.
O modal mostra "Não foi possível carregar"
- Erro 419: falta a tag
<meta name="csrf-token">no layout. - Erro 404: a URL em
data-ga-audit-urlnão bate com a rota (confira comphp artisan route:list). - Erro 401/403: a rota exige login ou permissão.
- Erro 500: veja
storage/logs/laravel.log.
Na aba Rede (Network) das ferramentas do navegador (F12) aparece o código de erro do pedido.
Meus ids são UUID ou ULID
Antes de rodar php artisan migrate, abra a migration publicada e ajuste as
três linhas do topo:
private string $subjectKeyType = 'uuid'; // ids dos seus models
private string $userKeyType = 'integer'; // ids dos usuários
private string $tenantKeyType = 'integer'; // ids dos tenants (se usar)
Valores aceitos: 'integer', 'uuid', 'ulid' e 'string'. Se a tabela já
existe, faça uma migration que altere as colunas subject_id, created_by
(e tenant_id).
Aparece ******** no lugar de um valor
O campo usa o cast encrypted. É de propósito: o valor nunca vai para a
auditoria em texto claro.
O label do resolveMap vem null
Não foi encontrada a linha procurada: confira o nome da tabela e da coluna no
ResolveMap::direct() e se o registro ainda existe.
Erro ao usar php artisan config:cache
Há uma função anônima (fn () => ...) em config/auditable.php. Troque por
uma classe ou use Audit::resolveTenantUsing() (veja Multitenancy).
Referência rápida
Um resumo de tudo, para consulta (não é um arquivo para copiar inteiro):
use Gsebastiao\Auditable\Audit;
use Gsebastiao\Auditable\Concerns\Auditable;
use Gsebastiao\Auditable\Concerns\BelongsToTenant;
use Gsebastiao\Auditable\Support\AuditColumnJoiner;
use Gsebastiao\Auditable\Support\AuditOptions;
use Gsebastiao\Auditable\Support\AuditWidget;
use Gsebastiao\Auditable\Support\ResolveMap;
// ── No model ──────────────────────────────────────────────────────────
use Auditable; // auditoria automática
use BelongsToTenant; // (opcional) filtro por tenant
public function getAuditOptions(): AuditOptions
{
return AuditOptions::defaults()
->events(['created', 'updated', 'deleted'])
->only([...]) ->except([...]) ->neverSnapshot([...])
->onlyDirty() ->logEmpty() ->logTimestamps() ->fullSnapshotOnDelete()
->resolveMap([
'status_id' => ResolveMap::direct('Status', 'status', 'nome'),
'estado_id' => ResolveMap::join([...]),
'preco' => ResolveMap::alias('Preço'),
]);
}
// ── Registrar ─────────────────────────────────────────────────────────
$model->auditAction('aprovado', ['motivo' => '...']); // linha nova
$model->auditFailure('cobranca', $e, ['extra' => 1], 'Mensagem'); // falha
$model->audit(event: 'importado', createdBy: 5); // ajusta a entrada automática
Audit::for('pessoa', $id, 'updated')->changes([...])->save(); // sem model
// ── Operações ─────────────────────────────────────────────────────────
Audit::transaction(fn () => ...); // transação + mesmo batch
Audit::batch(fn () => ...); // só o mesmo batch
Audit::currentBatch(); // batch atual (ou null)
Audit::useBatch($batch, fn () => ...); // continuar um batch (filas)
Audit::withoutAuditing(fn () => ...); // não auditar este trecho
// ── Consultar ─────────────────────────────────────────────────────────
$model->audits(); Model::auditsFor($id); // linhas do registro
$model->operation(); Model::operationFor($id); // última operação
$model->operations(); Model::operationsFor($id); // todas as operações
$model->batchOf(); // id da última operação
Audit::action('updated') Audit::byUser($id) Audit::inBatch($b)
Audit::failures() Audit::forRecord(Model::class, $id) Audit::withoutTenantScope()
$audit->user $audit->subject $audit->changes $audit->changeLines()
$audit->isFailure() $audit->isRestorable() $audit->restore()
// ── Telas ─────────────────────────────────────────────────────────────
AuditColumnJoiner::apply($query, Model::class); // colunas numa listagem
AuditWidget::full(Model::operationsFor($id), recordId: $id); // JSON do GaAudit.full
AuditWidget::simple(Model::auditsFor($id)); // JSON do GaAudit.simple
// ── Multitenancy ──────────────────────────────────────────────────────
Audit::resolveTenantUsing(fn () => auth()->user()?->empresa_id);
Model::withoutTenantScope();
php artisan vendor:publish --tag=auditable-config # config/auditable.php
php artisan vendor:publish --tag=auditable-migrations # migration da tabela
php artisan auditable:publish-js [--path=...] [--force] # widget JS em public/
Testes do pacote
composer install
composer test
Atualizando de uma versão anterior
Veja o CHANGELOG: ele lista o que mudou e o que ajustar no seu código.
Licença
MIT. Veja o arquivo LICENSE.