tomatophp/tomato-user-activities
Laravel package for tracking and displaying user activities in TomatoPHP admin panels. Logs logins, CRUD events, and custom actions, with a ready-made Filament resource for browsing activity history and auditing user behavior.
Install and publish
composer require tomatophp/tomato-user-activities:^1.0
php artisan vendor:publish --tag=tomato-user-activities-config
php artisan vendor:publish --tag=tomato-user-activities-migrations
php artisan migrate
The package is auto-discovered on Laravel 10/11. If your app disables discovery, register the provider manually in bootstrap/providers.php (or config/app.php).
Where to look first, in this order
config/tomato-user-activities.php — this is where the model, table name, retention window, and any middleware/enabled flags live. Read it top to bottom before writing a single logging call; it usually tells you the whole design of the package.subject_type/subject_id, causer_*, event, properties, IP/user-agent) and whether it is UUID/ULID safe. Check this against your users table key type.latest(), forUser(), ofEvent()) you should reuse instead of writing raw queries.First use case
Record a login and render it on a profile page. Log manually first (fastest feedback), then wire the automatic middleware once you trust the schema:
use TomatoPHP\UserActivities\Facades\UserActivities; // verify namespace after install
UserActivities::log('login', $user, ['guard' => 'web']);
Then query the timeline:
$timeline = $user->activities()->latest()->limit(20)->get();
Once that round-trips, move on to instrumenting profile updates and record changes.
1. Model trait for the "subject" side
Attach the package trait to any model that should own a history (usually User, sometimes Team, Order). This gives you a relation you use everywhere downstream:
class User extends Authenticatable
{
use HasActivities; // name from the package; confirm on install
}
// In a controller / Filament resource:
return view('profile.timeline', [
'activities' => $user->activities()->with('causer')->latest()->paginate(25),
]);
Always eager-load the causer (or use the package's built-in withCauser scope). Without it, a 25-row timeline on an admin dashboard becomes 26 queries.
2. Wrapping sensitive mutations in a service, not a controller
Don't scatter logging calls across controllers. Put the write + log in one place so the event taxonomy stays consistent:
class UpdateProfile
{
public function handle(User $user, array $data): User
{
$changed = array_keys($user->getDirty()); // capture before save
$user->update($data);
UserActivities::log('profile.updated', $user, [
'changed' => $changed,
'actor' => auth()->id(),
]);
return $user;
}
}
This keeps the log call testable and lets you swap the package later behind one seam.
3. Polymorphic records on business entities
Use the same call shape against a domain model so audits read consistently across the app:
UserActivities::log('invoice.paid', $invoice, [
'amount' => $invoice->total,
'method' => 'card',
]);
The subject becomes the invoice, the causer stays the authenticated user. This is the pattern that makes the package worth using over plain Log::info — you get filterable history for any model from one table.
4. Automatic request logging via middleware
If the package ships a request middleware (check the provider/config), register it globally or on the web group only:
// app/Http/Kernel.php or bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\TomatoPHP\UserActivities\Http\Middleware\LogUserActivity::class, // verify FQCN
]);
});
Scope it to web (or specific route groups). Global registration logs asset/healthcheck noise and, worse, runs on unauthenticated requests.
5. Filament / TomatoPHP admin integration
The package's value in the Tomato ecosystem is the ready-made timeline. Use a relation manager on the User resource rather than building a table by hand:
public static function getRelations(): array
{
return [
\TomatoPHP\UserActivities\Filament\RelationManagers\ActivitiesRelationManager::class,
];
}
Verify the class exists in your installed version (php artisan filament:list won't show it — grep the vendor directory). If it doesn't, build a plain Table over the activities relation with event, created_at, causer.name, and a JSON column for properties.
6. Retention as a scheduled job, not a config hope
Config retention flags are advisory — nothing prunes unless you schedule it. Add a daily command:
// routes/console.php
Schedule::command('model:prune')->daily(); // if the model implements Prunable
// or a custom command:
Schedule::call(fn () => Activity::where('created_at', '<', now()->subDays(config('tomato-user-activities.retention', 90)))->delete())->daily();
Prefer chunked deletes (->chunkById(1000, ...)) once the table is over a few million rows.
7. Explicit event naming convention
Define an enum or constant list before instrumenting 20 places. Filtering a timeline by event is the primary query path; free-form strings rot fast:
enum ActivityEvent: string {
case Login = 'auth.login';
case ProfileUpdated = 'profile.updated';
case InvoicePaid = 'invoice.paid';
}
PII leaks into properties faster than you expect. A $user->toArray() in the properties payload will happily store emails, phone numbers, and occasionally a hashed password if the model exposes it. Whitelist keys explicitly and add a global $hidden sanity check on any model you log. This is the single biggest compliance risk with audit packages.
Auth resolution fails outside HTTP. Console commands, queued jobs, and API requests with token guards often yield null for auth()->user(), so causer is empty. Pass the actor explicitly (['causer_id' => $userId] or the package's equivalent) rather than relying on the global helper.
IP and user agent are wrong behind a load balancer. Trust proxies (TrustProxies middleware with correct $proxies) or you'll record your nginx IP for every user — and the package will store it faithfully, making it look correct.
Double logging. If you enable both the request middleware and manual log() calls on model events, one update produces two rows. Pick one strategy per surface: middleware for coarse request trails, manual calls for semantic business events.
Table growth is the real operational cost. The activities table outgrows the users table by 100x within a year on an active app. Add an index on (subject_type, subject_id, created_at) and (causer_id, created_at) in a follow-up migration — the package's own migration may not include them, and altering a large table later is painful.
Migration conflicts and key types. If your users table uses UUIDs/ULIDs and the published migration declares unsignedBigInteger on the causer column, the FK (if any) or the query will break silently — records just won't match. Diff the migration against Schema::getColumnType('users', 'id') before running it on anything with existing data.
Queues and transactions. Logging inside a DB::transaction that later rolls back leaves a phantom event if the log write is not on the same connection. Conversely, logging after commit means a crash between commit and log loses the event. Decide deliberately; for audit purposes, log after commit and accept the rare gap.
Version pinning and vendor risk. This is a 0.x-adjacent, single-maintainer package with zero dependents and changelogs that are literally "Full Changelog: link" — v1.0.3 discloses nothing. Pin ^1.0 and read the diff between tags before upgrading. Keep the write path behind a small internal service interface (see pattern #2) so you can swap to spatie/laravel-activitylog in an afternoon if the package stalls.
Testing. Use RefreshDatabase and assert on the table directly rather than the package's API, so your tests survive a package swap:
$this->actingAs($user)->put('/profile', ['name' => 'New']);
$this->assertDatabaseHas('user_activities', [
'event' => 'profile.updated',
'causer_id' => $user->id,
]);
Extension points worth knowing. The configurable model class in config lets you subclass and add scopes/accessors without forking. The publishable config is also the safest place to disable auto-logging in testing and local environments — otherwise your test suite fills the table and your local dev timeline is 90% seeder noise.
How can I help you explore Laravel packages today?