cyrildewit / eloquent-viewable
A minimalistic analytics package for Laravel with seamless view tracking for Eloquent models
Fund package maintenance!
Requires
- php: ^8.5
- illuminate/cache: ^13.0
- illuminate/contracts: ^13.0
- illuminate/cookie: ^13.0
- illuminate/database: ^13.0
- illuminate/http: ^13.0
- illuminate/support: ^13.0
- jaybizzle/crawler-detect: ^1.0
- nesbot/carbon: ^3.0
Requires (Dev)
- illuminate/config: ^13.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.21
- mockery/mockery: ^1.2.4
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-rector: ^5.0
- pestphp/pest-plugin-type-coverage: ^5.0
- rector/rector: ^2.3
This package is auto-updated.
Last update: 2026-08-03 11:02:59 UTC
README
Eloquent Viewable
A minimalistic analytics package for Laravel with seamless view tracking for Eloquent models
Table of Contents
- Introduction
- Getting Started
- Usage
- Optimizing
- Extending
- Upgrading
- Changelog
- Contributing
- Credits
Introduction
Eloquent Viewable is a flexible and minimalistic analytics package for Laravel that allows seamless tracking of views for Eloquent models. Rather than incrementing a single counter, it stores each view as its own database record, so you can analyze totals, unique visitors, and custom time periods entirely within your own application. Whether you're running a blog, an e-commerce store, or a custom Laravel application, this package lets you log and analyze views without relying on external analytics services.
Quick Example
Once installed, you can track and retrieve views effortlessly:
// Return total view count views($post)->count(); // Return total unique view count since 20 February 2017 views($post)->unique()->period(Period::since('2017-02-20'))->count(); // Record a view views($post)->record();
Key Features
- Track total and unique views for any Eloquent model
- Query views by custom date ranges or time periods
- Prevent duplicate views with a configurable cooldown system
- Order models by views and unique visitors
- Optimize performance with built-in caching
- Ignore views from crawlers, blocked IPs, and DNT users
Getting Started
Version Compatibility
| Package Version | Laravel | PHP |
|---|---|---|
| 8.x | 13.x | 8.5+ |
| 7.x | 6.x – 13.x | 7.4+ |
Support for Lumen is not maintained.
Installation
First, you need to install the package via Composer:
composer require cyrildewit/eloquent-viewable:^8
Publish the database migrations and review them:
php artisan vendor:publish --provider="CyrildeWit\EloquentViewable\EloquentViewableServiceProvider" --tag="migrations"
Run the database migrations to create the necessary tables:
php artisan migrate
You can optionally publish the config file:
php artisan vendor:publish --provider="CyrildeWit\EloquentViewable\EloquentViewableServiceProvider" --tag="config"
Usage
Preparing your model
To associate views with a model, the model must implement the following interface and trait:
- Interface:
CyrildeWit\EloquentViewable\Contracts\Viewable - Trait:
CyrildeWit\EloquentViewable\InteractsWithViews
Example:
use Illuminate\Database\Eloquent\Model; use CyrildeWit\EloquentViewable\InteractsWithViews; use CyrildeWit\EloquentViewable\Contracts\Viewable; class Post extends Model implements Viewable { use InteractsWithViews; // ... }
Recording views
To track a view, simply call the record method on the fluent Views instance:
views($post)->record();
Where Should You Record Views?
The recommended place to record views is inside your controller’s method that handles displaying the model. For example:
// PostController.php public function show(Post $post) { views($post)->record(); return view('post.show', compact('post')); }
This ensures that views are only recorded when the page is actually rendered for a user.
Warning
By default, this package automatically ignores views from crawlers to prevent inaccurate counts. Keep this in mind when testing—tools like Postman are often detected as crawlers and will not trigger a recorded view.
Queueing view recording
By default, views are stored during the request. On high-traffic pages you can defer the database write to a queued job instead. This keeps the request fast and moves the insert to a queue worker.
Queue an individual view on the fly using the queue() method:
views($post)->queue()->record();
Or enable queueing globally in the eloquent-viewable.php config file:
'queue' => [ 'enabled' => true, // queue every recorded view 'connection' => null, // null uses the default queue connection 'queue' => null, // null uses the connection's default queue ],
When queueing is enabled globally, you can still force an individual view to be recorded synchronously:
views($post)->queue(false)->record();
All filtering still runs during the request, including crawler detection, the Do Not Track header, ignored IP addresses and cooldowns. Bots and views on cooldown are therefore never queued; only the database write is deferred.
Warning
When a view is queued, the ViewRecorded event is dispatched from the queue worker
instead of the request. Its listeners therefore run without request context. The
session, cookies, request() and auth()->user() are unavailable and will return empty
or null values. If a listener needs request-derived data (such as the authenticated
user or the IP address), capture it during the request instead of reading it inside the
listener.
Setting a cooldown
You may use the cooldown method on the Views instance to add a cooldown between view records. When you set a
cooldown, you need to specify the number of minutes.
views($post) ->cooldown($minutes) ->record();
Instead of passing the number of minutes as an integer, you can also pass a DateTimeInterface instance.
$expiresAt = now()->addHours(3); views($post) ->cooldown($expiresAt) ->record();
How it works
When recording a view with a session delay, this package also saves a snapshot of the view in the visitor’s session with an expiration datetime. Whenever the visitor views the item again, the package checks their session and decides whether the view should be saved in the database.
Retrieving view counts
Get total view count
views($post)->count();
Get view count for a specific period
use CyrildeWit\EloquentViewable\Support\Period; // Example: get view count from 2017 up to 2018 views($post) ->period(Period::create('2017', '2018')) ->count();
The Period class that comes with this package provides many handy features. The API of the Period class looks as
follows:
Specifying a date range
$startDateTime = Carbon::createFromDate(2017, 4, 12); $endDateTime = '2017-06-12'; Period::create($startDateTime, $endDateTime);
Since a specific date
Period::since(Carbon::create(2017));
Up to a specific date
Period::upto(Carbon::createFromDate(2018, 6, 1));
For past period
Uses Carbon::today() as start datetime minus the given unit.
Period::pastDays(int $days); Period::pastWeeks(int $weeks); Period::pastMonths(int $months); Period::pastYears(int $years);
For custom time subtraction
Uses Carbon::now() as start datetime minus the given unit.
Period::subSeconds(int $seconds); Period::subMinutes(int $minutes); Period::subHours(int $hours); Period::subDays(int $days); Period::subWeeks(int $weeks); Period::subMonths(int $months); Period::subYears(int $years);
Get unique view count
If you only want to retrieve the unique view count, you can simply add the unique method to the chain.
views($post) ->unique() ->count();
Ordering models by view count
The Viewable trait adds two scopes to your model: orderByViews and orderByUniqueViews.
Order by view count
Post::orderByViews()->get(); // descending Post::orderByViews('asc')->get(); // ascending
Order by unique view count
Post::orderByUniqueViews()->get(); // descending Post::orderByUniqueViews('asc')->get(); // ascending
Order by view count within the specified period
Post::orderByViews('asc', Period::pastDays(3))->get(); // ascending Post::orderByViews('desc', Period::pastDays(3))->get(); // descending
And of course, it's also possible with the unique views variant:
Post::orderByUniqueViews('asc', Period::pastDays(3))->get(); // ascending Post::orderByUniqueViews('desc', Period::pastDays(3))->get(); // descending
Order by view count within the specified collection
Post::orderByViews('asc', null, 'custom-collection')->get(); // ascending Post::orderByViews('desc', null, 'custom-collection')->get(); // descending Post::orderByUniqueViews('asc', null, 'custom-collection')->get(); // ascending Post::orderByUniqueViews('desc', null, 'custom-collection')->get(); // descending
Get view count of viewable type
If you want to know how many views a specific viewable type has, you need to pass an empty Eloquent model to the
views() helper like so:
views(new Post())->count();
You can also pass a fully qualified class name. The package will then resolve an instance from the application container.
views(Post::class)->count(); views('App\Post')->count();
View collections
If you have different types of views for the same viewable type, you may want to store them in their own collection.
views($post) ->collection('customCollection') ->record();
To retrieve the view count in a specific collection, you can reuse the same collection() method.
views($post) ->collection('customCollection') ->count();
Remove views on delete
To automatically delete all views associated with a viewable Eloquent model when it is deleted, set the
removeViewsOnDelete property to true in your model definition.
protected $removeViewsOnDelete = true;
Caching view counts
Caching the view count can be challenging in some scenarios. The period can be for example dynamic which makes caching not possible. That's why you can make use of the in-built caching functionality.
To cache the view count, simply add the remember() method to the chain. The default lifetime is forever.
Examples:
views($post)->remember()->count(); views($post)->period(Period::create('2018-01-24', '2018-05-22'))->remember()->count(); views($post)->period(Period::upto('2018-11-10'))->unique()->remember()->count(); views($post)->period(Period::pastMonths(2))->remember()->count(); views($post)->period(Period::subHours(6))->remember()->count();
// Cache for 3600 seconds views($post)->remember(3600)->count(); // Cache until the defined DateTime views($post)->remember(now()->addWeeks(2))->count(); // Cache forever views($post)->remember()->count();
Optimizing
Storing every view as its own record is what makes detailed, time-based analytics possible, but it also means the
views table grows with traffic. For high-traffic applications, keep the following scalability considerations in mind:
- Caching counts (see below) to reduce load on the growing table.
- Removing old records you no longer need. The package does not prune records for you, so if you don't need a full
history you can periodically delete rows from the
viewstable yourself (for example with a scheduled command). - Table partitioning at very large scale to keep queries fast.
Database indexes
The default views table migration file already has a composite index on viewable_type and viewable_id (created by
morphs()).
If you have enough storage available, you can add another index for the visitor column. Depending on the amount of
views, this may speed up unique view counts (->unique()) in some cases. The visitor column is a string
(VARCHAR(255)), so it can be indexed directly.
Caching
Caching view counts can have a big impact on the performance of your application. You can read the documentation about caching the view count here.
Using the remember() method will only cache view counts made by the count() method. The orderByViews and
orderByUnique query scopes aren't using these values because they only add something to the query builder. To optimize
these queries, you can add an extra column or multiple columns to your viewable database table with these counts.
Example: we want to order our blog posts by unique views count. The first thing that may come to your mind is to use
the orderByUniqueViews query scope.
$posts = Post::latest()->orderByUniqueViews()->paginate(20);
This query is quite slow when you have a lot of views stored. To speed things up, you can add for example a
unique_views_count column to your posts table. We will have to update this column periodically with the unique views
count. This can easily be achieved using a scheduled Laravel command.
There may be a faster way to do this, but such command can be like:
$posts = Post::all(); foreach($posts as $post) { $post->unique_views_count = views($post)->unique()->count(); }
Extending
If you want to extend or replace one of the core classes with your own implementations, you can override them:
CyrildeWit\EloquentViewable\ViewsCyrildeWit\EloquentViewable\ViewCyrildeWit\EloquentViewable\VisitorCyrildeWit\EloquentViewable\CrawlerDetectAdapterCyrildeWit\EloquentViewable\Actions\CreateView
Note
Don't forget that all custom classes must implement their original interfaces.
Custom information about visitor
The Visitor class is responsible for providing the Views builder information about the current visitor. The
following information is provided:
- a unique identifier (stored in a cookie)
- ip address
- check for Do No Track header
- check for crawler
The default Visitor class gets its information from the request. Therefore, you may experience some issues when using
the Views builder via a RESTful API. To solve this, you will need to provide your own data about the visitor.
You can override the Visitor class globally or locally.
Create your own Visitor class
Create you own Visitor class in your Laravel application and implement the
CyrildeWit\EloquentViewable\Contracts\Visitor interface. Create the required methods by the interface.
Alternatively, you can extend the default Visitor class that comes with this package.
Globally
Simply bind your custom Visitor implementation to the CyrildeWit\EloquentViewable\Contracts\Visitor contract.
$this->app->bind( \CyrildeWit\EloquentViewable\Contracts\Visitor::class, \App\Services\Views\Visitor::class );
Locally
You can also set the visitor instance using the useVisitor setter method on the Views builder.
use App\Services\Views\Visitor; views($post) ->useVisitor(new Visitor()) // or app(Visitor::class) ->record();
Using your own Views Eloquent model
Bind your custom Views implementation to the \CyrildeWit\EloquentViewable\Contracts\Views.
Change the following code snippet and place it in the register method in a service provider (for example
AppServiceProvider).
$this->app->bind( \CyrildeWit\EloquentViewable\Contracts\Views::class, \App\Services\Views\Views::class );
Using your own View Eloquent model
Bind your custom View implementation to the \CyrildeWit\EloquentViewable\Contracts\View.
Change the following code snippet and place it in the register method in a service provider (for example
AppServiceProvider).
$this->app->bind( \CyrildeWit\EloquentViewable\Contracts\View::class, \App\Models\View::class );
Customizing how views are created
The CreateView action is responsible for turning a resolved PendingView into a stored view and dispatching the
ViewRecorded event. Both the synchronous and queued recording paths go through this action, so it is the single place
to hook into if you want to change how a view is persisted (for example to add extra attributes, write to a different
store, or skip the event).
Bind your custom implementation to the \CyrildeWit\EloquentViewable\Contracts\CreateView contract.
Change the following code snippet and place it in the register method in a service provider (for example
AppServiceProvider).
$this->app->bind( \CyrildeWit\EloquentViewable\Contracts\CreateView::class, \App\Actions\Views\CreateView::class );
Your implementation receives the PendingView value object and must return a View instance.
use CyrildeWit\EloquentViewable\Contracts\CreateView as CreateViewContract; use CyrildeWit\EloquentViewable\Contracts\View as ViewContract; use CyrildeWit\EloquentViewable\PendingView; final class CreateView implements CreateViewContract { public function handle(PendingView $pending): ViewContract { // ... } }
Using a custom crawler detector
Bind your custom CrawlerDetector implementation to the \CyrildeWit\EloquentViewable\Contracts\CrawlerDetector.
Change the following code snippet and place it in the register method in a service provider (for example
AppServiceProvider).
$this->app->singleton( \CyrildeWit\EloquentViewable\Contracts\CrawlerDetector::class, \App\Services\Views\CustomCrawlerDetectorAdapter::class );
Adding macros to the Views class
use CyrildeWit\EloquentViewable\Views; Views::macro('countAndRemember', function () { return $this->remember()->count(); });
Now you're able to use this shorthand like this:
views($post)->countAndRemember(); Views::forViewable($post)->countAndRemember();
Upgrading
Please see UPGRADING for detailed upgrade guide.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Credits
- Cyril de Wit - Author - cyrildewit
See also the list of contributors who participated in this project.
License
This project is licensed under the MIT License - see the LICENSE file for details.