├── .github └── workflows │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config └── limit.php ├── database └── migrations │ └── 2024_06_04_000001_create_limits_tables.php ├── phpunit.xml.dist ├── src ├── Commands │ ├── CreateLimit.php │ ├── DeleteLimit.php │ ├── ListLimits.php │ ├── ResetCache.php │ └── ResetLimitUsages.php ├── Contracts │ └── Limit.php ├── Exceptions │ ├── InvalidLimitResetFrequencyValue.php │ ├── LimitAlreadyExists.php │ ├── LimitDoesNotExist.php │ ├── LimitNotSetOnModel.php │ └── UsedAmountShouldBePositiveIntAndLessThanAllowedAmount.php ├── LimitManager.php ├── Models │ └── Limit.php ├── ServiceProvider.php └── Traits │ ├── HasLimits.php │ └── RefreshCache.php ├── tests ├── Feature │ ├── BladeTest.php │ ├── CacheTest.php │ ├── CacheWithDatabaseTest.php │ ├── CommandTest.php │ ├── HasLimitsTest.php │ ├── HasLimitsWithCustomModelTest.php │ ├── LimitManagerTest.php │ ├── LimitTest.php │ └── LimitWithCustomModelTest.php └── TestCase.php └── workbench ├── app └── Models │ ├── .gitkeep │ ├── Restrict.php │ └── User.php ├── database └── factories │ └── UserFactory.php └── resources └── views └── index.blade.php /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | fail-fast: true 11 | matrix: 12 | php: [8.3, 8.2, 8.1, 8.0] 13 | laravel: ["^11.0", "^10.0", "^9.0", "^8.0"] 14 | dependency-version: [prefer-lowest, prefer-stable] 15 | include: 16 | - laravel: "^11.0" 17 | testbench: "^9.0" 18 | - laravel: "^10.0" 19 | testbench: "^8.18" 20 | - laravel: "^9.0" 21 | testbench: "^7.37" 22 | - laravel: "^8.0" 23 | testbench: "^6.41" 24 | exclude: 25 | - laravel: "^11.0" 26 | php: 8.1 27 | - laravel: "^11.0" 28 | php: 8.0 29 | - laravel: "^10.0" 30 | php: 8.0 31 | - laravel: "^8.0" 32 | php: 8.3 33 | - laravel: "^8.0" 34 | php: 8.4 35 | 36 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} 37 | 38 | steps: 39 | - name: Checkout code 40 | uses: actions/checkout@v4 41 | 42 | - name: Setup PHP 43 | uses: shivammathur/setup-php@v2 44 | with: 45 | php-version: ${{ matrix.php }} 46 | extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv 47 | coverage: none 48 | 49 | - name: Install dependencies 50 | run: | 51 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 52 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction 53 | 54 | - name: Execute tests 55 | run: vendor/bin/phpunit 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | .phpunit.result.cache 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Nabil Hassen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Usage Limiter 2 | 3 | [![Tests](https://github.com/NabilHassen/laravel-usage-limiter/actions/workflows/tests.yml/badge.svg)](https://github.com/NabilHassen/laravel-usage-limiter/actions/workflows/tests.yml) 4 | 5 | ## Introduction 6 | 7 | A Laravel package to track, limit, & restrict usages of users, accounts, or any other model. 8 | 9 | ## Features 10 | 11 | - Define usage limits you need for your app per plan 12 | - Set reset frequency for each of your limits 13 | - Attach usage limits to models (e.g. User model) 14 | - Consume and unconsume usage limits whenever a resource is created or deleted 15 | - Get usage report of a specific model (e.g. User model) 16 | - Check and determine if a model can consume more resources 17 | - Manually reset consumed usage limits of a model 18 | - Automatically reset consumed usage limits by setting reset frequencies such as every second, every minute, every hour, every day, every month, etc and scheduling a built-in artisan command 19 | 20 | ## Use cases 21 | 22 | Basically with this package you can track your users' or any other models' usages and restrict them when they hit their maximum limits. 23 | 24 | #### Example use-cases: 25 | 26 | - API usages per second, minute, month, etc 27 | - Resource creation. E.g: projects, teams, users, products, etc 28 | - Resource usages. E.g: storage, etc 29 | 30 | ## Versions 31 | 32 | Compatible for **_Laravel versions >= 8.0_**. 33 | 34 | ## Quick Tutorial 35 | 36 | This README documentation serves more as a reference. 37 | For a step-by-step getting started tutorial check https://nabilhassen.com/laravel-usage-limiter-manage-rate-and-usage-limits and you can always refer to this README documentation for details and advanced stuff. 38 | 39 | ## Installation 40 | 41 | Install Laravel Usage Limiter using the Composer package manager: 42 | 43 | ```bash 44 | composer require nabilhassen/laravel-usage-limiter 45 | ``` 46 | 47 | Next, you should publish the Laravel Usage Limiter configuration and migration files using the vendor:publish Artisan command: 48 | 49 | ```bash 50 | php artisan vendor:publish --provider="NabilHassen\LaravelUsageLimiter\ServiceProvider" 51 | ``` 52 | 53 | Finally, you should run the migrate command in order to create the tables needed to store Laravel Usage Limiter's data: 54 | 55 | ```bash 56 | php artisan migrate 57 | ``` 58 | 59 | ## Basic Usage 60 | 61 | First, you need to use the **HasLimits** trait on your model. 62 | 63 | ```php 64 | use NabilHassen\LaravelUsageLimiter\Traits\HasLimits; 65 | 66 | class User extends Authenticatable 67 | { 68 | use HasLimits; 69 | } 70 | ``` 71 | 72 | #### Create your Limits 73 | 74 | ```php 75 | # On standard plan 5 projects are allowed per month 76 | $projectsStandardLimit = Limit::create([ 77 | 'name' => 'projects', 78 | 'allowed_amount' => 5, 79 | 'plan' => 'standard', // optional 80 | 'reset_frequency' => 'every month' // optional 81 | ]); 82 | 83 | # On pro plan 10 projects are allowed per month 84 | $projectsProLimit = Limit::create([ 85 | 'name' => 'projects', 86 | 'allowed_amount' => 10, 87 | 'plan' => 'pro', // optional 88 | 'reset_frequency' => 'every month' // optional 89 | ]); 90 | 91 | # Increment projects limit on standard plan from 5 to 15 per month 92 | $projectsStandardLimit->incrementBy(10); 93 | 94 | # Decrement projects limit on pro plan from 10 to 7 per month 95 | $projectsProLimit->decrementBy(3); 96 | ``` 97 | 98 | ###### Possible values for "reset_frequency" column 99 | 100 | - null 101 | - "every second" // works in Laravel >= 10 102 | - "every minute" 103 | - "every hour" 104 | - "every day" 105 | - "every week", 106 | - "every two weeks", 107 | - "every month", 108 | - "every quarter", 109 | - "every six months", 110 | - "every year" 111 | 112 | #### Set Limits on models 113 | 114 | ```php 115 | $user->setLimit('projects', 'standard'); OR 116 | $user->setLimit($projectsStandardLimit); 117 | ``` 118 | 119 | #### Set Limits on models with beginning used amounts 120 | 121 | If a user has already consumed limits then: 122 | 123 | ```php 124 | $user->setLimit('projects', 'standard', 2); OR 125 | $user->setLimit($projectsStandardLimit, usedAmount: 2); 126 | ``` 127 | 128 | #### Unset Limits from models 129 | 130 | ```php 131 | $user->unsetLimit('projects', 'standard'); OR 132 | $user->unsetLimit($projectsStandardLimit); 133 | ``` 134 | 135 | #### Consume/Unconsume Limits 136 | 137 | ```php 138 | # When a user creates a project 139 | $user->useLimit('projects', 'standard'); OR 140 | $user->useLimit($projectsStandardLimit); 141 | 142 | # When a user creates multiple projects 143 | $user->useLimit('projects', 'standard', 3); OR 144 | $user->useLimit($projectsStandardLimit, amount: 3); 145 | 146 | # When a user deletes a project 147 | $user->unuseLimit('projects', 'standard'); OR 148 | $user->unuseLimit($projectsStandardLimit); 149 | 150 | # When a user deletes multiple projects 151 | $user->unuseLimit('projects', 'standard', 3); OR 152 | $user->unuseLimit($projectsStandardLimit, amount: 3); 153 | ``` 154 | 155 | > ###### _Both useLimit and unuseLimit methods throws an exception if a user exceeded limits or tried to unuse limits below 0_. 156 | 157 | #### Reset Limits for models 158 | 159 | ```php 160 | $user->resetLimit('projects', 'standard'); OR 161 | $user->resetLimit($projectsStandardLimit); 162 | ``` 163 | 164 | #### All available methods 165 | 166 | | Method | Return Type | Parameters | 167 | | ---------------- | ----------- | ---------------------------------------------------------------------------------- | 168 | | setLimit | true\|throw | string\|Limit $limit,
?string $plan = null,
float\|int $usedAmount = 0.0 | 169 | | unsetLimit | bool | string\|Limit $limit,
?string $plan = null | 170 | | isLimitSet | bool | string\|Limit $limit,
?string $plan = null | 171 | | useLimit | true\|throw | string\|Limit $limit,
?string $plan = null,
float\|int $amount = 1.0 | 172 | | unuseLimit | true\|throw | string\|Limit $limit,
?string $plan = null,
float\|int $amount = 1.0 | 173 | | resetLimit | bool | string\|Limit $limit,
?string $plan = null | 174 | | hasEnoughLimit | bool | string\|Limit $limit,
?string $plan = null | 175 | | usedLimit | float | string\|Limit $limit,
?string $plan = null | 176 | | remainingLimit | float | string\|Limit $limit,
?string $plan = null | 177 | | limitUsageReport | array | string\|Limit\|null $limit = null,
?string $plan = null | 178 | 179 | #### All available commands 180 | 181 | | Command | Arguments | Example | 182 | | ----------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------- | 183 | | limit:create | name: required
allowed_amount: required
plan: optional | php artisan limit:create --name products --allowed_amount 20 --plan premium | 184 | | limit:delete | name: required
plan: optional | php artisan limit:delete --name products --plan premium | 185 | | limit:list | None | php artisan limit:list | 186 | | limit:reset | None | php artisan limit:reset # reset limit usages to 0 | 187 | | limit:cache-reset | None | php artisan limit:cache-reset # flushes limits cache | 188 | 189 | #### Blade 190 | 191 | ```blade 192 | # Using limit instance 193 | @limit($user, $projectsStandardLimit) 194 | // user has enough limits left 195 | @else 196 | // user has NO enough limits left 197 | @endlimit 198 | 199 | # Using limit name and plan 200 | @limit($user, 'projects', 'standard') 201 | // user has enough limits left 202 | @else 203 | // user has NO enough limits left 204 | @endlimit 205 | ``` 206 | 207 | ## Schedule Limit Usage Resetting 208 | 209 | The `limit:reset` command will reset your model's (e.g. user) limit usages based on the Limit's `reset_frequency`. 210 | 211 | Add `limit:reset` command to the console kernel. 212 | 213 | ```php 214 | // app/Console/Kernel.php 215 | protected function schedule(Schedule $schedule) 216 | { 217 | ... 218 | // Laravel < 10 219 | $schedule->command('limit:reset')->everyMinute(); 220 | 221 | // Laravel >= 10 222 | $schedule->command('limit:reset')->everySecond(); 223 | ... 224 | } 225 | ``` 226 | 227 | ## Advanced Usage 228 | 229 | ### Extending 230 | 231 | - If you would like to write your own model, make sure that your new Limit model extends `NabilHassen\LaravelUsageLimiter\Models\Limit::class` and change the model in the `limit.php` config file to your new model. 232 | - If you already have used the `limits` method or property where the `HasLimits` trait is used, you can change it to any string value (e.g. 'restricts') by changing the `relationship` key in the `limit.php` config file and you're done. 233 | - If there are any conficts in the database table names, you will just need to change the tables names in the `limit.php` config file and you're good to go. 234 | 235 | **_Clear your config cache if you have made any changes in the `limit.php` config file._** 236 | 237 | ### Caching 238 | 239 | By default, Laravel Usage Limiter uses the default cache you chose for your app. If you would like to use any other cache store you will need to change the store key in the `limit.php` config file to your preferred cache store. 240 | 241 | - All of your Limits will be cached for 24 hours. 242 | - On create, update, or deleting of a limit, the Limits cache will be refreshed. 243 | - Model-specific limits are cached in-memory (i.e. during the request). 244 | 245 | ## Manual Cache Reset 246 | 247 | In your code 248 | 249 | ```php 250 | app()->make(\NabilHassen\LaravelUsageLimiter\LimitManager::class)->flushCache(); 251 | ``` 252 | 253 | Via command line 254 | 255 | ```bash 256 | php artisan limit:cache-reset 257 | ``` 258 | 259 | ## Testing 260 | 261 | ```bash 262 | composer test 263 | ``` 264 | 265 | ## Security 266 | 267 | If you have found any security issues, please send an email to the author at hello@nabilhassen.com. 268 | 269 | ## Contributing 270 | 271 | You are welcome to contribute to the package and you will be credited. Just make sure your PR does one thing and add tests. 272 | 273 | ## License 274 | 275 | The Laravel Usage Limiter is open-sourced software licensed under the MIT license [MIT license](https://github.com/NabilHassen/laravel-usage-limiter/blob/main/LICENSE). 276 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nabilhassen/laravel-usage-limiter", 3 | "description": "A laravel package to manage and limit usages/seats by plan, users, or other models", 4 | "keywords": [ 5 | "laravel", 6 | "track usage", 7 | "limit usage", 8 | "restrict usage", 9 | "api usage", 10 | "resource usage", 11 | "limit account", 12 | "plan limits", 13 | "per usage" 14 | ], 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Nabil Hassen", 19 | "email": "nabil.hassen08@gmail.com" 20 | } 21 | ], 22 | "homepage": "https://github.com/nabilhassen/laravel-usage-limiter", 23 | "require": { 24 | "php": "^8.0", 25 | "illuminate/database": "^8.0|^9.0|^10.0|^11.0|^12.0", 26 | "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0", 27 | "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0", 28 | "nesbot/carbon": "^2.67|^3.0" 29 | }, 30 | "require-dev": { 31 | "laravel/pint": "^1.5", 32 | "orchestra/testbench": "^6.41|^7.37|^8.18|^9.0", 33 | "phpunit/phpunit": "^9.4|^10.1" 34 | }, 35 | "config": { 36 | "sort-packages": true 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "NabilHassen\\LaravelUsageLimiter\\": "src/" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "NabilHassen\\LaravelUsageLimiter\\Tests\\": "tests/", 46 | "Workbench\\App\\": "workbench/app/", 47 | "Workbench\\Database\\Factories\\": "workbench/database/factories/" 48 | } 49 | }, 50 | "extra": { 51 | "laravel": { 52 | "providers": [ 53 | "NabilHassen\\LaravelUsageLimiter\\ServiceProvider" 54 | ] 55 | } 56 | }, 57 | "minimum-stability": "dev", 58 | "prefer-stable": true, 59 | "scripts": { 60 | "post-autoload-dump": [ 61 | "@prepare" 62 | ], 63 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 64 | "lint": [ 65 | "@php vendor/bin/phpstan analyse" 66 | ], 67 | "test": [ 68 | "@php vendor/bin/phpunit" 69 | ] 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /config/limit.php: -------------------------------------------------------------------------------- 1 | [ 15 | 16 | 'limit' => NabilHassen\LaravelUsageLimiter\Models\Limit::class, 17 | 18 | ], 19 | 20 | /* 21 | * Change the relationship method name if you already have used for other purposes. 22 | */ 23 | 24 | 'relationship' => 'limits', 25 | 26 | 'tables' => [ 27 | 28 | /* 29 | * When using the "HasLimits" trait from this package, we need to know which 30 | * table should be used to retrieve your permissions. We have chosen a basic 31 | * default value but you may easily change it to any table you like. 32 | */ 33 | 34 | 'limits' => 'limits', 35 | 36 | /* 37 | * When using the "HasLimits" trait from this package, we need to know which 38 | * table should be used to retrieve your models limits. We have chosen a 39 | * basic default value but you may easily change it to any table you like. 40 | */ 41 | 42 | 'model_has_limits' => 'model_has_limits', 43 | ], 44 | 45 | 'columns' => [ 46 | 47 | /* 48 | * Change this if you want to name the related pivots other than defaults 49 | */ 50 | 51 | 'limit_pivot_key' => 'limit_id', 52 | ], 53 | 54 | /* Cache-specific settings */ 55 | 56 | 'cache' => [ 57 | 58 | /* 59 | * By default all limits are cached for 24 hours to speed up performance. When 60 | * limits are created/updated/deleted the cache is flushed automatically. 61 | */ 62 | 63 | 'expiration_time' => \DateInterval::createFromDateString('24 hours'), 64 | 65 | /* 66 | * The cache key used to store all limits. 67 | */ 68 | 69 | 'key' => 'nabilhassen.limits.cache', 70 | 71 | /* 72 | * You may optionally indicate a specific cache driver/store to use for limits 73 | * caching using any of the `store` drivers listed in the cache.php config 74 | * file. Using 'default' here means to use the `default` set in cache.php. 75 | */ 76 | 77 | 'store' => 'default', 78 | ], 79 | ]; 80 | -------------------------------------------------------------------------------- /database/migrations/2024_06_04_000001_create_limits_tables.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('name'); 14 | $table->string('plan')->nullable(); 15 | $table->decimal('allowed_amount', 11, 4); 16 | $table->string('reset_frequency')->nullable(); 17 | $table->timestamps(); 18 | $table->softDeletes(); 19 | 20 | $table->unique(['name', 'plan']); 21 | }); 22 | 23 | Schema::create(config('limit.tables.model_has_limits'), function (Blueprint $table) { 24 | $table->id(); 25 | 26 | $table->foreignId(config('limit.columns.limit_pivot_key')) 27 | ->nullable() 28 | ->references('id') 29 | ->on(config('limit.tables.limits')) 30 | ->cascadeOnDelete() 31 | ->cascadeOnUpdate(); 32 | 33 | $table->morphs('model'); 34 | $table->decimal('used_amount', 11, 4); 35 | $table->dateTime('last_reset')->nullable(); 36 | $table->dateTime('next_reset')->nullable(); 37 | $table->timestamps(); 38 | 39 | $table->unique([ 40 | 'model_type', 41 | 'model_id', 42 | config('limit.columns.limit_pivot_key'), 43 | ]); 44 | }); 45 | } 46 | 47 | public function down(): void 48 | { 49 | Schema::dropIfExists(config('limit.tables.model_has_limits')); 50 | 51 | Schema::dropIfExists(config('limit.tables.limits')); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests/Feature 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Commands/CreateLimit.php: -------------------------------------------------------------------------------- 1 | $this->argument('name'), 36 | 'allowed_amount' => $this->argument('allowed_amount'), 37 | 'plan' => $this->argument('plan'), 38 | ]); 39 | } catch (Exception $ex) { 40 | $this->fail($ex->getMessage()); 41 | } 42 | 43 | $this->info('Limit created successfully.'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Commands/DeleteLimit.php: -------------------------------------------------------------------------------- 1 | getLimit( 35 | Arr::only($this->arguments(), ['name', 'plan']) 36 | ) 37 | ?->delete(); 38 | 39 | if (! $limits) { 40 | $this->info('No limits found to be deleted.'); 41 | 42 | return; 43 | } 44 | 45 | $this->info( 46 | sprintf('%s %s were deleted successfully.', $limits, Str::of('limit')->plural($limits)) 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Commands/ListLimits.php: -------------------------------------------------------------------------------- 1 | isEmpty()) { 34 | $this->info('No limits available.'); 35 | 36 | return; 37 | } 38 | 39 | $this->table($columns, $limits); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Commands/ResetCache.php: -------------------------------------------------------------------------------- 1 | flushCache(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Commands/ResetLimitUsages.php: -------------------------------------------------------------------------------- 1 | get(['id', 'reset_frequency']); 32 | 33 | $affectedRows = 0; 34 | 35 | foreach ($limits as $limit) { 36 | $affectedRows += DB::table(config('limit.tables.model_has_limits')) 37 | ->where('used_amount', '>', 0) 38 | ->where('next_reset', '<=', now()) 39 | ->update([ 40 | 'used_amount' => 0, 41 | 'last_reset' => now(), 42 | 'next_reset' => app(LimitManager::class)->getNextReset($limit->reset_frequency, now()), 43 | ]); 44 | } 45 | 46 | $this->info("$affectedRows usages/rows where resetted."); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Contracts/Limit.php: -------------------------------------------------------------------------------- 1 | getResetFrequencyOptions()->join(', ') 16 | ) 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Exceptions/LimitAlreadyExists.php: -------------------------------------------------------------------------------- 1 | limits = $limits; 28 | 29 | $this->limitClass = $limitClass; 30 | 31 | $this->initCache(); 32 | } 33 | 34 | public function initCache(): void 35 | { 36 | $cacheStore = config('limit.cache.store'); 37 | 38 | $this->cacheExpirationTime = config('limit.cache.expiration_time') ?: \DateInterval::createFromDateString('24 hours'); 39 | 40 | $this->cacheKey = config('limit.cache.key'); 41 | 42 | if ($cacheStore === 'default') { 43 | $this->cache = Cache::store(); 44 | 45 | return; 46 | } 47 | 48 | if (! array_key_exists($cacheStore, config('cache.stores'))) { 49 | $cacheStore = 'array'; 50 | } 51 | 52 | $this->cache = Cache::store($cacheStore); 53 | } 54 | 55 | public function getNextReset(string $limitResetFrequency, string|Carbon $lastReset): Carbon 56 | { 57 | if ($this->limitClass->getResetFrequencyOptions()->doesntContain($limitResetFrequency)) { 58 | throw new InvalidLimitResetFrequencyValue; 59 | } 60 | 61 | $lastReset = Carbon::parse($lastReset); 62 | 63 | return match ($limitResetFrequency) { 64 | 'every second' => $lastReset->addSecond(), 65 | 'every minute' => $lastReset->addMinute(), 66 | 'every hour' => $lastReset->addHour(), 67 | 'every day' => $lastReset->addDay(), 68 | 'every week' => $lastReset->addWeek(), 69 | 'every two weeks' => $lastReset->addWeeks(2), 70 | 'every month' => $lastReset->addMonth(), 71 | 'every quarter' => $lastReset->addQuarter(), 72 | 'every six months' => $lastReset->addMonths(6), 73 | 'every year' => $lastReset->addYear(), 74 | }; 75 | } 76 | 77 | public function loadLimits(): void 78 | { 79 | if ($this->limits->isNotEmpty()) { 80 | return; 81 | } 82 | 83 | $this->limits = $this->cache->remember($this->cacheKey, $this->cacheExpirationTime, function () { 84 | return $this->limitClass::all([ 85 | 'id', 86 | 'name', 87 | 'plan', 88 | 'allowed_amount', 89 | 'reset_frequency', 90 | ]); 91 | }); 92 | } 93 | 94 | public function getLimit(array $data) 95 | { 96 | $id = $data['id'] ?? null; 97 | $name = $data['name'] ?? null; 98 | $plan = $data['plan'] ?? null; 99 | 100 | if (is_null($id) && is_null($name)) { 101 | throw new InvalidArgumentException('Either Limit id OR name parameters should be filled.'); 102 | } 103 | 104 | $this->loadLimits(); 105 | 106 | if (filled($id)) { 107 | return $this->limits->firstWhere('id', $id); 108 | } 109 | 110 | return $this 111 | ->limits 112 | ->where('name', $name) 113 | ->when( 114 | filled($plan), 115 | fn ($q) => $q->where('plan', $plan), 116 | fn ($q) => $q->whereNull('plan') 117 | ) 118 | ->first(); 119 | } 120 | 121 | public function getLimits(): Collection 122 | { 123 | $this->loadLimits(); 124 | 125 | return $this->limits; 126 | } 127 | 128 | public function flushCache(): void 129 | { 130 | $this->limits = collect(); 131 | 132 | $this->cache->forget($this->cacheKey); 133 | } 134 | 135 | public function getCacheStore() 136 | { 137 | return $this->cache->getStore(); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Models/Limit.php: -------------------------------------------------------------------------------- 1 | table = config('limit.tables.limits') ?: parent::getTable(); 41 | } 42 | 43 | public static function create(array $data): LimitContract 44 | { 45 | return static::findOrCreate($data, true); 46 | } 47 | 48 | public static function findOrCreate(array $data, bool $throw = false): LimitContract 49 | { 50 | $data = static::validateArgs($data); 51 | 52 | $limit = app(LimitManager::class)->getLimit($data); 53 | 54 | if (! $limit) { 55 | return static::query()->create($data); 56 | } 57 | 58 | if ($throw) { 59 | throw new LimitAlreadyExists($data['name'], $data['plan'] ?? null); 60 | } 61 | 62 | return $limit; 63 | } 64 | 65 | protected static function validateArgs(array $data): array 66 | { 67 | if (! Arr::has($data, ['name', 'allowed_amount'])) { 68 | throw new InvalidArgumentException('"name" and "allowed_amount" keys do not exist on the array.'); 69 | } 70 | 71 | if (! is_numeric($data['allowed_amount']) || $data['allowed_amount'] < 0) { 72 | throw new InvalidArgumentException('"allowed_amount" should be a float|int type and greater than or equal to 0.'); 73 | } 74 | 75 | if ( 76 | Arr::has($data, ['reset_frequency']) && 77 | filled($data['reset_frequency']) && 78 | array_search($data['reset_frequency'], static::$resetFrequencyPossibleValues) === false 79 | ) { 80 | throw new InvalidLimitResetFrequencyValue; 81 | } 82 | 83 | if (isset($data['plan']) && blank($data['plan'])) { 84 | unset($data['plan']); 85 | } 86 | 87 | return $data; 88 | } 89 | 90 | public static function findByName(string|LimitContract $name, ?string $plan = null): LimitContract 91 | { 92 | if (is_object($name)) { 93 | return $name; 94 | } 95 | 96 | $limit = app(LimitManager::class)->getLimit(compact('name', 'plan')); 97 | 98 | if (! $limit) { 99 | throw new LimitDoesNotExist($name, $plan); 100 | } 101 | 102 | return $limit; 103 | } 104 | 105 | public static function findById(int|LimitContract $id): LimitContract 106 | { 107 | if (is_object($id)) { 108 | return $id; 109 | } 110 | 111 | $limit = app(LimitManager::class)->getLimit(compact('id')); 112 | 113 | if (! $limit) { 114 | throw new LimitDoesNotExist($id); 115 | } 116 | 117 | return $limit; 118 | } 119 | 120 | public function incrementBy(float|int $amount = 1.0): bool 121 | { 122 | if ($amount <= 0) { 123 | throw new InvalidArgumentException('"amount" should be greater than 0.'); 124 | } 125 | 126 | $this->allowed_amount += $amount; 127 | 128 | return $this->save(); 129 | } 130 | 131 | public function decrementBy(float|int $amount = 1.0): bool 132 | { 133 | $this->allowed_amount -= $amount; 134 | 135 | if ($this->allowed_amount < 0) { 136 | throw new InvalidArgumentException('"allowed_amount" should be greater than or equal to 0.'); 137 | } 138 | 139 | return $this->save(); 140 | } 141 | 142 | public function getResetFrequencyOptions(): Collection 143 | { 144 | return collect(static::$resetFrequencyPossibleValues); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__.'/../config/limit.php', 'limit'); 20 | 21 | $this->app->bind(Limit::class, $this->app['config']['limit.models.limit']); 22 | 23 | $this->app->singleton(LimitManager::class); 24 | } 25 | 26 | public function boot(): void 27 | { 28 | $this->publishes([ 29 | __DIR__.'/../config/limit.php' => config_path('limit.php'), 30 | __DIR__.'/../database/migrations' => database_path('migrations'), 31 | ]); 32 | 33 | Blade::if('limit', function (Model $model, string|Limit $name, ?string $plan = null): bool { 34 | try { 35 | return $model->hasEnoughLimit($name, $plan); 36 | } catch (\Throwable $th) { 37 | return false; 38 | } 39 | }); 40 | 41 | if ($this->app->runningInConsole()) { 42 | $this->commands([ 43 | CreateLimit::class, 44 | DeleteLimit::class, 45 | ListLimits::class, 46 | ResetLimitUsages::class, 47 | ResetCache::class, 48 | ]); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Traits/HasLimits.php: -------------------------------------------------------------------------------- 1 | morphToMany( 22 | config('limit.models.limit'), 23 | 'model', 24 | config('limit.tables.model_has_limits'), 25 | 'model_id', 26 | config('limit.columns.limit_pivot_key'), 27 | ) 28 | ->withPivot(['used_amount', 'last_reset', 'next_reset']) 29 | ->withTimestamps(); 30 | }); 31 | } 32 | 33 | public function setLimit(string|LimitContract $name, ?string $plan = null, float|int $usedAmount = 0.0): bool 34 | { 35 | $limit = app(LimitContract::class)::findByName($name, $plan); 36 | 37 | if ($this->isLimitSet($limit)) { 38 | return true; 39 | } 40 | 41 | if ($usedAmount > $limit->allowed_amount) { 42 | throw new InvalidArgumentException('"used_amount" should always be less than or equal to the limit "allowed_amount"'); 43 | } 44 | 45 | DB::transaction(function () use ($limit, $usedAmount) { 46 | $this->limitsRelationship()->attach([ 47 | $limit->id => [ 48 | 'used_amount' => $usedAmount, 49 | 'last_reset' => now(), 50 | ], 51 | ]); 52 | 53 | if ($limit->reset_frequency) { 54 | $this->limitsRelationship()->updateExistingPivot($limit->id, [ 55 | 'next_reset' => app(LimitManager::class)->getNextReset($limit->reset_frequency, now()), 56 | ]); 57 | } 58 | }); 59 | 60 | $this->unloadLimitsRelationship(); 61 | 62 | return true; 63 | } 64 | 65 | public function isLimitSet(string|LimitContract $name, ?string $plan = null): bool 66 | { 67 | $limit = app(LimitContract::class)::findByName($name, $plan); 68 | 69 | return $this->getModelLimits()->where('name', $limit->name)->isNotEmpty(); 70 | } 71 | 72 | public function unsetLimit(string|LimitContract $name, ?string $plan = null): bool 73 | { 74 | $limit = $this->getModelLimit($name, $plan); 75 | 76 | $this->limitsRelationship()->detach($limit->id); 77 | 78 | $this->unloadLimitsRelationship(); 79 | 80 | return true; 81 | } 82 | 83 | public function useLimit(string|LimitContract $name, ?string $plan = null, float|int $amount = 1.0): bool 84 | { 85 | $limit = $this->getModelLimit($name, $plan); 86 | 87 | $newUsedAmount = $limit->pivot->used_amount + $amount; 88 | 89 | if ($newUsedAmount <= 0 || ! $this->ensureUsedAmountIsLessThanAllowedAmount($name, $plan, $newUsedAmount)) { 90 | throw new UsedAmountShouldBePositiveIntAndLessThanAllowedAmount; 91 | } 92 | 93 | $this->limitsRelationship()->updateExistingPivot($limit->id, [ 94 | 'used_amount' => $newUsedAmount, 95 | ]); 96 | 97 | $this->unloadLimitsRelationship(); 98 | 99 | return true; 100 | } 101 | 102 | public function unuseLimit(string|LimitContract $name, ?string $plan = null, float|int $amount = 1.0): bool 103 | { 104 | $limit = $this->getModelLimit($name, $plan); 105 | 106 | $newUsedAmount = $limit->pivot->used_amount - $amount; 107 | 108 | if ($newUsedAmount < 0 || ! $this->ensureUsedAmountIsLessThanAllowedAmount($name, $plan, $newUsedAmount)) { 109 | throw new UsedAmountShouldBePositiveIntAndLessThanAllowedAmount; 110 | } 111 | 112 | $this->limitsRelationship()->updateExistingPivot($limit->id, [ 113 | 'used_amount' => $newUsedAmount, 114 | ]); 115 | 116 | $this->unloadLimitsRelationship(); 117 | 118 | return true; 119 | } 120 | 121 | public function resetLimit(string|LimitContract $name, ?string $plan = null): bool 122 | { 123 | $limit = $this->getModelLimit($name, $plan); 124 | 125 | $this->limitsRelationship()->updateExistingPivot($limit->id, [ 126 | 'used_amount' => 0, 127 | ]); 128 | 129 | $this->unloadLimitsRelationship(); 130 | 131 | return true; 132 | } 133 | 134 | public function hasEnoughLimit(string|LimitContract $name, ?string $plan = null): bool 135 | { 136 | $limit = $this->getModelLimit($name, $plan); 137 | 138 | $usedAmount = $limit->pivot->used_amount; 139 | 140 | return $limit->allowed_amount > $usedAmount; 141 | } 142 | 143 | public function ensureUsedAmountIsLessThanAllowedAmount(string|LimitContract $name, ?string $plan, float|int $usedAmount): bool 144 | { 145 | $limit = $this->getModelLimit($name, $plan); 146 | 147 | return $usedAmount <= $limit->allowed_amount; 148 | } 149 | 150 | public function usedLimit(string|LimitContract $name, ?string $plan = null): float 151 | { 152 | $limit = $this->getModelLimit($name, $plan); 153 | 154 | return $limit->pivot->used_amount; 155 | } 156 | 157 | public function remainingLimit(string|LimitContract $name, ?string $plan = null): float 158 | { 159 | $limit = $this->getModelLimit($name, $plan); 160 | 161 | return $limit->allowed_amount - $limit->pivot->used_amount; 162 | } 163 | 164 | public function getModelLimit(string|LimitContract $name, ?string $plan = null): LimitContract 165 | { 166 | $limit = app(LimitContract::class)::findByName($name, $plan); 167 | 168 | $modelLimit = $this->getModelLimits()->firstWhere('id', $limit->id); 169 | 170 | if (! $modelLimit) { 171 | throw new LimitNotSetOnModel($name); 172 | } 173 | 174 | return $modelLimit; 175 | } 176 | 177 | public function getModelLimits(): Collection 178 | { 179 | $relationshipName = static::getLimitsRelationship(); 180 | 181 | $this->loadMissing($relationshipName); 182 | 183 | return $this->$relationshipName; 184 | } 185 | 186 | public function limitsRelationship(): MorphToMany 187 | { 188 | $relationshipName = static::getLimitsRelationship(); 189 | 190 | return $this->$relationshipName(); 191 | } 192 | 193 | public function unloadLimitsRelationship(): void 194 | { 195 | $relationshipName = static::getLimitsRelationship(); 196 | 197 | $this->unsetRelation($relationshipName); 198 | } 199 | 200 | private static function getLimitsRelationship(): string 201 | { 202 | return config('limit.relationship'); 203 | } 204 | 205 | public function limitUsageReport(string|LimitContract|null $name = null, ?string $plan = null): array 206 | { 207 | $modelLimits = ! is_null($name) ? collect([$this->getModelLimit($name, $plan)]) : $this->getModelLimits(); 208 | 209 | return 210 | $modelLimits 211 | ->mapWithKeys(function (LimitContract $modelLimit) { 212 | return [ 213 | $modelLimit->name => [ 214 | 'allowed_amount' => $modelLimit->allowed_amount, 215 | 'used_amount' => $modelLimit->pivot->used_amount, 216 | 'remaining_amount' => $modelLimit->allowed_amount - $modelLimit->pivot->used_amount, 217 | ], 218 | ]; 219 | })->all(); 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/Traits/RefreshCache.php: -------------------------------------------------------------------------------- 1 | flushCache(); 13 | }); 14 | 15 | static::deleted(function () { 16 | app(LimitManager::class)->flushCache(); 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Feature/BladeTest.php: -------------------------------------------------------------------------------- 1 | view('index', ['user' => $this->user]); 12 | 13 | $view->assertSee('User does not have enough limit to create locations'); 14 | $view->assertDontSee('User has enough limit to create locations'); 15 | } 16 | 17 | public function test_limit_directive_evaluates_false_if_limit_is_not_set_on_model(): void 18 | { 19 | $this->createLimit(); 20 | 21 | $view = $this->view('index', ['user' => $this->user]); 22 | 23 | $view->assertSee('User does not have enough limit to create locations'); 24 | $view->assertDontSee('User has enough limit to create locations'); 25 | } 26 | 27 | public function test_limit_directive_evaluates_true(): void 28 | { 29 | $limit = $this->createLimit(); 30 | 31 | $this->user->setLimit($limit); 32 | 33 | $view = $this->view('index', ['user' => $this->user]); 34 | 35 | $view->assertSee('User has enough limit to create locations'); 36 | $view->assertDontSee('User does not have enough limit to create locations'); 37 | } 38 | 39 | public function test_limit_directive_evaluates_true_with_limit_instance(): void 40 | { 41 | $limit = $this->createLimit(); 42 | 43 | $this->user->setLimit($limit); 44 | 45 | $view = $this->view('index', [ 46 | 'limit' => $limit, 47 | 'user' => $this->user, 48 | ]); 49 | 50 | $view->assertSee('User has enough limit to create locations'); 51 | $view->assertDontSee('User does not have enough limit to create locations'); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/Feature/CacheTest.php: -------------------------------------------------------------------------------- 1 | createLimit(); 18 | } 19 | 20 | public function test_cache_is_set(): void 21 | { 22 | $this->assertInstanceOf(ArrayStore::class, app(LimitManager::class)->getCacheStore()); 23 | } 24 | 25 | public function test_limits_are_cached(): void 26 | { 27 | DB::flushQueryLog(); 28 | 29 | app(LimitManager::class)->getLimits(); 30 | app(LimitManager::class)->getLimits(); 31 | 32 | app(LimitManager::class)->getLimit([ 33 | 'name' => 'locations', 34 | 'plan' => 'standard', 35 | ]); 36 | 37 | $this->assertQueriesExecuted(1); 38 | } 39 | 40 | public function test_find_by_name_loads_from_cache(): void 41 | { 42 | $limit = $this->createLimit(name: 'users'); 43 | 44 | $productLimit = $this->createLimit(name: 'product'); 45 | 46 | DB::flushQueryLog(); 47 | 48 | app(Limit::class)->findByName($limit->name, $limit->plan); 49 | app(Limit::class)->findByName($productLimit->name, $productLimit->plan); 50 | 51 | $this->assertQueriesExecuted(1); 52 | } 53 | 54 | public function test_find_by_id_loads_from_cache(): void 55 | { 56 | $limit = $this->createLimit(name: 'users'); 57 | 58 | $productLimit = $this->createLimit(name: 'product'); 59 | 60 | DB::flushQueryLog(); 61 | 62 | app(Limit::class)->findById($limit->id); 63 | app(Limit::class)->findById($productLimit->id); 64 | 65 | $this->assertQueriesExecuted(1); 66 | } 67 | 68 | public function test_cache_is_flushed_on_creating(): void 69 | { 70 | DB::flushQueryLog(); 71 | 72 | app(LimitManager::class)->getLimits(); 73 | 74 | $this->assertQueriesExecuted(1); 75 | } 76 | 77 | public function test_cache_is_flushed_on_deleting(): void 78 | { 79 | app(LimitManager::class)->getLimits()->first()->delete(); 80 | 81 | DB::flushQueryLog(); 82 | 83 | app(LimitManager::class)->getLimits(); 84 | 85 | $this->assertQueriesExecuted(1); 86 | } 87 | 88 | public function test_cache_is_flushed_on_limit_increment_and_decrement(): void 89 | { 90 | app(LimitManager::class)->getLimits()->first()->incrementBy(2); 91 | 92 | app(LimitManager::class)->getLimits()->first()->decrementBy(1); 93 | 94 | DB::flushQueryLog(); 95 | 96 | app(LimitManager::class)->getLimits(); 97 | 98 | $this->assertQueriesExecuted(1); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/Feature/CacheWithDatabaseTest.php: -------------------------------------------------------------------------------- 1 | initQueryCounts = 2; 16 | } 17 | 18 | protected function defineEnvironment($app) 19 | { 20 | tap($app['config'], function (Repository $config) { 21 | $config->set('cache.default', 'database'); 22 | }); 23 | } 24 | 25 | public function test_cache_is_set(): void 26 | { 27 | $this->assertInstanceOf(DatabaseStore::class, app(LimitManager::class)->getCacheStore()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Feature/CommandTest.php: -------------------------------------------------------------------------------- 1 | 'products', 16 | 'allowed_amount' => '3', 17 | ]; 18 | 19 | $this->artisan('limit:create', $data)->assertSuccessful(); 20 | 21 | $this->assertDatabaseCount(app(Limit::class), 1); 22 | $this->assertDatabaseHas(config('limit.tables.limits'), $data); 23 | } 24 | 25 | public function test_create_limit_command_creates_limit_with_plan(): void 26 | { 27 | $data = [ 28 | 'name' => 'products', 29 | 'allowed_amount' => '3', 30 | 'plan' => 'premium', 31 | ]; 32 | 33 | $this->artisan('limit:create', $data)->assertSuccessful(); 34 | 35 | $this->assertDatabaseCount(app(Limit::class), 1); 36 | $this->assertDatabaseHas(config('limit.tables.limits'), $data); 37 | } 38 | 39 | public function test_delete_limit_command_did_not_found_limits_to_delete(): void 40 | { 41 | $this 42 | ->artisan('limit:delete', ['name' => 'locations']) 43 | ->assertSuccessful() 44 | ->expectsOutput('No limits found to be deleted.'); 45 | } 46 | 47 | public function test_delete_limit_command_deletes_limit(): void 48 | { 49 | $limit = $this->createLimit(); 50 | 51 | $this->assertDatabaseCount(app(Limit::class), 1); 52 | 53 | $this->artisan('limit:delete', $limit->only(['name', 'plan']))->assertSuccessful(); 54 | 55 | $this->assertSoftDeleted($limit); 56 | } 57 | 58 | public function test_delete_limit_command_deletes_limit_if_plan_is_null(): void 59 | { 60 | $limit = $this->createLimit(); 61 | 62 | $nullLimit = $this->createLimit(plan: null); 63 | 64 | $this->assertDatabaseCount(app(Limit::class), 2); 65 | 66 | $this->artisan('limit:delete', ['name' => 'locations'])->assertSuccessful(); 67 | 68 | $this->assertNotSoftDeleted($limit); 69 | $this->assertSoftDeleted($nullLimit); 70 | } 71 | 72 | public function test_list_limits_command_renders_table_if_limits_are_available(): void 73 | { 74 | $columns = ['name', 'plan', 'allowed_amount', 'reset_frequency']; 75 | 76 | $this->createLimit(); 77 | 78 | $this->createLimit(plan: 'pro'); 79 | 80 | $this->assertDatabaseCount(app(Limit::class), 2); 81 | 82 | $this 83 | ->artisan('limit:list') 84 | ->assertSuccessful() 85 | ->expectsTable($columns, app(Limit::class)::all($columns)); 86 | } 87 | 88 | public function test_list_limits_command_does_not_render_table_if_limits_are_not_available(): void 89 | { 90 | $this 91 | ->artisan('limit:list') 92 | ->assertSuccessful() 93 | ->expectsOutput('No limits available.'); 94 | } 95 | 96 | public function test_reset_limit_usages_command_resets_usages_if_next_reset_is_due(): void 97 | { 98 | $limit = $this->createLimit(); 99 | 100 | $this->user->setLimit($limit); 101 | 102 | $this->user->useLimit($limit, amount: 2.0); 103 | 104 | $this->assertEquals(3, $this->user->remainingLimit($limit)); 105 | 106 | // set next_reset behind now() to trigger resetting 107 | $this->user->limitsRelationship()->updateExistingPivot($limit->id, ['next_reset' => now()->subDay()]); 108 | 109 | $this->artisan('limit:reset')->assertSuccessful(); 110 | 111 | $this->assertEquals(5, $this->user->refresh()->remainingLimit($limit)); 112 | 113 | $this->assertTrue( 114 | app(LimitManager::class) 115 | ->getNextReset($limit->reset_frequency, now()) 116 | ->isSameAs('yyyy-mm-dd h:i:s', $this->user->getModelLimit($limit)->pivot->next_reset), 117 | ); 118 | 119 | $this->assertTrue( 120 | now()->isSameAs('yyyy-mm-dd h:i:s', $this->user->getModelLimit($limit)->pivot->last_reset), 121 | ); 122 | } 123 | 124 | public function test_reset_limit_usages_command_does_not_reset_usages_if_next_reset_is_not_due(): void 125 | { 126 | $limit = $this->createLimit(); 127 | 128 | $this->user->setLimit($limit); 129 | 130 | $this->user->useLimit($limit, amount: 2.0); 131 | 132 | $this->assertEquals(3, $this->user->remainingLimit($limit)); 133 | 134 | $this->artisan('limit:reset')->assertSuccessful()->expectsOutput('0 usages/rows where resetted.'); 135 | 136 | $this->assertEquals(3, $this->user->remainingLimit($limit)); 137 | } 138 | 139 | public function test_reset_cache_limit_resets_cache(): void 140 | { 141 | $this->createLimit(); 142 | 143 | $this->createLimit(name: 'products'); 144 | 145 | $this->createLimit(name: 'users'); 146 | 147 | DB::flushQueryLog(); 148 | 149 | app(LimitManager::class)->getLimits(); 150 | 151 | $this->assertQueriesExecuted(1); 152 | 153 | DB::flushQueryLog(); 154 | 155 | app(LimitManager::class)->getLimits(); 156 | 157 | $this->assertQueriesExecuted(0); 158 | 159 | DB::flushQueryLog(); 160 | 161 | $this->artisan('limit:cache-reset')->assertSuccessful(); 162 | 163 | app(LimitManager::class)->getLimits(); 164 | 165 | $this->assertQueriesExecuted(1); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /tests/Feature/HasLimitsTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(MorphToMany::class, $this->user->limitsRelationship()); 18 | } 19 | 20 | public function test_cannot_set_limit_with_same_name_but_different_plan(): void 21 | { 22 | $limit = $this->createLimit(); 23 | 24 | $proLimit = $this->createLimit(plan: 'pro'); 25 | 26 | $this->user->setLimit($limit->name, $limit->plan); 27 | 28 | $this->user->setLimit($proLimit->name, $proLimit->plan); 29 | 30 | $this->assertEquals(1, $this->user->limitsRelationship()->count()); 31 | 32 | $this->assertEquals($limit->id, $this->user->limitsRelationship()->first()->id); 33 | } 34 | 35 | public function test_exception_is_thrown_if_beginning_used_amount_is_greater_than_limit_allowed_amount(): void 36 | { 37 | $limit = $this->createLimit(); 38 | 39 | $this->assertException( 40 | fn () => $this->user->setLimit($limit->name, $limit->plan, usedAmount: 6), 41 | InvalidArgumentException::class 42 | ); 43 | } 44 | 45 | public function test_can_set_limit_on_a_model_with_beginning_used_amount(): void 46 | { 47 | $limit = $this->createLimit(); 48 | 49 | $this->user->setLimit($limit->name, $limit->plan, usedAmount: 3); 50 | 51 | $this->assertEquals(3, $this->user->usedLimit($limit->name, $limit->plan)); 52 | } 53 | 54 | public function test_can_set_limit_on_a_model(): void 55 | { 56 | $limit = $this->createLimit(); 57 | 58 | $this->user->setLimit($limit->name, $limit->plan); 59 | 60 | $this->assertTrue($this->user->isLimitSet($limit->name, $limit->plan)); 61 | 62 | $this->assertEquals(0, $this->user->usedLimit($limit->name, $limit->plan)); 63 | 64 | $this->assertTrue(now()->isSameAs('yyyy-mm-dd h:i:s', $this->user->getModelLimit($limit)->pivot->last_reset)); 65 | } 66 | 67 | public function test_can_set_limits_with_different_names_on_a_model(): void 68 | { 69 | $limit = $this->createLimit(); 70 | 71 | $productLimit = $this->createLimit(name: 'products'); 72 | 73 | $this->user->setLimit($limit->name, $limit->plan); 74 | 75 | $this->user->setLimit($productLimit->name, $productLimit->plan); 76 | 77 | $this->assertEquals(2, $this->user->limitsRelationship()->count()); 78 | 79 | $this->assertTrue($this->user->isLimitSet($limit->name, $limit->plan)); 80 | 81 | $this->assertTrue($this->user->isLimitSet($productLimit->name, $productLimit->plan)); 82 | } 83 | 84 | public function test_next_reset_is_null_when_limit_reset_frequency_is_null(): void 85 | { 86 | $limit = $this->createLimit(resetFrequency: null); 87 | 88 | $this->user->setLimit($limit->name, $limit->plan); 89 | 90 | $this->assertTrue($this->user->isLimitSet($limit->name, $limit->plan)); 91 | 92 | $this->assertEquals( 93 | null, 94 | $this->user->getModelLimit($limit)->pivot->next_reset 95 | ); 96 | } 97 | 98 | public function test_reset_schedule_is_valid_when_setting_a_limit_on_a_model(): void 99 | { 100 | $limit = $this->createLimit(); 101 | 102 | $this->user->setLimit($limit->name, $limit->plan); 103 | 104 | $this->assertTrue($this->user->isLimitSet($limit->name, $limit->plan)); 105 | 106 | $this->assertEquals( 107 | now(), 108 | $this->user->getModelLimit($limit)->pivot->last_reset 109 | ); 110 | 111 | $this->assertTrue( 112 | app(LimitManager::class) 113 | ->getNextReset($limit->reset_frequency, now()) 114 | ->isSameAs('yyyy-mm-dd h:i:s', $this->user->getModelLimit($limit)->pivot->next_reset), 115 | ); 116 | } 117 | 118 | public function test_limit_is_set_on_a_model(): void 119 | { 120 | $limit = $this->createLimit(); 121 | 122 | $this->user->setLimit($limit->name, $limit->plan); 123 | 124 | $this->assertTrue($this->user->isLimitSet($limit->name, $limit->plan)); 125 | } 126 | 127 | public function test_limit_is_not_set_on_a_model(): void 128 | { 129 | $limit = $this->createLimit(); 130 | 131 | $this->assertFalse($this->user->isLimitSet($limit->name, $limit->plan)); 132 | } 133 | 134 | public function test_can_unset_limit_off_of_a_model(): void 135 | { 136 | $limit = $this->createLimit(); 137 | 138 | $this->user->setLimit($limit->name, $limit->plan); 139 | 140 | $this->user->unsetLimit($limit->name, $limit->plan); 141 | 142 | $this->assertTrue(! $this->user->isLimitSet($limit->name, $limit->plan)); 143 | } 144 | 145 | public function test_model_can_consume_limit(): void 146 | { 147 | $limit = $this->createLimit(); 148 | 149 | $this->user->setLimit($limit->name, $limit->plan); 150 | 151 | $this->user->useLimit($limit->name, $limit->plan); 152 | 153 | $this->assertEquals(1.0, $this->user->usedLimit($limit->name, $limit->plan)); 154 | 155 | $this->assertEquals(4.0, $this->user->remainingLimit($limit->name, $limit->plan)); 156 | } 157 | 158 | public function test_model_can_consume_multiple_limits(): void 159 | { 160 | $limit = $this->createLimit(); 161 | 162 | $productLimit = $this->createLimit(name: 'products'); 163 | 164 | $this->user->setLimit($limit->name, $limit->plan); 165 | 166 | $this->user->useLimit($limit->name, $limit->plan); 167 | 168 | $this->user->setLimit($productLimit->name, $productLimit->plan); 169 | 170 | $this->user->useLimit($productLimit->name, $productLimit->plan, 3.0); 171 | 172 | $this->assertEquals(1.0, $this->user->usedLimit($limit->name, $limit->plan)); 173 | 174 | $this->assertEquals(4.0, $this->user->remainingLimit($limit->name, $limit->plan)); 175 | 176 | $this->assertEquals(3.0, $this->user->usedLimit($productLimit->name, $productLimit->plan)); 177 | 178 | $this->assertEquals(2.0, $this->user->remainingLimit($productLimit->name, $productLimit->plan)); 179 | } 180 | 181 | public function test_exception_is_thrown_if_model_consumes_zero_limits(): void 182 | { 183 | $limit = $this->createLimit(); 184 | 185 | $this->user->setLimit($limit->name, $limit->plan); 186 | 187 | $this->assertException( 188 | fn () => $this->user->useLimit($limit->name, $limit->plan, 0), 189 | UsedAmountShouldBePositiveIntAndLessThanAllowedAmount::class 190 | ); 191 | } 192 | 193 | public function test_exception_is_thrown_if_model_consumes_unavailable_limits(): void 194 | { 195 | $limit = $this->createLimit(); 196 | 197 | $this->user->setLimit($limit->name, $limit->plan); 198 | 199 | $this->assertException( 200 | fn () => $this->user->useLimit($limit->name, $limit->plan, 6), 201 | UsedAmountShouldBePositiveIntAndLessThanAllowedAmount::class 202 | ); 203 | } 204 | 205 | public function test_model_can_unconsume_limit(): void 206 | { 207 | $limit = $this->createLimit(); 208 | 209 | $this->user->setLimit($limit->name, $limit->plan); 210 | 211 | $this->user->useLimit($limit->name, $limit->plan, 2.0); 212 | 213 | $this->assertEquals(2.0, $this->user->usedLimit($limit->name, $limit->plan)); 214 | 215 | $this->assertEquals(3.0, $this->user->remainingLimit($limit->name, $limit->plan)); 216 | 217 | $this->user->unuseLimit($limit->name, $limit->plan); 218 | 219 | $this->assertEquals(1.0, $this->user->usedLimit($limit->name, $limit->plan)); 220 | 221 | $this->assertEquals(4.0, $this->user->remainingLimit($limit->name, $limit->plan)); 222 | } 223 | 224 | public function test_exception_is_thrown_if_model_unconsumes_below_zero(): void 225 | { 226 | $limit = $this->createLimit(); 227 | 228 | $this->user->setLimit($limit->name, $limit->plan); 229 | 230 | $this->assertException( 231 | fn () => $this->user->unuseLimit($limit->name, $limit->plan, 6), 232 | UsedAmountShouldBePositiveIntAndLessThanAllowedAmount::class 233 | ); 234 | } 235 | 236 | public function test_model_can_reset_limit(): void 237 | { 238 | $limit = $this->createLimit(); 239 | 240 | $this->user->setLimit($limit->name, $limit->plan); 241 | 242 | $this->user->useLimit($limit->name, $limit->plan); 243 | 244 | $this->assertEquals(1.0, $this->user->usedLimit($limit->name, $limit->plan)); 245 | 246 | $this->assertEquals(4.0, $this->user->remainingLimit($limit->name, $limit->plan)); 247 | 248 | $this->user->resetLimit($limit->name, $limit->plan); 249 | 250 | $this->assertEquals(0.0, $this->user->usedLimit($limit->name, $limit->plan)); 251 | 252 | $this->assertEquals(5.0, $this->user->remainingLimit($limit->name, $limit->plan)); 253 | } 254 | 255 | public function test_model_cannot_exceed_limit(): void 256 | { 257 | $limit = $this->createLimit(); 258 | 259 | $this->user->setLimit($limit->name, $limit->plan); 260 | 261 | $this->user->useLimit($limit->name, $limit->plan, 5.0); 262 | 263 | $this->assertFalse($this->user->hasEnoughLimit($limit->name, $limit->plan)); 264 | 265 | $this->user->unuseLimit($limit->name, $limit->plan, 3.0); 266 | 267 | $this->assertTrue($this->user->hasEnoughLimit($limit->name, $limit->plan)); 268 | } 269 | 270 | public function test_used_amount_is_always_less_than_allowed_amount(): void 271 | { 272 | $limit = $this->createLimit(); 273 | 274 | $this->user->setLimit($limit->name, $limit->plan); 275 | 276 | $this->assertTrue( 277 | $this->user->ensureUsedAmountIsLessThanAllowedAmount($limit->name, $limit->plan, 4) 278 | ); 279 | 280 | $this->assertFalse( 281 | $this->user->ensureUsedAmountIsLessThanAllowedAmount($limit->name, $limit->plan, 6) 282 | ); 283 | } 284 | 285 | public function test_used_amount_is_valid(): void 286 | { 287 | $limit = $this->createLimit(); 288 | 289 | $this->user->setLimit($limit->name, $limit->plan); 290 | 291 | $this->user->useLimit($limit->name, $limit->plan, 2.0); 292 | 293 | $this->assertEquals(2, $this->user->usedLimit($limit->name, $limit->plan)); 294 | } 295 | 296 | public function test_remaining_amount_is_valid(): void 297 | { 298 | $limit = $this->createLimit(); 299 | 300 | $this->user->setLimit($limit->name, $limit->plan); 301 | 302 | $this->user->useLimit($limit->name, $limit->plan, 2.0); 303 | 304 | $this->assertEquals(3, $this->user->remainingLimit($limit->name, $limit->plan)); 305 | } 306 | 307 | public function test_exception_is_thrown_if_limit_is_not_set_on_a_model(): void 308 | { 309 | $limit = $this->createLimit(); 310 | 311 | $this->assertException( 312 | fn () => $this->user->getModelLimit($limit->name, $limit->plan), 313 | LimitNotSetOnModel::class 314 | ); 315 | } 316 | 317 | public function test_retrieving_limit_set_on_a_model_by_limit_name(): void 318 | { 319 | $limit = $this->createLimit(); 320 | 321 | $this->user->setLimit($limit->name, $limit->plan); 322 | 323 | $this->assertEquals($limit->id, $this->user->getModelLimit($limit->name, $limit->plan)->id); 324 | } 325 | 326 | public function test_retrieving_limit_set_on_a_model_by_limit_object(): void 327 | { 328 | $limit = $this->createLimit(); 329 | 330 | $this->user->setLimit($limit->name, $limit->plan); 331 | 332 | $this->assertEquals($limit->id, $this->user->getModelLimit($limit)->id); 333 | } 334 | 335 | public function test_can_get_all_limits_usage_report(): void 336 | { 337 | $limit = $this->createLimit(); 338 | $productLimit = $this->createLimit(name: 'products'); 339 | 340 | $this->user->setLimit($limit->name, $limit->plan); 341 | $this->user->setLimit($productLimit->name, $productLimit->plan); 342 | 343 | $this->user->useLimit($limit); 344 | 345 | $report = $this->user->limitUsageReport(); 346 | 347 | $this->assertCount(2, $report); 348 | 349 | $this->assertEquals(5, $report[$limit->name]['allowed_amount']); 350 | $this->assertEquals(1, $report[$limit->name]['used_amount']); 351 | $this->assertEquals(4, $report[$limit->name]['remaining_amount']); 352 | 353 | $this->assertEquals(5, $report[$productLimit->name]['allowed_amount']); 354 | $this->assertEquals(0, $report[$productLimit->name]['used_amount']); 355 | $this->assertEquals(5, $report[$productLimit->name]['remaining_amount']); 356 | } 357 | 358 | public function test_can_get_a_limit_usage_report_by_limit_name(): void 359 | { 360 | $limit = $this->createLimit(); 361 | 362 | $this->user->setLimit($limit->name, $limit->plan); 363 | 364 | $this->user->useLimit($limit); 365 | 366 | $report = $this->user->limitUsageReport($limit->name, $limit->plan); 367 | 368 | $this->assertCount(1, $report); 369 | 370 | $this->assertEquals(5, $report[$limit->name]['allowed_amount']); 371 | $this->assertEquals(1, $report[$limit->name]['used_amount']); 372 | $this->assertEquals(4, $report[$limit->name]['remaining_amount']); 373 | } 374 | 375 | public function test_can_get_a_limit_usage_report_by_limit_instance(): void 376 | { 377 | $limit = $this->createLimit(); 378 | 379 | $this->user->setLimit($limit->name, $limit->plan); 380 | 381 | $this->user->useLimit($limit); 382 | 383 | $report = $this->user->limitUsageReport($limit); 384 | 385 | $this->assertCount(1, $report); 386 | 387 | $this->assertEquals(5, $report[$limit->name]['allowed_amount']); 388 | $this->assertEquals(1, $report[$limit->name]['used_amount']); 389 | $this->assertEquals(4, $report[$limit->name]['remaining_amount']); 390 | } 391 | 392 | public function test_get_model_limits_returns_model_limits_collection(): void 393 | { 394 | $limit = $this->createLimit(); 395 | 396 | $this->createLimit(name: 'products'); 397 | 398 | $this->user->setLimit($limit); 399 | 400 | $this->assertCount( 401 | $this->user->limitsRelationship()->count(), 402 | $this->user->getModelLimits() 403 | ); 404 | } 405 | 406 | public function test_model_limits_are_cached(): void 407 | { 408 | $limit = $this->createLimit(); 409 | 410 | $this->user->setLimit($limit); 411 | 412 | DB::flushQueryLog(); 413 | 414 | $this->user->isLimitSet($limit); 415 | $this->user->hasEnoughLimit($limit); 416 | $this->user->usedLimit($limit); 417 | $this->user->remainingLimit($limit); 418 | $this->user->getModelLimit($limit); 419 | $this->user->getModelLimits(); 420 | 421 | $this->assertQueriesExecuted(1); 422 | } 423 | 424 | public function test_unload_limits_relationship_is_unsetting_relationship(): void 425 | { 426 | $limit = $this->createLimit(); 427 | 428 | $this->user->setLimit($limit); 429 | 430 | DB::flushQueryLog(); 431 | 432 | $this->user->isLimitSet($limit); 433 | 434 | $this->assertQueriesExecuted(1); 435 | 436 | DB::flushQueryLog(); 437 | 438 | $this->user->isLimitSet($limit); 439 | 440 | $this->assertQueriesExecuted(0); 441 | 442 | DB::flushQueryLog(); 443 | 444 | $this->user->unloadLimitsRelationship(); 445 | 446 | $this->user->isLimitSet($limit); 447 | 448 | $this->assertQueriesExecuted(1); 449 | } 450 | } 451 | -------------------------------------------------------------------------------- /tests/Feature/HasLimitsWithCustomModelTest.php: -------------------------------------------------------------------------------- 1 | set('limit.models.limit', Restrict::class); 15 | $config->set('limit.relationship', 'restricts'); 16 | $config->set('limit.tables.limits', 'restricts'); 17 | $config->set('limit.tables.model_has_limits', 'model_has_restricts'); 18 | $config->set('limit.columns.limit_pivot_key', 'restrict_id'); 19 | }); 20 | } 21 | 22 | public function test_model_can_assign_custom_limits_relationship_name(): void 23 | { 24 | $this->assertInstanceOf(MorphToMany::class, $this->user->restricts()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Feature/LimitManagerTest.php: -------------------------------------------------------------------------------- 1 | limitManagerClass = app(LimitManager::class); 24 | } 25 | 26 | public function test_cache_is_initialized(): void 27 | { 28 | $this->assertInstanceOf(Store::class, $this->limitManagerClass->getCacheStore()); 29 | } 30 | 31 | public function test_get_limits_return_limits_collection(): void 32 | { 33 | $this->createLimit(); 34 | 35 | $this->assertCount(1, $this->limitManagerClass->getLimits()); 36 | } 37 | 38 | public function test_get_limit_throws_exception_if_id_or_name_is_not_provided(): void 39 | { 40 | $limit = $this->createLimit(); 41 | 42 | $this->assertException( 43 | fn () => $this->limitManagerClass->getLimit([]), 44 | InvalidArgumentException::class 45 | ); 46 | 47 | $this->assertException( 48 | fn () => $this->limitManagerClass->getLimit([ 49 | 'plan' => $limit->plan, 50 | ]), 51 | InvalidArgumentException::class 52 | ); 53 | } 54 | 55 | public function test_get_limit_return_a_limit_by_id(): void 56 | { 57 | $limit = $this->createLimit(); 58 | 59 | $this->assertEquals($limit->name, $this->limitManagerClass->getLimit([ 60 | 'id' => $limit->id, 61 | ])->name); 62 | } 63 | 64 | public function test_get_limit_return_a_limit_by_name_and_plan(): void 65 | { 66 | $limit = $this->createLimit(); 67 | $nullPlanLimit = $this->createLimit(plan: null); 68 | 69 | $this->assertEquals($limit->name, $this->limitManagerClass->getLimit([ 70 | 'name' => $limit->name, 71 | 'plan' => $limit->plan, 72 | ])->name); 73 | 74 | $this->assertEquals($nullPlanLimit->name, $this->limitManagerClass->getLimit([ 75 | 'name' => $nullPlanLimit->name, 76 | ])->name); 77 | } 78 | 79 | public function test_limits_cache_is_flushed(): void 80 | { 81 | $this->createLimit(); 82 | $this->createLimit(plan: null); 83 | 84 | DB::flushQueryLog(); 85 | 86 | $this->limitManagerClass->getLimits(); 87 | 88 | $this->assertQueriesExecuted(1); 89 | 90 | DB::flushQueryLog(); 91 | 92 | $this->limitManagerClass->getLimits(); 93 | 94 | $this->assertQueriesExecuted(0); 95 | 96 | DB::flushQueryLog(); 97 | 98 | $this->limitManagerClass->flushCache(); 99 | 100 | $this->limitManagerClass->getLimits(); 101 | 102 | $this->assertQueriesExecuted(1); 103 | } 104 | 105 | public function test_exception_is_thrown_if_invalid_reset_frequency_is_passed_to_get_next_reset(): void 106 | { 107 | $this->assertException( 108 | fn () => $this->limitManagerClass->getNextReset(Str::random(), now()), 109 | InvalidLimitResetFrequencyValue::class 110 | ); 111 | } 112 | 113 | public function test_get_next_reset_returns_carbon_date(): void 114 | { 115 | $date = $this->limitManagerClass->getNextReset(app(Limit::class)->getResetFrequencyOptions()->random(), now()); 116 | 117 | $this->assertInstanceOf(Carbon::class, $date); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tests/Feature/LimitTest.php: -------------------------------------------------------------------------------- 1 | 'standard', 20 | 'allowed_amount' => 5.0, 21 | ]; 22 | 23 | $this->assertException( 24 | fn () => app(LimitContract::class)::findOrCreate($data), 25 | InvalidArgumentException::class 26 | ); 27 | } 28 | 29 | public function test_execption_is_thrown_if_allowed_amount_is_not_present(): void 30 | { 31 | $data = [ 32 | 'name' => 'locations', 33 | 'plan' => 'standard', 34 | ]; 35 | 36 | $this->assertException( 37 | fn () => app(LimitContract::class)::findOrCreate($data), 38 | InvalidArgumentException::class 39 | ); 40 | } 41 | 42 | public function test_execption_is_thrown_if_allowed_amount_is_not_a_number(): void 43 | { 44 | $data = [ 45 | 'name' => 'locations', 46 | 'plan' => 'standard', 47 | 'allowed_amount' => 'string', 48 | ]; 49 | 50 | $this->assertException( 51 | fn () => app(LimitContract::class)::findOrCreate($data), 52 | InvalidArgumentException::class 53 | ); 54 | } 55 | 56 | public function test_execption_is_thrown_if_allowed_amount_is_less_than_zero(): void 57 | { 58 | $data = [ 59 | 'name' => 'locations', 60 | 'plan' => 'standard', 61 | 'allowed_amount' => -1.0, 62 | ]; 63 | 64 | $this->assertException( 65 | fn () => app(LimitContract::class)::findOrCreate($data), 66 | InvalidArgumentException::class 67 | ); 68 | } 69 | 70 | public function test_exeception_is_thrown_when_creating_existing_limit_with_create_method(): void 71 | { 72 | $data = [ 73 | 'name' => 'locations', 74 | 'plan' => 'standard', 75 | 'allowed_amount' => 5.0, 76 | ]; 77 | 78 | app(LimitContract::class)::create($data); 79 | 80 | $this->assertException( 81 | fn () => app(LimitContract::class)::create($data), 82 | LimitAlreadyExists::class 83 | ); 84 | } 85 | 86 | public function test_exeception_is_thrown_if_reset_frequency_value_is_invalid(): void 87 | { 88 | $data = [ 89 | 'name' => 'locations', 90 | 'plan' => 'standard', 91 | 'allowed_amount' => 5.0, 92 | 'reset_frequency' => Str::random(), 93 | ]; 94 | 95 | $this->assertException( 96 | fn () => app(LimitContract::class)::create($data), 97 | InvalidLimitResetFrequencyValue::class 98 | ); 99 | } 100 | 101 | public function test_limit_is_created_if_reset_frequency_value_is_valid(): void 102 | { 103 | $data = [ 104 | 'name' => 'locations', 105 | 'plan' => 'standard', 106 | 'allowed_amount' => 5.0, 107 | 'reset_frequency' => collect(app(LimitContract::class)->getResetFrequencyOptions())->random(), 108 | ]; 109 | 110 | $limit = app(LimitContract::class)::create($data); 111 | 112 | $this->assertModelExists($limit); 113 | } 114 | 115 | public function test_limit_can_be_created(): void 116 | { 117 | $data = [ 118 | 'name' => 'locations', 119 | 'plan' => 'standard', 120 | 'allowed_amount' => 5.0, 121 | ]; 122 | 123 | $limit = app(LimitContract::class)::findOrCreate($data); 124 | 125 | $this->assertModelExists($limit); 126 | } 127 | 128 | public function test_limit_can_be_created_without_plan(): void 129 | { 130 | $data = [ 131 | 'name' => 'locations', 132 | 'allowed_amount' => 5.0, 133 | ]; 134 | 135 | $limit = app(LimitContract::class)::findOrCreate($data); 136 | 137 | $this->assertModelExists($limit); 138 | } 139 | 140 | public function test_duplicate_limit_cannot_be_created(): void 141 | { 142 | $data = [ 143 | 'name' => 'locations', 144 | 'plan' => 'standard', 145 | 'allowed_amount' => 5.0, 146 | ]; 147 | 148 | $firstLimit = app(LimitContract::class)::findOrCreate($data); 149 | 150 | $secondLimit = app(LimitContract::class)::findOrCreate($data); 151 | 152 | $this->assertEquals($firstLimit->id, $secondLimit->id); 153 | } 154 | 155 | public function test_same_limit_name_but_different_plan_can_be_created(): void 156 | { 157 | $data = [ 158 | 'name' => 'locations', 159 | 'plan' => 'standard', 160 | 'allowed_amount' => 5.0, 161 | ]; 162 | 163 | $firstLimit = app(LimitContract::class)::findOrCreate($data); 164 | 165 | $data['plan'] = 'pro'; 166 | 167 | $secondLimit = app(LimitContract::class)::findOrCreate($data); 168 | 169 | $this->assertDatabaseCount(app(LimitContract::class), 2); 170 | 171 | $this->assertModelExists($firstLimit); 172 | 173 | $this->assertModelExists($secondLimit); 174 | 175 | $this->assertNotEquals($firstLimit->id, $secondLimit->id); 176 | } 177 | 178 | public function test_same_limit_name_but_different_null_plan_can_be_created(): void 179 | { 180 | $data = [ 181 | 'name' => 'locations', 182 | 'plan' => 'standard', 183 | 'allowed_amount' => 5.0, 184 | ]; 185 | 186 | $firstLimit = app(LimitContract::class)::findOrCreate($data); 187 | 188 | $data['plan'] = null; 189 | 190 | $secondLimit = app(LimitContract::class)::findOrCreate($data); 191 | 192 | $this->assertDatabaseCount(app(LimitContract::class), 2); 193 | 194 | $this->assertModelExists($firstLimit); 195 | 196 | $this->assertModelExists($secondLimit); 197 | 198 | $this->assertNotEquals($firstLimit->id, $secondLimit->id); 199 | } 200 | 201 | public function test_same_plan_but_different_limit_names_can_be_created(): void 202 | { 203 | $data = [ 204 | 'name' => 'locations', 205 | 'plan' => 'standard', 206 | 'allowed_amount' => 5.0, 207 | ]; 208 | 209 | $firstLimit = app(LimitContract::class)::findOrCreate($data); 210 | 211 | $data['name'] = 'users'; 212 | 213 | $secondLimit = app(LimitContract::class)::findOrCreate($data); 214 | 215 | $this->assertDatabaseCount(app(LimitContract::class), 2); 216 | 217 | $this->assertModelExists($firstLimit); 218 | 219 | $this->assertModelExists($secondLimit); 220 | 221 | $this->assertNotEquals($firstLimit->id, $secondLimit->id); 222 | } 223 | 224 | public function test_exception_is_thrown_if_limit_does_not_exist(): void 225 | { 226 | $this->assertException( 227 | fn () => app(LimitContract::class)::findByName(Str::random(), Str::random()), 228 | LimitDoesNotExist::class 229 | ); 230 | } 231 | 232 | public function test_existing_limit_can_be_retrieved_by_name(): void 233 | { 234 | $data = [ 235 | 'name' => 'locations', 236 | 'plan' => 'standard', 237 | 'allowed_amount' => 5.0, 238 | ]; 239 | 240 | $limit = app(LimitContract::class)::findOrCreate($data); 241 | 242 | $this->assertEquals( 243 | $limit->id, 244 | app(LimitContract::class)::findByName($limit->name, $limit->plan)->id 245 | ); 246 | } 247 | 248 | public function test_retrieving_limit_by_limit_instance(): void 249 | { 250 | $data = [ 251 | 'name' => 'locations', 252 | 'plan' => 'standard', 253 | 'allowed_amount' => 5.0, 254 | ]; 255 | 256 | $limit = app(LimitContract::class)::findOrCreate($data); 257 | 258 | $this->assertEquals($limit->id, app(LimitContract::class)->findByName($limit)->id); 259 | } 260 | 261 | public function test_same_limit_name_but_different_plan_can_be_retrieved_by_name_and_plan(): void 262 | { 263 | $data = [ 264 | 'name' => 'locations', 265 | 'plan' => 'standard', 266 | 'allowed_amount' => 5.0, 267 | ]; 268 | 269 | $firstLimit = app(LimitContract::class)::findOrCreate($data); 270 | 271 | $data['plan'] = 'pro'; 272 | 273 | $secondLimit = app(LimitContract::class)::findOrCreate($data); 274 | 275 | $this->assertEquals( 276 | $firstLimit->id, 277 | app(LimitContract::class)::findByName($firstLimit->name, $firstLimit->plan)->id 278 | ); 279 | 280 | $this->assertEquals( 281 | $secondLimit->id, 282 | app(LimitContract::class)::findByName($secondLimit->name, $secondLimit->plan)->id 283 | ); 284 | } 285 | 286 | public function test_same_limit_name_but_different_null_plan_can_be_retrieved_by_name_and_plan(): void 287 | { 288 | $data = [ 289 | 'name' => 'locations', 290 | 'plan' => 'standard', 291 | 'allowed_amount' => 5.0, 292 | ]; 293 | 294 | $firstLimit = app(LimitContract::class)::findOrCreate($data); 295 | 296 | $data['plan'] = null; 297 | 298 | $secondLimit = app(LimitContract::class)::findOrCreate($data); 299 | 300 | $this->assertEquals( 301 | $firstLimit->id, 302 | app(LimitContract::class)::findByName($firstLimit->name, $firstLimit->plan)->id 303 | ); 304 | 305 | $this->assertEquals( 306 | $secondLimit->id, 307 | app(LimitContract::class)::findByName($secondLimit->name, $secondLimit->plan)->id 308 | ); 309 | } 310 | 311 | public function test_exception_is_thrown_when_non_existing_limit_is_retrieved_by_id(): void 312 | { 313 | $data = [ 314 | 'name' => 'locations', 315 | 'plan' => 'standard', 316 | 'allowed_amount' => 5.0, 317 | ]; 318 | 319 | $this->assertException( 320 | fn () => app(LimitContract::class)::findById(1), 321 | LimitDoesNotExist::class 322 | ); 323 | } 324 | 325 | public function test_limit_can_be_retrieved_by_id(): void 326 | { 327 | $data = [ 328 | 'name' => 'locations', 329 | 'plan' => 'standard', 330 | 'allowed_amount' => 5.0, 331 | ]; 332 | 333 | $limit = app(LimitContract::class)::findOrCreate($data); 334 | 335 | $this->assertEquals( 336 | $limit->id, 337 | app(LimitContract::class)::findById($limit->id)->id 338 | ); 339 | } 340 | 341 | public function test_exception_is_thrown_when_incrementing_existing_limit_by_zero_or_less(): void 342 | { 343 | $data = [ 344 | 'name' => 'locations', 345 | 'plan' => 'standard', 346 | 'allowed_amount' => 5.0, 347 | ]; 348 | 349 | $limit = app(LimitContract::class)::findOrCreate($data); 350 | 351 | $this->assertException( 352 | fn () => $limit->incrementBy(0.0), 353 | InvalidArgumentException::class 354 | ); 355 | } 356 | 357 | public function test_existing_limit_allowed_amount_can_be_incremented(): void 358 | { 359 | $data = [ 360 | 'name' => 'locations', 361 | 'plan' => 'standard', 362 | 'allowed_amount' => 5.0, 363 | ]; 364 | 365 | $limit = app(LimitContract::class)::findOrCreate($data); 366 | 367 | $limit->incrementBy(3.0); 368 | 369 | $this->assertEquals( 370 | 8, 371 | app(LimitContract::class)::findByName($limit->name, $limit->plan)->allowed_amount 372 | ); 373 | } 374 | 375 | public function test_exception_is_thrown_when_decrementing_existing_limit_to_zero_or_less(): void 376 | { 377 | $data = [ 378 | 'name' => 'locations', 379 | 'plan' => 'standard', 380 | 'allowed_amount' => 5.0, 381 | ]; 382 | 383 | $limit = app(LimitContract::class)::findOrCreate($data); 384 | 385 | $this->assertException( 386 | fn () => $limit->decrementBy(6.0), 387 | InvalidArgumentException::class 388 | ); 389 | } 390 | 391 | public function test_existing_limit_allowed_amount_can_be_decremented(): void 392 | { 393 | $data = [ 394 | 'name' => 'locations', 395 | 'plan' => 'standard', 396 | 'allowed_amount' => 5.0, 397 | ]; 398 | 399 | $limit = app(LimitContract::class)::findOrCreate($data); 400 | 401 | $limit->decrementBy(3.0); 402 | 403 | $this->assertEquals( 404 | 2, 405 | app(LimitContract::class)::findByName($limit->name, $limit->plan)->allowed_amount 406 | ); 407 | } 408 | 409 | public function test_get_reset_frequency_options_returns_collection(): void 410 | { 411 | $this->assertInstanceOf(Collection::class, app(LimitContract::class)->getResetFrequencyOptions()); 412 | 413 | $this->assertCount(10, app(LimitContract::class)->getResetFrequencyOptions()); 414 | } 415 | } 416 | -------------------------------------------------------------------------------- /tests/Feature/LimitWithCustomModelTest.php: -------------------------------------------------------------------------------- 1 | set('limit.models.limit', Restrict::class); 14 | $config->set('limit.tables.limits', 'restricts'); 15 | $config->set('limit.tables.model_has_limits', 'model_has_restricts'); 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | user = User::factory()->create(); 33 | 34 | View::addLocation(__DIR__.'/../workbench/resources/views'); 35 | 36 | $this->loadMigrationsFrom(__DIR__.'/../database/migrations/'); 37 | 38 | $this->createCacheTable(); 39 | 40 | $this->createUsersTable(); 41 | 42 | $this->artisan('migrate'); 43 | } 44 | 45 | protected function getPackageProviders($app) 46 | { 47 | return [ 48 | ServiceProvider::class, 49 | ]; 50 | } 51 | 52 | protected function assertException(Closure $test, string $exception): void 53 | { 54 | $this->expectException($exception); 55 | 56 | $test(); 57 | } 58 | 59 | protected function createLimit(string $name = 'locations', ?string $plan = 'standard', float|int $allowedAmount = 5.0, ?string $resetFrequency = 'every month'): Limit 60 | { 61 | return app(Limit::class)::findOrCreate([ 62 | 'name' => $name, 63 | 'plan' => $plan, 64 | 'allowed_amount' => $allowedAmount, 65 | 'reset_frequency' => $resetFrequency, 66 | ]); 67 | } 68 | 69 | protected function assertQueriesExecuted(int $expected): void 70 | { 71 | $this->assertCount( 72 | $this->initQueryCounts + $expected, 73 | DB::getQueryLog() 74 | ); 75 | } 76 | 77 | protected function createCacheTable(): void 78 | { 79 | if (! Schema::hasTable('cache')) { 80 | Schema::create('cache', function ($table) { 81 | $table->string('key')->unique(); 82 | $table->text('value'); 83 | $table->integer('expiration'); 84 | }); 85 | } 86 | } 87 | 88 | protected static function createUsersTable(): void 89 | { 90 | if (! Schema::hasTable('users')) { 91 | Schema::create('users', function (Blueprint $table) { 92 | $table->id(); 93 | $table->string('name'); 94 | $table->string('email')->unique(); 95 | $table->timestamp('email_verified_at')->nullable(); 96 | $table->string('password'); 97 | $table->rememberToken(); 98 | $table->timestamps(); 99 | }); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /workbench/app/Models/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabilhassen/laravel-usage-limiter/10e68b990f9698853788a4368fd49bf6b9f78419/workbench/app/Models/.gitkeep -------------------------------------------------------------------------------- /workbench/app/Models/Restrict.php: -------------------------------------------------------------------------------- 1 | $this->faker->name(), 18 | 'email' => $this->faker->unique()->safeEmail(), 19 | 'email_verified_at' => now(), 20 | 'password' => Hash::make('password'), 21 | 'remember_token' => Str::random(10), 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /workbench/resources/views/index.blade.php: -------------------------------------------------------------------------------- 1 | @if (isset($limit)) 2 | @limit($user, $limit) 3 | User has enough limit to create locations 4 | @else 5 | User does not have enough limit to create locations 6 | @endlimit 7 | @else 8 | @limit($user, 'locations', 'standard') 9 | User has enough limit to create locations 10 | @else 11 | User does not have enough limit to create locations 12 | @endlimit 13 | @endif 14 | --------------------------------------------------------------------------------