Activity log
daravel_activitylog records who changed what in your models, and any
other activity you log by hand, in the activity_log table of
spatie/laravel-activitylog
v4. A Laravel application using Spatie and a Daravel application can share
the table: each reads the activities the other wrote, with their subjects,
causers and batches.
dart pub add daravel_activitylog
// bootstrap/app.dart
import 'package:daravel_activitylog/daravel_activitylog.dart';
Application createApp() => Application.configure(basePath: basePath)
.withActivityLog()
.withMigrations([...migrations, const CreateActivityLogTable()])
.withModels(models)
// ...
.create();
CreateActivityLogTable creates the table of Spatie's three v4 migrations
(create_activity_log_table, add_event_column, add_batch_uuid_column)
with their column order and index names, and does nothing when the table
exists, so a database that ran Spatie's migrations keeps it. Spatie v5
changed the table (attribute_changes, no batch_uuid); this package
follows v4 (4.x), which Laravel 12 supports alongside v5.
Logging model changes#
A model mixes in LogsActivity, says what to record, and lists
ActivityLogObserver among its observers (Daravel has no boot hooks to
attach it for you):
final class Assessment extends Model with LogsActivity {
static final ModelDefinition<Assessment> definition =
ModelDefinition<Assessment>(
table: 'assessments',
factory: Assessment.new,
fillable: ['title', 'score', 'status'],
observers: [ActivityLogObserver<Assessment>()],
);
@override
ModelDefinition<Assessment> get modelDefinition => Assessment.definition;
static ModelQuery<Assessment> query() => definition.query();
@override
LogOptions get activitylogOptions => LogOptions.defaults()
.logOnly(['title', 'score', 'status'])
.logOnlyDirty()
.dontSubmitEmptyLogs()
.useLogName('assessments');
}
Saving a change writes one row, as Spatie writes it:
final exam = (await Assessment.query().find(42))!;
await exam.update({'score': 95, 'status': 'graded'});
{
"log_name": "assessments",
"description": "updated",
"event": "updated",
"subject_type": "App\\Models\\Assessment",
"subject_id": 42,
"causer_type": "App\\Models\\User",
"causer_id": 7,
"properties": {
"attributes": {"score": 95, "status": "graded"},
"old": {"score": 60, "status": "in_progress"}
}
}
created stores attributes, updated adds old, deleted stores only
old, and a soft-deleting model also records restored. The values are
read back from the database after the change (Spatie's fresh()), so
defaults the database filled in are logged; dates are written as Laravel
serializes them (2026-10-28T07:30:00.000000Z), enums as stored.
LogOptions |
Records |
|---|---|
logOnly([...]) |
these attributes; author.name (a relation's column) and meta->room.name (inside JSON) too |
logAll() |
every attribute, hidden ones included: pair it with logExcept |
logFillable() / logUnguarded() |
the model's fillable, or what guarded does not list |
logExcept([...]) |
not these |
logOnlyDirty() |
on updates, only the logged attributes that changed |
dontSubmitEmptyLogs() |
nothing when no logged attribute changed |
dontLogIfAttributesChangedOnly([...]) |
nothing when only these changed (updated_at, a counter) |
useLogName('...') |
the log_name (the default log otherwise) |
setDescriptionForEvent((event) => ...) |
the description; an empty one logs nothing |
useAttributeRawValues([...]) |
these as stored, without casts or accessors |
ActivityLogObserver(recordEvents: [...], doNotRecordEvents: [...])
replaces or trims the events (Spatie's $recordEvents and
$doNotRecordEvents). model.disableLogging() stops one instance, and
overriding tapActivity(activity, eventName) changes an activity before it
is saved.
As in Laravel, a model you just created does not hold the defaults the
database gave it, so its updates compare those columns with null until
it is loaded again (await model.refresh()). Set them when you create the
model to keep such entries out of the log.
The activity is written in the model's transaction: a rollback removes it with the change it describes, and the log never shows a change that did not happen.
Logging by hand#
await activity('security')
.performedOn(examSession)
.causedBy(user)
.withProperties({'ip': request.ip, 'tab_switches': 3})
.event('locked')
.log(':causer.name locked :subject.title from :properties.ip');
:subject.*, :causer.* and :properties.* in the description are
replaced by those values, as Spatie does. causedByAnonymous(),
withProperty(), createdAt() and tap((activity) => ...) work as in
Spatie.
Causers#
The causer is the authenticated user of the request (the guard named by
ACTIVITY_LOGGER_AUTH_GUARD, the request's guard otherwise), when that user
is a model. In a command or a job there is none, unless you choose one:
await ActivityLog.causedBy(admin, () async {
await importStudents(file); // every activity inside is caused by admin
});
causedBy replaces Spatie's CauserResolver::setCauser(), and
ActivityLog.withoutLogs(() async { ... }) its withoutLogs(). Both last
for the callback only: what Spatie keeps in singletons is scoped to a zone
here, so one request never switches logging off for the requests beside
it.
Batches#
await LogBatch.within((uuid) async {
await session.update({'status': 'finished'});
await score.update({'total': 90});
});
Every activity inside shares one batch_uuid, and a batch opened inside
another keeps the outer UUID. LogBatch.using(uuid, ...) continues a
batch elsewhere, for example in a job the request dispatched.
Reading the log#
final history = await exam.activities().latest().take(10).get();
for (final entry in history) {
final who = await entry.causer();
print('${who?['name']} ${entry.description} at ${entry.createdAt}');
print(entry.changes); // {attributes: {...}, old: {...}}
}
await user.actions().get(); // with CausesActivity on User
await Activity.query().inLog('security').get();
await Activity.query().causedBy(user).forEvent('updated').get();
await Activity.query().forBatch(uuid).get();
await Activity.query().with_('causer').get(); // eager loading
subject() and causer() load the model a *_type column names. Without
reflection, Daravel finds it among the models of withModels(...) or the
morph map (Relation.morphMap); rows written by Laravel store class names
such as App\Models\User, which match a model's morphClass.
getExtraProperty('a.b') reads a nested property.
Cleaning up#
dartisan activitylog:clean # older than 365 days
dartisan activitylog:clean security --days=30
// bootstrap/commands.dart
import 'package:daravel_activitylog/commands.dart';
final List<CommandFactory> commands = [...activitylogCommands];
// the scheduler
schedule.command('activitylog:clean').daily();
In production the command needs --force, as Spatie's asks for
confirmation.
Configuration#
| Variable | Default | |
|---|---|---|
ACTIVITY_LOGGER_ENABLED |
true |
false writes nothing |
ACTIVITY_LOGGER_TABLE_NAME |
activity_log |
|
ACTIVITY_LOGGER_DB_CONNECTION |
the default | |
ACTIVITY_LOGGER_DEFAULT_LOG_NAME |
default |
|
ACTIVITY_LOGGER_DELETE_RECORDS_OLDER_THAN_DAYS |
365 |
for activitylog:clean |
ACTIVITY_LOGGER_AUTH_GUARD |
the request's guard | Spatie's default_auth_driver |
ACTIVITY_LOGGER_SUBJECT_RETURNS_SOFT_DELETED_MODELS |
false |
The first three are Spatie's variables; the others are settings Spatie
keeps in config/activitylog.php.
Sharing the table with Laravel#
bench/interop/activitylog.sh runs Laravel 12 with
spatie/laravel-activitylog 4.10 and Daravel on one database. Daravel reads
Laravel's created and updated activities, a hand-written activity with its
subject, causer and batch; Laravel reads Daravel's, and a model created by
Daravel logs the same keys and date format as one created by Laravel.
Not ported: Spatie's change pipes (addLogChange), its global
disableLogging()/enableLogging() switches (use withoutLogs), and
causers given as an id rather than a model.
Laravel equivalents#
More of Laravel that this works with, and the API to look up on pub.dev.
| Laravel | Daravel | API | Note |
|---|---|---|---|
php artisan activitylog:clean |
dartisan activitylog:clean |
ActivitylogCleanCommand |