├── .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 | [](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 |
--------------------------------------------------------------------------------