├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config └── visitorstatistics.php ├── phpunit.xml ├── src ├── Console │ └── Commands │ │ └── UpdateMaxMindDatabase.php ├── Contracts │ ├── GeoIP.php │ ├── Tracker.php │ └── Visitor.php ├── GeoIP.php ├── Http │ ├── Controllers │ │ └── StatisticsController.php │ └── Middleware │ │ └── RecordVisits.php ├── Models │ ├── Statistic.php │ └── Visitor.php ├── Providers │ └── VisitorStatisticsProvider.php ├── Tracker.php ├── Visitor.php ├── database │ └── migrations │ │ ├── 2019_05_29_104509_create_visitors_table.php │ │ └── 2019_05_29_105719_create_statistics_table.php └── routes │ └── web.php └── tests ├── Feature ├── TrackerTest.php └── VisitorTest.php └── TestCase.php /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Aleksa Nikolic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Visitor Statistics 2 | 3 | [![Packagist](https://img.shields.io/packagist/v/aleksa/laravel-visitors-statistics.svg)](https://packagist.org/packages/aleksa/laravel-visitors-statistics) [![Packagist](https://img.shields.io/packagist/dm/aleksa/laravel-visitors-statistics.svg)](https://packagist.org/packages/aleksa/laravel-visitors-statistics) [![Packagist](https://img.shields.io/packagist/l/aleksa/laravel-visitors-statistics.svg)](https://opensource.org/licenses/MIT) 4 | 5 | Simple visitor tracker and statistics package for Laravel 5 that can be used for dashboard graphs. Includes controller and routes to fetch visitor statistics (all and unique visits) for a certain month or year. You can also get total number of visits per country. 6 | 7 | ## Installation 8 | 1) Install package using composer: 9 | 10 | ```bash 11 | composer require aleksa/laravel-visitors-statistics 12 | ``` 13 | 2) Since the package automatically adds it's middleware to `web` group you will have to register service provider manually 14 | 15 | ```php 16 | ... 17 | 'providers' => [ 18 | ... 19 | Aleksa\LaravelVisitorsStatistics\Providers\VisitorStatisticsProvider::class, 20 | ... 21 | ], 22 | ... 23 | ``` 24 | 25 | 3) Run migrations: 26 | 27 | ```bash 28 | php artisan migrate 29 | ``` 30 | 31 | 4) Publish configuration: 32 | 33 | ```bash 34 | php artisan vendor:publish 35 | ``` 36 | and choose `Aleksa\LaravelVisitorsStatistics\Providers\VisitorStatisticsProvider` from the list 37 | 38 | 5) Download MaxMind database 39 | ```bash 40 | php artisan maxmind:update 41 | ``` 42 | 43 | ## GeoIP 44 | 45 | Since fetching data from external API (eg: [ipstack](https://ipstack.com/), [ipdata](https://ipdata.co/) etc...) takes time and slows down your application and can also produce monthly costs the package uses local MaxMind database and `maxmind-db/reader` package for reading it's contents and locating the visitors. 46 | 47 | For more sophisticated tracking you should use something like [Google Analytics](https://analytics.google.com/). 48 | 49 | ## Configuration 50 | 51 | | Name | Description | Default | 52 | | --- | --- | --- | 53 | | track_authenticated_users | Should the tracker track authenticated users | false | 54 | | track_ajax_request | Should the tracker track ajax requests | false | 55 | | login_route_path | Admin login path so that login attempts don't track as visits | 'admin' | 56 | | prefix | Prefix to apply to all statistics fetching routes | 'admin' | 57 | | middleware | Middlewares to be applied to all statistics fetching routes | '['web', 'auth']' | 58 | | database_location | Location where to store MaxMind database | storage_path('app/maxmind.mmdb') | 59 | | database_download_url | MaxMind database download url | MAXMIND_URL | 60 | | auto_update | Should laravel automatically update MaxMind database | true | 61 | 62 | **NOTE:** If you set `auto_update` to true make sure to add Laravel cron entry that is needed for [Task Scheduling](https://laravel.com/docs/5.8/scheduling). 63 | 64 | ## Fetching statistics 65 | 66 | The package comes with a controller and a bunch of routes to fetch statistics. The idea is to fetch statistics on your dashboard with AJAX request and parse data to some JavaScript graph library like [Highcharts](https://www.highcharts.com/). 67 | 68 | | Route name | Route URI | Description | 69 | | --- | --- | --- | 70 | | visitorstatistics.all_statistics | /statistics/{year}/{month?} | Get statistics for the given year or month. | 71 | | visitorstatistics.unique_statistics | /statistics/unique/{year}/{month?} | Get unique statistics for the given year or month. | 72 | | visitorstatistics.total_statistics | /statistics/total/{year}/{month?} | Get both all and unique statistics for a given year or month. | 73 | | visitorstatistics.countries | /statistics/countries | Get visits count and percentage for each country. | 74 | | visitorstatistics.available_dates | /statistics/available/{year?} | Get years or months that have statistics tracked. | 75 | 76 | **NOTE:** All routes are prefixed with value set in configuration and return response in `JSON` format. 77 | 78 | ## Example responses 79 | 80 | `/admin/statistics/2019` 81 | 82 | ```json 83 | { 84 | "data": { 85 | "1": 712, 86 | "2": 1379, 87 | "3": 1095, 88 | "4": 624, 89 | "5": 1181, 90 | "6": 271, 91 | "7": 0, 92 | "8": 0, 93 | "9": 0, 94 | "10": 0, 95 | "11": 0 96 | } 97 | } 98 | ``` 99 | 100 | `/admin/statistics/2019/6` 101 | 102 | ```json 103 | { 104 | "data": { 105 | "1": 76, 106 | "2": 33, 107 | "3": 35, 108 | "4": 54, 109 | "5": 73, 110 | "6": 0, 111 | "7-26": "...", 112 | "27": 0, 113 | "28": 0, 114 | "29": 0 115 | } 116 | } 117 | ``` 118 | 119 | `/admin/statistics/total/2019` 120 | 121 | ```json 122 | { 123 | "all": { 124 | "1": 0, 125 | "2": 0, 126 | "3": 0, 127 | "4": 0, 128 | "5": 0, 129 | "6": 271, 130 | "7": 0, 131 | "8": 0, 132 | "9": 0, 133 | "10": 0, 134 | "11": 0 135 | }, 136 | "unique": { 137 | "1": 0, 138 | "2": 0, 139 | "3": 0, 140 | "4": 0, 141 | "5": 0, 142 | "6": 42, 143 | "7": 0, 144 | "8": 0, 145 | "9": 0, 146 | "10": 0, 147 | "11": 0 148 | } 149 | } 150 | ``` 151 | 152 | `/admin/statistics/countries` 153 | ```json 154 | { 155 | "data": [ 156 | { 157 | "country": "Germany", 158 | "count": 6, 159 | "percentage": 40 160 | }, 161 | { 162 | "country": "United States", 163 | "count": 4, 164 | "percentage": 26.67 165 | }, 166 | { 167 | "country": "Unknown", 168 | "count": 2, 169 | "percentage": 13.33 170 | }, 171 | { 172 | "country": "Thailand", 173 | "count": 1, 174 | "percentage": 6.67 175 | }, 176 | { 177 | "country": "Russia", 178 | "count": 1, 179 | "percentage": 6.67 180 | }, 181 | { 182 | "country": "Serbia", 183 | "count": 1, 184 | "percentage": 6.67 185 | } 186 | ] 187 | } 188 | ``` 189 | 190 | `/admin/statistics/available` 191 | ```json 192 | { 193 | "data": [ 194 | 2019 195 | ] 196 | } 197 | ``` 198 | 199 | ## Information being collected 200 | 201 | This is the data that is being tracked for each visitor. 202 | 203 | | Name | Description | 204 | | --- | --- | 205 | | ip | e.g. '127.0.0.1' | 206 | | country | e.g. 'Serbia' | 207 | | city | e.g. 'Belgrade' | 208 | | device | e.g. 'desktop' | 209 | | browser | e.g. 'Chrome' | 210 | 211 | ## License 212 | 213 | This is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT). -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aleksa/laravel-visitors-statistics", 3 | "description": "Simple visitor tracker and statistics package for Laravel 5 that can be used for dashboard graphs.", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Aleksa Nikolic", 9 | "email": "aleksa@codelab.rs" 10 | } 11 | ], 12 | "minimum-stability": "dev", 13 | "require": { 14 | "php": "^7.1.3", 15 | "ext-zlib": "*", 16 | "illuminate/http": "~7.0.0", 17 | "illuminate/database": "~7.0.0", 18 | "illuminate/routing": "~7.0.0", 19 | "illuminate/support": "~7.0.0", 20 | "piwik/device-detector": "^3.11", 21 | "maxmind-db/reader": "^1.4" 22 | }, 23 | "require-dev": { 24 | "orchestra/testbench": "^3.8" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "Aleksa\\LaravelVisitorsStatistics\\": "src/" 29 | } 30 | }, 31 | "autoload-dev": { 32 | "psr-4": { 33 | "Aleksa\\LaravelVisitorsStatistics\\Tests\\": "tests/" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /config/visitorstatistics.php: -------------------------------------------------------------------------------- 1 | false, 11 | 12 | 'track_ajax_request' => false, 13 | 14 | /* 15 | |-------------------------------------------------------------------------- 16 | | Login attempts 17 | |-------------------------------------------------------------------------- 18 | | 19 | | Login attempts should not be tracked as visits 20 | | If you want to track them set the value to false 21 | | 22 | */ 23 | 24 | 'login_route_path' => 'admin', 25 | 26 | /* 27 | |-------------------------------------------------------------------------- 28 | | Routing 29 | |-------------------------------------------------------------------------- 30 | | 31 | | Specifcy prefix and middleware that should be used 32 | | when registering routes for the package 33 | | 34 | */ 35 | 36 | 'prefix' => 'admin', 37 | 38 | 'middleware' => ['web', 'auth'], 39 | 40 | /* 41 | |-------------------------------------------------------------------------- 42 | | Maxmind database 43 | |-------------------------------------------------------------------------- 44 | */ 45 | 46 | 'database_location' => storage_path('app/maxmind.mmdb'), 47 | 48 | 'database_download_url' => 'https://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz', 49 | 50 | 'auto_update' => true, 51 | ]; 52 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests/Feature 14 | 15 | 16 | 17 | 18 | ./src 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Console/Commands/UpdateMaxMindDatabase.php: -------------------------------------------------------------------------------- 1 | option('scheduled') && config('visitorstatistics.auto_update') === false) { 44 | $this->comment('MaxMind database not updated, set auto_update option to true.'); 45 | 46 | return false; 47 | } 48 | 49 | $this->comment('Updating MaxMind database...'); 50 | 51 | $databaseDownloadUrl = config('visitorstatistics.database_download_url'); 52 | $saveLocation = config('visitorstatistics.database_location'); 53 | 54 | // Check if download URL returns headers 200 55 | $headers = get_headers($databaseDownloadUrl); 56 | 57 | if (substr($headers[0], 9, 3) != '200') { 58 | throw new Exception('Unable to download database update, check if the URL is valid.'); 59 | } 60 | 61 | // Download database to temporary directory 62 | $tmpFile = tempnam(sys_get_temp_dir(), 'maxmind'); 63 | file_put_contents($tmpFile, fopen($databaseDownloadUrl, 'r')); 64 | 65 | // Replace old database with new one 66 | @unlink($saveLocation); 67 | 68 | file_put_contents($saveLocation, gzopen($tmpFile, 'r')); 69 | 70 | @unlink($tmpFile); 71 | 72 | $this->info('MaxMind database updated.'); 73 | 74 | return true; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Contracts/GeoIP.php: -------------------------------------------------------------------------------- 1 | get($ipAddress); 32 | 33 | if (isset($info['country'], $info['city'])) { 34 | $this->country = $info['country']['names']['en']; 35 | $this->city = $info['city']['names']['en']; 36 | } 37 | } catch (Exception $ex) { 38 | Log::error($ex->getMessage()); 39 | } 40 | } 41 | 42 | /** 43 | * Locate country for the set ip. 44 | * 45 | * @return string 46 | */ 47 | public function getCountry(): string 48 | { 49 | return $this->country; 50 | } 51 | 52 | /** 53 | * Locate city for the set ip. 54 | * 55 | * @return string 56 | */ 57 | public function getCity(): string 58 | { 59 | return $this->city; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Http/Controllers/StatisticsController.php: -------------------------------------------------------------------------------- 1 | json([ 24 | 'data' => $this->retrieveStatistics(Statistic::TYPES['all'], $year, $month), 25 | ]); 26 | } 27 | 28 | /** 29 | * Get unique statistics for the given year or month. 30 | * 31 | * @param int $year 32 | * @param int|null $month 33 | * 34 | * @return JsonResponse 35 | */ 36 | public function getUniqueStatistics(int $year, ?int $month = null): JsonResponse 37 | { 38 | return response()->json([ 39 | 'data' => $this->retrieveStatistics(Statistic::TYPES['unique'], $year, $month), 40 | ]); 41 | } 42 | 43 | /** 44 | * Get both all and unique statistics for a given year or month. 45 | * 46 | * @param int $year 47 | * @param int|null $month 48 | * 49 | * @return JsonResponse 50 | */ 51 | public function getTotalStatistics(int $year, ?int $month = null): JsonResponse 52 | { 53 | return response()->json([ 54 | 'all' => $this->retrieveStatistics(Statistic::TYPES['all'], $year, $month), 55 | 'unique' => $this->retrieveStatistics(Statistic::TYPES['unique'], $year, $month), 56 | ]); 57 | } 58 | 59 | /** 60 | * Get visits count and percentage for each country. 61 | * 62 | * @return JsonResponse 63 | */ 64 | public function getCountriesStatistics(): JsonResponse 65 | { 66 | $visitors = Visitor::getVisitorCountPerCountry(); 67 | $visitorCount = Visitor::count(); 68 | 69 | foreach ($visitors as $visitor) { 70 | $visitor->percentage = round($visitor->count * 100 / $visitorCount, 2); 71 | } 72 | 73 | return response()->json([ 74 | 'data' => $visitors, 75 | ]); 76 | } 77 | 78 | /** 79 | * Get years or months that have statistics tracked. 80 | * 81 | * @param int|null $year 82 | * 83 | * @return JsonResponse 84 | */ 85 | public function getAvailableDates(?int $year = null): JsonResponse 86 | { 87 | $result = []; 88 | 89 | if (is_null($year)) { 90 | $min = Statistic::min('created_at'); 91 | $max = Statistic::max('created_at'); 92 | 93 | if (!is_null($min)) { 94 | $startYear = Carbon::createFromTimeString($min)->year; 95 | $endYear = Carbon::createFromTimeString($max)->year; 96 | 97 | for ($i = $startYear; $i <= $endYear; $i++) { 98 | $result[] = $i; 99 | } 100 | 101 | if ($startYear !== $endYear) { 102 | $result[] = $endYear; 103 | } 104 | } 105 | } else { 106 | $startDate = Carbon::createFromDate($year, 1, 1); 107 | $endDate = Carbon::createFromDate($year, 12, 31); 108 | 109 | $min = Statistic::whereBetween('created_at', [$startDate, $endDate])->min('created_at'); 110 | $max = Statistic::whereBetween('created_at', [$startDate, $endDate])->max('created_at'); 111 | 112 | if (!is_null($min)) { 113 | $startMonth = Carbon::createFromTimeString($min)->month; 114 | $endMonth = Carbon::createFromTimeString($max)->month; 115 | 116 | for ($i = $startMonth; $i <= $endMonth; $i++) { 117 | $result[] = $i; 118 | } 119 | } 120 | } 121 | 122 | return response()->json([ 123 | 'data' => $result 124 | ]); 125 | } 126 | 127 | /** 128 | * Retrieve statistics for given year or month and type. 129 | * 130 | * @param string $type 131 | * @param int $year 132 | * @param int|null $month 133 | * 134 | * @return array 135 | */ 136 | private function retrieveStatistics(string $type, int $year, ?int $month = null): array 137 | { 138 | if (is_null($month)) { 139 | $startDate = Carbon::createFromDate($year, 1, 1)->startOfDay(); 140 | $endDate = $startDate->copy()->endOfYear(); 141 | } else { 142 | $startDate = Carbon::createFromDate($year, $month, 1)->startOfDay(); 143 | $endDate = $startDate->copy()->endOfMonth(); 144 | } 145 | 146 | $data = []; 147 | $statistics = Statistic::select(['value', 'created_at']) 148 | ->whereBetween('created_at', [$startDate, $endDate]) 149 | ->where('type', $type) 150 | ->get(); 151 | 152 | if (is_null($month)) { 153 | for ($i = 1; $i <= 12; $i++) { 154 | $data[$i] = 0; 155 | } 156 | 157 | foreach ($statistics as $statistic) { 158 | $data[Carbon::createFromTimeString($statistic->created_at)->month] += $statistic->value; 159 | } 160 | } else { 161 | for ($i = 1; $i <= Carbon::createFromDate($year, $month, 1)->endOfMonth()->day; $i++) { 162 | $data[$i] = 0; 163 | } 164 | 165 | foreach ($statistics as $statistic) { 166 | $data[Carbon::createFromTimeString($statistic->created_at)->day] += $statistic->value; 167 | } 168 | } 169 | 170 | return $data; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/Http/Middleware/RecordVisits.php: -------------------------------------------------------------------------------- 1 | tracker = $tracker; 24 | } 25 | 26 | /** 27 | * Handle an incoming request. 28 | * 29 | * @param Request $request 30 | * @param Closure $next 31 | * @return mixed 32 | */ 33 | public function handle($request, Closure $next) 34 | { 35 | $this->tracker->recordVisit(); 36 | 37 | return $next($request); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Models/Statistic.php: -------------------------------------------------------------------------------- 1 | 'all', 32 | 'unique' => 'unique', 33 | 'max' => 'max', 34 | ]; 35 | 36 | /** 37 | * The table associated with the model. 38 | * 39 | * @var string 40 | */ 41 | protected $table = 'visitorsstatistics_statistics'; 42 | 43 | /** 44 | * The attributes that are mass assignable. 45 | * 46 | * @var array 47 | */ 48 | protected $fillable = [ 49 | 'name', 'value', 'type' 50 | ]; 51 | 52 | /** 53 | * Get the number of max visits for a certain period. 54 | * 55 | * @return int 56 | */ 57 | public static function maxVisitors(): int 58 | { 59 | $max = Statistic::select(['value'])->where('type', self::TYPES['max'])->first(); 60 | 61 | return $max->value ?? 0; 62 | } 63 | 64 | /** 65 | * Get the total number of visitors. 66 | * 67 | * @return int 68 | */ 69 | public static function getTotalVisitors(): int 70 | { 71 | return Statistic::where('type', self::TYPES['all'])->sum('value'); 72 | } 73 | 74 | /** 75 | * Get the total number of unique visitors. 76 | * 77 | * @return int 78 | */ 79 | public static function getTotalUniqueVisitors(): int 80 | { 81 | return Statistic::where('type', self::TYPES['unique'])->sum('value'); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Models/Visitor.php: -------------------------------------------------------------------------------- 1 | groupBy('country') 63 | ->orderBy('count', 'DESC') 64 | ->get(); 65 | } 66 | 67 | /** 68 | * Get the number of online users 69 | * 70 | * @param int $minutes 71 | * 72 | * @return int 73 | */ 74 | public static function onlineCount(int $minutes = 15): int 75 | { 76 | $date = Carbon::now()->subMinutes($minutes); 77 | 78 | return Visitor::where('updated_at', '>=', $date)->count(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Providers/VisitorStatisticsProvider.php: -------------------------------------------------------------------------------- 1 | app->bind( 22 | 'Aleksa\LaravelVisitorsStatistics\Contracts\Tracker', 23 | 'Aleksa\LaravelVisitorsStatistics\Tracker' 24 | ); 25 | $this->app->bind('Aleksa\LaravelVisitorsStatistics\Contracts\Visitor', function ($app, $parameters) { 26 | return new Visitor($parameters['ipAddress'], $parameters['userAgent'], new DeviceDetector()); 27 | }); 28 | $this->app->bind('Aleksa\LaravelVisitorsStatistics\Contracts\GeoIP', function ($app, $parameters) { 29 | return new GeoIP($parameters['ipAddress']); 30 | }); 31 | } 32 | 33 | /** 34 | * Bootstrap services. 35 | */ 36 | public function boot() 37 | { 38 | // Register config 39 | $this->publishes([ 40 | __DIR__ . '/../../config/visitorstatistics.php' => config_path('visitorstatistics.php'), 41 | ], 'config'); 42 | $this->mergeConfigFrom( 43 | __DIR__ . '/../../config/visitorstatistics.php', 44 | 'visitorstatistics' 45 | ); 46 | 47 | // Register routes 48 | $this->mapStatisticsRoutes(); 49 | 50 | // Register migrations 51 | $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); 52 | 53 | // Register commands and set task scheduling 54 | $this->commands([ 55 | UpdateMaxMindDatabase::class 56 | ]); 57 | $this->app->booted(function () { 58 | // Since maxmind database is updated every first Thursday of the month 59 | // day 12 of each month is guaranteed to be on or after first Thursday 60 | $schedule = app(Schedule::class); 61 | $schedule->command(UpdateMaxMindDatabase::class, ['scheduled' => true])->monthlyOn(12, '00:00'); 62 | }); 63 | 64 | // Register middleware and add it to 'web' group 65 | app('router')->pushMiddlewareToGroup('web', RecordVisits::class); 66 | } 67 | 68 | /** 69 | * Define routes for getting statistics data. 70 | * 71 | * @return void 72 | */ 73 | private function mapStatisticsRoutes() 74 | { 75 | $config = config('visitorstatistics'); 76 | 77 | Route::prefix($config['prefix']) 78 | ->middleware($config['middleware']) 79 | ->namespace('Aleksa\LaravelVisitorsStatistics\Http\Controllers') 80 | ->group(__DIR__ . '/../routes/web.php'); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Tracker.php: -------------------------------------------------------------------------------- 1 | request = $request; 38 | $this->visitor = resolve(VisitorContact::class, [ 39 | 'ipAddress' => $this->request->header('HTTP_CF_CONNECTING_IP') ?? $this->request->getClientIp(), 40 | 'userAgent' => $this->request->userAgent() 41 | ]); 42 | $this->today = Carbon::today(); 43 | } 44 | 45 | /** 46 | * Save visitor information in the database. 47 | */ 48 | public function recordVisit() 49 | { 50 | if ($this->shouldTrackUser()) { 51 | $isNewVisitor = $this->saveVisitor($this->getVisitorInformation()); 52 | $this->updateStatistics(); 53 | 54 | if ($isNewVisitor) { 55 | $this->updateUniqueStatistics(); 56 | } 57 | 58 | $this->updateMaxOnline(); 59 | } 60 | } 61 | 62 | /** 63 | * Check if the visitor should be tracked. 64 | * 65 | * @return bool 66 | */ 67 | public function shouldTrackUser(): bool 68 | { 69 | if ((config('visitorstatistics.track_authenticated_users') === false && !is_null(auth()->user())) || 70 | (config('visitorstatistics.track_ajax_request') === false && request()->ajax()) || 71 | $this->request->is(config('visitorstatistics.login_route_path')) || 72 | $this->visitor->isBot()) { 73 | return false; 74 | } 75 | 76 | return true; 77 | } 78 | 79 | /** 80 | * Gather visitor information. 81 | * 82 | * @return array 83 | */ 84 | public function getVisitorInformation(): array 85 | { 86 | return [ 87 | 'ip' => $this->visitor->getIp(), 88 | 'country' => $this->visitor->getCountry(), 89 | 'city' => $this->visitor->getCity(), 90 | 'device' => $this->visitor->getDevice(), 91 | 'browser' => $this->visitor->getBrowser(), 92 | ]; 93 | } 94 | 95 | /** 96 | * Save visitor information in database. 97 | * 98 | * @param array $visitorInformation 99 | * 100 | * @return bool True if new visitor, false if existing 101 | */ 102 | private function saveVisitor(array $visitorInformation): bool 103 | { 104 | $hasVisitedToday = VisitorModel::where('ip', $visitorInformation['ip']) 105 | ->whereBetween('created_at', [$this->today, $this->today->copy()->endOfDay()]) 106 | ->first(); 107 | 108 | if ($hasVisitedToday) { 109 | $hasVisitedToday->touch(); 110 | 111 | return false; 112 | } 113 | 114 | VisitorModel::create($visitorInformation); 115 | 116 | return true; 117 | } 118 | 119 | /** 120 | * Update statistics in the database. 121 | */ 122 | private function updateStatistics(): void 123 | { 124 | $rowName = sprintf('%s_%s', $this->today->format('Y_m_d'), Statistic::TYPES['all']); 125 | $statistic = Statistic::firstOrNew([ 126 | 'name' => $rowName, 127 | 'type' => Statistic::TYPES['all'], 128 | ]); 129 | $statistic->value++; 130 | $statistic->save(); 131 | } 132 | 133 | /** 134 | * Update unique statistics in the database. 135 | */ 136 | private function updateUniqueStatistics(): void 137 | { 138 | $rowName = sprintf('%s_%s', $this->today->format('Y_m_d'), Statistic::TYPES['unique']); 139 | $statistic = Statistic::firstOrNew([ 140 | 'name' => $rowName, 141 | 'type' => Statistic::TYPES['unique'], 142 | ]); 143 | $statistic->value++; 144 | $statistic->save(); 145 | } 146 | 147 | /** 148 | * Update max visitors online in the database. 149 | */ 150 | private function updateMaxOnline() 151 | { 152 | $max = Statistic::maxVisitors(); 153 | 154 | $endDate = Carbon::now(); 155 | $startDate = $endDate->copy()->subMinutes(15); 156 | 157 | $currentMax = VisitorModel::whereBetween('updated_at', [$startDate, $endDate])->count(); 158 | 159 | if ($currentMax > $max) { 160 | Statistic::updateOrCreate([ 161 | 'name' => 'max_online', 162 | 'type' => Statistic::TYPES['max'], 163 | ], [ 164 | 'value' => $currentMax, 165 | ]); 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/Visitor.php: -------------------------------------------------------------------------------- 1 | ipAddress = $ipAddress; 36 | $this->geoIP = resolve(GeoIPContract::class, [ 37 | 'ipAddress' => $this->ipAddress 38 | ]); 39 | 40 | $this->deviceDetector = $deviceDetector; 41 | $this->deviceDetector->setUserAgent($userAgent); 42 | $this->deviceDetector->parse(); 43 | } 44 | 45 | /** 46 | * Get visitor IP. 47 | * 48 | * @return string 49 | */ 50 | public function getIp(): string 51 | { 52 | return $this->ipAddress; 53 | } 54 | 55 | /** 56 | * Get visitor country name. 57 | * 58 | * @return string 59 | */ 60 | public function getCountry(): string 61 | { 62 | return $this->geoIP->getCountry(); 63 | } 64 | 65 | /** 66 | * Get visitor city name. 67 | * 68 | * @return string 69 | */ 70 | public function getCity(): string 71 | { 72 | return $this->geoIP->getCity(); 73 | } 74 | 75 | /** 76 | * Get visitor device name. 77 | * 78 | * @return string 79 | */ 80 | public function getDevice(): string 81 | { 82 | return $this->deviceDetector->getDeviceName(); 83 | } 84 | 85 | /** 86 | * Get visitor browser name. 87 | * 88 | * @return string 89 | */ 90 | public function getBrowser(): string 91 | { 92 | return $this->deviceDetector->getClient('name'); 93 | } 94 | 95 | /** 96 | * Check whether the visitor is a bot. 97 | * 98 | * @return bool 99 | */ 100 | public function isBot(): bool 101 | { 102 | return $this->deviceDetector->isBot(); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/database/migrations/2019_05_29_104509_create_visitors_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->string('ip', 46); 19 | $table->string('country', 64); 20 | $table->string('city', 128); 21 | $table->string('device', 32); 22 | $table->string('browser', 128); 23 | $table->timestamps(); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('visitorsstatistics_visitors'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/database/migrations/2019_05_29_105719_create_statistics_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->string('name'); 19 | $table->integer('value')->default(0); 20 | $table->enum('type', ['all', 'unique', 'max']); 21 | $table->timestamps(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::dropIfExists('visitorsstatistics_statistics'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/routes/web.php: -------------------------------------------------------------------------------- 1 | where([ 5 | 'year' => '\d{4}', 6 | 'month' => '\d{1,2}' 7 | ]) 8 | ->name('visitorstatistics.all_statistics'); 9 | 10 | Route::get('/statistics/unique/{year}/{month?}', 'StatisticsController@getUniqueStatistics') 11 | ->where([ 12 | 'year' => '\d{4}', 13 | 'month' => '\d{1,2}' 14 | ]) 15 | ->name('visitorstatistics.unique_statistics'); 16 | 17 | Route::get('/statistics/total/{year}/{month?}', 'StatisticsController@getTotalStatistics') 18 | ->where([ 19 | 'year' => '\d{4}', 20 | 'month' => '\d{1,2}' 21 | ]) 22 | ->name('visitorstatistics.total_statistics'); 23 | 24 | Route::get('/statistics/countries', 'StatisticsController@getCountriesStatistics') 25 | ->name('visitorstatistics.countries'); 26 | 27 | Route::get('/statistics/available/{year?}', 'StatisticsController@getAvailableDates') 28 | ->where([ 29 | 'year' => '\d{4}' 30 | ]) 31 | ->name('visitorstatistics.available_dates'); 32 | -------------------------------------------------------------------------------- /tests/Feature/TrackerTest.php: -------------------------------------------------------------------------------- 1 | routeUrl, function () { 35 | return 'Laravel Visitors Statistics test.'; 36 | }); 37 | } 38 | 39 | /** 40 | * Test if visit is being tracked. 41 | */ 42 | public function testVisitorTracking() 43 | { 44 | // Make request 45 | $request = Request::create($this->routeUrl, 'GET'); 46 | $tracker = $this->app->make(Tracker::class); 47 | $middleware = new RecordVisits($tracker); 48 | $middleware->handle($request, function () { 49 | }); 50 | 51 | // Check if visit is tracked 52 | $this->assertEquals(1, Statistic::getTotalVisitors()); 53 | 54 | // Check if visitor information is tracked 55 | $visitor = VisitorModel::first(); 56 | 57 | $this->assertNotNull($visitor->ip); 58 | $this->assertEquals('Unknown', $visitor->country); 59 | $this->assertEquals('Unknown', $visitor->city); 60 | } 61 | 62 | /** 63 | * Test tracking of authenticated users. 64 | */ 65 | public function testDontTrackAuthenticatedUser() 66 | { 67 | // Check if user is not authenticated and do authentication 68 | $this->assertFalse(auth()->check()); 69 | Auth::login(new User()); 70 | $this->assertTrue(auth()->check()); 71 | 72 | // Set tracking of authenticated users to false 73 | config(['visitorstatistics.track_authenticated_users' => false]); 74 | 75 | // Make request 76 | $request = Request::create($this->routeUrl, 'GET'); 77 | $tracker = $this->app->make(Tracker::class); 78 | $middleware = new RecordVisits($tracker); 79 | $middleware->handle($request, function () { 80 | }); 81 | 82 | // Check if visit is not tracked 83 | $this->assertEquals(0, Statistic::getTotalVisitors()); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/Feature/VisitorTest.php: -------------------------------------------------------------------------------- 1 | app->makeWith(VisitorContact::class, [ 25 | 'ipAddress' => self::DEFAULT_IP_ADDRESS, 26 | 'userAgent' => $userAgent 27 | ]); 28 | 29 | // Check browser and device 30 | $this->assertEquals($expectedBrowser, $visitor->getBrowser()); 31 | $this->assertEquals($expectedDevice, $visitor->getDevice()); 32 | } 33 | 34 | public function userAgentDataSet(): array 35 | { 36 | return [ 37 | 'Desktop Chrome' => [ 38 | 'userAgent' => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.80 Safari/537.36', 39 | 'browser' => 'Chrome', 40 | 'device' => 'desktop', 41 | ], 42 | 'Desktop Firefox' => [ 43 | 'userAgent' => 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:58.0) Gecko/20100101 Firefox/58.0', 44 | 'browser' => 'Firefox', 45 | 'device' => 'desktop', 46 | ], 47 | 'Android Chrome' => [ 48 | 'userAgent' => 'Mozilla/5.0 (Linux; Android 6.0.1; SM-G935S Build/MMB29K; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/55.0.2883.91 Mobile Safari/537.36', 49 | 'browser' => 'Chrome Mobile', 50 | 'device' => 'smartphone', 51 | ], 52 | 'iPhone Safari' => [ 53 | 'userAgent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A356 Safari/604.1', 54 | 'browser' => 'Mobile Safari', 55 | 'device' => 'smartphone' 56 | ] 57 | ]; 58 | } 59 | } -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 |