gsebastiao/laravel-auditable
Composer 安装命令:
composer require 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.
README 文档
README
Auditoria automática para Eloquent que grava labels legíveis, não IDs crus.
Toda vez que um model muda, o Auditable registra quem mudou, o quê, e quando — traduzindo chaves estrangeiras para nomes que um humano entende. Funciona por eventos do Eloquent, então você não muda uma linha da forma como já salva seus dados. Multitenancy é opcional e plugável.
Requer PHP 8.2+ · Laravel 11, 12 ou 13 · Licença MIT
O problema que ele resolve
A maioria dos pacotes de auditoria grava isto quando um pedido muda de status:
{ "status_id": { "old": 2, "new": 5 } }
E aí alguém abre o log e pergunta: "o que é status 2? e 5?". Ninguém sabe sem ir ao banco.
O Auditable grava isto:
{ "Status": { "old": "Aguardando pagamento", "new": "Enviado" } }
O mesmo evento. A diferença é que o log se explica sozinho. É para isso que o pacote existe.
🇵🇹 Português
Instalação
composer require gsebastiao/laravel-auditable
Publique a configuração e a migration, depois rode a migration:
php artisan vendor:publish --tag=auditable-config php artisan vendor:publish --tag=auditable-migrations php artisan migrate
Pronto. Nada mais é obrigatório.
Começando (2 minutos)
Passo 1 — Adicione o trait Auditable a qualquer model:
use Illuminate\Database\Eloquent\Model; use Gsebastiao\Auditable\Concerns\Auditable; class Produto extends Model { use Auditable; }
É só isso para começar. A partir de agora, create, update e delete
deste model são auditados automaticamente:
$produto = Produto::create(['nome' => 'Café', 'preco' => 20]); $produto->update(['preco' => 25]);
Passo 2 — Consulte o histórico a qualquer momento:
$produto->audits; // coleção com todo o histórico do registro
Cada entrada traz o evento (created/updated/deleted), o que mudou, quem
fez, e quando. Sem configurar mais nada.
Traduzindo IDs para nomes (o diferencial)
Se o seu model tem chaves estrangeiras, diga ao Auditable como transformá-las em texto legível. Você faz isso adicionando um método ao model:
use Gsebastiao\Auditable\Support\AuditOptions; use Gsebastiao\Auditable\Support\ResolveMap; class Produto extends Model { use Auditable; public function getAuditOptions(): AuditOptions { return AuditOptions::defaults()->resolveMap([ // status_id: busca o nome na tabela "status" 'status_id' => ResolveMap::direct( label: 'Status', // como aparece no log table: 'status', // onde buscar column: 'nome', // qual coluna é o texto ), ]); } }
Agora, em vez de status_id: 2 → 5, o log grava Status: "Ativo" → "Bloqueado".
Os três modos de tradução
| Modo | Quando usar | Exemplo |
|---|---|---|
direct |
A FK aponta direto para uma tabela com o nome | status_id → tabela status |
join |
Precisa navegar por tabelas intermediárias | estado_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
O mesmo getAuditOptions() controla o resto. Tudo é opcional:
AuditOptions::defaults() ->except(['updated_at', 'senha']) // nunca audita estes campos ->only(['preco', 'status_id']) // OU: audita só estes ->events(['updated', 'deleted']) // ignora o "created" ->onlyDirty() // só grava o que de fato mudou (padrão) ->logEmpty(false); // não grava se nada mudou (padrão)
Senhas e tokens (
password,remember_token) já são ignorados por padrão.
Operações multi-tabela: um batch, uma história
Este é o cenário que dá sentido ao resto. Você cria um pedido — e junto com ele entram o cliente, os itens, uma baixa de estoque. São escritas em tabelas diferentes, mas fazem parte da mesma operação. Você quer poder olhar para qualquer uma delas depois e reconstruir a operação inteira.
Envolva a operação em Audit::transaction() (ou Audit::batch() se não quiser
transação). Tudo que for auditado lá dentro — de qualquer model — recebe o
mesmo batch:
use Gsebastiao\Auditable\Audit; Audit::transaction(function () use ($dados) { $cliente = Cliente::create($dados['cliente']); $pedido = Pedido::create(['cliente_id' => $cliente->id, ...]); foreach ($dados['itens'] as $item) { Item::create(['pedido_id' => $pedido->id, ...]); } });
O cliente, o pedido e todos os itens ficam gravados sob um único batch. E como é uma transação, se qualquer parte falhar, tudo volta atrás — escritas e auditoria juntas.
Recuperando a operação inteira a partir de um registro
Agora a parte que você descreveu: você tem só o cliente e quer ver tudo que
entrou junto com ele. Chame operation():
$cliente = Cliente::find($id); $cliente->operation()->get(); // -> devolve as auditorias do cliente, do pedido E dos itens // (tudo o que compartilhou o batch)
Ou, se você só tem o id:
Cliente::operationFor($id)->get();
Como o resultado é um query builder normal, você agrupa por tabela para exibir:
$cliente->operation()->get()->groupBy('subject_type'); // [ // 'App\Models\Cliente' => [ ... ], // 'App\Models\Pedido' => [ ... ], // 'App\Models\Item' => [ ... ], // ]
Precisa só do id do batch?
$cliente->batchOf()devolve o identificador da última operação daquele registro — útil para logs ou para passar adiante.
Propagando o batch para filas
Se parte da operação roda numa job assíncrona e você quer que ela caia no mesmo batch, passe o id para a job e reabra lá dentro:
// Ao despachar: ProcessarPedido::dispatch($pedido, Audit::currentBatch()); // Dentro da job: public function handle(): void { Audit::useBatch($this->batchId); // tudo auditado aqui entra no mesmo batch da operação original }
Ações customizadas (além de create/update/delete)
Os três eventos automáticos cobrem escritas no banco. Mas nem tudo que você quer
auditar é uma escrita — "aprovou o pedido", "reenviou o e-mail", "fez login",
"exportou". Para esses, chame auditAction() com o nome que quiser:
$pedido->auditAction('aprovado'); $pedido->auditAction('email_reenviado', [ 'para' => $cliente->email, 'via' => 'ses', ]);
Fica no mesmo histórico dos eventos automáticos, com o nome que você deu.
Consultar o histórico de um registro específico
$produto->audits te dá o histórico do model que você já carregou. Quando
você tem só o id, ou quer filtrar, use auditsFor() — que devolve um
query builder:
// Tudo do registro 42, sem precisar carregar o Produto Produto::auditsFor(42)->get(); // Só as aprovações Produto::auditsFor(42)->action('aprovado')->get(); // A última alteração feita por um usuário Produto::auditsFor(42)->byUser($userId)->latest()->first(); // Só as falhas deste registro Produto::auditsFor(42)->failures()->get();
Filtros disponíveis: action(), byUser(), failures(), inBatch().
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_infotraz driver e nome do banco, mas nunca host ou credenciais. Detalhes de servidor só aparecem fora de produção.
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:
// config/auditable.php 'connection' => 'tenant',
Cenário B — um banco só, com coluna tenant_id
Todos os tenants no mesmo banco, separados por uma coluna? Ative o modo por coluna e diga ao pacote como descobrir o tenant atual:
// config/auditable.php 'tenant' => [ 'enabled' => true, 'column' => 'tenant_id', 'resolver' => fn () => auth()->user()?->tenant_id, // ajuste à sua realidade ],
Depois, use o trait BelongsToTenant nos models que devem ser isolados:
use Gsebastiao\Auditable\Concerns\Auditable; use Gsebastiao\Auditable\Concerns\BelongsToTenant; class Produto extends Model { use Auditable; use BelongsToTenant; // filtra por tenant e preenche tenant_id sozinho }
A partir daí, cada tenant só enxerga os próprios dados — e a auditoria de um tenant nunca vaza para outro.
Regra do pacote: ele 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
Trocar onde/como a auditoria é gravada (fila, serviço externo…)
Cada peça do pacote é uma interface com implementação padrão. Para trocar,
religue no seu AppServiceProvider:
| Interface | O que faz | Padrão |
|---|---|---|
AuditRepository |
Persiste a auditoria | Grava via Eloquent |
BatchIdGenerator |
Agrupa operações relacionadas | ULID |
ContextResolver |
Descobre usuário e tenant atuais | auth() + seu resolver |
use Gsebastiao\Auditable\Contracts\AuditRepository; public function register(): void { $this->app->bind(AuditRepository::class, MinhaAuditoriaNaFila::class); }
Usar seu próprio model de auditoria (outra tabela, relações extras…)
use Gsebastiao\Auditable\Models\Audit as BaseAudit; class Audit extends BaseAudit { // suas relações, scopes, accessors… }
// config/auditable.php 'model' => App\Models\Audit::class,
Ligar/desligar auditoria globalmente (testes, seeders…)
// config/auditable.php 'enabled' => env('AUDITABLE_ENABLED', true),
# .env.testing AUDITABLE_ENABLED=false
Referência rápida
// No model use Gsebastiao\Auditable\Concerns\Auditable; // torna auditável use Gsebastiao\Auditable\Concerns\BelongsToTenant; // isolamento por tenant (opcional) // Nas opções (getAuditOptions) AuditOptions::defaults() ->resolveMap([...]) // traduz FKs ->except([...]) // ignora campos ->only([...]) // ou: só estes campos ->events([...]) // quais eventos auditar ->onlyDirty() // só o que mudou ->logEmpty(false); // pular logs vazios // Modos de tradução ResolveMap::direct(label, table, column); // FK → tabela ResolveMap::join([...]); // por tabelas intermediárias ResolveMap::alias(label); // só renomear // Registrar (além dos eventos automáticos) $model->auditAction('aprovado', [...]); // ação de domínio nomeada $model->auditFailure('op', $exception, [...]); // falha com debug técnico // Consultar histórico $model->audits; // do model já carregado Model::auditsFor($id); // por id (query builder) ->action('aprovado') // filtros encadeáveis: ->byUser($userId) ->failures() ->inBatch($batch); // 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)
Licença
MIT. Use à vontade.
🇬🇧 English
Installation
composer require gsebastiao/auditable
Publish the config and migration, then run the migration:
php artisan vendor:publish --tag=auditable-config php artisan vendor:publish --tag=auditable-migrations php artisan migrate
That's it. Nothing else is required.
Getting started (2 minutes)
Step 1 — Add the Auditable trait to any model:
use Illuminate\Database\Eloquent\Model; use Gsebastiao\Auditable\Concerns\Auditable; class Product extends Model { use Auditable; }
That's all you need to start. From now on, create, update and delete
on this model are audited automatically:
$product = Product::create(['name' => 'Coffee', 'price' => 20]); $product->update(['price' => 25]);
Step 2 — Read the history whenever you want:
$product->audits; // collection with the full history of the record
Each entry carries the event (created/updated/deleted), what changed, who
did it, and when. No further setup.
Turning IDs into names (the whole point)
If your model has foreign keys, tell Auditable how to turn them into readable text. You do that by adding one method to the model:
use Gsebastiao\Auditable\Support\AuditOptions; use Gsebastiao\Auditable\Support\ResolveMap; class Product extends Model { use Auditable; public function getAuditOptions(): AuditOptions { return AuditOptions::defaults()->resolveMap([ // status_id: look up the name in the "statuses" table 'status_id' => ResolveMap::direct( label: 'Status', // how it shows in the log table: 'statuses', // where to look column: 'name', // which column is the text ), ]); } }
Now, instead of status_id: 2 → 5, the log records Status: "Active" → "Blocked".
The three translation modes
| Mode | When to use | Example |
|---|---|---|
direct |
The FK points straight to a table with the name | status_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
The same getAuditOptions() controls the rest. Everything is optional:
AuditOptions::defaults() ->except(['updated_at', 'secret']) // never audit these fields ->only(['price', 'status_id']) // OR: audit only these ->events(['updated', 'deleted']) // skip "created" ->onlyDirty() // only record what actually changed (default) ->logEmpty(false); // don't record if nothing changed (default)
Passwords and tokens (
password,remember_token) are ignored by default.
Multi-table operations: one batch, one story
This is the scenario that ties everything together. You create an order — and along with it come the customer, the line items, a stock decrement. These are writes across different tables, but they're part of the same operation. You want to look at any one of them later and reconstruct the whole thing.
Wrap the operation in Audit::transaction() (or Audit::batch() if you don't
want a transaction). Everything audited inside — from any model — gets the
same batch:
use Gsebastiao\Auditable\Audit; Audit::transaction(function () use ($data) { $customer = Customer::create($data['customer']); $order = Order::create(['customer_id' => $customer->id, ...]); foreach ($data['items'] as $item) { Item::create(['order_id' => $order->id, ...]); } });
The customer, the order and all items are recorded under a single batch. And because it's a transaction, if any part fails, everything rolls back — writes and audit trail together.
Recovering the whole operation from a single record
Now the part you described: you have just the customer and want to see
everything that came in with it. Call operation():
$customer = Customer::find($id); $customer->operation()->get(); // -> returns the audits for the customer, the order AND the items // (everything that shared the batch)
Or, if you only have the id:
Customer::operationFor($id)->get();
Since the result is a normal query builder, group by table to display it:
$customer->operation()->get()->groupBy('subject_type'); // [ // 'App\Models\Customer' => [ ... ], // 'App\Models\Order' => [ ... ], // 'App\Models\Item' => [ ... ], // ]
Just need the batch id?
$customer->batchOf()returns the identifier of that record's latest operation — handy for logs or passing along.
Propagating the batch to queues
If part of the operation runs in an async job and you want it in the same batch, pass the id to the job and reopen it there:
// When dispatching: ProcessOrder::dispatch($order, Audit::currentBatch()); // Inside the job: public function handle(): void { Audit::useBatch($this->batchId); // everything audited here joins the original operation's batch }
Custom actions (beyond create/update/delete)
The three automatic events cover database writes. But not everything you want to
audit is a write — "approved the order", "resent the email", "logged in",
"exported". For those, call auditAction() with whatever name you want:
$order->auditAction('approved'); $order->auditAction('email_resent', [ 'to' => $customer->email, 'via' => 'ses', ]);
It lands in the same history as the automatic events, under the name you gave.
Querying a specific record's history
$product->audits gives you the history of a model you already loaded. When
you only have the id, or want to filter, use auditsFor() — it returns a
query builder:
// Everything for record 42, without loading the Product Product::auditsFor(42)->get(); // Only approvals Product::auditsFor(42)->action('approved')->get(); // The last change made by a user Product::auditsFor(42)->byUser($userId)->latest()->first(); // Only failures for this record Product::auditsFor(42)->failures()->get();
Available filters: action(), byUser(), failures(), inBatch().
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_infoincludes the driver and database name, but never host or credentials. Server details only show outside production.
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:
// config/auditable.php 'connection' => 'tenant',
Scenario B — one database, with a tenant_id column
All tenants in the same database, separated by a column? Enable column mode and tell the package how to find the current tenant:
// config/auditable.php 'tenant' => [ 'enabled' => true, 'column' => 'tenant_id', 'resolver' => fn () => auth()->user()?->tenant_id, // adjust to your setup ],
Then use the BelongsToTenant trait on the models that must be isolated:
use Gsebastiao\Auditable\Concerns\Auditable; use Gsebastiao\Auditable\Concerns\BelongsToTenant; class Product extends Model { use Auditable; use BelongsToTenant; // filters by tenant and fills tenant_id on its own }
From then on, each tenant only sees its own data — and one tenant's audit trail never leaks into another's.
Package rule: it reads which tenant is current, it never decides. Your app or your tenancy package decides. That's why the
resolveris yours.
Advanced customization
Change where/how audits are stored (queue, external service…)
Every piece of the package is an interface with a default implementation. To
swap one, rebind it in your AppServiceProvider:
| Interface | What it does | Default |
|---|---|---|
AuditRepository |
Persists the audit entry | Writes via Eloquent |
BatchIdGenerator |
Groups related operations | ULID |
ContextResolver |
Finds current user and tenant | auth() + your resolver |
use Gsebastiao\Auditable\Contracts\AuditRepository; public function register(): void { $this->app->bind(AuditRepository::class, MyQueuedAudit::class); }
Use your own audit model (other table, extra relations…)
use Gsebastiao\Auditable\Models\Audit as BaseAudit; class Audit extends BaseAudit { // your relations, scopes, accessors… }
// config/auditable.php 'model' => App\Models\Audit::class,
Toggle auditing globally (tests, seeders…)
// config/auditable.php 'enabled' => env('AUDITABLE_ENABLED', true),
# .env.testing AUDITABLE_ENABLED=false
Quick reference
// On the model use Gsebastiao\Auditable\Concerns\Auditable; // makes it auditable use Gsebastiao\Auditable\Concerns\BelongsToTenant; // per-tenant isolation (optional) // In the options (getAuditOptions) AuditOptions::defaults() ->resolveMap([...]) // translate FKs ->except([...]) // ignore fields ->only([...]) // or: only these fields ->events([...]) // which events to audit ->onlyDirty() // only what changed ->logEmpty(false); // skip empty logs // Translation modes ResolveMap::direct(label, table, column); // FK → table ResolveMap::join([...]); // through intermediate tables ResolveMap::alias(label); // just rename // Record (beyond the automatic events) $model->auditAction('approved', [...]); // named domain action $model->auditFailure('op', $exception, [...]); // failure with tech debug // Read history $model->audits; // of the already-loaded model Model::auditsFor($id); // by id (query builder) ->action('approved') // chainable filters: ->byUser($userId) ->failures() ->inBatch($batch); // 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)
License
MIT. Use it freely.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 1
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-07-09