taldres/laravel-last-seen
A lightweight Laravel package for tracking user last-seen timestamps and online status. Record activity, query when users were last active, and display presence indicators with minimal setup and Eloquent-friendly integration. Ideal for dashboards, admin panels, and member directories.
composer require taldres/laravel-last-seen
php artisan vendor:publish --provider="Taldres\LastSeen\LastSeenServiceProvider"
php artisan migrate
Where to look first:
config/last-seen.php (published) — confirms the column name, the cache/throttle behaviour, and any "online" window settings. This is the single source of truth if something "doesn't update".app/Models/User.php — add the trait:use Taldres\LastSeen\Traits\HasLastSeen;
class User extends Authenticatable
{
use HasLastSeen;
}
app/Http/Kernel.php — register the middleware in the web group (or a narrower group, see below):protected $middlewareGroups = [
'web' => [
// ...
\Taldres\LastSeen\Http\Middleware\LastSeen::class,
],
];
database/migrations/*_add_last_seen_at_* — check the column type (timestamp nullable) matches what the trait/middleware expects.First use case: show presence next to a user in a listing.
{{ $user->name }}
@if ($user->last_seen_at)
<span class="text-xs text-gray-500">
{{ $user->last_seen_at->diffForHumans() }}
</span>
@endif
Hit any web route while authenticated, then dd(auth()->user()->fresh()->last_seen_at) — if it's still null, the problem is middleware registration or the column, not the package.
Attach the middleware narrowly, not globally. Putting it on web means a DB write on every asset-less page view. Common alternatives:
// only where presence actually matters
Route::middleware(['auth', \Taldres\LastSeen\Http\Middleware\LastSeen::class])
->group(function () {
Route::get('/dashboard', DashboardController::class);
});
Online list / "who's active" query. Drive it from config rather than hardcoding the window:
$window = config('last-seen.online_window', 5); // minutes
$online = User::query()
->where('last_seen_at', '>=', now()->subMinutes($window))
->orderByDesc('last_seen_at')
->get();
API surface. Don't leak raw timestamps to the front end; ship both forms:
return [
'last_seen_at' => $this->last_seen_at?->toIso8601String(),
'last_seen_human' => $this->last_seen_at?->diffForHumans(),
'is_online' => $this->last_seen_at?->gt(now()->subMinutes(5)) ?? false,
];
Consistent shape here means the SPA can render presence without re-implementing the window logic.
Presence badge component (Blade or Livewire) so the "online / 5m / 2d ago" decision lives in one place, keyed off the same config value the query uses. Otherwise the list and the badge will disagree at the boundary.
Testing. Freeze time and assert against the column directly:
Carbon::setTestNow('2024-01-01 12:00:00');
$this->actingAs($user)->get('/dashboard')->assertOk();
$this->assertTrue(
$user->fresh()->last_seen_at->equalTo(now())
);
Throttling. If you keep the middleware on a busy web group, add an if (! Cache::has(...)) guard around the update, or wrap the write in Cache::lock(). Writes on every request are the main cost of this package in production.
Integration with queues / jobs. The middleware only fires on HTTP. Long-running work (websockets, queue workers, scheduled jobs) will make users look offline. If presence matters there, call the same update path explicitly from a job or a PresenceService.
StartSession and auth middleware, otherwise auth()->user() is null and nothing is written. Symptom: works on some routes, silently no-ops on others.web group registration does nothing for api. Add the middleware to the api group or to specific route groups if mobile/SPA clients need presence.last_seen_at (different type, different name, or a last_activity column), publish and edit the config rather than editing the migration after it has run.$fillable / mass assignment. The package's write path may bypass fillable, but any of your own code doing $user->update(['last_seen_at' => ...]) will silently drop the field. Add it to $fillable or use forceFill.last_seen_at on every request also bumps updated_at if you use a normal save(), which breaks "profile last edited" displays. Prefer a query-builder update or updateQuietly.$user->last_seen_at->timezone(config('app.timezone'))->diffForHumans(). Comparing a naive local now() against a UTC column produces "in 2 hours" artifacts.array (tests) vs redis (prod). Assert on the column in tests, not on cache keys.diffForHumans() on null. Always null-check; new users who never hit a middleware route will have null, not a zero timestamp.php artisan route:list --path=dashboard -v (is the middleware listed?) → check the column exists via Schema::hasColumn('users', 'last_seen_at') → DB::listen() to see whether an UPDATE fires at all.User model if you need custom "online" semantics; wrap the whole thing in your own service if you plan to move presence to Redis later — keeps the call sites stable.php artisan vendor:publish --force into a scratch dir and diff the config against yours — style-only releases occasionally reformat arrays or key ordering, and a hardcoded lookup will break silently if you copy the new file over.How can I help you explore Laravel packages today?