shetabit/visitor
Laravel package to extract and access visitor information like browser, IP, platform, and device. It also lets you recognize online users, check if a user is online, and log visits for your models using the Visitable trait.
1. Install and publish
composer require shetabit/visitor
php artisan vendor:publish --provider="Shetabit\Visitor\Provider\VisitorServiceProvider"
php artisan migrate
Laravel 5.5+ uses auto-discovery, so you normally don't need to touch config/app.php. If your app has discovery disabled, register Shetabit\Visitor\Provider\VisitorServiceProvider::class and the Visitor alias from Shetabit\Visitor\Facade\Visitor::class.
2. Read the config before writing any code
config/visitor.php is the first place to look. It controls the agent/parser driver, the route/path exclusion list used by automatic logging, the cache store and TTL used for "online" detection, and the optional GeoIP enrichment added in v4.5. Most "why is this logging / why isn't this online" questions are answered there.
3. Smoke-test the helper
// routes/web.php
Route::get('/whoami', function () {
return [
'ip' => visitor()->ip(),
'browser' => visitor()->browser(),
'device' => visitor()->device(),
'platform' => visitor()->platform(),
];
});
If that returns sane values, the package is wired correctly and you can start layering logging on top.
First real use case: put Shetabit\Visitor\Traits\Visitable on the model you care about (Post, Product, Listing) and log a view in the show action:
public function show(Post $post)
{
$post->createVisitLog(); // or visitor()->visit($post)
return view('posts.show', compact('post'));
}
Then render {{ $post->visitLogs()->count() }} as your first "views" counter. That's the whole loop — everything else is refinement.
Metadata without persistence. $request->visitor()->browser() and friends are pure reads — no DB writes. Use them for lightweight personalization (hint banners, locale defaults from languages()), for tagging support tickets with browser/device, or for enriching exception reports. Cache the result per request if you call several accessors in a view composer.
Model visit tracking — two equivalent styles. Pick one and stay consistent:
// Explicit, controller-driven
visitor()->visit($post);
// Model-driven, easier to reuse in observers or policies
$post->createVisitLog();
Both write a polymorphic log row. $post->visitLogs() is the query builder relation (for counting and filtering); $post->visits is the loaded relation.
Attributing visits to a user. When someone is authenticated, tell the package who the visitor was so you can distinguish guest traffic:
visitor()->setVisitor($user)->visit($post);
$post->createVisitLog($user);
Automatic logging via middleware. For "log everything on these routes", append Shetabit\Visitor\Middlewares\LogVisits to a route group. It logs only models that (a) are resolved by route–model binding and (b) use the Visitable trait — it is not a generic request logger. Combine it with wildcard exclusions (supported since v4.1.0) in config:
// config/visitor.php
'except' => [
'admin/*',
'api/*',
'login',
'register',
],
Put the middleware after StartSession and your auth middleware in the group so setVisitor resolves the current user.
Online users. Add Shetabit\Visitor\Traits\Visitor to your User model, then:
visitor()->onlineVisitors(User::class); // Collection<User>
User::online()->get(); // same, as a scope
visitor()->isOnline($user); // bool
$user->isOnline(); // bool
This is cache-backed, not a websocket — treat "online" as "active within TTL" and set the TTL to match your product language (2 minutes for a live dashboard, 15 for an admin presence widget).
Counting and reporting. The three numbers you'll actually ship:
$post->visitLogs()->count(); // all views
$post->visitLogs()->distinct('ip')->count('ip'); // unique guests by IP
$post->visitLogs()->visitor()->count(); // unique known users (scope)
For anything richer, query the log table directly and group:
DB::table('visit_logs')
->select('browser', DB::raw('count(*) as hits'))
->where('visited_type', $post->getMorphClass())
->where('visited_id', $post->getKey())
->where('created_at', '>=', now()->subDays(30))
->groupBy('browser')
->orderByDesc('hits')
->get();
Use getMorphClass() rather than a hardcoded class string so morph maps don't bite you.
GeoIP. v4.5 added optional enrichment. Enable it in config and pick a local database path; keep it optional in local/CI environments so tests don't depend on a geo database being present. If you need city-level fidelity beyond what it offers, resolve country yourself in a model accessor and skip the feature.
Write-behind for hot pages. Don't call visit() inline on a page that gets hammered. Either guard with a per-session cache key:
$key = "visited:{$post->id}:" . visitor()->ip();
if (Cache::add($key, true, now()->addHour())) {
$post->createVisitLog();
}
…or dispatch your own job from terminate() in a middleware so the insert happens after the response is flushed.
Testing. Use actingAs($user) plus a get() request so a real request object exists, then assert $post->visitLogs()->count(). Freeze time and flush the online-users cache between tests — presence assertions are the flakiest part of a suite built on this package.
visitor() needs a request. In console commands, queued jobs, and artisan tinker, there is no request bound and accessors can return null (v4.4.2 moved to the nullsafe operator, so you get null rather than a fatal — but still null). Guard with app()->runningInConsole() or pass IP/useragent explicitly from the caller.migrate — don't assume vendor:publish created visit_logs.Agent::__call signature break with mobiledetectlib 4.10, and v4.5.4 fixed a PHP 8.4 nullable-type deprecation. On PHP 8.4 or with a floating mobiledetectlib constraint, require ^4.5.6 or you'll get hard failures rather than warnings.ip() reads the request's client IP. Configure TrustProxies/APP_TRUSTED_PROXIES correctly first, or every unique-visitor count collapses to your load balancer and every GeoIP lookup resolves to a datacenter.request() stores the payload. The package has excluded password fields since v2.2.0, but the full input array still lands in the log row. If you're under GDPR/CCPA scrutiny, either drop that column from the migration or add a model mutator/observer that strips it. Don't rely on the exclusion list as your only safeguard.distinct('ip') is exact but slow without an index. Add composite indexes on (visited_type, visited_id, created_at) and (visited_type, visited_id, ip). Note that IP-based uniqueness breaks for shared NAT/corporate networks — pair it with the visitor() scope when you have authenticated users.visit_logs. Schedule a command that deletes rows older than your retention window (or archives them) before the table reaches millions of rows on a page-view-heavy site.visitor()->device()/useragent() for known bot signatures in your own middleware, or exclude the routes that bots hammer most.LogVisits rather than editing vendor/. If you need a different log schema, publish the migration and edit it before the first migrate — retrofitting columns later means a manual migration plus backfill.visitor(), add a _ide_helper stub or use Visitor::ip() from the facade — same instance, better autocomplete.How can I help you explore Laravel packages today?