The instrument, not the install guide

activity_log is a growing database object, not a free append.

Log everything and the table grows without limit. Log the few real business events and it stays fast, cheap, and readable. Here is the same order workload under both, and the three levers that close the gap.

  • Log domain events, not every model save.
  • logOnlyDirty + logExcept shrink each row about 3.4x.
  • Retention by compliance class caps the table at steady state.

Laravel activitylog best practices for a fast, compliant audit trail

Logging everything is the anti-pattern. Design an audit trail that stays fast, compliant, and meaningful as activity_log grows into millions of rows.

Laravel activitylog best practices start with the event, not the model#

Search this topic and every guide reads the same. First it installs the package. Then it calls logAll() and moves on. However, that step decides your table's future, and almost no page treats it as a decision. Because logging is framed as a switch you flip, the hard question never gets asked: what is actually worth auditing?

The reframe is simple. An audit trail answers "who changed what, when, and why". Therefore it should record business events, not model saves. Instead of logging every write to a row, log the transitions a human might one day dispute. In short, auditing is domain-event modeling, and the good Laravel activitylog best practices all follow from that.

The default every ranking page teaches: logAll() and logFillable()#

Here is the happy path the tutorials ship. It is one trait and two method calls. Because it captures everything, it looks safe and complete. Yet that is exactly the anti-pattern this post argues against.

Order.php · php
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions;

class Order extends Model
{
    use LogsActivity;

    // The default nearly every tutorial ships: log the whole model, every save.
    public function getActivitylogOptions(): LogOptions
    {
        return LogOptions::defaults()
            ->logAll()        // every attribute, timestamps included
            ->logFillable();  // and every fillable column too
    }
}

// Result: one activity row on every create, update, and delete, each row
// carrying a full snapshot of ~20 attributes as JSON. Nothing is filtered.

Read what this actually does. Every create, update, and delete writes a row. Moreover each row stores a full snapshot of around twenty attributes as JSON. In practice most of those saves are churn, and most of those attributes never change. So you pay to store noise, and you pay again every time a query has to read past it.

The reframe: a few business events worth an immutable record#

Now flip the default. Instead of logging every save, name the transitions that matter. For an order, that is placed, paid, and shipped. For a user, that is role granted and account suspended. Because these are the events a customer or a regulator might question, they deserve a durable, indexed, redacted row.

The distinction is not cosmetic. Consequently it drives every later decision in this post: how big the table gets, which indexes it needs, how long you keep each row, and what you must never store in it. The table below turns the line into a rule you can apply change by change.

Audit as a domain event, or skip as model churn
The changeVerdictWhyPII and tamper note
Order status paid to shippedAudit as eventA real transition someone may disputeNo PII; keep on a retained channel
User role user to adminAudit as eventA security-relevant privilege changeImmutable; consider a hash chain
updated_at bumped by a cron touchSkipNo business meaning, pure churndontLogIfAttributesChangedOnly
last_seen_at heartbeatSkipHigh-frequency noise, telemetry not auditlogExcept, or send to metrics
password or token column changedAudit the event, redact the valueThe fact matters, the secret must not persistlogExcept plus beforeActivityLogged

How big does activity_log actually get?#

Numbers make the stakes concrete, so start with a real workload. Consider an e-commerce app that processes 50,000 orders a day. Each order row is written about nine times across its lifecycle: one create and eight status or field updates. That write rate is the input to everything that follows.

The worked numbers: 50,000 orders a day, nine writes each#

Under logAll(), every one of those writes becomes an activity row. Therefore you record 450,000 rows a day, each near 1.2 KB because it snapshots twenty attributes. Now compare the pattern: log only three business events per order, and shrink each row with logOnlyDirty(). The felt gap is large.

A year of order auditing: log everything vs log the events~40x smaller

~197 GB

logAll(), full snapshots, 365-day retention

The pattern

~4.7 GB

domain events, logOnlyDirty, 90-day retention

Same order volume, two logging strategies. Logging everything settles near 197 GB and 164 million rows; logging the three real events settles near 4.7 GB and 13.5 million rows. These are illustrative, order-of-magnitude figures, not a benchmark.

A year of order auditing: log everything vs log the events (steady-state activity_log footprint (illustrative))
Optionsteady-state activity_log footprint (illustrative)
logAll(), full snapshots, 365-day retention~197 GB
domain events, logOnlyDirty, 90-day retention~4.7 GB

Read the ratio, not just the totals. Because the anti-pattern reaches roughly 197 GB and 164 million rows at steady state, every unindexed lookup scans that table. Meanwhile the pattern settles near 4.7 GB and 13.5 million rows. So the same audit intent costs about forty times less, purely from three independent levers.

Where the savings come from: three independent levers#

The forty-fold gap is not one trick. Instead it is three multiplying cuts, and you can apply them one at a time. First, log domain events, not churn, which drops the row count from 450,000 to 150,000 a day. Second, logOnlyDirty and logExcept shrink each row about 3.4 times. Third, a 90-day retention window caps the table at steady state.

Show data table
Projected GB per year as each lever is applied (illustrative)
Step Change Running total
logAll, full rows, 365d 197 GB 197 GB
logOnlyDirty + logExcept -139 GB 58 GB
Domain events only -39 GB 19 GB
90-day retention -14.3 GB 4.7 GB
All three levers 4.7 GB

Each lever is independent, so you can stop at the footprint your budget allows. Shrinking the row comes first, logging events not churn comes next, and retention caps the steady state. These are illustrative, order-of-magnitude figures, not a benchmark.

Figure Projected GB per year as each lever is applied (illustrative) Projection from Spatie activitylog row sizes at the stated write volume. Modelled, not measured.

Notice that no lever needs a rewrite. Each is a first-party option or a config value. Therefore you keep the package, keep the API, and simply stop storing what you will never read. Next, put your own numbers through the same model.

Model the cost of your own audit trail#

A general curve is not your curve, so enter your own workload. Set your audited writes a day, pick a row shape, choose a retention window, and mark the share of events that are compliance-relevant. Then read the steady-state footprint, the reduction against logging everything, and the verdict band your numbers land in.

Audit-volume and retention calculator: size your own activity_log table
Row shape (bytes per row)
Watch itIndex it and reclaim disk on a schedule
Steady-state size
11.3 GB
Rows retained
23M
Smaller than logAll
8.1x

This table is large enough to feel. Add the composite indexes so queries seek instead of scan, run activitylog:clean daily, and reclaim freed pages with an OPTIMIZE TABLE in a maintenance window. Shorten the operational window if reads slow.

Steady-state footprint by operational retention window (illustrative)
Retention windowRows retainedTable size
30 days15M7.35 GB
90 days23M11.3 GB
180 days35M17.3 GB
365 days60M29.5 GB

At 150K audited writes per day, changed-attributes-only rows, a 90-day operational window, and 10 percent compliance events: steady-state footprint 11.3 GB across 23M rows, about 8.1 times smaller than logging everything for a year. Verdict: Index it and reclaim disk on a schedule.

An illustrative planning model, not a benchmark. It assumes a full-snapshot row near 1.2 KB, a changed-attributes-only row near 0.35 KB, about 40 percent index overhead, and a separate 730-day channel for compliance events. Real numbers move with your attribute count, JSON payloads, indexes, and MySQL version, so size your own table before you commit to a retention policy.

Enter your own workload. The model computes the steady-state table size and row count, compares it against the taught logAll-everything baseline as two proportional bars, and bands the result. The verdict, the footprint numbers, and the retention table are the accessible source of truth; the bars are decorative. Every figure is illustrative planning math, never a quote.

Watch how the levers interact. Because a full-snapshot row is roughly 3.4 times heavier, flipping the row shape moves the footprint before you touch retention. In contrast, a longer compliance share pulls the steady state back up. This is illustrative planning math, not a quote, so size your own table before you commit to a policy.

Logging the right thing: LogsActivity, logOnly, logExcept, logOnlyDirty#

The reframe now becomes mechanical. Because you decided to record signal not churn, LogOptions is where you encode that. The goal is a row that captures a real change and nothing else. First, contrast the two configurations directly.

Anti-pattern vs pattern LogOptions, side by side#

The same trait supports both extremes. However, the difference in what they store is enormous. Switch the tab to see the shape the pattern produces.

Order.antipattern.php · php
// Anti-pattern: log everything, every save.
public function getActivitylogOptions(): LogOptions
{
    // The table grows without limit and each row stores a full attribute
    // snapshot you will almost never read back.
    return LogOptions::defaults()
        ->logAll()
        ->logFillable();
}

The pattern reads longer, yet it does far less work. Because logOnly names the columns that matter and logOnlyDirty records only what changed, each row is the true diff. Moreover dontLogIfAttributesChangedOnly kills the updated_at-only save that would otherwise write a meaningless row. In short, the configuration is where auditing stops being logging.

Name the event: event('placed') for real transitions#

Automatic model events get you created, updated, and deleted. But those names carry no business meaning. Therefore log the important transitions explicitly and give them a real name. A shipped order is not merely "updated".

OrderService.php · php
use Spatie\Activitylog\Contracts\Activity as ActivityContract;

// A real state transition, logged once, named. Not every save() on the model.
public function markShipped(Order $order, User $admin): void
{
    $order->update(['status' => 'shipped']);

    activity('orders')
        ->causedBy($admin)                    // who did it
        ->performedOn($order)                 // what it happened to
        ->event('shipped')                    // the domain event, not "updated"
        ->withProperties(['carrier' => $order->carrier])
        ->log('Order shipped');
}

// Log placed / paid / shipped as three named events. Skip the eight
// intermediate saves that carry no business meaning.

This is the inversion in code. Instead of eight anonymous "updated" rows per order, you write three named ones: placed, paid, shipped. Because the event name is queryable, an auditor can ask for every refund directly. So the trail becomes a record of decisions, not a diff of saves.

Causer and subject resolution, and the null-causer job case#

Every activity has two actors. The causer is who did it, and the subject is what it happened to. Normally causedBy resolves the authenticated user automatically. However, that breaks the moment the write moves off the request.

SettleRefund.php · php
use Illuminate\Contracts\Queue\ShouldQueue;

// In a queued job there is no authenticated user, so causedBy() has nothing to
// resolve and the causer column lands null. Capture the actor and pass it in.
class SettleRefund implements ShouldQueue
{
    public function __construct(
        public int $orderId,
        public int $actorId,          // captured on the request thread
    ) {}

    public function handle(): void
    {
        $order = Order::findOrFail($this->orderId);
        $actor = User::findOrFail($this->actorId);

        activity('orders')
            ->causedBy($actor)        // explicit, because auth() is empty here
            ->performedOn($order)
            ->event('refunded')
            ->log('Refund settled');
    }
}

The property diff: old vs attributes, and what NOT to store#

The recorded change is a diff, and its shape has sharp edges. Because the diff is built from Eloquent attributes, casts and JSON columns can distort or bloat it. First, see exactly where the old and new values live.

How old vs attributes is built, and where casts and JSON columns bite#

Spatie stores the change in the properties JSON, under an old key and an attributes key. The changes() helper returns both sides together. Yet the values are post-cast, which is where rows quietly grow fat.

inspect-diff.php · php
$activity = $order->activities()->latest()->first();

// The recorded diff lives in the properties JSON, under two keys.
$activity->properties['old'];        // ['status' => 'paid']
$activity->properties['attributes']; // ['status' => 'shipped']

// The changes() helper returns both sides at once.
$activity->changes();
// => ['attributes' => ['status' => 'shipped'], 'old' => ['status' => 'paid']]

// Watch the casts. An Eloquent cast is applied before the diff is taken, so a
// JSON column, a money value object, or an encrypted attribute is serialized
// whole. One wide cast attribute can bloat a row from bytes into kilobytes.

Consider a model with a large JSON settings column. Because a cast serializes the whole value, a one-field edit records the entire blob twice, old and new. Consequently a single logical change can write kilobytes. So audit narrow, typed columns, and keep wide JSON payloads out of the diff.

Redacting PII and secrets from the recorded diff#

Here is the risk no ranking page names. Because logAll() and logFillable() serialize full attributes, they write PII and secrets into an unencrypted JSON column. A password hash, a card number, a token: all of it lands in plain properties. Therefore redaction is not optional.

Customer.php · php
use Spatie\Activitylog\Contracts\Activity as ActivityContract;

class Customer extends Model
{
    use LogsActivity;

    // First defense: never diff these columns at all.
    public function getActivitylogOptions(): LogOptions
    {
        return LogOptions::defaults()
            ->logOnly(['status', 'plan', 'email_verified_at'])
            ->logExcept(['password', 'card_number', 'api_token'])
            ->logOnlyDirty();
    }

    // Second defense: runs before the row is written. Strip any secret that
    // slipped through, so it never reaches the unencrypted JSON column.
    public function beforeActivityLogged(ActivityContract $activity, string $eventName): void
    {
        $redact = ['ssn', 'card_number', 'password', 'api_token'];

        $activity->properties = $activity->properties->map(
            fn ($side) => is_array($side)
                ? collect($side)->except($redact)->all()
                : $side
        );
    }
}

One business transaction, one audit story: batching and correlation#

A single business action often writes several rows. A refund returns stock, issues money, and sends an email. However, those rows scatter unless you tie them together. Because an auditor needs the whole story, group them under one identifier.

How causer, subject, and a correlation id group one transaction, and the row lifecycleOne refund transaction: the causer and the subject attach to each named activity, a LogBatch UUID and a correlation_id group every row of the transaction, and each row then follows the write, index, prune, archive lifecycle.

The diagram shows both halves of the model. First, the causer and subject attach to each activity. Second, a batch UUID and a correlation_id bind the rows of one transaction. Then each row follows the same lifecycle: write, index, prune, archive. The next snippet wires the grouping.

Grouping writes with a batch UUID and a correlation id#

Spatie ships a LogBatch facade for exactly this. Wrap the transaction, and every activity inside shares one batch UUID. Moreover a custom correlation_id property lets you join across batches when you need to.

RefundOrder.php · php
use Spatie\Activitylog\Facades\LogBatch;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

// One transaction, one story. Group every row so an audit event reconstructs as
// a single unit instead of scattered, unrelated rows.
$correlationId = (string) Str::uuid();

LogBatch::startBatch();

DB::transaction(function () use ($order, $admin, $correlationId) {
    $order->refund();

    foreach (['refunded', 'stock_returned', 'refund_emailed'] as $event) {
        activity('orders')
            ->causedBy($admin)
            ->performedOn($order)
            ->event($event)
            ->withProperties(['correlation_id' => $correlationId])
            ->log("Order {$event}");
    }
});

LogBatch::endBatch();

// Every row now shares one batch_uuid and one correlation_id. Query either one
// to replay the whole refund as a single audit story.

Now the refund reconstructs as one unit. Because the rows share a batch UUID and a correlation_id, a single query replays the event in order. In short, correlation turns scattered writes into a coherent audit story, which is the whole point of keeping the trail.

Keeping it fast: index the queries you actually run#

A big table is fine if the queries seek instead of scan. However, the default migration under-indexes for real audit questions. Because it only indexes the polymorphic morph columns, a filter by time, by causer, or by channel falls back to a scan. Match an index to each pattern.

Query pattern to the composite index that serves it
You run this queryFilters onComposite index that serves itDefault stub covers it?
Everything that happened to one ordersubject_type, subject_id, created_at(subject_type, subject_id, created_at)Partly: morph index, no created_at
Everything one admin didcauser_type, causer_id(causer_type, causer_id)Partly: morph index only
The orders channel in a date windowlog_name, created_at(log_name, created_at)No
Find rows by a custom propertyproperties correlation_idGenerated column plus indexNo

The composite-index migration the default stub omits#

The fix is a short migration, and it is fully reversible. Add one composite index per query pattern, ordered so the equality columns come first and the time column last. Because that order lets a range scan follow the seek, the newest-first query stays fast as the table grows.

2026_07_14_add_activity_log_indexes.php · php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

// The default create_activity_log_table stub indexes only the morph columns.
// Add the composite indexes that serve the queries you actually run.
return new class extends Migration {
    public function up(): void
    {
        Schema::table('activity_log', function (Blueprint $table) {
            // "Everything that happened to this order, newest first."
            $table->index(['subject_type', 'subject_id', 'created_at'], 'idx_subject_time');
            // "Everything this admin did."
            $table->index(['causer_type', 'causer_id'], 'idx_causer');
            // "The orders channel across a date window."
            $table->index(['log_name', 'created_at'], 'idx_logname_time');
        });
    }
};

These indexes cost write time and disk, so add only the ones your queries need. To confirm each one is used, read the plan. Our guide to diagnosing MySQL performance bottlenecks in production shows how to read an EXPLAIN plan node by node. If you are weighing a dedicated table instead, the same schema-design trade-off applies to activity_log tables.

Keeping it small: retention, pruning, and cold archival#

Indexes keep reads fast, but they do not cap growth. Therefore retention is a separate discipline, not a one-line config note. Because different events carry different obligations, retention should vary by compliance class. Set a window per channel, then prune on a schedule.

Retention window and storage tier by compliance class
Compliance classExample eventsRetention windowStorage tier
Operationalstatus changes, edits, low-risk updates30 to 90 daysHot table, pruned nightly
Securityrole changes, logins, permission grants180 to 365 daysHot table, separate channel
Financial or regulatoryrefunds, invoices, ledger entries1 to 7 years per obligationPartitioned, archived to object storage

Scheduling activitylog:clean per log_name channel#

Spatie ships the activitylog:clean command for pruning. Crucially, it accepts a channel name, so you can prune each log_name on its own cadence. Pass --days to override the config, and --force to run unattended.

console.php · php
// config/activitylog.php
'clean_after_days' => 90, // the operational channel default

// routes/console.php (Laravel 11+) or app/Console/Kernel.php
use Illuminate\Support\Facades\Schedule;
use Illuminate\Support\Facades\DB;

// Prune the noisy operational channel nightly.
Schedule::command('activitylog:clean orders --days=90 --force')->dailyAt('03:00');

// Keep compliance-relevant events far longer, on their own channel.
Schedule::command('activitylog:clean compliance --days=730 --force')->dailyAt('03:15');

// Reclaim freed pages after a large prune. InnoDB does not shrink the file
// on its own, and OPTIMIZE locks the table, so run it off peak.
Schedule::call(fn () => DB::statement('OPTIMIZE TABLE activity_log'))
    ->weeklyOn(7, '04:00');

Two details matter here. First, prune the operational channel hard while keeping compliance events long, which is why the channels are separate. Second, InnoDB does not return freed space to disk after a large delete, so run OPTIMIZE TABLE in a maintenance window to reclaim it. Because OPTIMIZE locks the table, keep it off peak.

Security and compliance: PII, tamper-evidence, and who can read#

Three compliance concerns sit almost entirely absent from the search results. First, the properties column stores unencrypted PII when you log full attributes. Second, the table is mutable, so a row can be altered or deleted with no trace. Third, everyone with a database connection can read the trail unless you restrict it.

None of this means the package is wrong. Instead it means you must know its limits. Because spatie/laravel-activitylog gives you a readable "who changed what" trail, it is excellent for operational and security auditing. For an immutable ledger, though, it is a starting point, not the finish line.

activitylog vs laravel-auditing vs event sourcing#

The right tool depends on what "audit" means for you. Because these three approaches solve different problems, tie the choice to a concrete requirement. Do you need a readable trail, a tamper-proof ledger, or a system whose history is its source of truth?

spatie/laravel-activitylog vs laravel-auditing vs event sourcing
What you needactivityloglaravel-auditingEvent sourcing
Readable "who changed what"StrongStrongIndirect: derive from events
Immutable, tamper-evident ledgerNo: mutable tableNo: mutable tableYes: append-only store
Rebuild state from historyNoNoYes: the whole point
Setup costLowLowHigh: rearchitect writes
Best fitAudit an existing appPer-model audit configHistory is the source of truth

Read the ledger row as the deciding line. Because both activitylog and laravel-auditing store to a mutable table, neither is a tamper-proof record on its own. If immutability is a hard requirement, event sourcing earns its cost. Otherwise activitylog is the pragmatic choice, and this post is how you run it well.

When NOT to use this#

The boundary is the same one the whole post draws. Because an audit trail records business decisions, anything that is not a decision does not belong in it. So if a write is noise, metrics, or a debug line, send it elsewhere and keep activity_log meaningful.

Sources and further reading#

Every API surface above is first-party. For the option methods, read the Spatie guide to LogOptions and logging model events. For pruning, see cleaning up the log, and for the package itself, the spatie/laravel-activitylog listing on Packagist. If you are still weighing frameworks, our take on WordPress against Laravel for this kind of build covers the wider choice, and once your Laravel app is deployed via Forge the scheduled prune above runs on the same cron.

Laravel activitylog best practices: common questions

What retention period should I set for the activity_log table?
There is no single right number, so retain by compliance class. Keep operational noise (status edits, low-risk updates) for 30 to 90 days on one channel, and set clean_after_days to match. Move security and financial events onto a separate log_name channel and keep them for the period your obligation requires, often one to seven years. Then schedule activitylog:clean per channel with its own --days value, so the hot table stays small while the compliance record stays complete.
Is buffering the same as batching or correlation?
No, they solve different problems. Buffering (activity()->withoutLogs() and the batch write helpers) is a performance concern: it reduces write pressure by flushing many activities together. Correlation is a modeling concern: a shared correlation_id or LogBatch UUID lets you reconstruct one business transaction from its scattered rows. You can buffer for speed and still correlate for meaning. If the write itself is expensive on a busy request, queue activity-log writes off the request cycle instead.
How do I keep PII out of the recorded diff?
Use two defenses. First, logExcept the sensitive columns so the diff never captures them. Second, implement beforeActivityLogged on the model and strip any secret that slipped through, because logAll() and logFillable() serialize full attributes into an unencrypted JSON column. Treat that column as public within your database: no passwords, no tokens, no full card numbers. This is general information, not legal advice, so confirm your obligations with a licensed attorney.
Why is my activity_log query slow, and how do I fix it?
The default migration indexes only the polymorphic morph columns, so a query filtered by subject and time, by causer, or by log_name and a date window falls back to a scan. Add the composite index that matches each query pattern: (subject_type, subject_id, created_at), (causer_type, causer_id), and (log_name, created_at). A lookup into the properties JSON is unindexed by default, so promote a hot key like correlation_id to a generated column and index that.

Auditing at scale is a data-modeling problem before it is a package choice. If you want a second set of hands on an audit trail that has to stay fast and hold up to scrutiny, we can help.

Talk to us about an audit trail that scales

Ronak Makwana

Software Engineer, Atyantik Technologies

Ronak Makwana is a Software Engineer at Atyantik Technologies, a software product studio building web platforms, mobile apps, and integrated systems since 2015. Ronak writes about the software engineering practice behind shipping and maintaining real software.

More from Ronak MakwanaLaravel queues at scaleHire Laravel developers

Keep reading