├── .php-cs-fixer.php ├── .scrutinizer.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── UPGRAGE.md ├── composer.json ├── config └── recently-viewed.php ├── database └── migrations │ └── 2022_02_14_000001_create_recent_views_tables.php ├── psalm.xml └── src ├── Exceptions └── ShouldBeViewableException.php ├── Facades └── RecentlyViewed.php ├── Models ├── Contracts │ ├── Viewable.php │ └── Viewer.php ├── RecentViews.php └── Traits │ ├── CanBeViewed.php │ └── CanView.php ├── PersistManager.php ├── RecentlyViewed.php └── ServiceProvider.php /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | notPath('bootstrap/*') 5 | ->notPath('storage/*') 6 | ->in([ 7 | __DIR__ . '/src', 8 | __DIR__ . '/config', 9 | __DIR__ . '/database', 10 | __DIR__ . '/tests', 11 | ]) 12 | ->name('*.php') 13 | ->notName('*.blade.php') 14 | ->ignoreDotFiles(true) 15 | ->ignoreVCS(true); 16 | 17 | $config = new PhpCsFixer\Config(); 18 | 19 | return $config->setRules([ 20 | '@PSR2' => true, 21 | 'array_syntax' => [ 'syntax' => 'short' ], 22 | 'ordered_imports' => [ 'sort_algorithm' => 'alpha' ], 23 | 'no_unused_imports' => true, 24 | 'single_quote' => true, 25 | 'not_operator_with_successor_space' => false, 26 | 'trailing_comma_in_multiline' => true, 27 | 'phpdoc_scalar' => true, 28 | 'unary_operator_spaces' => true, 29 | 'binary_operator_spaces' => [ 'default' => 'align' ], 30 | 'blank_line_before_statement' => [ 31 | 'statements' => [ 'break', 'continue', 'declare', 'return', 'throw', 'try' ], 32 | ], 33 | 'phpdoc_single_line_var_spacing' => true, 34 | 'phpdoc_var_without_name' => true, 35 | 'method_argument_space' => [ 36 | 'on_multiline' => 'ensure_fully_multiline', 37 | 'keep_multiple_spaces_after_comma' => true, 38 | ], 39 | ]) 40 | ->setFinder($finder); 41 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | filter: 2 | excluded_paths: [ tests/* ] 3 | 4 | build: 5 | nodes: 6 | tests-with-coverage: 7 | environment: 8 | php: 9 | version: 8.0 10 | ini: 11 | "xdebug.mode": coverage 12 | tests: 13 | override: 14 | - command: vendor/bin/phpunit --coverage-clover=coverage-file 15 | coverage: 16 | file: coverage-file 17 | format: php-clover 18 | - php-scrutinizer-run 19 | 20 | checks: 21 | php: 22 | fix_doc_comments: true 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-recently-viewed` will be documented in this file 4 | 5 | ## 3.0.0 - 2022-02-20 6 | 7 | - Remove support 7.4 8 | - Change "OrderBy" functionality from query to collection 9 | - Added tests coverage 10 | 11 | ## 2.0.0 - 2021-05-25 12 | 13 | - Support php 8.0 14 | - Support only array as $values in Viewable contract 15 | - Added order in "CanBeViewed" trait 16 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Think Studio dev@think.studio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 7 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 8 | persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 11 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 13 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel: Recently Viewed 2 | 3 | ![Packagist License](https://img.shields.io/packagist/l/think.studio/laravel-recently-viewed?color=%234dc71f) 4 | [![Packagist Version](https://img.shields.io/packagist/v/think.studio/laravel-recently-viewed)](https://packagist.org/packages/think.studio/laravel-recently-viewed) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/think.studio/laravel-recently-viewed)](https://packagist.org/packages/think.studio/laravel-recently-viewed) 6 | [![Build Status](https://scrutinizer-ci.com/g/dev-think-one/laravel-recently-viewed/badges/build.png?b=main)](https://scrutinizer-ci.com/g/dev-think-one/laravel-recently-viewed/build-status/main) 7 | [![Code Coverage](https://scrutinizer-ci.com/g/dev-think-one/laravel-recently-viewed/badges/coverage.png?b=main)](https://scrutinizer-ci.com/g/dev-think-one/laravel-recently-viewed/?branch=main) 8 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/dev-think-one/laravel-recently-viewed/badges/quality-score.png?b=main)](https://scrutinizer-ci.com/g/dev-think-one/laravel-recently-viewed/?branch=main) 9 | 10 | Add functionality to save/get in session recently viewed entities 11 | 12 | You can track any number of entities. Each list will be saved separately. 13 | 14 | ## Session storage (without persist) 15 | 16 | For example: 17 | 18 | ```php 19 | "recently_viewed" => array:2 [ 20 | "App\Models\Product" => array:2 [ 21 | 0 => 'a3cda131-e599-4802-84ea-a3dddc19fa8c' 22 | 1 => '4413b636-9752-43b3-8361-3ef38c27acf9' 23 | ] 24 | "App\Domain\Property" => array:3 [ 25 | 0 => 133 26 | 1 => 134 27 | 2 => 653 28 | ] 29 | ] 30 | ``` 31 | 32 | ## Installation 33 | 34 | You can install the package via composer: 35 | 36 | ```bash 37 | composer require think.studio/laravel-recently-viewed 38 | ``` 39 | 40 | You can publish the config file with: 41 | 42 | ```bash 43 | php artisan vendor:publish --provider="RecentlyViewed\ServiceProvider" --tag="config" 44 | ``` 45 | 46 | Configuration in *.env* 47 | 48 | ```dotenv 49 | # Optional 50 | RECENTLY_VIEWED_SESSION_PREFIX=recently_viewed 51 | ``` 52 | 53 | ## Usage example 54 | 55 | ```php 56 | with([ 88 | 'recentlyViewedProducts' => \RecentlyViewed\Facades\RecentlyViewed::get(Product::class), 89 | // or 90 | 'recentlyViewedProductsWithoutLast' => \RecentlyViewed\Facades\RecentlyViewed::get(Product::class)->slice(1), 91 | ]); 92 | // or 93 | $view->with([ 94 | 'recentlyViewedProductsFiltered' => \RecentlyViewed\Facades\RecentlyViewed::getQuery(Product::class) 95 | ?->where('not_display_in_recently_list', false)->get() 96 | ??collect([]), 97 | ]); 98 | } 99 | } 100 | ``` 101 | 102 | ## Add persist storage 103 | 104 | You can enable migration and run the migrations with adding this to `register()` in AppServiceProvider or any other service provider: 105 | 106 | ```php 107 | \RecentlyViewed\PersistManager::enableMigrations(); 108 | ``` 109 | 110 | ```bash 111 | php artisan migrate 112 | ``` 113 | 114 | Configuration in *.env* 115 | 116 | ```dotenv 117 | RECENTLY_VIEWED_PERSIST_ENABLED=true 118 | ``` 119 | 120 | ```php 121 | use RecentlyViewed\Models\Contracts\Viewer; 122 | use RecentlyViewed\Models\Traits\CanView; 123 | 124 | class User extends Authenticatable implements Viewer 125 | { 126 | use CanView; 127 | 128 | // ... 129 | } 130 | ``` 131 | 132 | Add "merge" method after login (if you want merge saved data before login and already stored data) 133 | 134 | ```php 135 | class LoginController extends Controller 136 | { 137 | // ... 138 | 139 | protected function authenticated(Request $request, $user) 140 | { 141 | \RecentlyViewed::mergePersistToCurrentSession(); 142 | } 143 | } 144 | ``` 145 | 146 | ## Credits 147 | 148 | - [![Think Studio](https://yaroslawww.github.io/images/sponsors/packages/logo-think-studio.png)](https://think.studio/) 149 | -------------------------------------------------------------------------------- /UPGRAGE.md: -------------------------------------------------------------------------------- 1 | # Upgrade guide 2 | 3 | ## v2 ===> v3 4 | 5 | 1. Config key `persist_model` is removed, please use new method: 6 | 7 | ```php 8 | \RecentlyViewed\PersistManager::useRecentlyViewedModel(MyRecentViews::class); 9 | ``` 10 | 11 | 2. Since v3, no need publish migrations, you can just enable and migrate 12 | 13 | ```php 14 | \RecentlyViewed\PersistManager::enableMigrations(); 15 | ``` 16 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "think.studio/laravel-recently-viewed", 3 | "description": "Package to make quickly recently viewed functionality", 4 | "keywords": [ 5 | "recently-viewed", 6 | "laravel-recently-viewed" 7 | ], 8 | "homepage": "https://github.com/dev-think-one/laravel-recently-viewed", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Yaroslav Georgitsa", 13 | "email": "yg@think.studio", 14 | "homepage": "https://github.com/yaroslawww", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.1", 20 | "illuminate/support": "^9.0|^10.0|^11.0" 21 | }, 22 | "require-dev": { 23 | "friendsofphp/php-cs-fixer": "^3.25", 24 | "orchestra/testbench": "^8.10", 25 | "phpunit/phpunit": "^10.3", 26 | "psalm/plugin-laravel": "^2.8", 27 | "vimeo/psalm": "^5.12" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "RecentlyViewed\\": "src" 32 | } 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "RecentlyViewed\\Tests\\": "tests" 37 | } 38 | }, 39 | "scripts": { 40 | "psalm": "vendor/bin/psalm", 41 | "test": "XDEBUG_MODE=coverage vendor/bin/phpunit --colors=always", 42 | "test-coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --colors=always --coverage-html coverage", 43 | "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes" 44 | }, 45 | "config": { 46 | "sort-packages": true 47 | }, 48 | "extra": { 49 | "laravel": { 50 | "providers": [ 51 | "RecentlyViewed\\ServiceProvider" 52 | ], 53 | "aliases": { 54 | "RecentlyViewed": "RecentlyViewed\\Facades\\RecentlyViewed" 55 | } 56 | } 57 | }, 58 | "minimum-stability": "dev", 59 | "prefer-stable": true 60 | } 61 | -------------------------------------------------------------------------------- /config/recently-viewed.php: -------------------------------------------------------------------------------- 1 | env('RECENTLY_VIEWED_SESSION_PREFIX', 'recently_viewed'), 5 | 6 | 'persist_enabled' => (bool)env('RECENTLY_VIEWED_PERSIST_ENABLED', false), 7 | 8 | 'persist_table' => env('RECENTLY_VIEWED_PERSIST_TABLE', 'recent_views'), 9 | 10 | 'auth_guard' => env('RECENTLY_VIEWED_AUTH_GUARD', null), 11 | ]; 12 | -------------------------------------------------------------------------------- /database/migrations/2022_02_14_000001_create_recent_views_tables.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 13 | $table->morphs('viewer'); 14 | // $table->uuidMorphs('viewer'); 15 | $table->string('type'); 16 | $table->text('views'); 17 | $table->timestamps(); 18 | }); 19 | } 20 | 21 | /** 22 | * Reverse the migrations. 23 | */ 24 | public function down() 25 | { 26 | Schema::dropIfExists(config('recently-viewed.persist_table')); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/Exceptions/ShouldBeViewableException.php: -------------------------------------------------------------------------------- 1 | (is_int($v) || is_string($v))); 22 | 23 | if (count($values)) { 24 | return static::whereIn($this->getKeyName(), $values); 25 | } 26 | 27 | return null; 28 | } 29 | 30 | /** 31 | * Get recently viewed items count. 32 | * 33 | * @return int 34 | */ 35 | public function getRecentlyViewsLimit(): int 36 | { 37 | return (int) (property_exists($this, 'recentlyViewsLimit') ? $this->recentlyViewsLimit : 10); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Models/Traits/CanView.php: -------------------------------------------------------------------------------- 1 | recentViews()->delete(); 16 | } 17 | 18 | return $this->recentViews()->whereIn('type', (array) $types)->delete(); 19 | } 20 | 21 | public function getRecentViews(array|string|null $types = null): Collection 22 | { 23 | $query = $this->recentViews(); 24 | if (!is_null($types)) { 25 | $query->whereIn('type', (array) $types); 26 | } 27 | 28 | return $query->get()->pluck('views', 'type')->map(fn ($views) => json_decode($views, true)); 29 | } 30 | 31 | public function syncRecentViews(string $type, array $data = []): Model 32 | { 33 | $data = array_filter($data, fn ($i) => !empty($i) && (is_string($i) || is_integer($i))); 34 | 35 | return $this->recentViews()->updateOrCreate(['type' => $type], ['views' => json_encode($data)]); 36 | } 37 | 38 | public function recentViews(): MorphMany 39 | { 40 | return $this->morphMany(PersistManager::$model, 'viewer'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/PersistManager.php: -------------------------------------------------------------------------------- 1 | sessionPrefix = config('recently-viewed.session_prefix'); 19 | } 20 | 21 | public function add(Viewable $viewable): static 22 | { 23 | if (method_exists($viewable, 'getKey')) { 24 | $keys = Session::get("{$this->sessionPrefix}.".get_class($viewable)); 25 | if (!is_array($keys)) { 26 | $keys = []; 27 | } 28 | array_unshift($keys, $viewable->getKey()); 29 | $keys = array_slice(array_unique($keys), 0, $viewable->getRecentlyViewsLimit()); 30 | Session::put("{$this->sessionPrefix}.".get_class($viewable), $keys); 31 | 32 | if (PersistManager::isEnabled()) { 33 | $this->persist($viewable, $keys); 34 | } 35 | } 36 | 37 | return $this; 38 | } 39 | 40 | /** 41 | * @return Collection Eloquent collection of items. 42 | * @throws ShouldBeViewableException 43 | */ 44 | public function get(Viewable|string $viewable, ?int $limit = null): Collection 45 | { 46 | throw_if( 47 | !is_a($viewable, Viewable::class, true), 48 | new ShouldBeViewableException('Entity should implement Viewable interface.') 49 | ); 50 | 51 | if (is_string($viewable)) { 52 | $viewable = app()->make($viewable); 53 | } 54 | 55 | $keys = Session::get("{$this->sessionPrefix}.".get_class($viewable)); 56 | 57 | if (!is_array($keys)) { 58 | $keys = []; 59 | } 60 | 61 | return $viewable 62 | ->whereRecentlyViewedIn($keys) 63 | ?->take($limit ?? $viewable->getRecentlyViewsLimit()) 64 | ->get() 65 | ->sortBy(fn ($model) => array_search($model->getKey(), $keys)) ?? collect([]); 66 | } 67 | 68 | /** 69 | * @throws ShouldBeViewableException 70 | */ 71 | public function clear(Viewable|string $viewable): static 72 | { 73 | throw_if( 74 | !is_a($viewable, Viewable::class, true), 75 | new ShouldBeViewableException('Entity should implement Viewable interface.') 76 | ); 77 | 78 | if (is_string($viewable)) { 79 | $viewable = app()->make($viewable); 80 | } 81 | 82 | Session::forget("{$this->sessionPrefix}.".get_class($viewable)); 83 | 84 | if (PersistManager::isEnabled()) { 85 | $this->clearPersist($viewable); 86 | } 87 | 88 | return $this; 89 | } 90 | 91 | public function clearAll(): static 92 | { 93 | Session::forget(config('recently-viewed.session_prefix')); 94 | 95 | if (PersistManager::isEnabled()) { 96 | $this->clearPersistAll(); 97 | } 98 | 99 | return $this; 100 | } 101 | 102 | public function persist(Viewable $viewable, array $data): static 103 | { 104 | if ($viewer = $this->getViewer()) { 105 | $viewer->syncRecentViews(get_class($viewable), $data); 106 | } 107 | 108 | return $this; 109 | } 110 | 111 | public function clearPersist(Viewable $viewable): static 112 | { 113 | if ($viewer = $this->getViewer()) { 114 | $viewer->deleteRecentViews([get_class($viewable)]); 115 | } 116 | 117 | return $this; 118 | } 119 | 120 | public function clearPersistAll(): static 121 | { 122 | $this->getViewer()?->deleteRecentViews(); 123 | 124 | return $this; 125 | } 126 | 127 | protected function getViewer(): ?Viewer 128 | { 129 | if ( 130 | ($user = Auth::guard(config('recently-viewed.auth_guard'))->user()) && 131 | ($user instanceof Viewer) 132 | ) { 133 | return $user; 134 | } 135 | 136 | return null; 137 | } 138 | 139 | public function mergePersistToCurrentSession(): static 140 | { 141 | if (!($viewer = $this->getViewer())) { 142 | return $this; 143 | } 144 | 145 | $persist = $viewer->getRecentViews()->toArray(); 146 | $session = Session::get($this->sessionPrefix); 147 | 148 | $merged = []; 149 | if (is_array($session)) { 150 | foreach ($session as $type => $keys) { 151 | if (!class_exists($type)) { 152 | continue; 153 | } 154 | $obj = new $type(); 155 | if ($obj instanceof Viewable) { 156 | $limit = $obj->getRecentlyViewsLimit(); 157 | if (count($keys) >= $limit) { 158 | $keys = array_slice($keys, 0, $limit); 159 | } else { 160 | if (isset($persist[$type])) { 161 | $keys = array_slice(array_merge($keys, $persist[$type]), 0, $limit); 162 | } 163 | } 164 | $keys = array_unique($keys); 165 | if (count($keys)) { 166 | $merged[$type] = array_unique($keys); 167 | } 168 | } 169 | if (isset($persist[$type])) { 170 | unset($persist[$type]); 171 | } 172 | } 173 | } 174 | 175 | if (is_array($persist)) { 176 | foreach ($persist as $type => $keys) { 177 | if (!class_exists($type)) { 178 | continue; 179 | } 180 | $obj = new $type(); 181 | if ($obj instanceof Viewable) { 182 | $limit = $obj->getRecentlyViewsLimit(); 183 | if (count($keys)) { 184 | $merged[$type] = array_slice($keys, 0, $limit); 185 | } 186 | } 187 | } 188 | } 189 | 190 | Session::put($this->sessionPrefix, $merged); 191 | $viewer->deleteRecentViews(); 192 | foreach ($merged as $type => $keys) { 193 | $viewer->syncRecentViews($type, $keys); 194 | } 195 | 196 | return $this; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 10 | $this->publishes([ 11 | __DIR__ . '/../config/recently-viewed.php' => config_path('recently-viewed.php'), 12 | ], 'config'); 13 | 14 | $this->commands([ 15 | ]); 16 | 17 | $this->registerMigrations(); 18 | } 19 | } 20 | 21 | public function register() 22 | { 23 | $this->mergeConfigFrom(__DIR__ . '/../config/recently-viewed.php', 'recently-viewed'); 24 | } 25 | 26 | /** 27 | * Register the package migrations. 28 | * 29 | * @return void 30 | */ 31 | protected function registerMigrations() 32 | { 33 | if (PersistManager::$runsMigrations) { 34 | $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); 35 | } 36 | } 37 | } 38 | --------------------------------------------------------------------------------