wotz/laravel-online-scope
A lightweight Laravel package that adds an online scope to your Eloquent models, making it simple to query users who are currently online. Track activity with minimal setup and filter results cleanly in your queries.
1. Install it, then read the source before writing any code.
composer require wotz/laravel-online-scope
This is a single-purpose package — expect src/ to contain one trait and one scope method (roughly 20–60 lines total). Read it first. It answers the three questions you actually need: what column does it read, what config key sets the window, and does it ship a migration, a middleware, or neither. Guessing the column name (last_seen_at vs last_activity vs last_activity_at) will cost you more time than opening the file.
2. Add the trait to the model that represents the actor.
use Wotz\OnlineScope\HasOnlineScope; // verify namespace in src/
class User extends Authenticatable
{
use HasOnlineScope;
}
3. Ensure the backing column exists.
The package almost certainly does not ship a migration (scopes rarely do). Add it yourself:
Schema::table('users', function (Blueprint $table) {
$table->timestamp('last_seen_at')->nullable()->index(); // match the column in the trait
});
4. Publish config if a config file is present.
php artisan vendor:publish --tag=online-scope-config
Check src/ or the README for the real tag; if there's no config/ directory in the package, skip this step entirely.
5. First use case — a dashboard counter.
$onlineCount = User::online()->count();
6. Verify before trusting it. Write one test with a frozen clock. This takes two minutes and catches every off-by-one in the window logic:
it('counts users seen in the last 5 minutes', function () {
$this->travelTo(now()->setTime(12, 0));
User::factory()->create(['last_seen_at' => now()->subMinutes(2)]);
User::factory()->create(['last_seen_at' => now()->subMinutes(30)]);
expect(User::online()->count())->toBe(1);
});
Presence middleware (the missing half). A scope only reads presence — something has to write the timestamp. If the package doesn't ship middleware, add it:
class TouchLastSeen
{
public function handle($request, Closure $next)
{
$user = $request->user();
// Throttle the write — don't UPDATE on every asset request.
if ($user && (! $user->last_seen_at || $user->last_seen_at->lt(now()->subMinute()))) {
$user->forceFill(['last_seen_at' => now()])->saveQuietly();
}
return $next($request);
}
}
Register it on your web group after Authenticate so $request->user() is populated.
Compose the scope with everything else. This is where it earns its keep — it's a query builder macro, so it stacks:
User::online()->where('role', 'admin')->pluck('id');
User::online()
->withCount('openTickets')
->orderByDesc('last_seen_at')
->paginate(25);
Use inverse/additional scopes if present. Many small packages of this shape ship online(), offline(), and seenWithin($minutes). Check the trait before hand-rolling equivalents.
Cache the count, not the query. A User::online()->count() on a 500k-row table is fine with an index and terrible without. Either way, cache it for 30–60 seconds:
$online = Cache::remember('users.online.count', 60, fn () => User::online()->count());
Scheduled pruning / archival. Presence data isn't worth keeping forever. If you audit logins separately, null out stale values nightly rather than carrying dead rows into every index scan.
Testing pattern. Always freeze time; never assert against now() directly:
$this->travelTo('2026-04-16 12:00:00');
Timezones will bite you. now() returns app-timezone Carbon; the DB stores whatever the connection's timezone setting is. If those differ, your "5 minutes" is wrong by hours. Either store UTC everywhere ('timezone' => '+00:00' in config/database.php) or explicitly compare now()->utc(). Test on MySQL and SQLite if you support both — SQLite's datetime comparison semantics differ from MySQL's timestamp.
Boundary condition. Prefer where('last_seen_at', '>=', $cutoff) over >. A user touched exactly at the cutoff flickering in/out is a real support ticket in live-presence UIs.
NULL is not offline — it's "never seen." If you display "0% of users online" or compute retention, exclude null explicitly. Most scope implementations do handle this, but confirm in the source; where('last_seen_at', '>=', ...) already excludes nulls in SQL, which is correct here, but a naive Carbon cast on the model can produce confusing null reads elsewhere.
The index is not optional. Without INDEX(last_seen_at), every online() call is a full table scan, and if you put it behind a dashboard polling every 10s you will notice. Add the index at migration time; adding it later on a hot table is a maintenance window.
Write amplification is the real cost. One UPDATE per authenticated request is a row lock and a WAL write. Throttle it (see middleware above) — a 60-second granularity is invisible to users and cuts writes by 10–100×. If you're on a read replica setup, make sure presence writes go to the primary and the reading dashboard tolerates a second of lag.
Clock skew across app servers. With multiple web nodes, now() is each node's clock. If NTP drifts, a user can appear "online 40 seconds ago" from one server and "2 minutes ago" from another. Keep the window comfortably wider than your expected skew (5 minutes, not 30 seconds).
Cache-backed vs. DB-backed. If the package uses Cache::has("online:{$id}") instead of a column, nothing works across multiple servers without a shared Redis/Memcached store, and Cache::flush() silently resets everyone to offline. Read src/ and confirm which strategy it uses before sizing the window.
Scope name collisions. If you already define scopeOnline on User, the trait's method silently loses (or wins, depending on trait resolution order). Grep your models first.
Watch for global middleware the package registers. If a service provider auto-pushes a TouchLastSeen middleware onto the web group, you're now writing to the DB on requests you never intended. Check providers/ in the package and, if it auto-registers, use withoutMiddleware() or skip it in API routes.
Extension point: the scope is a one-liner around a where clause. If you need different semantics (per-tenant windows, "online in the last N minutes based on activity table", Redis-backed presence), don't fork the package — write your own scopeOnline in a trait you control, or call the package method then ->where(...) on top. The abstraction has no hidden machinery worth preserving.
Dependency risk. Zero stars, zero dependents, a single maintainer. This is textbook copy-in-and-own territory. It's 20 lines; vendoring it into app/Models/Concerns/ removes an unmaintained dependency from your composer.lock for zero functionality loss, and gives you the index, config, and timezone handling you actually want. Use the package to learn the pattern, then own the code.
How can I help you explore Laravel packages today?