├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── 1_Bug_report.md └── workflows │ └── run-tests.yml ├── .gitignore ├── .styleci.yml ├── .travis.yml ├── CHANGELOG.md ├── ISSUE_TEMPLATE.md ├── LICENSE.md ├── README.md ├── composer.json ├── database └── migrations │ └── create_visits_table.php.stub ├── docs ├── 1_introduction.md ├── 2_requirements.md ├── 3_installation.md ├── 4_quick-start.md ├── 5_increments-and-decrements.md ├── 6_retrieve-visits-and-stats.md ├── 7_visits-lists.md └── 8_clear-and-reset-values.md ├── phpunit.xml ├── src ├── Commands │ └── CleanCommand.php ├── DataEngines │ ├── DataEngine.php │ ├── EloquentEngine.php │ └── RedisEngine.php ├── Exceptions │ └── InvalidPeriod.php ├── Keys.php ├── Models │ └── Visit.php ├── Reset.php ├── Traits │ ├── Lists.php │ ├── Periods.php │ ├── Record.php │ └── Setters.php ├── Visits.php ├── VisitsServiceProvider.php ├── config │ └── visits.php └── helpers.php └── tests ├── Feature ├── EloquentPeriodsTest.php ├── EloquentVisitsTest.php ├── PeriodsTestCase.php ├── RedisPeriodsTest.php ├── RedisVisitsTest.php └── VisitsTestCase.php └── TestCase.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [awssat] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1_Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🐛 Bug Report" 3 | about: Report a general package issue 4 | 5 | --- 6 | 7 | - Operating system and version (e.g. Ubuntu 16.04, Windows 7): 8 | - Package Version: #.#.# 9 | - Laravel Version: #.#.# 10 | - Link to your project: 11 | 12 | ### Description: 13 | 14 | 15 | ### Steps To Reproduce: 16 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | php: [8.2, 8.3, 8.4] 13 | laravel: [11.*, 12.*] 14 | dependency-version: [prefer-lowest, prefer-stable] 15 | include: 16 | - laravel: 12.* 17 | testbench: 10.* 18 | - laravel: 11.* 19 | testbench: 9.* 20 | # exclude: 21 | # # excludes laravel 11 on php 8.1 22 | # - php: 8.1 23 | # laravel: [11.*, 12.*] 24 | # - php: 8.2 25 | # laravel: 12.* 26 | 27 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} 28 | 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v4 32 | 33 | - name: Install SQLite 3 34 | run: | 35 | sudo apt-get update 36 | sudo apt-get install sqlite3 redis 37 | 38 | # - name: Cache dependencies 39 | # uses: actions/cache@v2 40 | # with: 41 | # path: ~/.composer/cache/files 42 | # key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 43 | 44 | - name: Setup PHP 45 | uses: shivammathur/setup-php@v2 46 | with: 47 | php-version: ${{ matrix.php }} 48 | extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv, redis 49 | coverage: none 50 | 51 | - name: Install dependencies 52 | run: | 53 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 54 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest 55 | - name: Execute tests 56 | run: vendor/bin/phpunit tests 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/* 2 | /vendor 3 | composer.lock 4 | /vendor 5 | /.idea 6 | .env 7 | .DS_Store 8 | /.vscode 9 | .phpunit.result.cache 10 | dump.rdb 11 | .phpunit.cache/test-results 12 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: psr2 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | php: 3 | - 7.2 4 | - 7.3 5 | - 7.4 6 | before_script: 7 | - composer self-update 8 | - composer install --prefer-source --no-interaction 9 | - composer dump-autoload 10 | script: 11 | - vendor/bin/phpunit 12 | services: 13 | - redis-server -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All Notable changes to `laravel-visits` will be documented in this file. 4 | 5 | Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles. 6 | 7 | ## 2.1.0 8 | 9 | - Rewrites huge part of the package to support multiple data engines. 10 | - Adds database's data engine support (Eloquent). 11 | 12 | 13 | ## 2.0.0 14 | 15 | - Global ignore feature (can be enabled from [config/visits.php](https://github.com/awssat/laravel-visits/blob/master/src/config/visits.php#L70)) 16 | - Parameter signature of methods increment/decrement/forceIncrement/forceDecrement has changed. 17 | 18 | ``` 19 | //old 20 | increment($inc = 1, $force = false, $periods = true, $country = true, $refer = true) 21 | //new 22 | increment($inc = 1, $force = false, $ignore = []) 23 | //old 24 | forceIncrement($inc = 1, $periods = true) 25 | //new 26 | forceIncrement($inc = 1, $ignore = []) 27 | ``` 28 | 29 | - Now you can get visitors OSes and browser's languages 30 | - Replace Laravel array/string helpers with clasess as they were deperecated in recent versions. 31 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Detailed description 4 | 5 | Provide a detailed description of the change or addition you are proposing. 6 | 7 | Make it clear if the issue is a bug, an enhancement or just a question. 8 | 9 | ## Context 10 | 11 | Why is this change important to you? How would you use it? 12 | 13 | How can it benefit other users? 14 | 15 | ## Possible implementation 16 | 17 | Not obligatory, but suggest an idea for implementing addition or change. 18 | 19 | ## Your environment 20 | 21 | Include as many relevant details about the environment you experienced the bug in and how to reproduce it. 22 | 23 | * Version used (e.g. PHP 5.6, HHVM 3): 24 | * Operating system and version (e.g. Ubuntu 16.04, Windows 7): 25 | * Link to your project: 26 | * ... 27 | * ... 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Bader Almutairi 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 13 | > all 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 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Visits 2 | 3 | ![aravel-visits](https://i.imgur.com/xHAzl0G.png) 4 | 5 | [![Latest Version on Packagist][ico-version]][link-packagist] 6 | [![Software License][ico-license]](LICENSE.md) 7 | [![Build Status][ico-travis]][link-travis] 8 | [![Quality Score][ico-code-quality]][link-code-quality] 9 | [![Total Downloads][ico-downloads]][link-downloads] 10 | 11 | 12 | > Please support our work here with donation so we can contuine improve this package once we raise fund we will contuine work on this package 13 | 14 | Laravel Visits is a counter that can be attached to any model to track its visits with useful features like IP-protection and lists caching. 15 | 16 | ## Install 17 | 18 | To get started with Laravel Visits, use Composer to add the package to your project's dependencies (or read more about installlation on [Installation](docs/3_installation.md) page): 19 | 20 | ```bash 21 | composer require awssat/laravel-visits 22 | ``` 23 | 24 | ## Docs & How-to use & configure 25 | 26 | - [Introduction](docs/1_introduction.md) 27 | - [Requirements](docs/2_requirements.md) 28 | - [Installation](docs/3_installation.md) 29 | - [Quick start](docs/4_quick-start.md) 30 | - [Increments and decrements](docs/5_increments-and-decrements.md) 31 | - [Retrieve visits and stats](docs/6_retrieve-visits-and-stats.md) 32 | - [Visits lists](docs/7_visits-lists.md) 33 | - [Clear and reset values](docs/8_clear-and-reset-values.md) 34 | 35 | ## Changelog 36 | 37 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 38 | 39 | ## Contributing 40 | 41 | Please see [CONTRIBUTING](CONTRIBUTING.md) and [CODE_OF_CONDUCT](CODE_OF_CONDUCT.md) for details. 42 | 43 | ## Credits 44 | 45 | - [Bader][link-author] 46 | - [All Contributors][link-contributors] 47 | 48 | ## Todo 49 | 50 | - An export command to save visits of any periods to a table on the database. 51 | 52 | ## Contributors 53 | 54 | ### Code Contributors 55 | 56 | This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. 57 | 58 | 59 | ## License 60 | 61 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 62 | 63 | [ico-version]: https://img.shields.io/packagist/v/awssat/laravel-visits.svg?style=flat-square 64 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 65 | [ico-travis]: https://travis-ci.org/awssat/laravel-visits.svg?branch=master 66 | [ico-code-quality]: https://scrutinizer-ci.com/g/awssat/laravel-visits/badges/quality-score.png?b=master 67 | [ico-downloads]: https://img.shields.io/packagist/dt/awssat/laravel-visits.svg?style=flat-square 68 | [link-packagist]: https://packagist.org/packages/awssat/laravel-visits 69 | [link-travis]: https://travis-ci.org/awssat/laravel-visits 70 | [link-scrutinizer]: https://scrutinizer-ci.com/g/awssat/laravel-visits/code-structure 71 | [link-code-quality]: https://scrutinizer-ci.com/g/awssat/laravel-visits 72 | [link-downloads]: https://packagist.org/packages/awssat/laravel-visits 73 | [link-author]: https://github.com/if4lcon 74 | [link-contributors]: ../../contributors 75 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "awssat/laravel-visits", 3 | "type": "library", 4 | "description": "Laravel Redis visits counter for Eloquent models", 5 | "keywords": [ 6 | "Laravel", 7 | "Visits", 8 | "Counter", 9 | "Package", 10 | "Redis", 11 | "Cache", 12 | "Php" 13 | ], 14 | "homepage": "https://github.com/awssat/laravel-visits", 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Bader Almutairi", 19 | "email": "bderemail@gmail.com" 20 | }, 21 | { 22 | "name": "Abdulrahman Alshuwayi", 23 | "email": "hi@abdumu.com" 24 | } 25 | ], 26 | "require": { 27 | "php": "^8.0", 28 | "illuminate/support": "~5.5.0 || ~5.6.0 || ~5.7.0 || ~5.8.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", 29 | "jaybizzle/crawler-detect": "^1.2", 30 | "spatie/laravel-referer": "^1.6", 31 | "torann/geoip": "^1.0|^3.0", 32 | "nesbot/carbon": "^2.0|^3.0" 33 | }, 34 | "require-dev": { 35 | "doctrine/dbal": "^2.6 || ^3.0 || ^4.0", 36 | "illuminate/support": "~5.5.0 || ~5.6.0 || ~5.7.0 || ~5.8.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", 37 | "mockery/mockery": "^1.4 || ^1.6", 38 | "orchestra/testbench": "^3.5 || ^3.6 || ^3.7 || ^3.8 || ^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0", 39 | "phpunit/phpunit": "^9.0 || ^10.1 || ^11.0", 40 | "predis/predis": "^1.1|^2.0" 41 | }, 42 | "suggest": { 43 | "predis/predis": "Needed if you are using redis as data engine of laravel-visits", 44 | "ext-redis": "Needed if you are using redis as engine data of laravel-visits", 45 | "illuminate/database": "Needed if you are using database as engine data of laravel-visits" 46 | }, 47 | "autoload": { 48 | "psr-4": { 49 | "Awssat\\Visits\\": "src" 50 | }, 51 | "files": [ 52 | "src/helpers.php" 53 | ] 54 | }, 55 | "autoload-dev": { 56 | "psr-4": { 57 | "Awssat\\Visits\\Tests\\": "tests" 58 | } 59 | }, 60 | "scripts": { 61 | "test": "phpunit", 62 | "check-style": "phpcs -p --standard=PSR2 --runtime-set ignore_errors_on_exit 1 --runtime-set ignore_warnings_on_exit 1 src tests", 63 | "fix-style": "phpcbf -p --standard=PSR2 --runtime-set ignore_errors_on_exit 1 --runtime-set ignore_warnings_on_exit 1 src tests" 64 | }, 65 | "extra": { 66 | "branch-alias": { 67 | "dev-master": "2.0-dev" 68 | }, 69 | "laravel": { 70 | "providers": [ 71 | "Awssat\\Visits\\VisitsServiceProvider" 72 | ], 73 | "aliases": { 74 | "Visits": "Awssat\\Visits\\Visits" 75 | } 76 | } 77 | }, 78 | "config": { 79 | "sort-packages": true 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /database/migrations/create_visits_table.php.stub: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->string('primary_key'); 19 | $table->string('secondary_key')->nullable(); 20 | $table->unsignedBigInteger('score'); 21 | $table->json('list')->nullable(); 22 | $table->timestamp('expired_at')->nullable(); 23 | $table->timestamps(); 24 | $table->unique(['primary_key', 'secondary_key']); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::dropIfExists('visits'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /docs/1_introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Laravel Visits is a counter that can be attached to any model to track its visits with useful features like IP-protection and lists caching. 4 | 5 | ## Features 6 | 7 | - A model item can have **many types** of recorded visits (using tags). 8 | - It's **not limited to one type of Model** (like some packages that allow only User model). 9 | - Record per visitor and not by vistis using IP detecting, so even with refresh, **a visit won't duplicate** (can be changed from config/visits.php). 10 | - Get **Top/Lowest visited items** per a model. 11 | - Get most visited **countries, refs, OSes, and languages**. 12 | - Get **visits per a period of time** like a month of a year of an item or model. 13 | - Supports **multiple data engines**: Redis or database (any SQL engine that Eloquent supports). 14 | 15 | --- 16 | 17 |

18 | Next: Requirements > 19 |

20 | -------------------------------------------------------------------------------- /docs/2_requirements.md: -------------------------------------------------------------------------------- 1 | # Requirements 2 | 3 | - Laravel 5.5+ 4 | - PHP 7.2+ 5 | - Data engine (Redis or Database) 6 | 7 | ### Data Egnine options 8 | 9 | You can choose to use Redis or database as your data engine from `config/visits.php` 10 | 11 | #### Redis 12 | 13 | Make sure that Redis is configured and ready. (see [Laravel Redis Configuration](https://laravel.com/docs/5.6/redis#configuration)) 14 | 15 | #### (Eloquent) Database 16 | 17 | Laravel visits uses any database that Eloquent uses. 18 | 19 | --- 20 | 21 |

22 | Prev: < Iintroduction 23 |

24 | 25 |

26 | Next: Installation > 27 |

28 | -------------------------------------------------------------------------------- /docs/3_installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | To get started with Laravel Visits, use Composer to add the package to your project's dependencies: 4 | 5 | ```bash 6 | composer require awssat/laravel-visits 7 | ``` 8 | 9 | ## Configurations 10 | 11 | To adjust the package to your needs, you can publish the config file `config/visits.php` to your project's config folder using: 12 | 13 | ```bash 14 | php artisan vendor:publish --provider="Awssat\Visits\VisitsServiceProvider" --tag=config 15 | ``` 16 | 17 | ## Redis Configuration 18 | 19 | If you are not using Redis as your default data engine, skip this. 20 | 21 | By default `laravel-visits` doesn't use the default laravel redis configuration (see [issue #5](https://github.com/awssat/laravel-visits/issues/5)) 22 | 23 | To prevent any data loss add a new connection in `config/database.php` 24 | 25 | ```php 26 | 'laravel-visits' => [ 27 | 'host' => env('REDIS_HOST', '127.0.0.1'), 28 | 'password' => env('REDIS_PASSWORD', null), 29 | 'port' => env('REDIS_PORT', 6379), 30 | 'database' => 3, // anything from 1 to 15, except 0 (or what is set in default) 31 | ], 32 | ``` 33 | 34 | and you can define your redis connection name in `config/visits.php` 35 | 36 | ```php 37 | 'connection' => 'laravel-visits' 38 | ``` 39 | 40 | ## Eloquent (database) configuration 41 | 42 | If you are using Redis as your default data engine, skip this. 43 | 44 | Publish migration file, then migrate 45 | 46 | ```sh 47 | php artisan vendor:publish --provider="Awssat\Visits\VisitsServiceProvider" --tag=migrations 48 | ``` 49 | 50 | ```sh 51 | php artisan migrate 52 | ``` 53 | 54 | ## Package Configuration 55 | 56 | Laravel Visits can be configured to act the way you like, `config/visits.php` is clear and easy to understand but here some explanation for its settings. 57 | 58 | ### config/visits.php settings explained 59 | 60 | #### engine 61 | 62 | ```php 63 | 'engine' => \Awssat\Visits\DataEngines\RedisEngine::class, 64 | ``` 65 | 66 | Suported data engines are `\Awssat\Visits\DataEngines\RedisEngine::class`, and `\Awssat\Visits\DataEngines\EloquentEngine::class` currently. 67 | If you use `\Awssat\Visits\DataEngines\EloquentEngine::class` then data will be stored in the default database (MySQL, SQLite or the one you are using) 68 | 69 | #### connection 70 | 71 | ```php 72 | 'connection' => 'laravel-visits', 73 | ``` 74 | 75 | Currently only applies when using Redis as data engine. Check [Redis Configuration](#redis-configuration) 76 | 77 | #### periods 78 | 79 | ```php 80 | 'periods' => [ 81 | 'day', 82 | 'week', 83 | 'month', 84 | 'year', 85 | ], 86 | ``` 87 | 88 | By default, Visits of `day`, `week`, `month`, and `year` are recorded. But you can add or remove any of them as you like. 89 | 90 | > **Note** supported periods can be found in [periods-options](8_clear-and-reset-values.html#periods-options.md) 91 | 92 | > You can add `periods` to global_ignore setting to skip recording any of these periods. 93 | 94 | #### keys_prefix 95 | 96 | ```php 97 | 'keys_prefix' => 'visits', 98 | ``` 99 | 100 | A word that's appended to the begining of keys names. If you are using shared Redis database, it's important to keep this filled. 101 | 102 | #### remember_ip 103 | 104 | ```php 105 | 'remember_ip' => 15 * 60, // seconds 106 | ``` 107 | 108 | Every distinct IP will only be recorded as one visit every 15 min (default). 109 | 110 | #### always_fresh 111 | 112 | ```php 113 | 'always_fresh' => false, 114 | ``` 115 | 116 | ## If you set this to `true`, then any [Visits Lists](7_visits-lists.md) won't be cached any will return a new generated list. 117 | 118 | ## We don't recommend enabling this feature as it's not good for performance. 119 | 120 | #### ignore_crawlers 121 | 122 | ```php 123 | 'ignore_crawlers' => true, 124 | ``` 125 | 126 | By default, visits from search engines bots and any other recognizable bots are not recorded. By enabling this you allow visits from bots to be recoded. 127 | 128 | #### global_ignore 129 | 130 | ```php 131 | 'global_ignore' => [], 132 | ``` 133 | 134 | By default, 'country', 'refer', 'periods', 'operatingSystem', and 'language' of a visitor are recoded. You can disable recoding any of them by adding them to the list. 135 | 136 | --- 137 | 138 |

139 | Prev: < Requirements 140 |

141 | 142 |

143 | Next: Quick start > 144 |

145 | -------------------------------------------------------------------------------- /docs/4_quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | ## Start using it 4 | 5 | It's simple. 6 | 7 | Using `visits` helper as: 8 | 9 | ```php 10 | visits($model)->{method}() 11 | ``` 12 | 13 | Where: 14 | 15 | - **$model**: is any Eloquent model from your project. 16 | - **{method}**: any method that is supported by this library, and they are documented below. 17 | 18 | ## Tags 19 | 20 | - You can track multiple kinds of visits to a single model using the tags as 21 | 22 | ```php 23 | visits($model,'tag1')->increment(); 24 | ``` 25 | 26 | ## Integration with any model 27 | 28 | You can add a `visits` method to your model class: 29 | 30 | ```php 31 | class Post extends Model 32 | { 33 | 34 | //.... 35 | 36 | public function vzt() 37 | { 38 | return visits($this); 39 | } 40 | } 41 | ``` 42 | 43 | Then you can use it as: 44 | 45 | ```php 46 | $post = Post::find(1); 47 | $post->vzt()->increment(); 48 | $post->vzt()->count(); 49 | ``` 50 | 51 | ## Relationship with models (only for Eloquent engine) 52 | 53 | If you are using visits with eloquent as engine (from config/visits.php; engine => \Awssat\Visits\DataEngines\EloquentEngine::class) then you can add a relationship method to your models. 54 | 55 | ```php 56 | class Post extends Model 57 | { 58 | 59 | //.... 60 | 61 | public function visits() 62 | { 63 | return visits($this)->relation(); 64 | } 65 | } 66 | 67 | //then: 68 | 69 | Post::with('visits')->get(); 70 | ``` 71 | 72 | --- 73 | 74 |

75 | Prev: < Installation 76 |

77 | 78 |

79 | Next: Increments and decrements > 80 |

81 | -------------------------------------------------------------------------------- /docs/5_increments-and-decrements.md: -------------------------------------------------------------------------------- 1 | # Increments and Decrements 2 | 3 | ## Increment 4 | 5 | ### One 6 | 7 | ```php 8 | visits($post)->increment(); 9 | ``` 10 | 11 | ### More than one 12 | 13 | ```php 14 | visits($post)->increment(10); 15 | ``` 16 | 17 | ## Decrement 18 | 19 | ### One 20 | 21 | ```php 22 | visits($post)->decrement(); 23 | ``` 24 | 25 | ### More than one 26 | 27 | ```php 28 | visits($post)->decrement(10); 29 | ``` 30 | 31 | > **Note:** Using Increment/decrement method will only work once every 15 minutes (default setting). You can use force methods or modifiy the time from settings or using seconds method. 32 | 33 | ## Increment/decrement once per x seconds 34 | 35 | based on visitor's IP 36 | 37 | ```php 38 | visits($post)->seconds(30)->increment() 39 | ``` 40 | 41 | > **Note:** this will override default config setting (once each 15 minutes per IP). 42 | 43 | ## Force increment/decrement 44 | 45 | ```php 46 | visits($post)->forceIncrement(); 47 | visits($post)->forceDecrement(); 48 | ``` 49 | 50 | - This will ignore IP limitation and increment/decrement every visit. 51 | 52 | ## Ignore recording extra information 53 | 54 | If you want to stop recoding some of the extra information that the package collected during incrementing the counter such as country and language of visior, then just pass it to the ignore parameter 55 | 56 | ```php 57 | //any of 'country', 'refer', 'periods', 'operatingSystem', 'language' 58 | visits('App\Post')->increment(1, false, ['country', 'language']); 59 | ``` 60 | 61 | or you can ignore it permanently from config/visits.php 62 | 63 | > **warning:** If you choose to ignore `periods` then you won't be able to get the count of visits during specific period of time. 64 | 65 | --- 66 | 67 |

68 | Prev: < Quick start 69 |

70 | 71 |

72 | Next: Retrieve visits and Stats > 73 |

74 | -------------------------------------------------------------------------------- /docs/6_retrieve-visits-and-stats.md: -------------------------------------------------------------------------------- 1 | # Retrieve visits and stats 2 | 3 | ## An item visits 4 | 5 | #### All visits of an item 6 | 7 | ```php 8 | visits($post)->count(); 9 | ``` 10 | 11 | > **Note:** $post is a row of a model, i.e. $post = Post::find(22); 12 | 13 | #### Item's visits by a period 14 | 15 | ```php 16 | visits($post)->period('day')->count(); 17 | ``` 18 | 19 | ## A model class visits 20 | 21 | #### All visits of a model type 22 | 23 | ```php 24 | visits('App\Post')->count(); 25 | ``` 26 | 27 | #### Visits of a model type in period 28 | 29 | ```php 30 | visits('App\Post')->period('day')->count(); 31 | ``` 32 | 33 | ## Countries of visitors 34 | 35 | ```php 36 | visits($post)->countries(); 37 | ``` 38 | 39 | ## Referers of visitors 40 | 41 | ```php 42 | visits($post)->refs(); 43 | ``` 44 | 45 | ## Operating Systems of visitors 46 | 47 | ```php 48 | visits($post)->operatingSystems(); 49 | ``` 50 | 51 | ## Languages of visitors 52 | 53 | ```php 54 | visits($post)->languages(); 55 | ``` 56 | 57 | --- 58 | 59 |

60 | Prev: < Increments and decrements 61 |

62 | 63 |

64 | Next: Visits lists > 65 |

66 | -------------------------------------------------------------------------------- /docs/7_visits-lists.md: -------------------------------------------------------------------------------- 1 | # Visits Lists 2 | 3 | Top or Lowest list per model type 4 | 5 | ## Top/Lowest visited items per model 6 | 7 | ```php 8 | visits('App\Post')->top(10); 9 | ``` 10 | 11 | ```php 12 | visits('App\Post')->low(10); 13 | ``` 14 | 15 | Top or Lowest list ids 16 | 17 | ## Top/Lowest visited items Ids 18 | 19 | ```php 20 | visits('App\Post')->topIds(10); 21 | ``` 22 | 23 | ```php 24 | visits('App\Post')->lowIds(10); 25 | ``` 26 | 27 | 28 | 29 | ### Filter by model attributes 30 | 31 | You can get only some of the top/low models by query where clause. For example if Post model has `shares` & `likes` attributes you can filter the models like this: 32 | 33 | ```php 34 | visits('App\Post')->top(10, [['likes', '>', 30], ['shares', '<', 20]]); 35 | ``` 36 | 37 | or just ... 38 | 39 | ```php 40 | visits('App\Post')->top(10, ['likes' => 20]); 41 | ``` 42 | 43 | ## Uncached list 44 | 45 | ```php 46 | visits('App\Post')->fresh()->top(10); 47 | ``` 48 | 49 | > **Note:** you can always get uncached list by enabling `alwaysFresh` from config/visits.php file. 50 | 51 | ## By a period of time 52 | 53 | ```php 54 | visits('App\Post')->period('month')->top(10); 55 | ``` 56 | 57 | > **Note** supported periods can be found in [periods-options](8_clear-and-reset-values.md#periods-options) 58 | 59 | --- 60 | 61 |

62 | Prev: < Retrieve visits and stats 63 |

64 | 65 |

66 | Next: Clear and reset values > 67 |

68 | -------------------------------------------------------------------------------- /docs/8_clear-and-reset-values.md: -------------------------------------------------------------------------------- 1 | # Clear and reset values 2 | 3 | ## Clear an item's visits 4 | 5 | ```php 6 | visits($post)->reset(); 7 | ``` 8 | 9 | ## Clear an item's visits of a specific period 10 | 11 | ```php 12 | visits($post)->period('year')->reset(); 13 | ``` 14 | 15 | ### Periods options 16 | 17 | - minute 18 | - hour 19 | - 1hours to 12hours 20 | - day 21 | - week 22 | - month 23 | - year 24 | - quarter 25 | - decade 26 | - century 27 | 28 | You can also make your custom period by adding a carbon marco in `AppServiceProvider`: 29 | 30 | ```php 31 | Carbon::macro('endOf...', function () { 32 | // 33 | }); 34 | ``` 35 | 36 | ## Clear recorded visitors' IPs 37 | 38 | ```php 39 | //all 40 | visits($post)->reset('ips'); 41 | //one 42 | visits($post)->reset('ips','127.0.0.1'); 43 | ``` 44 | 45 | ## Clear items and its visits of a given model 46 | 47 | ```php 48 | visits('App\Post')->reset(); 49 | ``` 50 | 51 | ## Clear all cached top/lowest lists 52 | 53 | ```php 54 | visits('App\Post')->reset('lists'); 55 | ``` 56 | 57 | ## Clear visits from all items of the given model of a period 58 | 59 | ```php 60 | visits('App\Post')->period('year')->reset(); 61 | ``` 62 | 63 | ## Clear & reset everything! 64 | 65 | ```php 66 | visits('App\Post')->reset('factory'); 67 | ``` 68 | 69 | --- 70 | 71 |

72 | Prev: < Visits-lists 73 |

74 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests/Unit 6 | 7 | 8 | ./tests/Feature 9 | 10 | 11 | 12 | 13 | ./src 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Commands/CleanCommand.php: -------------------------------------------------------------------------------- 1 | cleanEloquent(); 26 | } 27 | } 28 | 29 | protected function cleanEloquent() 30 | { 31 | Visit::where('expired_at', '<', \Carbon\Carbon::now())->delete(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/DataEngines/DataEngine.php: -------------------------------------------------------------------------------- 1 | model = $model; 14 | } 15 | 16 | public function connect(string $connection): DataEngine 17 | { 18 | return $this; 19 | } 20 | 21 | public function setPrefix(string $prefix): DataEngine 22 | { 23 | $this->prefix = $prefix . ':'; 24 | return $this; 25 | } 26 | 27 | public function increment(string $key, int $value, $member = null): bool 28 | { 29 | if (! empty($member) || is_numeric($member)) { 30 | $row = $this->model->firstOrNew(['primary_key' => $this->prefix.$key, 'secondary_key' => $member]); 31 | } else { 32 | $row = $this->model->firstOrNew(['primary_key' => $this->prefix.$key, 'secondary_key' => null]); 33 | } 34 | 35 | if($row->expired_at !== null && \Carbon\Carbon::now()->gt($row->expired_at)) { 36 | $row->score = $value; 37 | $row->expired_at = null; 38 | } else { 39 | $row->score += $value; 40 | } 41 | 42 | return $row->save(); 43 | } 44 | 45 | public function decrement(string $key, int $value, $member = null): bool 46 | { 47 | return $this->increment($key, -$value, $member); 48 | } 49 | 50 | public function delete($key, $member = null): bool 51 | { 52 | if(is_array($key)) { 53 | array_walk($key, function($item) { 54 | $this->delete($item); 55 | }); 56 | return true; 57 | } 58 | 59 | if(! empty($member) || is_numeric($member)) { 60 | return $this->model->where(['primary_key' => $this->prefix.$key, 'secondary_key' => $member])->delete(); 61 | } else { 62 | return $this->model->where(['primary_key' => $this->prefix.$key])->delete(); 63 | } 64 | } 65 | 66 | public function get(string $key, $member = null) 67 | { 68 | if(! empty($member) || is_numeric($member)) { 69 | return $this->model->where(['primary_key' => $this->prefix.$key, 'secondary_key' => $member]) 70 | ->where(function($q) { 71 | return $q->where('expired_at', '>', \Carbon\Carbon::now())->orWhereNull('expired_at'); 72 | }) 73 | ->value('score'); 74 | } else { 75 | return $this->model->where(['primary_key' => $this->prefix.$key, 'secondary_key' => null]) 76 | ->where(function($q) { 77 | return $q->where('expired_at', '>', \Carbon\Carbon::now())->orWhereNull('expired_at'); 78 | }) 79 | ->value('score'); 80 | } 81 | } 82 | 83 | public function set(string $key, $value, $member = null): bool 84 | { 85 | if(! empty($member) || is_numeric($member)) { 86 | return $this->model->updateOrCreate([ 87 | 'primary_key' => $this->prefix.$key, 88 | 'secondary_key' => $member, 89 | 'score' => $value, 90 | 'expired_at' => null, 91 | ]) instanceof Model; 92 | } else { 93 | return $this->model->updateOrCreate([ 94 | 'primary_key' => $this->prefix.$key, 95 | 'score' => $value, 96 | 'expired_at' => null, 97 | ]) instanceof Model; 98 | } 99 | } 100 | 101 | public function search(string $word, bool $noPrefix = true): array 102 | { 103 | $results = []; 104 | 105 | if($word == '*') { 106 | $results = $this->model 107 | ->where(function($q) { 108 | return $q->where('expired_at', '>', \Carbon\Carbon::now())->orWhereNull('expired_at'); 109 | }) 110 | ->pluck('primary_key'); 111 | } else { 112 | $results = $this->model->where('primary_key', 'like', $this->prefix.str_replace('*', '%', $word)) 113 | ->where(function($q) { 114 | return $q->where('expired_at', '>', \Carbon\Carbon::now())->orWhereNull('expired_at'); 115 | }) 116 | ->pluck('primary_key'); 117 | } 118 | 119 | return array_map( 120 | function($item) use($noPrefix) { 121 | if ($noPrefix && substr($item, 0, strlen($this->prefix)) == $this->prefix) { 122 | return substr($item, strlen($this->prefix)); 123 | } 124 | 125 | return $item; 126 | }, 127 | $results->toArray() ?? [] 128 | ); 129 | } 130 | 131 | public function flatList(string $key, int $limit = -1): array 132 | { 133 | return array_slice( 134 | $this->model->where(['primary_key' => $this->prefix.$key, 'secondary_key' => null]) 135 | ->where(function($q) { 136 | return $q->where('expired_at', '>', \Carbon\Carbon::now())->orWhereNull('expired_at'); 137 | }) 138 | ->value('list') ?? [], 0, $limit 139 | ); 140 | } 141 | 142 | public function addToFlatList(string $key, $value): bool 143 | { 144 | $row = $this->model->firstOrNew(['primary_key' => $this->prefix.$key, 'secondary_key' => null]); 145 | 146 | if($row->expired_at !== null && \Carbon\Carbon::now()->gt($row->expired_at)) { 147 | $row->list = (array) $value; 148 | $row->expired_at = null; 149 | } else { 150 | $row->list = array_merge($row->list ?? [], (array) $value); 151 | } 152 | 153 | $row->score = $row->score ?? 0; 154 | return (bool) $row->save(); 155 | } 156 | 157 | public function valueList(string $key, int $limit = -1, bool $orderByAsc = false, bool $withValues = false): array 158 | { 159 | $rows = $this->model->where('primary_key', $this->prefix.$key) 160 | ->where(function($q) { 161 | return $q->where('expired_at', '>', \Carbon\Carbon::now())->orWhereNull('expired_at'); 162 | }) 163 | ->whereNotNull('secondary_key') 164 | ->orderBy('score', $orderByAsc ? 'asc' : 'desc') 165 | ->when($limit > -1, function($q) use($limit) { 166 | return $q->limit($limit+1); 167 | })->pluck('score', 'secondary_key') ?? \Illuminate\Support\Collection::make(); 168 | 169 | return $withValues ? $rows->toArray() : array_keys($rows->toArray()); 170 | } 171 | 172 | public function exists(string $key): bool 173 | { 174 | return $this->model->where(['primary_key' => $this->prefix.$key, 'secondary_key' => null]) 175 | ->where(function($q) { 176 | return $q->where('expired_at', '>', \Carbon\Carbon::now())->orWhereNull('expired_at'); 177 | }) 178 | ->exists(); 179 | } 180 | 181 | public function timeLeft(string $key): int 182 | { 183 | $expired_at = $this->model->where(['primary_key' => $this->prefix.$key])->value('expired_at'); 184 | 185 | if($expired_at === null) { 186 | return -2; 187 | } 188 | 189 | $ttl = $expired_at->timestamp - \Carbon\Carbon::now()->timestamp; 190 | return $ttl <= 0 ? -1 : $ttl; 191 | } 192 | 193 | public function setExpiration(string $key, int $time): bool 194 | { 195 | return $this->model->where(['primary_key' => $this->prefix.$key]) 196 | ->where(function($q) { 197 | return $q->where('expired_at', '>', \Carbon\Carbon::now())->orWhereNull('expired_at'); 198 | }) 199 | ->update([ 200 | 'expired_at' => \Carbon\Carbon::now()->addSeconds($time) 201 | ]); 202 | } 203 | } -------------------------------------------------------------------------------- /src/DataEngines/RedisEngine.php: -------------------------------------------------------------------------------- 1 | redis = $redis; 18 | $this->isPHPRedis = strtolower(config('database.redis.client', 'phpredis')) === 'phpredis'; 19 | } 20 | 21 | public function connect(string $connection): DataEngine 22 | { 23 | $this->connection = $this->redis->connection($connection); 24 | return $this; 25 | } 26 | 27 | public function setPrefix(string $prefix): DataEngine 28 | { 29 | $this->prefix = $prefix . ':'; 30 | return $this; 31 | } 32 | 33 | public function increment(string $key, int $value, $member = null): bool 34 | { 35 | if (!empty($member) || is_numeric($member)) { 36 | try { 37 | $this->connection->zincrby($this->prefix . $key, $value, $member); 38 | } catch (\Exception $e) { 39 | if (strpos($e->getMessage(), 'WRONGTYPE') !== false) { 40 | //key was not saved properly TODO: find better way to handle this to support both phpredis and predis 41 | $this->delete($key); 42 | } else { 43 | throw $e; 44 | } 45 | return false; 46 | } 47 | } else { 48 | $this->connection->incrby($this->prefix . $key, $value); 49 | } 50 | 51 | // both methods returns integer and raise an exception in case of an error. 52 | return true; 53 | } 54 | 55 | public function decrement(string $key, int $value, $member = null): bool 56 | { 57 | return $this->increment($key, -$value, $member); 58 | } 59 | 60 | public function delete($key, $member = null): bool 61 | { 62 | if (is_array($key)) { 63 | array_walk($key, function ($item) { 64 | $this->delete($item); 65 | }); 66 | return true; 67 | } 68 | 69 | if (!empty($member) || is_numeric($member)) { 70 | return $this->connection->zrem($this->prefix . $key, $member) > 0; 71 | } else { 72 | return $this->connection->del($this->prefix . $key) > 0; 73 | } 74 | } 75 | 76 | public function get(string $key, $member = null) 77 | { 78 | if (!empty($member) || is_numeric($member)) { 79 | return $this->connection->zscore($this->prefix . $key, $member); 80 | } else { 81 | return $this->connection->get($this->prefix . $key); 82 | } 83 | } 84 | 85 | public function set(string $key, $value, $member = null): bool 86 | { 87 | if (!empty($member) || is_numeric($member)) { 88 | return $this->connection->zAdd($this->prefix . $key, $value, $member) > 0; 89 | } else { 90 | return (bool) $this->connection->set($this->prefix . $key, $value); 91 | } 92 | } 93 | 94 | public function search(string $word, bool $noPrefix = true): array 95 | { 96 | return array_map( 97 | function ($item) use ($noPrefix) { 98 | if ($noPrefix && substr($item, 0, strlen($this->prefix)) == $this->prefix) { 99 | return substr($item, strlen($this->prefix)); 100 | } 101 | 102 | return $item; 103 | }, 104 | $this->connection->keys($this->prefix . $word) ?? [] 105 | ); 106 | } 107 | 108 | public function flatList(string $key, int $limit = -1): array 109 | { 110 | return $this->connection->lrange($this->prefix . $key, 0, $limit); 111 | } 112 | 113 | public function addToFlatList(string $key, $value): bool 114 | { 115 | return $this->connection->rpush($this->prefix . $key, $value) !== false; 116 | } 117 | 118 | public function valueList(string $key, int $limit = -1, bool $orderByAsc = false, bool $withValues = false): array 119 | { 120 | $range = $orderByAsc ? 'zrange' : 'zrevrange'; 121 | 122 | return $this->connection->$range($this->prefix . $key, 0, $limit, $this->isPHPRedis ? $withValues : ['withscores' => $withValues]) ?: []; 123 | } 124 | 125 | public function exists(string $key): bool 126 | { 127 | return (bool) $this->connection->exists($this->prefix . $key); 128 | } 129 | 130 | public function timeLeft(string $key): int 131 | { 132 | return $this->connection->ttl($this->prefix . $key); 133 | } 134 | 135 | public function setExpiration(string $key, int $time): bool 136 | { 137 | return $this->connection->expire($this->prefix . $key, $time); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidPeriod.php: -------------------------------------------------------------------------------- 1 | modelName = $this->pluralModelName($subject); 20 | $this->primary = (new $subject)->getKeyName(); 21 | $this->tag = $tag; 22 | $this->visits = $this->visits(); 23 | 24 | if ($subject instanceof Model) { 25 | $this->instanceOfModel = true; 26 | $this->modelName = $this->modelName($subject); 27 | $this->id = $subject->{$subject->getKeyName()}; 28 | } 29 | } 30 | 31 | /** 32 | * Get cache key 33 | */ 34 | public function visits() 35 | { 36 | return (app()->environment('testing') ? 'testing:' : '').$this->modelName."_{$this->tag}"; 37 | } 38 | 39 | /** 40 | * Get cache key for total values 41 | */ 42 | public function visitsTotal() 43 | { 44 | return "{$this->visits}_total"; 45 | } 46 | 47 | /** 48 | * ip key 49 | */ 50 | public function ip($ip) 51 | { 52 | return $this->visits.'_'.Str::snake( 53 | 'recorded_ips:'.($this->instanceOfModel ? "{$this->id}:" : '') . $ip 54 | ); 55 | } 56 | 57 | /** 58 | * list cache key 59 | */ 60 | public function cache($limit = '*', $isLow = false, $constraints = []) 61 | { 62 | $key = $this->visits.'_lists'; 63 | 64 | if ($limit == '*') { 65 | return "{$key}:*"; 66 | } 67 | 68 | //it might not be that unique but it does the job since not many lists 69 | //will be generated to one key. 70 | $constraintsPart = count($constraints) ? ':'.substr(sha1(serialize($constraints)), 0, 7) : ''; 71 | 72 | return "{$key}:".($isLow ? 'low' : 'top').$constraintsPart.$limit; 73 | } 74 | 75 | /** 76 | * period key 77 | */ 78 | public function period($period) 79 | { 80 | return "{$this->visits}_{$period}"; 81 | } 82 | 83 | public function append($relation, $id) 84 | { 85 | $this->visits .= "_{$relation}_{$id}"; 86 | } 87 | 88 | public function modelName($subject) 89 | { 90 | return strtolower(Str::singular(class_basename(get_class($subject)))); 91 | } 92 | 93 | public function pluralModelName($subject) 94 | { 95 | return strtolower(Str::plural(class_basename(is_string($subject) ? $subject : get_class($subject)))); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Models/Visit.php: -------------------------------------------------------------------------------- 1 | 'array', 'expired_at' => 'datetime']; 11 | } 12 | -------------------------------------------------------------------------------- /src/Reset.php: -------------------------------------------------------------------------------- 1 | subject); 12 | $this->keys = $parent->keys; 13 | 14 | if (method_exists($this, $method)) { 15 | if (empty($args)) { 16 | $this->$method(); 17 | } else { 18 | $this->$method($args); 19 | } 20 | } 21 | } 22 | 23 | /** 24 | * Reset everything 25 | */ 26 | public function factory() 27 | { 28 | $this->visits(); 29 | $this->periods(); 30 | $this->ips(); 31 | $this->lists(); 32 | $this->allcountries(); 33 | $this->allrefs(); 34 | $this->allOperatingSystems(); 35 | $this->allLanguages(); 36 | } 37 | 38 | /** 39 | * reset all time visits 40 | */ 41 | public function visits() 42 | { 43 | if ($this->keys->id) { 44 | $this->connection->delete($this->keys->visits, $this->keys->id); 45 | foreach (['countries', 'referers', 'OSes', 'languages'] as $item) { 46 | $this->connection->delete($this->keys->visits."_{$item}:{$this->keys->id}"); 47 | } 48 | 49 | foreach ($this->periods as $period => $_) { 50 | $this->connection->delete($this->keys->period($period), $this->keys->id); 51 | } 52 | 53 | $this->ips(); 54 | } else { 55 | $this->connection->delete($this->keys->visits); 56 | $this->connection->delete($this->keys->visits.'_total'); 57 | } 58 | } 59 | 60 | public function allrefs() 61 | { 62 | $cc = $this->connection->search($this->keys->visits.'_referers:*'); 63 | 64 | if (count($cc)) { 65 | $this->connection->delete($cc); 66 | } 67 | } 68 | 69 | public function allOperatingSystems() 70 | { 71 | $cc = $this->connection->search($this->keys->visits.'_OSes:*'); 72 | 73 | if (count($cc)) { 74 | $this->connection->delete($cc); 75 | } 76 | } 77 | 78 | public function allLanguages() 79 | { 80 | $cc = $this->connection->search($this->keys->visits.'_languages:*'); 81 | 82 | if (count($cc)) { 83 | $this->connection->delete($cc); 84 | } 85 | } 86 | 87 | public function allcountries() 88 | { 89 | $cc = $this->connection->search($this->keys->visits.'_countries:*'); 90 | 91 | if (count($cc)) { 92 | $this->connection->delete($cc); 93 | } 94 | } 95 | 96 | /** 97 | * reset day,week counters 98 | */ 99 | public function periods() 100 | { 101 | foreach ($this->periods as $period => $_) { 102 | $periodKey = $this->keys->period($period); 103 | $this->connection->delete($periodKey); 104 | $this->connection->delete($periodKey.'_total'); 105 | } 106 | } 107 | 108 | /** 109 | * reset ips protection 110 | * @param string $ips 111 | */ 112 | public function ips($ips = '*') 113 | { 114 | $ips = $this->connection->search($this->keys->ip($ips)); 115 | 116 | if (count($ips)) { 117 | $this->connection->delete($ips); 118 | } 119 | } 120 | 121 | /** 122 | * reset lists top/low 123 | */ 124 | public function lists() 125 | { 126 | $lists = $this->connection->search($this->keys->cache()); 127 | 128 | if (count($lists)) { 129 | $this->connection->delete($lists); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Traits/Lists.php: -------------------------------------------------------------------------------- 1 | keys->cache($limit, $orderByAsc, $constraints); 24 | 25 | $cachedList = $this->cachedList($limit, $cacheKey); 26 | return $this->getVisitsIds($limit, $this->keys->visits, $orderByAsc); 27 | } 28 | 29 | /** 30 | * Fetch all time trending subjects. 31 | * 32 | * @param int $limit 33 | * @param array $constraints optional. filter models by attributes (where=[...]) 34 | * @return \Illuminate\Support\Collection|array 35 | */ 36 | public function top($limit = 5, $orderByAsc = false, $constraints = []) 37 | { 38 | if(is_array($orderByAsc)) { 39 | $constraints = $orderByAsc; 40 | $orderByAsc = false; 41 | } 42 | 43 | $cacheKey = $this->keys->cache($limit, $orderByAsc, $constraints); 44 | 45 | $cachedList = $this->cachedList($limit, $cacheKey); 46 | $visitsIds = $this->getVisitsIds($limit, $this->keys->visits, $orderByAsc); 47 | 48 | if($visitsIds === $cachedList->pluck($this->keys->primary)->toArray() && ! $this->fresh) { 49 | return $cachedList; 50 | } 51 | 52 | return $this->freshList($cacheKey, $visitsIds, $constraints); 53 | } 54 | 55 | 56 | /** 57 | * Top/low countries 58 | */ 59 | public function countries($limit = -1, $orderByAsc = false) 60 | { 61 | return $this->getSortedList('countries', $limit, $orderByAsc, true); 62 | } 63 | 64 | /** 65 | * top/lows refs 66 | */ 67 | public function refs($limit = -1, $orderByAsc = false) 68 | { 69 | return $this->getSortedList('referers', $limit, $orderByAsc, true); 70 | } 71 | 72 | /** 73 | * top/lows operating systems 74 | */ 75 | public function operatingSystems($limit = -1, $orderByAsc = false) 76 | { 77 | return $this->getSortedList('OSes', $limit, $orderByAsc, true); 78 | } 79 | 80 | /** 81 | * top/lows languages 82 | */ 83 | public function languages($limit = -1, $orderByAsc = false) 84 | { 85 | return $this->getSortedList('languages', $limit, $orderByAsc, true); 86 | } 87 | 88 | 89 | protected function getSortedList($name, $limit, $orderByAsc = false, $withValues = true) 90 | { 91 | return $this->connection->valueList($this->keys->visits . "_{$name}:{$this->keys->id}", $limit, $orderByAsc, $withValues); 92 | } 93 | 94 | 95 | /** 96 | * Fetch lowest subjects Ids. 97 | * 98 | * @param int $limit 99 | * @param array $constraints optional 100 | * @return array 101 | */ 102 | public function lowIds($limit = 5, $constraints = []) 103 | { 104 | return $this->topIds($limit, true, $constraints); 105 | } 106 | 107 | /** 108 | * Fetch lowest subjects. 109 | * 110 | * @param int $limit 111 | * @param array $constraints optional 112 | * @return \Illuminate\Support\Collection|array 113 | */ 114 | public function low($limit = 5, $constraints = []) 115 | { 116 | return $this->top($limit, true, $constraints); 117 | } 118 | 119 | 120 | /** 121 | * @param $limit 122 | * @param $visitsKey 123 | * @param bool $isLow 124 | * @return mixed 125 | */ 126 | protected function getVisitsIds($limit, $visitsKey, $orderByAsc = false) 127 | { 128 | return array_map(function($item) { 129 | return is_numeric($item) ? intval($item) : $item; 130 | }, $this->connection->valueList($visitsKey, $limit - 1, $orderByAsc)); 131 | } 132 | 133 | /** 134 | * @param $cacheKey 135 | * @param $visitsIds 136 | * @return mixed 137 | */ 138 | protected function freshList($cacheKey, $visitsIds, $constraints = []) 139 | { 140 | if (count($visitsIds)) { 141 | 142 | $this->connection->delete($cacheKey); 143 | 144 | return ($this->subject)::whereIn($this->keys->primary, $visitsIds) 145 | ->when(count($constraints), function($query) use($constraints) { 146 | return $query->where($constraints); 147 | }) 148 | ->get() 149 | ->sortBy(function ($subject) use ($visitsIds) { 150 | return array_search($subject->{$this->keys->primary}, $visitsIds); 151 | })->each(function ($subject) use ($cacheKey) { 152 | $this->connection->addToFlatList($cacheKey, serialize($subject)); 153 | }); 154 | } 155 | 156 | return []; 157 | } 158 | 159 | /** 160 | * @param $limit 161 | * @param $cacheKey 162 | * @return \Illuminate\Support\Collection|array 163 | */ 164 | protected function cachedList($limit, $cacheKey) 165 | { 166 | return Collection::make( 167 | array_map('unserialize', $this->connection->flatList($cacheKey, $limit)) 168 | ); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Traits/Periods.php: -------------------------------------------------------------------------------- 1 | periods as $period) { 17 | $periodKey = $this->keys->period($period); 18 | 19 | if ($this->noExpiration($periodKey)) { 20 | $expireInSeconds = $this->newExpiration($period); 21 | $this->connection->increment($periodKey.'_total', 0); 22 | $this->connection->increment($periodKey, 0, 0); 23 | $this->connection->setExpiration($periodKey, $expireInSeconds); 24 | $this->connection->setExpiration($periodKey.'_total', $expireInSeconds); 25 | } 26 | } 27 | } 28 | 29 | protected function noExpiration($periodKey) 30 | { 31 | return $this->connection->timeLeft($periodKey) == -1 || ! $this->connection->exists($periodKey); 32 | } 33 | 34 | protected function newExpiration($period) 35 | { 36 | try { 37 | $periodCarbon = $this->xHoursPeriod($period) ?? Carbon::now()->{'endOf' . Str::studly($period)}(); 38 | } catch (Exception $e) { 39 | throw new Exception("Wrong period: `{$period}`! please update config/visits.php file."); 40 | } 41 | 42 | return intval(abs($periodCarbon->diffInSeconds())) + 1; 43 | } 44 | 45 | /** 46 | * @param $period 47 | * @return mixed 48 | */ 49 | protected function xHoursPeriod($period) 50 | { 51 | preg_match('/([\d]+)\s?([\w]+)/', $period, $match); 52 | return isset($match[2]) && isset($match[1]) && $match[2] == 'hours' && $match[1] < 12 53 | ? Carbon::now()->endOfxHours((int) $match[1]) 54 | : null; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Traits/Record.php: -------------------------------------------------------------------------------- 1 | connection->increment($this->keys->visits . "_countries:{$this->keys->id}", $inc, $this->getVisitorCountry()); 15 | } 16 | 17 | /** 18 | * @param $inc 19 | */ 20 | protected function recordRefer($inc) 21 | { 22 | $this->connection->increment($this->keys->visits . "_referers:{$this->keys->id}", $inc, $this->getVisitorReferer()); 23 | } 24 | 25 | /** 26 | * @param $inc 27 | */ 28 | protected function recordOperatingSystem($inc) 29 | { 30 | $this->connection->increment($this->keys->visits . "_OSes:{$this->keys->id}", $inc, $this->getVisitorOperatingSystem()); 31 | } 32 | 33 | /** 34 | * @param $inc 35 | */ 36 | protected function recordLanguage($inc) 37 | { 38 | $this->connection->increment($this->keys->visits . "_languages:{$this->keys->id}", $inc, $this->getVisitorLanguage()); 39 | } 40 | 41 | /** 42 | * @param $inc 43 | */ 44 | protected function recordPeriods($inc) 45 | { 46 | foreach ($this->periods as $period) { 47 | $periodKey = $this->keys->period($period); 48 | 49 | $this->connection->increment($periodKey, $inc, $this->keys->id); 50 | $this->connection->increment($periodKey . '_total', $inc); 51 | } 52 | } 53 | 54 | /** 55 | * Gets visitor country code 56 | * @return mixed|string 57 | */ 58 | public function getVisitorCountry() 59 | { 60 | //In case of using unsupported cache driver. Although 'country' is globally 61 | //ignored already, we can not rely on user awareness of geoIP package restriction. 62 | if ( 63 | in_array(config('cache.default'), ['file', 'dynamodb', 'database']) && 64 | is_array($geoipTags = config('geoip.cache_tags')) && count($geoipTags) > 0 65 | ) { 66 | return 'zz'; 67 | } 68 | 69 | return strtolower(geoip()->getLocation()->iso_code); 70 | } 71 | 72 | /** 73 | * Gets visitor operating system 74 | * @return mixed|string 75 | */ 76 | public function getVisitorOperatingSystem() 77 | { 78 | $osArray = [ 79 | '/windows|win32|win16|win95/i' => 'Windows', 80 | '/iphone/i' => 'iPhone', 81 | '/ipad/i' => 'iPad', 82 | '/macintosh|mac os x|mac_powerpc/i' => 'MacOS', 83 | '/(?=.*mobile)android/i' => 'AndroidMobile', 84 | '/(?!.*mobile)android/i' => 'AndroidTablet', 85 | '/android/i' => 'Android', 86 | '/blackberry/i' => 'BlackBerry', 87 | '/linux/i' => 'Linux', 88 | ]; 89 | 90 | foreach ($osArray as $regex => $value) { 91 | if (preg_match($regex, request()->server('HTTP_USER_AGENT') ?? '')) { 92 | return $value; 93 | } 94 | } 95 | 96 | return 'unknown'; 97 | } 98 | 99 | /** 100 | * Gets visitor language 101 | * @return mixed|string 102 | */ 103 | public function getVisitorLanguage() 104 | { 105 | $language = request()->getPreferredLanguage(); 106 | if (false !== $position = strpos($language, '_')) { 107 | $language = substr($language, 0, $position); 108 | } 109 | return $language; 110 | } 111 | 112 | /** 113 | * Gets visitor referer 114 | * @return mixed|string 115 | */ 116 | public function getVisitorReferer() 117 | { 118 | return app(Referer::class)->get() ?? 'direct'; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Traits/Setters.php: -------------------------------------------------------------------------------- 1 | fresh = true; 16 | 17 | return $this; 18 | } 19 | 20 | /** 21 | * set x seconds for ip expiration 22 | * 23 | * @param $seconds 24 | * @return $this 25 | */ 26 | public function seconds($seconds) 27 | { 28 | $this->ipSeconds = $seconds; 29 | 30 | return $this; 31 | } 32 | 33 | 34 | /** 35 | * @param $country 36 | * @return $this 37 | */ 38 | public function country($country) 39 | { 40 | $this->country = $country; 41 | 42 | return $this; 43 | } 44 | 45 | 46 | /** 47 | * @param $referer 48 | * @return $this 49 | */ 50 | public function referer($referer) 51 | { 52 | $this->referer = $referer; 53 | 54 | return $this; 55 | } 56 | 57 | /** 58 | * @param $operatingSystem 59 | * @return $this 60 | */ 61 | public function operatingSystem($operatingSystem) 62 | { 63 | $this->operatingSystem = $operatingSystem; 64 | 65 | return $this; 66 | } 67 | 68 | /** 69 | * @param $language 70 | * @return $this 71 | */ 72 | public function language($language) 73 | { 74 | $this->language = $language; 75 | 76 | return $this; 77 | } 78 | 79 | /** 80 | * Change period 81 | * 82 | * @param $period 83 | * @return $this 84 | */ 85 | public function period($period) 86 | { 87 | if (in_array($period, $this->periods)) { 88 | $this->keys->visits = $this->keys->period($period); 89 | } else { 90 | throw new InvalidPeriod($period); 91 | } 92 | 93 | return $this; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Visits.php: -------------------------------------------------------------------------------- 1 | config = config('visits'); 79 | 80 | $this->connection = $this->determineConnection($this->config['engine'] ?? 'redis') 81 | ->connect($this->config['connection']) 82 | ->setPrefix($this->config['keys_prefix'] ?? $this->config['redis_keys_prefix'] ?? 'visits'); 83 | 84 | if(! $this->connection) { 85 | return; 86 | } 87 | 88 | $this->periods = $this->config['periods']; 89 | $this->ipSeconds = $this->config['remember_ip']; 90 | $this->fresh = $this->config['always_fresh']; 91 | $this->ignoreCrawlers = $this->config['ignore_crawlers']; 92 | $this->globalIgnore = $this->config['global_ignore']; 93 | $this->subject = $subject; 94 | $this->keys = new Keys($subject, preg_replace('/[^a-z0-9_]/i', '', $tag)); 95 | 96 | if (! empty($this->keys->id)) { 97 | $this->periodsSync(); 98 | } 99 | } 100 | 101 | protected function determineConnection($name) 102 | { 103 | return app($name); 104 | } 105 | 106 | /** 107 | * @param $subject 108 | * @return self 109 | */ 110 | public function by($subject) 111 | { 112 | if($subject instanceof Model) { 113 | $this->keys->append($this->keys->modelName($subject), $subject->{$subject->getKeyName()}); 114 | } else if (is_array($subject)) { 115 | $this->keys->append(array_keys($subject)[0], Arr::first($subject)); 116 | } 117 | 118 | return $this; 119 | } 120 | 121 | /** 122 | * Reset methods 123 | * 124 | * @param $method 125 | * @param string $args 126 | * @return \Awssat\Visits\Reset 127 | */ 128 | public function reset($method = 'visits', $args = '') 129 | { 130 | return new Reset($this, $method, $args); 131 | } 132 | 133 | /** 134 | * Check for the ip is has been recorded before 135 | * @return bool 136 | */ 137 | public function recordedIp() 138 | { 139 | if(! $this->connection->exists($this->keys->ip(request()->ip()))) { 140 | $this->connection->set($this->keys->ip(request()->ip()), true); 141 | $this->connection->setExpiration($this->keys->ip(request()->ip()), $this->ipSeconds); 142 | 143 | return false; 144 | } 145 | 146 | return true; 147 | } 148 | 149 | /** 150 | * Get visits of model incount(stance. 151 | * @return mixed 152 | */ 153 | public function count() 154 | { 155 | if ($this->country) { 156 | return $this->connection->get($this->keys->visits."_countries:{$this->keys->id}", $this->country); 157 | } else if ($this->referer) { 158 | return $this->connection->get($this->keys->visits."_referers:{$this->keys->id}", $this->referer); 159 | } else if ($this->operatingSystem) { 160 | return $this->connection->get($this->keys->visits."_OSes:{$this->keys->id}", $this->operatingSystem); 161 | } else if ($this->language) { 162 | return $this->connection->get($this->keys->visits."_languages:{$this->keys->id}", $this->language); 163 | } 164 | 165 | return intval( 166 | $this->keys->instanceOfModel 167 | ? $this->connection->get($this->keys->visits, $this->keys->id) 168 | : $this->connection->get($this->keys->visitsTotal()) 169 | ); 170 | } 171 | 172 | /** 173 | * @return integer time left in seconds 174 | */ 175 | public function timeLeft() 176 | { 177 | return $this->connection->timeLeft($this->keys->visits); 178 | } 179 | 180 | /** 181 | * @return integer time left in seconds 182 | */ 183 | public function ipTimeLeft() 184 | { 185 | return $this->connection->timeLeft($this->keys->ip(request()->ip())); 186 | } 187 | 188 | protected function isCrawler() 189 | { 190 | return $this->ignoreCrawlers && app(CrawlerDetect::class)->isCrawler(); 191 | } 192 | 193 | /** 194 | * @param int $inc value to increment 195 | * @param bool $force force increment, skip time limit 196 | * @param array $ignore to ignore recording visits of periods, country, refer, language and operatingSystem. pass them on this array. 197 | * @return bool 198 | */ 199 | public function increment($inc = 1, $force = false, $ignore = []) 200 | { 201 | if ($force || (!$this->isCrawler() && !$this->recordedIp())) { 202 | 203 | $this->connection->increment($this->keys->visits, $inc, $this->keys->id); 204 | $this->connection->increment($this->keys->visitsTotal(), $inc); 205 | 206 | if(is_array($this->globalIgnore) && sizeof($this->globalIgnore) > 0) { 207 | $ignore = array_merge($ignore, $this->globalIgnore); 208 | } 209 | 210 | //NOTE: $$method is parameter also .. ($periods,$country,$refer) 211 | foreach (['country', 'refer', 'periods', 'operatingSystem', 'language'] as $method) { 212 | if(! in_array($method, $ignore)) { 213 | $this->{'record'.Str::studly($method)}($inc); 214 | } 215 | } 216 | 217 | return true; 218 | } 219 | 220 | return false; 221 | } 222 | 223 | /** 224 | * @param int $inc 225 | * @param array $ignore to ignore recording visits like country, periods ... 226 | * @return bool 227 | */ 228 | public function forceIncrement($inc = 1, $ignore = []) 229 | { 230 | return $this->increment($inc, true, $ignore); 231 | } 232 | 233 | /** 234 | * Decrement a new/old subject to the cache cache. 235 | * 236 | * @param int $dec 237 | * @param array $ignore to ignore recording visits like country, periods ... 238 | * @return bool 239 | */ 240 | public function decrement($dec = 1, $force = false, $ignore = []) 241 | { 242 | return $this->increment(-$dec, $force, $ignore); 243 | } 244 | 245 | /** 246 | * @param int $dec 247 | * @param array $ignore to ignore recording visits like country, periods ... 248 | * @return bool 249 | */ 250 | public function forceDecrement($dec = 1, $ignore = []) 251 | { 252 | return $this->decrement($dec, true, $ignore); 253 | } 254 | 255 | /** 256 | * @param $period 257 | * @param int $time 258 | * @return bool 259 | */ 260 | public function expireAt($period, $time) 261 | { 262 | $periodKey = $this->keys->period($period); 263 | return $this->connection->setExpiration($periodKey, $time); 264 | } 265 | 266 | /** 267 | * To be used with models to integrate relationship with visits model. 268 | * @return \Illuminate\Database\Eloquent\Relations\Relation 269 | */ 270 | public function relation() 271 | { 272 | $prefix = $this->config['keys_prefix'] ?? $this->config['redis_keys_prefix'] ?? 'visits'; 273 | 274 | return $this->subject->hasOne(Visit::class, 'secondary_key')->where('primary_key', $prefix.':'.$this->keys->visits); 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/VisitsServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 20 | __DIR__.'/config/visits.php' => config_path('visits.php'), 21 | ], 'config'); 22 | 23 | if (! class_exists('CreateVisitsTable')) { 24 | $this->publishes([ 25 | __DIR__.'/../database/migrations/create_visits_table.php.stub' => database_path('migrations/'.date('Y_m_d_His', time()).'_create_visits_table.php'), 26 | ], 'migrations'); 27 | } 28 | 29 | Carbon::macro('endOfxHours', function ($xhours) { 30 | if ($xhours > 12) { 31 | throw new \Exception('12 is the maximum period in xHours feature'); 32 | } 33 | $h = $this->hour; 34 | 35 | return $this->setTime( 36 | ($h % $xhours == 0 ? 'min' : 'max')($h - ($h % $xhours), $h - ($h % $xhours) + $xhours), 37 | 59, 38 | 59 39 | ); 40 | }); 41 | } 42 | 43 | /** 44 | * Register any package services. 45 | * 46 | * @return void 47 | */ 48 | public function register() 49 | { 50 | $this->mergeConfigFrom( 51 | __DIR__.'/config/visits.php', 52 | 'visits' 53 | ); 54 | 55 | // Register GeoIP service provider if not already registered 56 | if (!$this->app->providerIsLoaded(GeoIPServiceProvider::class)) { 57 | $this->app->register(GeoIPServiceProvider::class); 58 | } 59 | 60 | // Register GeoIP facade if not already registered 61 | $geoipAlias = $this->app->getAlias('GeoIP'); 62 | if ($geoipAlias === null) { 63 | $this->app->alias('GeoIP', \Torann\GeoIP\Facades\GeoIP::class); 64 | } 65 | 66 | // For testing environments, use a mock implementation 67 | if ($this->app->environment('testing')) { 68 | $this->app->singleton('geoip', function () { 69 | return new class { 70 | public function getLocation() { 71 | return (object)[ 72 | 'ip' => '127.0.0.0', 73 | 'iso_code' => 'US', 74 | 'country' => 'United States', 75 | 'city' => 'New Haven', 76 | 'state' => 'CT', 77 | 'state_name' => 'Connecticut', 78 | 'postal_code' => '06510', 79 | 'lat' => 41.31, 80 | 'lon' => -72.92, 81 | 'timezone' => 'America/New_York', 82 | 'continent' => 'NA', 83 | 'default' => true, 84 | 'currency' => 'USD', 85 | ]; 86 | } 87 | }; 88 | }); 89 | } 90 | 91 | $this->app->bind('command.visits:clean', CleanCommand::class); 92 | 93 | $this->commands([ 94 | 'command.visits:clean', 95 | ]); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/config/visits.php: -------------------------------------------------------------------------------- 1 | \Awssat\Visits\DataEngines\RedisEngine::class, 14 | 'connection' => 'laravel-visits', 15 | 16 | 17 | /* 18 | |-------------------------------------------------------------------------- 19 | | Counters periods 20 | |-------------------------------------------------------------------------- 21 | | 22 | | Record visits (total) of each one of these periods in this set (can be empty) 23 | | 24 | */ 25 | 'periods' => [ 26 | 'day', 27 | 'week', 28 | 'month', 29 | 'year', 30 | ], 31 | 32 | /* 33 | |-------------------------------------------------------------------------- 34 | | Redis prefix 35 | |-------------------------------------------------------------------------- 36 | */ 37 | 'keys_prefix' => 'visits', 38 | 39 | /* 40 | |-------------------------------------------------------------------------- 41 | | Remember ip for x seconds of time 42 | |-------------------------------------------------------------------------- 43 | | 44 | | Will count only one visit of an IP during this specified time. 45 | | 46 | */ 47 | 'remember_ip' => 15 * 60, 48 | 49 | /* 50 | |-------------------------------------------------------------------------- 51 | | Always return uncached fresh top/low lists 52 | |-------------------------------------------------------------------------- 53 | */ 54 | 'always_fresh' => false, 55 | 56 | 57 | /* 58 | |-------------------------------------------------------------------------- 59 | | Ignore Crawlers 60 | |-------------------------------------------------------------------------- 61 | | 62 | | Ignore counting crawlers visits or not 63 | | 64 | */ 65 | 'ignore_crawlers' => true, 66 | 67 | /* 68 | |-------------------------------------------------------------------------- 69 | | Global Ignore Recording 70 | |-------------------------------------------------------------------------- 71 | | 72 | | stop recording specific items (can be any of these: 'country', 'refer', 'periods', 'operatingSystem', 'language') 73 | | 74 | */ 75 | 'global_ignore' => ['country'], 76 | 77 | ]; 78 | 79 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | app['config']['visits.engine'] = \Awssat\Visits\DataEngines\EloquentEngine::class; 17 | $this->connection = app(\Awssat\Visits\DataEngines\EloquentEngine::class) 18 | ->setPrefix($this->app['config']['visits.keys_prefix']); 19 | include_once __DIR__.'/../../database/migrations/create_visits_table.php.stub'; 20 | (new \CreateVisitsTable())->up(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Feature/EloquentVisitsTest.php: -------------------------------------------------------------------------------- 1 | app['config']['visits.engine'] = \Awssat\Visits\DataEngines\EloquentEngine::class; 16 | $this->connection = app(\Awssat\Visits\DataEngines\EloquentEngine::class) 17 | ->setPrefix($this->app['config']['visits.keys_prefix']); 18 | include_once __DIR__.'/../../database/migrations/create_visits_table.php.stub'; 19 | (new \CreateVisitsTable())->up(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Feature/PeriodsTestCase.php: -------------------------------------------------------------------------------- 1 | set('visits.periods', ['3hours']); 24 | 25 | Carbon::setTestNow( 26 | Carbon::now()->endOfxHours(3) 27 | ); 28 | 29 | $post = Post::create()->fresh(); 30 | 31 | visits($post)->increment(); 32 | 33 | $this->assertEquals([1, 1], [ 34 | visits($post)->count(), 35 | visits($post)->period('3hours')->count(), 36 | ]); 37 | 38 | Carbon::setTestNow(now()->addSeconds(1)); 39 | sleep(1);//for redis 40 | 41 | $this->assertEquals([1, 0], [ 42 | visits($post)->count(), 43 | visits($post)->period('3hours')->count(), 44 | ]); 45 | } 46 | 47 | /** @test */ 48 | public function day_test() 49 | { 50 | Carbon::setTestNow( 51 | $time = Carbon::now()->endOfDay() 52 | ); 53 | 54 | $post = Post::create()->fresh(); 55 | 56 | visits($post)->increment(); 57 | 58 | //it should be there fo breif of time 59 | $this->assertEquals([1, 1, 1], [ 60 | visits($post)->count(), 61 | visits($post)->period('day')->count(), 62 | visits('Awssat\Visits\Tests\Post')->period('day')->count(), 63 | ]); 64 | 65 | //time until redis delete periods 66 | $this->assertEquals(1, visits($post)->period('day')->timeLeft()); 67 | 68 | $this->assertEquals( 69 | 1, 70 | visits('Awssat\Visits\Tests\Post')->period('day')->timeLeft() 71 | ); 72 | 73 | //after seconds it should be empty for week and day 74 | Carbon::setTestNow(Carbon::now()->addSeconds(1)); 75 | sleep(1); //for redfis 76 | 77 | $this->assertEquals([1, 0,], [ 78 | visits($post)->count(), 79 | visits($post)->period('day')->count(), 80 | ]); 81 | 82 | //he came after a 5 minute later 83 | Carbon::setTestNow(Carbon::now()->addMinutes(5)); 84 | 85 | 86 | visits($post)->forceIncrement(); 87 | 88 | $this->assertEquals([2, 1,], [ 89 | visits($post)->count(), 90 | visits($post)->period('day')->count(), 91 | ]); 92 | 93 | 94 | //time until redis delete periods 95 | $this->assertEquals(1, intval(abs(now()->addSeconds(visits($post)->period('day')->timeLeft())->diffInDays($time)))); 96 | 97 | //time until redis delete periods 98 | $this->assertEquals(1, intval(abs(now()->addSeconds(visits('Awssat\Visits\Tests\Post')->period('day')->timeLeft())->diffInDays($time)))); 99 | } 100 | 101 | /** @test */ 102 | public function all_periods() 103 | { 104 | //somone add something on end of the week 105 | Carbon::setTestNow(Carbon::now()->startOfMonth()->endOfWeek()); 106 | 107 | $post = Post::create()->fresh(); 108 | 109 | visits($post)->increment(); 110 | 111 | //it should be there fo breif of time 112 | $this->assertEquals([1, 1, 1, 1, 1], [ 113 | visits($post)->count(), 114 | visits($post)->period('day')->count(), 115 | visits($post)->period('week')->count(), 116 | visits($post)->period('month')->count(), 117 | visits($post)->period('year')->count() 118 | ]); 119 | 120 | //after seconds it should be empty for week and day 121 | Carbon::setTestNow(Carbon::now()->addSeconds(1)); 122 | sleep(1); //for redis 123 | 124 | $this->assertEquals([1, 0, 0, 1, 1], [ 125 | visits($post)->count(), 126 | visits($post)->period('day')->count(), 127 | visits($post)->period('week')->count(), 128 | visits($post)->period('month')->count(), 129 | visits($post)->period('year')->count() 130 | ]); 131 | 132 | //he came after a 5 minute later 133 | Carbon::setTestNow(Carbon::now()->endOfWeek()->addHours(1)); 134 | 135 | 136 | visits($post)->forceIncrement(); 137 | 138 | $this->assertEquals([2, 1, 1, 2, 2], [ 139 | visits($post)->count(), 140 | visits($post)->period('day')->count(), 141 | visits($post)->period('week')->count(), 142 | visits($post)->period('month')->count(), 143 | visits($post)->period('year')->count() 144 | ]); 145 | } 146 | 147 | /** @test */ 148 | public function total_periods() 149 | { 150 | //somone add something on end of the week 151 | Carbon::setTestNow(Carbon::now()->startOfMonth()->endOfWeek()); 152 | 153 | $post = Post::create()->fresh(); 154 | 155 | visits($post)->increment(); 156 | 157 | $post2 = Post::create()->fresh(); 158 | 159 | visits($post2)->increment(); 160 | 161 | //it should be there fo breif of time 162 | $this->assertEquals([2, 2, 2, 2, 2], [ 163 | visits('Awssat\Visits\Tests\Post')->count(), 164 | visits('Awssat\Visits\Tests\Post')->period('day')->count(), 165 | visits('Awssat\Visits\Tests\Post')->period('week')->count(), 166 | visits('Awssat\Visits\Tests\Post')->period('month')->count(), 167 | visits('Awssat\Visits\Tests\Post')->period('year')->count() 168 | ]); 169 | 170 | //after seconds it should be empty for week and day 171 | Carbon::setTestNow(Carbon::now()->addSeconds(1)); 172 | sleep(1); //for redis 173 | 174 | $this->assertEquals([2, 0, 0, 2, 2], [ 175 | visits('Awssat\Visits\Tests\Post')->count(), 176 | visits('Awssat\Visits\Tests\Post')->period('day')->count(), 177 | visits('Awssat\Visits\Tests\Post')->period('week')->count(), 178 | visits('Awssat\Visits\Tests\Post')->period('month')->count(), 179 | visits('Awssat\Visits\Tests\Post')->period('year')->count() 180 | ]); 181 | 182 | //he came after a 5 minute later 183 | Carbon::setTestNow(Carbon::now()->endOfWeek()->addHours(1)); 184 | 185 | visits($post2)->forceIncrement(); 186 | visits($post2)->forceIncrement(); 187 | 188 | $this->assertEquals([4, 2, 2, 4, 4], [ 189 | visits('Awssat\Visits\Tests\Post')->count(), 190 | visits('Awssat\Visits\Tests\Post')->period('day')->count(), 191 | visits('Awssat\Visits\Tests\Post')->period('week')->count(), 192 | visits('Awssat\Visits\Tests\Post')->period('month')->count(), 193 | visits('Awssat\Visits\Tests\Post')->period('year')->count() 194 | ]); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /tests/Feature/RedisPeriodsTest.php: -------------------------------------------------------------------------------- 1 | app['config']['database.redis.client'] = 'predis'; // phpredis also works 18 | $this->app['config']['database.redis.options.prefix'] = ''; 19 | $this->app['config']['database.redis.laravel-visits'] = [ 20 | 'host' => env('REDIS_HOST', 'localhost'), 21 | 'password' => env('REDIS_PASSWORD', null), 22 | 'port' => env('REDIS_PORT', 6379), 23 | 'database' => 3, 24 | ]; 25 | 26 | $this->redis = Redis::connection('laravel-visits'); 27 | 28 | if (count($keys = $this->redis->keys($this->app['config']['visits.keys_prefix'].':testing:*'))) { 29 | $this->redis->del($keys); 30 | } 31 | 32 | 33 | $this->connection = app(\Awssat\Visits\DataEngines\RedisEngine::class) 34 | ->connect($this->app['config']['visits.connection']) 35 | ->setPrefix($this->app['config']['visits.keys_prefix']); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Feature/RedisVisitsTest.php: -------------------------------------------------------------------------------- 1 | app['config']['database.redis.client'] = 'predis'; // phpredis also works 17 | $this->app['config']['database.redis.options.prefix'] = ''; 18 | $this->app['config']['database.redis.laravel-visits'] = [ 19 | 'host' => env('REDIS_HOST', 'localhost'), 20 | 'password' => env('REDIS_PASSWORD', null), 21 | 'port' => env('REDIS_PORT', 6379), 22 | 'database' => 3, 23 | ]; 24 | 25 | $this->redis = Redis::connection('laravel-visits'); 26 | 27 | if (count($keys = $this->redis->keys($this->app['config']['visits.keys_prefix'].':testing:*'))) { 28 | $this->redis->del($keys); 29 | } 30 | 31 | 32 | $this->connection = app(\Awssat\Visits\DataEngines\RedisEngine::class) 33 | ->connect($this->app['config']['visits.connection']) 34 | ->setPrefix($this->app['config']['visits.keys_prefix']); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Feature/VisitsTestCase.php: -------------------------------------------------------------------------------- 1 | by(['user' => 1])->increment(); 29 | $this->assertEquals(1, visits($post)->by(['user' => 1])->count()); 30 | $this->assertEquals(0, visits($post)->count()); 31 | } 32 | 33 | /** @test * */ 34 | public function by_cant_repeat_accept_array() 35 | { 36 | User::create(); 37 | $post = Post::create(); 38 | 39 | $firstResult = visits($post)->increment(); 40 | $secondResult = visits($post)->increment(); 41 | 42 | $this->assertTrue($firstResult); 43 | $this->assertFalse($secondResult); 44 | $this->assertEquals(1, visits($post)->count()); 45 | } 46 | 47 | /** @test * */ 48 | public function visits_by_user_lists() 49 | { 50 | $user = User::create(); 51 | 52 | foreach (range(1, 20) as $id) { 53 | $post = Post::create(); 54 | visits($post)->by($user)->increment(); 55 | } 56 | 57 | $top_visits_overall = visits('Awssat\Visits\Tests\Post') 58 | ->top(10) 59 | ->toArray(); 60 | $this->assertEmpty($top_visits_overall); 61 | 62 | $top_visits = visits('Awssat\Visits\Tests\Post') 63 | ->by($user) 64 | ->top(20) 65 | ->toArray(); 66 | 67 | $this->assertCount(20, $top_visits); 68 | } 69 | 70 | /** @test * */ 71 | public function visits_by_user() 72 | { 73 | $user = User::create(); 74 | $post = Post::create(); 75 | 76 | visits($post)->by($user)->increment(); 77 | 78 | $this->assertEquals(1, visits($post)->by($user)->count()); 79 | $this->assertEquals(0, visits($post)->count()); 80 | } 81 | 82 | /** @test * */ 83 | public function laravel_visits_is_the_default_connection() 84 | { 85 | $this->assertEquals('laravel-visits', config('visits.connection')); 86 | } 87 | 88 | /** @test */ 89 | public function multi_tags_storing() 90 | { 91 | $userA = Post::create()->fresh(); 92 | 93 | visits($userA)->increment(); 94 | 95 | visits($userA, 'clicks')->increment(); 96 | visits($userA, 'clicks2')->increment(); 97 | 98 | $keys = $this->connection->search('testing:*'); 99 | 100 | $this->assertContains('testing:posts_visits', $keys); 101 | $this->assertContains('testing:posts_clicks', $keys); 102 | $this->assertContains('testing:posts_clicks2', $keys); 103 | } 104 | 105 | /** @test */ 106 | public function multi_tags_visits() 107 | { 108 | $userA = Post::create()->fresh(); 109 | 110 | visits($userA)->increment(); 111 | 112 | visits($userA, 'clicks')->increment(); 113 | 114 | $this->assertEquals([1, 1], [visits($userA)->count(), visits($userA, 'clicks')->count()]); 115 | } 116 | 117 | /** @test */ 118 | public function referer_test() 119 | { 120 | $this->referer->put('google.com'); 121 | 122 | $Post = Post::create()->fresh(); 123 | 124 | visits($Post)->forceIncrement(); 125 | 126 | $this->referer->put('twitter.com'); 127 | 128 | visits($Post)->forceIncrement(10); 129 | 130 | $this->assertEquals(['twitter.com' => 10, 'google.com' => 1], visits($Post)->refs()); 131 | } 132 | 133 | /** @test */ 134 | public function operating_system_test() 135 | { 136 | $Post = Post::create()->fresh(); 137 | 138 | request()->server->replace([ 139 | 'HTTP_USER_AGENT' => 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148' 140 | ]); 141 | 142 | 143 | visits($Post)->forceIncrement(); 144 | 145 | request()->server->replace([ 146 | 'HTTP_USER_AGENT' => 'Mozilla/5.0 (Linux; Android 6.0.1; SAMSUNG SM-N920T Build/MMB29K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/4.0 Chrome/44.0.2403.133 Mobile Safari/537.36' 147 | ]); 148 | 149 | visits($Post)->forceIncrement(10); 150 | 151 | $this->assertEquals(['AndroidMobile' => 10, 'iPad' => 1], visits($Post)->operatingSystems()); 152 | } 153 | 154 | /** @test */ 155 | public function language_test() 156 | { 157 | $Post = Post::create()->fresh(); 158 | 159 | request()->headers->replace([ 160 | 'Accept-Language' => 'ar' 161 | ]); 162 | 163 | visits($Post)->forceIncrement(); 164 | 165 | $this->assertEquals(['ar' => 1], visits($Post)->languages()); 166 | } 167 | 168 | /** @test */ 169 | public function store_country_aswell() 170 | { 171 | $Post = Post::create()->fresh(); 172 | 173 | visits($Post)->increment(1); 174 | 175 | $this->assertEquals(1, visits($Post)->country('us')->count()); 176 | } 177 | 178 | /** @test */ 179 | /* 180 | public function get_countries() 181 | { 182 | $Post = Post::create()->fresh(); 183 | 184 | $ips = [ 185 | '88.17.102.155', 186 | '178.80.134.112', 187 | '83.96.36.50', 188 | '211.202.2.111', 189 | ]; 190 | 191 | $x = 1; 192 | foreach ($ips as $ip) 193 | { 194 | visits($Post)->increment($x++, true, true, true, $ip); 195 | } 196 | 197 | visits($Post)->increment(20, true, true, true, '178.80.134.112'); 198 | 199 | $this->assertEquals(['sa' => 22, 'kr' => 4, 'kw' => 3, 'es' => 1], visits($Post)->countries(-1)); 200 | }*/ 201 | 202 | /** 203 | * @test 204 | */ 205 | public function it_reset_counter() 206 | { 207 | $post1 = Post::create()->fresh(); 208 | $post2 = Post::create()->fresh(); 209 | $post3 = Post::create()->fresh(); 210 | 211 | visits($post1)->increment(10); 212 | visits($post1)->reset(); 213 | 214 | visits($post2)->increment(5); 215 | visits($post3)->increment(); 216 | 217 | 218 | $this->assertEquals( 219 | [2, 3], 220 | visits('Awssat\Visits\Tests\Post')->top(5)->pluck('id')->toArray() 221 | ); 222 | } 223 | 224 | /** @test */ 225 | public function reset_specific_ip() 226 | { 227 | $post = Post::create()->fresh(); 228 | 229 | visits($post)->increment(10); 230 | 231 | //dd 232 | $ips = [ 233 | '125.0.0.2', 234 | '129.0.0.2', 235 | '124.0.0.2' 236 | ]; 237 | 238 | $prefix = 'testing:posts_visits_'; 239 | $key = $prefix.'recorded_ips:1:'; 240 | 241 | foreach ($ips as $ip) { 242 | if(! $this->connection->exists($key.$ip)) { 243 | $this->connection->set($key.$ip, true); 244 | } else { 245 | $this->connection->setExpiration($key.$ip, 15*60); 246 | } 247 | } 248 | 249 | visits($post)->increment(10); 250 | 251 | $this->assertEquals( 252 | 10, 253 | visits($post)->count() 254 | ); 255 | 256 | visits($post)->reset('ips', '127.0.0.1'); 257 | 258 | $ips_in_db = Collection::make($this->connection->search($prefix.'recorded_ips:*')) 259 | ->map(function ($ip){ 260 | return substr($ip, strrpos($ip, ':') + 1); 261 | }); 262 | 263 | $this->assertNotContains( 264 | '127.0.0.1', 265 | $ips_in_db 266 | ); 267 | 268 | visits($post)->increment(10); 269 | 270 | $this->assertEquals( 271 | 20, 272 | visits($post)->count() 273 | ); 274 | } 275 | 276 | /** @test */ 277 | public function it_shows_proper_tops_and_lows() 278 | { 279 | $arr = []; 280 | $unique = []; 281 | 282 | //increase 283 | foreach (range(1, 20) as $id) { 284 | $post = Post::create()->fresh(); 285 | 286 | while ($inc = rand(1, 200)) { 287 | if (! in_array($inc, $unique)) { 288 | $unique[] = $inc; 289 | break; 290 | } 291 | } 292 | 293 | visits($post)->period('day')->forceIncrement($inc, ['periods']); 294 | visits($post)->forceIncrement($inc, ['periods']); 295 | 296 | $arr[$id] = visits($post)->period('day')->count(); 297 | } 298 | 299 | $this->assertEquals( 300 | Collection::make($arr)->sort()->reverse()->keys()->take(10)->toArray(), 301 | visits('Awssat\Visits\Tests\Post')->period('day')->top(10)->pluck('id')->toArray() 302 | ); 303 | 304 | $this->assertEquals( 305 | Collection::make($arr)->sort()->keys()->take(10)->toArray(), 306 | visits('Awssat\Visits\Tests\Post')->period('day')->low(11)->pluck('id')->toArray() 307 | ); 308 | 309 | visits('Awssat\Visits\Tests\Post')->period('day')->reset(); 310 | 311 | $this->assertEquals( 312 | 0, 313 | visits('Awssat\Visits\Tests\Post')->period('day')->count() 314 | ); 315 | // dd(visits('Awssat\Visits\Tests\Post')->period('day')->top(10)); 316 | 317 | $this->assertEmpty( 318 | visits('Awssat\Visits\Tests\Post')->period('day')->top(10) 319 | ); 320 | 321 | $this->assertNotEmpty( 322 | visits('Awssat\Visits\Tests\Post')->top(10) 323 | ); 324 | 325 | $this->assertEquals( 326 | Collection::make($arr)->sum(), 327 | visits('Awssat\Visits\Tests\Post')->count() 328 | ); 329 | } 330 | 331 | /** @test */ 332 | public function it_reset_ips() 333 | { 334 | $post1 = Post::create()->fresh(); 335 | $post2 = Post::create()->fresh(); 336 | 337 | visits($post1)->increment(); 338 | 339 | visits($post2)->increment(); 340 | 341 | visits($post1)->reset('ips'); 342 | 343 | visits($post1)->increment(); 344 | 345 | $this->assertEquals(2, visits($post1)->count()); 346 | 347 | visits($post2)->increment(); 348 | 349 | $this->assertEquals(1, visits($post2)->count()); 350 | } 351 | 352 | /** 353 | * @test 354 | */ 355 | public function it_counts_visits() 356 | { 357 | $post = Post::create()->fresh(); 358 | 359 | $this->assertEquals( 360 | 0, 361 | visits($post)->count() 362 | ); 363 | 364 | visits($post)->increment(); 365 | 366 | $this->assertEquals( 367 | 1, 368 | visits($post)->count() 369 | ); 370 | 371 | visits($post)->forceDecrement(); 372 | 373 | $this->assertEquals( 374 | 0, 375 | visits($post)->count() 376 | ); 377 | } 378 | 379 | /** 380 | * @test 381 | */ 382 | public function it_only_record_ip_for_amount_of_time() 383 | { 384 | $post = Post::create()->fresh(); 385 | 386 | visits($post)->seconds(1)->increment(); 387 | 388 | Carbon::setTestNow(Carbon::now()->addSeconds(visits($post)->ipTimeLeft() + 1)); 389 | sleep(1);//for redis 390 | 391 | 392 | visits($post)->increment(); 393 | 394 | $this->assertEquals(2, visits($post)->count()); 395 | } 396 | 397 | /** 398 | * @test 399 | */ 400 | public function n_minus_1_bug() 401 | { 402 | foreach (range(1, 6) as $i) { 403 | $post = Post::create(['name' => $i])->fresh(); 404 | visits($post)->forceIncrement(); 405 | } 406 | 407 | $list = visits('Awssat\Visits\Tests\Post')->top(5)->pluck('name'); 408 | 409 | $this->assertEquals(5, $list->count()); 410 | } 411 | 412 | /** 413 | * @test 414 | */ 415 | public function it_list_from_cache() 416 | { 417 | $post1 = Post::create(['id' => 1, 'name' => '1'])->fresh(); 418 | $post2 = Post::create(['id' => 2, 'name' => '2'])->fresh(); 419 | $post3 = Post::create(['id' => 3, 'name' => '3'])->fresh(); 420 | $post4 = Post::create(['id' => 4, 'name' => '4'])->fresh(); 421 | $post5 = Post::create(['id' => 5, 'name' => '5'])->fresh(); 422 | 423 | visits($post5)->forceIncrement(5); 424 | visits($post1)->forceIncrement(4); 425 | visits($post2)->forceIncrement(3); 426 | visits($post3)->forceIncrement(2); 427 | visits($post4)->forceIncrement(1); 428 | 429 | $fresh = visits('Awssat\Visits\Tests\Post')->top()->pluck('name'); 430 | 431 | $post5->update(['name' => 'changed']); 432 | 433 | $cached = visits('Awssat\Visits\Tests\Post')->top()->pluck('name'); 434 | 435 | $this->assertEquals($fresh->first(), $cached->first()); 436 | 437 | $fresh2 = visits('Awssat\Visits\Tests\Post') 438 | ->fresh() 439 | ->top() 440 | ->pluck('name'); 441 | 442 | $this->assertNotEquals($fresh2->first(), $cached->first()); 443 | } 444 | 445 | /** 446 | * @test 447 | */ 448 | public function it_list_filtered_by_constraints() 449 | { 450 | $posts =[]; 451 | 452 | foreach (['naji', 'fadi', 'hanadi', 'maghi', 'lafi'] as $player) { 453 | $posts[$player] = Post::create(['name' => $player])->fresh(); 454 | visits($posts[$player])->forceIncrement(rand(2, 109)); 455 | } 456 | 457 | $this->assertNotEquals(visits('Awssat\Visits\Tests\Post')->top(5, ['name' => 'naji']), [$posts['naji']]); 458 | } 459 | } 460 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | app['config']->set('geoip', array_merge(require __DIR__ . '/../vendor/torann/geoip/config/geoip.php')); 35 | $this->app['router']->middleware(CaptureReferer::class)->get('/', function () { 36 | return response(null, 200); 37 | }); 38 | $this->session = $this->app['session.store']; 39 | $this->referer = $this->app['referer']; 40 | 41 | $this->runTestMigrations(); 42 | } 43 | 44 | 45 | protected function withConfig(array $config) 46 | { 47 | $this->app['config']->set($config); 48 | $this->app->forgetInstance(Referer::class); 49 | $this->referer = $this->app->make(Referer::class); 50 | } 51 | 52 | /** 53 | * Get package service providers. 54 | * 55 | * @param \Illuminate\Foundation\Application $app 56 | * @return array 57 | */ 58 | protected function getPackageProviders($app) 59 | { 60 | return [ 61 | GeoIPServiceProvider::class, 62 | RefererServiceProvider::class, 63 | VisitsServiceProvider::class, 64 | ]; 65 | } 66 | 67 | protected function getPackageAliases($app) 68 | { 69 | return [ 70 | 'GeoIP' => \Torann\GeoIP\Facades\GeoIP::class, 71 | ]; 72 | } 73 | 74 | /** 75 | * Define environment setup. 76 | * 77 | * @param \Illuminate\Foundation\Application $app 78 | * @return void 79 | */ 80 | protected function getEnvironmentSetUp($app) 81 | { 82 | $app['config']->set('database.default', 'testbench'); 83 | $app['config']->set('database.connections.testbench', [ 84 | 'driver' => 'sqlite', 85 | 'database' => ':memory:', 86 | ]); 87 | $app['config']->set('visits.global_ignore', []); 88 | 89 | } 90 | /** 91 | * Run migrations for tables used for testing purposes. 92 | * 93 | * @return void 94 | */ 95 | private function runTestMigrations() 96 | { 97 | $schema = $this->app['db']->connection()->getSchemaBuilder(); 98 | if (! $schema->hasTable('posts')) { 99 | $schema->create('posts', function (Blueprint $table) { 100 | $table->increments('id'); 101 | $table->string('name')->nullable(); 102 | $table->unsignedInteger('user_id')->nullable(); 103 | $table->timestamps(); 104 | }); 105 | } 106 | 107 | if (! $schema->hasTable('users')) { 108 | $schema->create('users', function (Blueprint $table) { 109 | $table->increments('id'); 110 | $table->string('name')->nullable(); 111 | $table->timestamps(); 112 | }); 113 | } 114 | } 115 | } 116 | 117 | class Post extends Model 118 | { 119 | protected $guarded = []; 120 | protected $table = 'posts'; 121 | 122 | public function creator() 123 | { 124 | return $this->belongsTo(User::class, 'user_id', 'id'); 125 | } 126 | } 127 | 128 | class User extends Model 129 | { 130 | protected $guarded = []; 131 | protected $table = 'users'; 132 | 133 | public function posts() 134 | { 135 | return $this->hasMany(Post::class); 136 | } 137 | } --------------------------------------------------------------------------------