siberfx / laravel-logger
A tiny, zero-config Eloquent activity logger that stores old/new values together with relational snapshots (belongsTo, hasMany, belongsToMany + pivot).
Requires
- php: ^8.4
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/log: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^3.8 || ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A tiny Eloquent activity logger. Add one trait and it logs created, updated, deleted and restored events.
Each activity stores old and new values together with the related data as it was at that moment: the author's name, the tags and their pivot values, the comments. You can still see who it was about after the related rows have changed or been deleted.
Why not spatie/laravel-activitylog?
| spatie/activitylog | siberfx/laravel-logger | |
|---|---|---|
| Setup on a model | trait + getActivitylogOptions() |
trait only |
Hidden attributes (password, tokens) |
logged unless you exclude them | never logged |
Updates that only touch updated_at |
need extra config | skipped |
| Relational data | foreign key ids only | snapshots of related records (belongsTo, morphTo, hasOne, hasMany, belongsToMany + pivot) |
Foreign key change (user_id 1 → 2) |
1 → 2 |
old and new related record |
sync() / attach() / detach() |
not logged | logRelation() records attached / detached / pivot-updated records |
| Delete with cascades | children gone | relations captured before delete |
| Restore of a soft-deleted model | updated + restored |
restored once |
| Batches | manual | automatic per request / job / command, carried into queued jobs |
| Code | many classes | ~6 small classes |
Install
composer require siberfx/laravel-logger
php artisan vendor:publish --tag=laravel-logger-migrations
php artisan migrate
# optional
php artisan vendor:publish --tag=laravel-logger-config
Requires PHP 8.4+ and Laravel 12 or 13.
Usage
use Siberfx\LaravelLogger\Concerns\LogsActivity; class Post extends Model { use LogsActivity; // Everything below is optional. protected array $logOnly = ['title', 'status']; // whitelist protected array $logExcept = ['views']; // blacklist protected array $logEvents = ['created', 'updated']; // default: created, updated, deleted, restored protected array $logRelations = [ 'author' => ['id', 'name'], // only these columns of the related model 'tags', // all visible columns + pivot 'comments', ]; }
What gets stored
$post->update(['title' => 'New', 'user_id' => $john->id]); $activity = $post->activities()->first(); $activity->event; // "updated" $activity->old_values; // ['title' => 'Old', 'user_id' => 1] $activity->new_values; // ['title' => 'New', 'user_id' => 2] $activity->diff(); // ['title' => ['old' => 'Old', 'new' => 'New'], ...] $activity->causer; // the authenticated user $activity->batch; // uuid shared by everything in this request $activity->context; // ['ip' => ..., 'user_agent' => ..., 'method' => ..., 'url' => ...] $activity->relationSnapshot('author'); // [ // 'relation' => 'belongsTo', // 'value' => ['type' => 'App\Models\User', 'id' => 2, 'attributes' => ['id' => 2, 'name' => 'John']], // 'old' => ['type' => 'App\Models\User', 'id' => 1, 'attributes' => ['id' => 1, 'name' => 'Jane']], // ] $activity->relationSnapshot('tags'); // ['relation' => 'belongsToMany', 'value' => [ // ['type' => 'App\Models\Tag', 'id' => 3, 'attributes' => ['id' => 3, 'name' => 'php'], 'pivot' => ['post_id' => 1, 'tag_id' => 3, 'weight' => 5]], // ]]
Values go through the model's own serialization, so casts, enums and dates appear the way toArray() shows them.
Pivot / to-many changes
Eloquent fires no model events for sync, attach, detach or updateExistingPivot. Wrap the call in logRelation():
$post->logRelation('tags', fn ($tags) => $tags->sync([1 => ['weight' => 9], 4]));
This writes one relation_updated activity:
$activity->old_values; // ['tags' => [1, 2]] $activity->new_values; // ['tags' => [1, 4]] $activity->relationSnapshot('tags'); // ['attached' => [...records], 'detached' => [...], 'updated' => [['old' => ..., 'new' => ...]]]
It works with any to-many relation, including hasMany. If nothing changed, nothing is written.
Manual activities
activity()->on($order)->by($admin)->event('shipped')->with('carrier', 'DHL')->log('Order shipped'); $order->logActivity()->event('viewed')->log(); ActivityLog::new()->event('export')->with(['rows' => 1200])->log('CSV export');
by(null) logs explicitly without a causer.
Querying
use Siberfx\LaravelLogger\Models\Activity; $post->activities; // latest first Activity::forSubject($post)->get(); Activity::causedBy($user)->forEvent('updated', 'deleted')->get(); Activity::inBatch($uuid)->get(); // everything that happened together
Control
use Siberfx\LaravelLogger\Facades\ActivityLog; ActivityLog::withoutLogging(fn () => Post::query()->each->touch()); ActivityLog::disable(); /* ... */ ActivityLog::enable(); ActivityLog::batch(function (string $batch) { // force a fresh batch id // ... }); ActivityLog::resolveCauserUsing(fn () => auth('api')->user()); // e.g. in AppServiceProvider
Pruning
Set prune_after_days in the config and schedule:
Schedule::command('model:prune', ['--model' => [\Siberfx\LaravelLogger\Models\Activity::class]])->daily();
Use cases
Each scenario below uses only the API documented above.
1. Admin panel audit trail: "who changed this, and when?"
Add the trait to the models your back office edits. Every save through a form, Livewire component or Filament/Nova resource is logged against the logged-in admin.
class Customer extends Model { use LogsActivity; protected array $logRelations = ['account_manager' => ['id', 'name']]; } // Controller / component for the "History" tab $history = $customer->activities()->with('causer')->paginate(20);
@foreach ($history as $activity) <li> {{ $activity->causer?->name ?? 'System' }} {{ $activity->event }} {{ $activity->created_at->diffForHumans() }} @foreach ($activity->diff() as $field => $change) <div>{{ $field }}: {{ json_encode($change['old']) }} → {{ json_encode($change['new']) }}</div> @endforeach </li> @endforeach
2. E-commerce order history
Track status changes, the address at the time of the change, and line items, including what the cart looked like when the order was cancelled.
class Order extends Model { use LogsActivity; protected array $logOnly = ['status', 'total', 'shipping_address_id', 'paid_at']; protected array $logRelations = [ 'customer' => ['id', 'name', 'email'], 'shippingAddress', 'items', ]; } $order->update(['status' => 'shipped']); activity()->on($order)->event('shipped')->with(['carrier' => 'DHL', 'tracking' => 'JD0146'])->log('Order shipped'); // Every status the order went through Activity::forSubject($order)->forEvent('updated')->get() ->filter(fn ($a) => array_key_exists('status', $a->new_values ?? [])) ->map(fn ($a) => [$a->created_at, $a->old_values['status'], $a->new_values['status']]);
3. Roles, permissions and team membership
sync() on a pivot table fires no model events. Wrap it so that privilege changes are recorded together with the records that were attached or detached:
$user->logRelation('roles', fn ($roles) => $roles->sync($request->input('roles'))); $team->logRelation('members', fn ($members) => $members->updateExistingPivot($user->id, ['role' => 'owner'])); // "Who gave Jane admin rights?" Activity::forSubject($jane)->forEvent('relation_updated')->get() ->first(fn ($a) => collect($a->relationSnapshot('roles')['attached'] ?? []) ->contains(fn ($r) => $r['attributes']['name'] === 'admin')) ?->causer;
4. CMS content revisions
Keep a timeline of a page or article, including the author and tags it had at each revision, and restore an earlier value.
class Article extends Model { use LogsActivity, SoftDeletes; protected array $logExcept = ['view_count']; protected array $logRelations = ['author' => ['id', 'name'], 'tags', 'category' => ['id', 'title']]; } // Restore the title from a given revision $revision = $article->activities()->findOrFail($id); $article->update(['title' => $revision->old_values['title']]);
old_valueshold serialized values (astoArray()returns them), so check casts like dates or enums before writing them back.
5. Investigating deleted records
On delete, relations are snapshotted before the row and any cascaded children are removed, so the log still shows what was lost (here Customer lists 'orders' in $logRelations):
$activity = Activity::query() ->where('subject_type', (new Customer)->getMorphClass()) ->where('subject_id', 42) ->forEvent('deleted', 'force_deleted') ->latest('id') ->first(); $activity->causer; // who deleted it $activity->old_values; // the customer's last attributes $activity->relationSnapshot('orders')['value']; // the orders it had at that moment
6. Everything that happened in one request, job or import
All activities written during one request, queued job or artisan command share a batch UUID, and queued jobs dispatched from that request inherit it. Use it to show a checkout or a bulk action as one entry:
// "Checkout" created an order, decremented stock on 3 products and updated the customer Activity::inBatch($activity->batch)->with('subject')->get(); // Give each row of a CSV import its own batch foreach ($rows as $row) { ActivityLog::batch(fn () => Product::updateOrCreate(['sku' => $row['sku']], $row)); }
7. APIs, multiple guards and machine users
// AppServiceProvider::boot() ActivityLog::resolveCauserUsing(fn () => auth('sanctum')->user() ?? auth('web')->user()); // A webhook or integration acting as a dedicated system user ActivityLog::resolveCauserUsing(fn () => User::firstWhere('email', 'stripe-bot@example.com'));
Or set guard in the config for a single non-default guard.
8. Seeders, migrations and maintenance scripts
Don't fill the log with noise from bulk jobs you control:
// DatabaseSeeder ActivityLog::withoutLogging(fn () => $this->call([UserSeeder::class, ProductSeeder::class])); // Recalculate cached columns without logging the changes ActivityLog::withoutLogging(fn () => Product::query()->each(fn ($p) => $p->update(['rating' => $p->reviews()->avg('stars')])));
Set ACTIVITY_LOGGER_ENABLED=false to turn logging off for a whole environment.
9. Business events that aren't model changes
Log logins, exports, downloads and other domain events in the same table, so one timeline holds everything:
// Listener for Illuminate\Auth\Events\Login activity()->by($event->user)->event('login')->log(); // Export endpoint ActivityLog::new()->event('export')->with(['report' => 'sales', 'rows' => $count])->log('Sales CSV export'); // Visiting a sensitive record $patient->logActivity()->event('viewed')->log();
10. Compliance and privacy (GDPR, SOC 2, HIPAA)
- Attributes in
$hidden(password,remember_token, API tokens) are never written. Add other sensitive fields (ssn,iban, encrypted casts) to$hiddenor$logExcept. - Keep the log on a separate, locked-down database with
ACTIVITY_LOGGER_DB_CONNECTION. - Enforce a retention period with
prune_after_daysand a dailymodel:prune. contextrecords IP, user agent, method and URL for each web change. Setstore_contexttofalseif you can't keep IP addresses.
11. Notifications and real-time feeds
Activity is a plain Eloquent model, so you can hook into its events:
// AppServiceProvider::boot() Activity::created(function (Activity $activity) { if ($activity->event === 'deleted' && $activity->subject_type === (new Invoice)->getMorphClass()) { Notification::route('slack', config('services.slack.audit'))->notify(new InvoiceDeleted($activity)); } broadcast(new ActivityRecorded($activity)); // live "recent activity" widget });
12. Extending the Activity model
Point activity_model in the config at your own subclass to add scopes, accessors or relations:
class Activity extends \Siberfx\LaravelLogger\Models\Activity { public function scopeToday($query) { return $query->whereDate('created_at', today()); } public function getSummaryAttribute(): string { return sprintf('%s %s #%s', $this->causer?->name ?? 'System', $this->event, $this->subject_id); } }
Config
See config/laravel-logger.php: enabled, table_name, connection, activity_model, guard, ignore_attributes, auto_batch, store_context, relation_limit, prune_after_days.
Notes
- Relation snapshots run one query per logged relation per event. List only the relations you need in
$logRelations; to-many relations are capped byrelation_limit. - Encrypted casts are serialized decrypted, the same as
toArray(). Add those attributes to$hiddenor$logExceptif they shouldn't be in the log. - Mass updates and deletes (
Post::where(...)->update()) skip Eloquent events. This is true of every event-based logger. - The morph columns follow
Schema::morphUsingUuids()/morphUsingUlids()if your app uses them.
Testing
The suite uses Pest:
composer test # or vendor/bin/pest
License
MIT. See LICENSE.md.