cyrildewit/eloquent-viewable
Eloquent Viewable is a minimalistic analytics package for Laravel that adds seamless view tracking to Eloquent models, with cooldowns, queued recording, unique counts, and period-based queries.
Install in four steps:
composer require cyrildewit/eloquent-viewable:^8
php artisan vendor:publish --provider="CyrildeWit\EloquentViewable\EloquentViewableServiceProvider" --tag="migrations"
php artisan migrate
# optional, but do it before you go live:
php artisan vendor:publish --provider="CyrildeWit\EloquentViewable\EloquentViewableServiceProvider" --tag="config"
Check composer.json first: 8.x requires Laravel 13 / PHP 8.5+, while 7.x covers Laravel 6–13 on PHP 7.4+. Most existing apps will land on 7.x.
Where to look first:
config/eloquent-viewable.php — bot/DNT/IP filtering, queueing, caching all live here.Period support class — it is the main query API surface you will use day to day.First use case — a blog post counter:
use CyrildeWit\EloquentViewable\InteractsWithViews;
use CyrildeWit\EloquentViewable\Contracts\Viewable;
class Post extends Model implements Viewable
{
use InteractsWithViews;
}
// PostController@show
public function show(Post $post)
{
views($post)->record();
return view('post.show', compact('post'));
}
Then in the view: {{ views($post)->count() }}. Record in the controller that renders the page, not in a model observer or middleware — you only want a row when a human actually received the page.
Record on render, count on read. Keep record() in the show action and count() in the blade/API resource. Do not record inside the model, an accessor, or a retrieved event — those fire on every query and will inflate counts by orders of magnitude.
Cooldown for sane unique-ish numbers. The cooldown is session-backed, which makes it the right tool for "one view per visitor per 30 minutes" without needing auth:
views($post)->cooldown(30)->record();
// or an absolute expiry
views($post)->cooldown(now()->addHours(3))->record();
Because it stores a session snapshot, it works for guests. Use unique() on the read side when you want true deduplication independent of session state.
Period-based dashboards. Period composes cleanly, so build a small reporting layer rather than scattering whereBetween calls:
$stats = [
'today' => views($post)->period(Period::pastDays(1))->count(),
'week' => views($post)->period(Period::pastWeeks(1))->count(),
'month' => views($post)->period( Period::pastMonths(1))->count(),
'all_time' => views($post)->count(),
'unique_all' => views($post)->unique()->count(),
];
sub* variants anchor on now(), past* variants anchor on today() — pick deliberately depending on whether you want a rolling window or a calendar-aligned one.
Popular content lists. The trait scopes let you sort in the database instead of pulling everything into PHP:
Post::orderByViews('desc', Period::pastDays(7))->take(10)->get();
Post::orderByUniqueViews('desc')->paginate(20);
This is the pattern that scales worst — see Gotchas.
Collections for multi-context views. Same model, different meaning:
views($post)->collection('detail')->record(); // full page view
views($post)->collection('preview')->record(); // card impression
Read them back with the same collection() chain and pass the collection name as the third argument to the order scopes. Decide this naming scheme before you ship; retrofitting means backfilling rows.
Queue the write on hot pages. Filtering (bots, DNT, IPs, cooldown) still happens synchronously, so correctness is preserved and only the insert moves:
views($post)->queue()->record();
Or globally via queue.enabled in config, with queue(false) as the per-call escape hatch.
Cache counts for anything rendered per-request. Views counts are read far more often than written. Cache the number, not the model, and pick a TTL you can tolerate being stale (60s is usually fine for a public counter). Invalidate explicitly if you show the count on the same page you just recorded on.
Type-wide counts and polymorphic reporting:
views(Post::class)->count(); // all Post views
views(new Post())->count(); // same, via an empty model
Use this for admin dashboards ("total article reads") rather than looping models.
Housekeeping. Set removeViewsOnDelete on models whose views are meaningless once the record is gone; otherwise orphan rows accumulate under a morph type that no longer exists.
Crawler detection will bite you in testing. Postman, curl, and many test HTTP clients are classified as bots, so record() silently does nothing. When writing feature tests, either assert against a directly-inserted row, disable bot filtering in the test config, or swap in a permissive crawler detector. The silent no-op is the single most common "the package is broken" report.
Cooldown needs a session. On a stateless API (no session middleware, no cookies), the cooldown snapshot has nowhere to live and every request records. Fall back to unique() semantics or do your own dedupe keyed on user/IP.
Queued views lose request context. ViewRecorded listeners fire in the worker, so request(), auth()->user(), session, and cookies are empty. If a listener needs the user or IP, capture it in the request and carry it through — do not read it in the listener.
Every view is a row. This is the design, and it is also the scaling limit. Before launch:
(viewable_type, viewable_id, collection, viewed_at) — the package's own queries filter on all of these.unique lookups too, since unique() queries hit a different column set.orderByViews is a correlated subquery per row. Fine for a take(10) on a modest table; painful on deep paginate() or when combined with other heavy joins. For large catalogs, denormalize a cached counter column updated on the ViewRecorded event and sort on that instead.
Uniqueness is derived from visitor fingerprint + config. Changing the IP/UA hashing or the ignore settings changes what "unique" means going forward. Treat unique counts as non-comparable across config changes.
Double-recording on one page load. A layout that renders a teaser and the full post, or a Livewire component that re-renders, will call record() twice. Guard with a per-request flag or move the call to the single action that renders the canonical page.
Caching and cooldown are independent. A cached count can lag a recorded view; a cooldown can suppress a record that your cache invalidation assumed happened. Keep TTLs short and don't couple the two.
Extension points worth knowing before you fork anything:
View / Views Eloquent models — bind your own in config if you need extra columns or scopes; do this early, since the default models are what the migrations create.Views class — good place for repeated business logic like views($post)->fromVerifiedUsers()->count().Config is published once and then stale. After upgrading major versions, re-diff the config file against the vendor copy; new keys (queue settings, cache keys) are not merged automatically and a missing key silently falls back to a default.
Period boundaries. Period::create($start, $end) semantics around inclusive/exclusive end dates are worth a five-minute test in tinker before you build reporting on top of it — off-by-one at midnight is the classic dashboard bug here.
How can I help you explore Laravel packages today?