zepfietje/laravel-seeable
Laravel Seeable adds seen/unseen tracking to your Eloquent models. Attach the trait to let users mark records as seen, check whether an item has been viewed, and query unseen records per user—handy for notifications, activity feeds, and read receipts.
composer require zepfietje/laravel-seeablelast_seen_at nullable timestamp to users) and run it:
php artisan vendor:publish --tag="seeable-migrations"
php artisan migrate
Seeable trait to your User model:
use Zepfietje\Seeable\Seeable;
class User extends Authenticatable
{
use Seeable;
}
app/Http/Kernel.php in the web group (or a named group for authenticated routes):
protected $middlewareGroups = [
'web' => [
// ...
\Zepfietje\Seeable\Middleware\RecordUserLastSeen::class,
],
];
On Laravel 11+, register it in bootstrap/app.php via ->withMiddleware(...).First use case: the "Last seen 12 minutes ago" line on a profile or admin user list. With the trait applied, $user->last_seen_at is a Carbon instance, so $user->last_seen_at?->diffForHumans() is the one-liner you ship. The package is deliberately minimal — there is no model, no config file, no dashboard; it is essentially a migration, a trait, and a middleware.
Show activity in API resources and Blade. Cast already comes from the trait, so in an API resource:
return [
'name' => $this->name,
'last_seen_at' => $this->last_seen_at?->toIso8601String(),
];
And in Blade: {{ $user->last_seen_at?->diffForHumans() ?? 'Never' }}.
Scope to a route group, not globally. Writing a timestamp on every web request is wasteful; attach the middleware to your authenticated group:
Route::middleware(['web', 'auth', RecordUserLastSeen::class])
->group(function () { /* ... */ });
Build an "online now" query. Since last_seen_at is a normal column, you can filter cheaply:
User::where('last_seen_at', '>=', now()->subMinutes(5))->count();
Wrap it in a local scope (scopeOnline, scopeInactive) for reuse across admin dashboards.
Throttle writes to reduce DB load. The middleware writes on each request by default (depending on version behavior). At scale, either (a) put the middleware on a low-traffic group, (b) guard it yourself in a custom middleware that only updates when last_seen_at is older than N minutes, or (c) move the update into a queued job / dispatchAfterResponse():
if (! $user->last_seen_at || $user->last_seen_at->lt(now()->subMinutes(5))) {
$user->forceFill(['last_seen_at' => now()])->saveQuietly();
}
saveQuietly() avoids touching updated_at and skipping model events — important if you already use those.
Combine with session or activity tables. For richer presence, use this as the lightweight "last touch" and layer on an activities table or session driver for "current page" context.
Backfill on the fly. Existing users will have last_seen_at = null until their next request; treat null as "never seen" in UI rather than "long ago."
$request->user() is null). Place it after auth in the stack, not before.User, no timestamp — that's expected; don't rely on it for anonymous traffic analytics.users. If your guard uses another table (e.g. admins), edit the migration before running it, and confirm the trait reads/writes last_seen_at on that model.last_seen_at is usually not in $fillable — assign it with forceFill() or a direct property set, not create()/update().->setTimezone() before saving or you'll corrupt ordering when servers span zones.updated_at churn. If you override the middleware to write frequently, use saveQuietly() or timestamps = false; otherwise updated_at becomes meaningless on the users table and can trigger observers.Cache::add("seen:{$id}", true, 300)) over a write per request.timestamp column, a trait with $casts = ['last_seen_at' => 'datetime'], and a middleware that does $request->user()?->forceFill(...)->saveQuietly(). Copying it into your app is often a better choice than pinning a dead dependency.How can I help you explore Laravel packages today?