Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Eloquent Viewable Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. The published migrations — this is the schema you will query and index. Read it before writing any reporting code.
  2. config/eloquent-viewable.php — bot/DNT/IP filtering, queueing, caching all live here.
  3. The Period support class — it is the main query API surface you will use day to day.
  4. The README's "Usage" section, in order: Preparing your model → Recording → Retrieving → Ordering.

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.

Implementation Patterns

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.

Gotchas and Tips

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:

  • Index the views table on (viewable_type, viewable_id, collection, viewed_at) — the package's own queries filter on all of these.
  • Consider unique lookups too, since unique() queries hit a different column set.
  • Enable queueing once write volume is nontrivial.

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:

  • Custom visitor metadata — the README's "Custom information about visitor" section; use it instead of adding columns ad hoc.
  • Custom 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.
  • Custom crawler detector — swap the implementation if your traffic profile (headless browsers, monitoring probes) trips false positives.
  • Macros on the 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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
labyrinthe/connect-php
encore2/laravel-admin2
cboxdk/laravel-console-kit
shapecode/doctrine-dbal-datetimeutc
gilads-otiannoh254/inertia-protocol
dakiquang/laravel-oauth-refresh-lock
woda/laravel-worktrees
dalefranks/ramasite
faghy/laracashbook
roundly-consulting/google-places-for-laravel
roundly-consulting/kubernetes-api-for-laravel
roundly-consulting/plausible-for-laravel
gvp/gvp-laravel-template
metasyncsite/laravel-metasync-client
roundly-consulting/onboarding-for-laravel
adachsoft/cli-process-tester
adachsoft/changelog-linter
adachsoft/collection
adachsoft/php-code-style
adachsoft/api-version-bundle