Версія v1.6.5 Попередні версії: документація на GitHub
Журнал аудиту
На цій сторінці
- Швидкий старт
- Що записується
- Які поля потрапляють у різницю
- Значення
- Маскування чутливих полів
- Вимкнення для Entity
- Дії
- Транзакції
- Зміни поза формою
- Хто бачить журнал
- Незмінність
- Інтерфейс
- Історія змін запису
- Сторінка «Журнал аудиту»
- Конфігурація
- Міграція
- Підпис користувача
- Очищення
- Видалення й знеособлення даних людини
- Подія AuditRecorded
- HTTP-ендпоінти
- Продуктивність
- Обмеження
Журнал аудиту записує, хто, коли і що змінив через панель: створення, зміну й видалення записів, запуск
дій, прив'язування й відв'язування в BelongsToMany. Для кожної зміни зберігаються значення полів
«було → стало». Запис журналу пишеться в тій самій транзакції БД, що й зміна.
Журнал переглядається в панелі «Історія змін» унизу сторінки запису та на сторінці «Журнал аудиту» в
боковому меню. Обидва місця доступні лише користувачам, яким застосунок явно дозволив gate
viewLarsadminAudit.
Журнал вимкнено за замовчуванням. Додаткових залежностей Composer чи npm не потрібно.
Швидкий старт
-
Опублікуйте й виконайте міграцію (
larsadmin:installу нових проєктах уже публікує її):php artisan vendor:publish --tag=larsadmin-migrations php artisan migrate -
Увімкніть журнал у
.env:LARSADMIN_AUDIT=true -
Дозвольте перегляд у
app/Providers/LarsadminServiceProvider.php:protected function gate(): void { Gate::define('viewLarsadmin', fn ($user) => in_array($user->email, ['[email protected]'])); Gate::define('viewLarsadminAudit', fn ($user) => in_array($user->email, ['[email protected]'])); } -
Заплануйте очищення старих записів у
routes/console.php:use Illuminate\Support\Facades\Schedule; Schedule::command('larsadmin:prune-audit')->daily();
Відтепер усі Entity записуються в журнал. Entity, яку не треба аудитувати, вимикається через
$auditable = false.
Увага. Якщо журнал увімкнено, а таблиці немає, кожне збереження в панелі завершується помилкою «The audit log table [...] does not exist», і зміна відкочується. Так панель не робить змін без запису в журналі. Виконайте міграцію або поверніть
LARSADMIN_AUDIT=false.
Що записується
| Подія | Коли | Що зберігається |
|---|---|---|
created |
створення запису у формі | непорожні значення полів |
updated |
збереження форми редагування | лише змінені поля, «було → стало» |
deleted |
видалення запису (кнопка видалення) | значення полів на момент видалення; для soft delete позначка «Переміщено в кошик» |
action |
запуск дії | по одному запису на кожен оброблений запис з різницею полів; для standalone-дії один запис без запису-об'єкта |
attached, detached |
прив'язування й відв'язування в панелі BelongsToMany |
поле панелі, Entity й ключ пов'язаного запису; запис журналу належить батьківському запису |
Кожен запис журналу також зберігає:
- користувача: тип і ключ моделі, guard і знімок підпису на момент зміни (див. Підпис користувача);
- IP-адресу й User-Agent (кожне вимикається конфігом:
store_ip,store_user_agent); batch_id: спільний ідентифікатор записів однієї операції, наприклад усіх записів одного запуску дії.
Збереження форми без жодної зміни запису в журнал не додає. Якщо збереження або видалення скасовано
подією моделі (saving, deleting тощо повертає false), запис у журнал теж не пишеться.
Які поля потрапляють у різницю
Різниця рахується за полями Entity, а не за всіма атрибутами моделі:
- записуються всі поля з
fields(), незалежно від видимості (hideFrom*,onlyOn*,canSee): що показати, вирішується під час перегляду (див. Хто бачить журнал); - панелі зв'язків (
HasMany,BelongsToMany) не записуються як поля; прив'язування записуються окремими подіямиattached/detached; - обчислювані поля без колонки в моделі не записуються, крім полів, які повертають своє значення через
hasAuditValue()іauditValue()(з 1.5, наприклад медіа чи теги в іншій таблиці; див. Розширення: аудит); - атрибути моделі, яких немає серед полів Entity (
updated_at, службові колонки), не записуються.
Поля беруться з fields() для поточного запиту. Якщо fields() повертає різний набір для різних
користувачів, у журнал потрапляють лише поля, які fields() повернув для користувача, що зробив
зміну. Щоб журнал фіксував поле завжди, додавайте його безумовно, а видимість обмежуйте через
canSee().
Значення
BelongsToзберігає зовнішній ключ. Назва пов'язаного запису визначається під час перегляду і лише в межах того, що бачить глядач (див. Хто бачить журнал).FileіImageзберігають назви й шляхи файлів, не вміст. У браузер передаються лише назви.Selectзберігає значення, а при перегляді показується підпис опції.- Дати й enum нормалізуються: атрибут з кастом
dateякY-m-d, інші дати як ISO-8601, enum якvalue(backed) абоname. - Порівняння таке саме, як у перевірці «брудних» атрибутів Eloquent: надісланий формою
"5"для колонки, де вже5, не є зміною;true/falseпроти1/0(колонка без кастуboolean) теж ні. Два рядки порівнюються строго:"007"→"7"це зміна. - Довжина. Кожне значення обрізається до
max_value_lengthсимволів (за замовчуванням 1000, не менше 100); масиви спершу кодуються в JSON. Уся різниця одного запису журналу обмежена 64 КБ: найбільші значення замінюються порожніми. Обрізані значення позначаються в інтерфейсі як «(обрізано)».
Маскування чутливих полів
Для чутливих полів журнал записує лише факт зміни, без значень «було» і «стало». В інтерфейсі такі зміни показуються як «Приховано».
Маскуються автоматично:
- усі поля
Password(їх не можна «розмаскувати»:->sensitive(false)наPasswordне діє); - атрибути, перелічені в
$hiddenмоделі, а якщо модель має непорожній$visible, то всі атрибути поза ним; - атрибути з кастами
hashed,encrypted,encrypted:*(encrypted:array,encrypted:collection,encrypted:object),AsEncryptedArrayObject,AsEncryptedCollection, а також з будь-яким класом касту, у назві якого є «encrypt» (наприклад, власнийEncryptedJson).
Під час перегляду як «Приховано» показуються ще й зміни полів, яких глядач не бачить ні в списку, ні на
сторінці запису (наприклад, onlyOnForms() або hideFromIndex() разом з hideFromDetail()).
Будь-яке інше поле позначається вручну:
Text::make('API-токен', 'api_token')->sensitive(),
Textarea::make('Внутрішня нотатка', 'internal_note')->sensitive(),
sensitive() впливає лише на журнал: форми, список і сторінка запису показують поле як завжди.
Перевірте поля, яких автоматика не впізнає. Журнал записує значення, яке повертає модель. Якщо атрибут розшифровується в accessor (
get…Attribute,Attribute::make()), а не кастом, або JSON-колонка містить токени чи інші секрети, позначте поле->sensitive(), інакше розшифроване значення потрапить у журнал.
Маскування застосовується ще раз під час перегляду за поточними правилами. Якщо поле стало sensitive()
пізніше, його старі значення в журналі теж показуються як «Приховано» (у таблиці вони лишаються до
очищення).
Значення полів дії маскуються за тими самими правилами Password і sensitive(), див.
Дії.
Вимкнення для Entity
class Tag extends Entity
{
public static string $model = \App\Models\Tag::class;
public static bool $auditable = false;
}
Зміни такої Entity не записуються, панель «Історія змін» на сторінці її запису не показується, а ендпоінт історії запису відповідає 404. Записи, зроблені раніше, лишаються на сторінці «Журнал аудиту».
Entity::audited() повертає, чи записується Entity зараз: журнал увімкнено і $auditable дорівнює
true.
| Член | Тип, значення за замовчуванням | Опис |
|---|---|---|
$auditable |
bool, true |
Чи записувати зміни Entity, коли журнал увімкнено |
audited() |
bool |
Larsadmin::auditEnabled() && static::$auditable |
Дії
Запуск дії записується одним записом на кожен оброблений запис. Усі записи одного запуску мають
спільний batch_id.
- Перед
handle()значення полів записів пакета запам'ятовуються, післяhandle()записи перечитуються одним запитом. Різниця між ними і є змінами дії. Записи журналу пишуться в транзакції пакета (chunkSize): якщо пакет відкотився, його записів у журналі немає. - Виклики
Entity::save()іEntity::deleteRecord()усерединіhandle()для записів самого пакета окремих записів журналу не створюють: їх покриває запис дії. Збереження інших записів (наприклад, пов'язаних через їхню Entity) записуються як звичайно, зі спільнимbatch_idзапуску. - Якщо після дії запису в базі вже немає, запис журналу позначається «Запис видалено цією дією».
- Записується лише різниця полів цільової Entity. Зміни інших таблиць, які робить
handle(), у різницю не потрапляють. - Запис також зберігає назву дії на момент запуску і значення її полів. Значення
Passwordі полів зsensitive()маскуються, довгі значення обрізаються. standalone-дія пише один запис без запису-об'єкта: він видно лише на сторінці «Журнал аудиту».- Фонова дія записується так само. Користувач, IP-адреса й
User-Agent беруться з моменту запуску, усі пакети запуску мають спільний
batch_id, аmetaзаписів містить ідентифікатор запуску (run). У JSON журналу така дія маєaction.queued: true.
Під час перегляду показуються лише поля дії, які є в поточному fields() дії й проходять canSee.
Якщо дію видалено з Entity, показується лише її назва.
Транзакції
Запис журналу вставляється в тій самій транзакції, що й зміна:
- зміна відкотилася (виняток під час збереження, падіння пакета дії) → запису в журналі немає;
- вставка в журнал не вдалася → виняток, і зміна теж відкочується.
Це діє, коли журнал і модель Entity працюють на одному з'єднанні БД. Якщо audit.connection вказує на
інше з'єднання, запис вставляється після коміту зміни, без атомарності: якщо вставка не вдалася,
зміна лишається, у лог пишеться повідомлення рівня critical (з batch_id, Entity і ключами
записів), а виняток передається в report(). Налаштуйте сповіщення на такі повідомлення.
Зміни поза формою
Власний код (наприклад, маршрут розширення) записує зміну запису в журнал через Larsadmin::audited():
Larsadmin::audited($request, 'posts', $record, function (Model $record) {
$record->tags()->sync($ids);
});
Панель знімає поля Entity до й після замикання, рахує різницю з маскуванням і записує подію updated
в одній транзакції із замиканням. Запису немає, якщо нічого не змінилося, журнал вимкнено чи Entity не
аудитується. Другий аргумент є uriKey Entity. Див.
Розширення: audited().
Хто бачить журнал
Сторінка «Журнал аудиту» не обмежується за рядками. Вона не перевіряє
indexQuery(),detailQuery()іviewдля кожного запису журналу (запис міг бути видалений, і політику для нього вже не обчислити). Будь-хто з gateviewLarsadminAuditіviewAnyна Entity бачить усі записи журналу цієї Entity, зокрема записи поза своїмindexQuery(), з усіма видимими йому полями. Gate це межа довіри: у multi-tenant застосунках давайте його лише тим, хто може бачити дані всіх орендарів.
Перегляд закритий, доки застосунок не визначить gate viewLarsadminAudit. Пакет цей gate не
визначає, тож без нього журнал не бачить ніхто (fail closed). Gate::before (наприклад, для
суперадміна) враховується. allow_local на цей gate не діє: навіть у APP_ENV=local потрібен gate.
| Що | Умови |
|---|---|
| Пункт меню «Журнал аудиту» | журнал увімкнено і gate viewLarsadminAudit дозволяє |
| Сторінка «Журнал аудиту» | gate; показуються лише записи Entity, зареєстрованих зараз, на яких користувач має viewAny |
| Панель «Історія змін» запису | gate, $auditable, viewAny Entity, запис у межах detailQuery() і право view на нього |
Під час перегляду:
- зміни полів, прихованих від глядача через
canSee, не показуються; кількість прихованих змін теж не показується; - зміни полів, яких уже немає серед полів Entity (видалені, перейменовані), не показуються;
- назва й посилання на запис, на пов'язаний запис
BelongsToі на запис зattached/detachedпоказуються лише тоді, коли запис є вdetailQuery()своєї Entity і глядач має на ньогоview. Інакше показується#idбез посилання; видалений і недоступний запис не розрізняються; - IP-адреса й User-Agent показуються лише на сторінці «Журнал аудиту» (у розгорнутому записі), не в панелі запису.
Незмінність
Журнал доступний лише для читання: у панелі немає маршрутів для зміни чи видалення записів. Модель
Maxcxam\Larsadmin\Audit\AuditEntry не дає змінити чи видалити запис через Eloquent: $guarded = ['*'],
а save(), push(), update(), increment()/decrement(), delete() і події updating/deleting
кидають LogicException. Записи пише лише сам пакет, а видаляє лише
larsadmin:prune-audit.
Це захист від випадкових змін у коді, а не від зловмисника: запити через query builder чи SQL напряму модель не зупинить. Для справжньої стійкості до підробки:
- дайте користувачу БД застосунку на таблицю журналу лише
INSERTіSELECT, аDELETEлише користувачу, від якого запускається prune; - або ведіть журнал на окремому з'єднанні (
audit.connection) з окремими правами. Пам'ятайте, що тоді запис не атомарний зі зміною (див. Транзакції).
Див. також Авторизація.
Інтерфейс
Історія змін запису
Панель «Історія змін» розташована внизу сторінки запису, після панелей зв'язків. Вона показує 10 найновіших записів; «Показати ще» дописує наступні. Після збереження, прив'язування чи дії на тій самій сторінці панель перезавантажується.
Кожен запис згорнутий в один рядок: користувач, подія, кількість змінених полів і час. Користувач
показується як підпис з ключем (Олена Коваль (#12)), тож однакові підписи не плутаються; зміна без
користувача позначається «Система». Кнопка розгортає таблицю «Поле / Було / Стало». Час показується в
часовому поясі панелі (larsadmin.timezone).
Історія видаленого запису доступна лише на сторінці «Журнал аудиту».
Сторінка «Журнал аудиту»
Пункт «Журнал аудиту» з'являється в бічному меню під Entity. Сторінка показує записи всіх доступних
Entity, найновіші першими, по audit.per_page на сторінку (25). Колонки: час, користувач, подія,
розділ (Entity), запис, зміни.
Фільтри:
- Розділ: Entity, на яких користувач має
viewAny; - Подія: створення, зміна, видалення, дія, прив'язування, відв'язування;
- Користувач: до 200 користувачів, що трапляються у видимих записах, плюс «Система». Однакові
підписи доповнюються ключем:
Олена Коваль (#12). Список кешується для кожного глядача на 5 хвилин, тож новий користувач може з'явитися у фільтрі не одразу; - Дата: діапазон днів у часовому поясі панелі, обидві межі включно.
Пагінація лише «Попередня / Наступна», без загальної кількості: так сторінка не рахує всю таблицю.
Панель і сторінка доступні з клавіатури та для скрінрідерів (WCAG 2.1 AA): розгортання записів кнопкою з
aria-expanded, різниця передається таблицею й текстом, а не лише кольором.
Конфігурація
config/larsadmin.php:
'audit' => [
'enabled' => env('LARSADMIN_AUDIT', false),
'table' => 'larsadmin_audit_entries',
'connection' => env('LARSADMIN_AUDIT_CONNECTION'),
'retention_days' => 90,
'store_ip' => true,
'store_user_agent' => true,
'max_value_length' => 1000,
'per_page' => 25,
'throttle' => 60,
],
| Ключ | Змінна середовища | За замовчуванням | Опис |
|---|---|---|---|
audit.enabled |
LARSADMIN_AUDIT |
false |
Вмикає запис і перегляд журналу. Вимкнений журнал нічого не пише, а його маршрути відповідають 404 |
audit.table |
larsadmin_audit_entries |
Таблиця журналу. Змінюйте до виконання міграції: міграція бере назву з цього ключа | |
audit.connection |
LARSADMIN_AUDIT_CONNECTION |
null (з'єднання за замовчуванням) |
З'єднання БД журналу. Інше з'єднання, ніж у моделі, означає запис після коміту без атомарності (див. Транзакції) |
audit.retention_days |
90 |
Скільки днів зберігати записи для larsadmin:prune-audit; null зберігає все |
|
audit.store_ip |
true |
Зберігати IP-адресу | |
audit.store_user_agent |
true |
Зберігати User-Agent (до 512 символів) | |
audit.max_value_length |
1000 |
Найдовше збережене значення в символах; не менше 100 | |
audit.per_page |
25 |
Записів на сторінці «Журнал аудиту»; обмежується 1..100 | |
audit.throttle |
60 |
Запитів на хвилину на користувача до маршрутів журналу (лімітер larsadmin-audit); null вимикає обмеження |
Опублікований раніше конфіг без секції audit працює зі значеннями за замовчуванням, тобто з
вимкненим журналом.
Міграція
Пакет не підключає свої міграції до migrate застосунку неявно: міграцію треба опублікувати.
php artisan vendor:publish --tag=larsadmin-migrations
php artisan migrate
Файл з'являється в database/migrations з поточною датою в назві. Таблицю й з'єднання міграція бере з
audit.table і audit.connection, тож задайте їх до migrate.
Підпис користувача
Разом із ключем користувача зберігається знімок його підпису, щоб журнал лишався читабельним і після
перейменування чи видалення користувача. За замовчуванням це name, якщо його немає, email, інакше
#{id}. Свій підпис задається в boot() сервіс-провайдера:
use Maxcxam\Larsadmin\Larsadmin;
Larsadmin::auditActorLabelUsing(fn ($user) => "{$user->name} ({$user->email})");
Замикання отримує Authenticatable і повертає ?string; підпис обрізається до 255 символів.
null замість замикання повертає стандартну поведінку.
Очищення
php artisan larsadmin:prune-audit # старші за audit.retention_days (90 днів)
php artisan larsadmin:prune-audit --days=30 # старші за 30 днів
Команда видаляє записи, старші за вказану кількість днів, пакетами по 1000 і виводить кількість
видалених. --days має бути цілим числом від 1. Якщо retention_days дорівнює null і --days не
задано, команда нічого не видаляє. Команда працює й коли журнал вимкнено.
Пакет не реєструє розклад сам. Додайте команду в планувальник застосунку:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('larsadmin:prune-audit')->daily();
Журнал містить персональні дані (імена й email у підписах, IP-адреси, змінені значення). Обирайте
retention_days відповідно до своїх вимог щодо приватності (наприклад, GDPR) і вимикайте
store_ip/store_user_agent, якщо ці дані не потрібні.
Видалення й знеособлення даних людини
Панель не має інтерфейсу для видалення чи знеособлення записів журналу. Якщо людина просить стерти свої
дані (GDPR, право на забуття), зробіть це запитом у БД від користувача з правом UPDATE/DELETE на
таблицю журналу (модель AuditEntry такі зміни не дозволяє, тож працюйте через DB):
use App\Models\User;
use Illuminate\Support\Facades\DB;
$audit = DB::connection(config('larsadmin.audit.connection'))
->table(config('larsadmin.audit.table', 'larsadmin_audit_entries'));
// Знеособити записи, які зробила людина: прибрати підпис, IP і User-Agent.
(clone $audit)
->where('actor_type', (new User)->getMorphClass())
->where('actor_id', (string) $user->getKey())
->update(['actor_label' => null, 'ip_address' => null, 'user_agent' => null]);
// Або видалити їх повністю.
(clone $audit)
->where('actor_type', (new User)->getMorphClass())
->where('actor_id', (string) $user->getKey())
->delete();
// Історія змін самого запису людини (наприклад, її профілю в Entity користувачів).
(clone $audit)
->where('auditable_type', (new User)->getMorphClass())
->where('auditable_id', (string) $user->getKey())
->delete();
Значення полів у колонці changes інших записів можуть теж містити персональні дані людини; перевірте
їх окремо. Щоб зменшити обсяг таких даних заздалегідь, вимкніть store_ip/store_user_agent і тримайте
retention_days якомога меншим.
Подія AuditRecorded
Після коміту зміни, що записала журнал, диспатчиться подія Maxcxam\Larsadmin\Events\AuditRecorded.
Її властивість entries це Collection моделей AuditEntry з атрибутами, як їх записано. Зручно,
наприклад, для пересилання записів у SIEM:
use Illuminate\Support\Facades\Event;
use Maxcxam\Larsadmin\Events\AuditRecorded;
Event::listen(function (AuditRecorded $event) {
foreach ($event->entries as $entry) {
logger()->channel('audit')->info($entry->event, $entry->only('entity', 'auditable_id', 'actor_label', 'batch_id'));
}
});
- Подія диспатчиться лише тоді, коли в неї є слухачі.
- Записи, вставлені одним запитом (запуск дії на кілька записів), мають
id = null; пов'язуйте їх заbatch_id. - Якщо зміну відкочено, подія не диспатчиться.
- Виняток у слухачі передається в
report()і не ламає запит: зміна вже закомічена.
HTTP-ендпоінти
Обидва маршрути працюють у групі auth_middleware, лише для читання, з лімітером larsadmin-audit
(audit.throttle) і заголовком Cache-Control: no-store, private.
| Маршрут | Ім'я | Відповідь |
|---|---|---|
GET {path}/audit |
larsadmin.audit.index |
Inertia-сторінка Audit/Index; параметри filters[entity|event|actor|date], page |
GET {path}/api/entities/{entity}/{id}/audit?page=N |
larsadmin.api.audit |
JSON-історія запису, 10 записів на сторінку |
{
"entries": [
{
"id": 41,
"event": "updated",
"createdAt": "2026-10-30T14:05:00+02:00",
"actor": { "label": "Олена Коваль", "id": "12", "system": false },
"action": null,
"related": null,
"soft": false,
"recordDeleted": false,
"changes": [
{ "attribute": "title", "label": "Заголовок", "old": { "kind": "text", "text": "Чернетка" }, "new": { "kind": "text", "text": "Київ восени" } },
{ "attribute": "password", "label": "Пароль", "masked": true }
]
}
],
"page": 1,
"perPage": 10,
"hasMore": false,
"timezone": "Europe/Kyiv"
}
actor дорівнює null для зміни без користувача («Система»), інакше { label, id, system: false };
на сторінці «Журнал аудиту» додається ще type.
| Ситуація | Код |
|---|---|
| Журнал вимкнено | 404 |
Немає viewLarsadminAudit |
403 |
Entity з $auditable = false (історія запису) |
404 |
Немає viewAny на Entity або view на запис (історія запису) |
403 |
Запис поза detailQuery() (історія запису) |
404 |
page не ціле число від 1 до 1000 (історія запису; сторінка «Журнал аудиту» тоді показує першу сторінку) |
422 |
Перевищено audit.throttle |
429 |
Відповідь 404 не містить назви класу моделі.
Фронтенд дізнається, чи показувати пункт меню, зі спільного Inertia-пропа larsadmin.audit: null або
{ url }. Сторінка запису отримує проп audit з { url } історії або null.
Продуктивність
| Операція | Додатково |
|---|---|
| Створення, зміна, видалення | 1 INSERT (знімки значень у пам'яті, без додаткових SELECT) |
| Прив'язування, відв'язування | 1 INSERT |
| Дія, на кожен пакет | 1 SELECT для перечитування записів і 1 пакетний INSERT |
Журнал вимкнено або $auditable = false |
нічого |
Таблиця має індекси для історії запису, фільтрів за Entity, користувачем і датою, і для batch_id.
Ріст таблиці обмежують retention_days, max_value_length і ліміт 64 КБ на різницю.
Обмеження
- Записуються лише зміни, зроблені через панель. Зміни, які застосунок робить деінде (свої контролери, черги, tinker, Eloquent напряму), у журнал не потрапляють.
- Не записуються вхід і вихід, перегляди сторінок, завантаження файлів.
- Немає відкату зміни з журналу, експорту, пошуку по вмісту змін.
- Атрибути pivot
BelongsToManyне записуються. Прив'язування видно в історії батьківського запису, а не пов'язаного. - Зміни дочірніх записів
HasManyзаписуються на самих дочірніх записах, якщо вони змінені через їхню Entity. - Різниця дії охоплює лише поля цільової Entity (див. Дії).
- Окреме з'єднання журналу (
audit.connection) не дає атомарності (див. Транзакції). - Зміна зашифрованого атрибута, чиє збережене значення вже не розшифровується (наприклад, після зміни
APP_KEYбезAPP_PREVIOUS_KEYS), усе ще кидає виняток з перевірки «брудних» атрибутів Eloquent, ще до журналу (CRA-15).
Далі: Сповіщення та фонові дії.