Search by

siberfx / laravel-logger

siberfx

A tiny, zero-config Eloquent activity logger that stores old/new values together with relational snapshots (belongsTo, hasMany, belongsToMany + pivot).

Package info

github.com/siberfx/laravel-logger

pkg:composer/siberfx/laravel-logger

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.1 2026-11-06 17:48 UTC

This package is auto-updated.

Last update: 2026-11-06 17:48:34 UTC


README

Latest Version on Packagist Tests Total Downloads PHP Version Laravel Pest License

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_values hold serialized values (as toArray() 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 $hidden or $logExcept.
  • Keep the log on a separate, locked-down database with ACTIVITY_LOGGER_DB_CONNECTION.
  • Enforce a retention period with prune_after_days and a daily model:prune.
  • context records IP, user agent, method and URL for each web change. Set store_context to false if 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 by relation_limit.
  • Encrypted casts are serialized decrypted, the same as toArray(). Add those attributes to $hidden or $logExcept if 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.