├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── composer.lock └── src ├── Commands ├── DebounceConsoleCommand.php ├── DebounceConsoleMakeCommand.php ├── DebounceJobMakeCommand.php ├── DebounceNotificationMakeCommand.php └── stubs │ ├── debounceConsole.stub │ ├── debounceJob.queued.stub │ ├── debounceJob.stub │ └── debounceNotification.stub ├── Concerns └── DebounceTrackable.php ├── Contracts ├── Debounceable.php ├── DebounceableCommand.php ├── DebounceableJob.php ├── DebounceableNotification.php └── Trackable.php ├── DebounceCommand.php ├── DebounceJob.php ├── DebounceNotification.php ├── DebounceServiceProvider.php ├── Debouncer.php ├── Debouncers ├── BaseDebouncer.php ├── CommandDebouncer.php ├── JobDebouncer.php ├── NotificationDebouncer.php └── TrackerDebouncer.php ├── Facades └── Debounce.php ├── Managers └── TrackerManager.php ├── Trackers ├── CacheTracker.php ├── Driver.php ├── Occurrence.php └── Report.php └── config └── debounce.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file, this project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## v2.0.0 - 2025-02-05 6 | 7 | ### BREAKING CHANGES 8 | - Changes User class to Authenticatable interface, adds PHP / Laravel requirements, adds tests and ci by @zackAJ in https://github.com/zackAJ/laravel-debounce/pull/4 9 | 10 | ### Other Changes 11 | - Fix: command stub by @zackAJ in https://github.com/zackAJ/laravel-debounce/pull/5 12 | **Full Changelog**: https://github.com/zackAJ/laravel-debounce/compare/v1.0.0...v2.0.0 13 | 14 | ## v1.0.0 - 2024-11-12 15 | 16 | - Adds first version 17 | 18 | ## v0.0.1 - 2024-11-09 19 | 20 | - Adds test first version 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) zackaj 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](https://github.com/user-attachments/assets/b30c65c0-f28b-41c9-a231-ad46e6699c8b) 2 | 3 | # Laravel debounce 4 | _by zackaj_ 5 | 6 | Laravel-debounce allows you to accumulate / debounce a job, notification or command to avoid spamming your users and your app's queue. 7 | 8 | It also tracks and registers every request occurrence and gives you a nice [report tracking](#report-tracking) with information like `ip address` and `authenticated user` per request. 9 | 10 | 11 | # Table of Contents 12 | 13 | - [Introduction](#introduction) 14 | - [Features](#features) 15 | - [Demo](#demo) 16 | 17 | - [Installation](#installation) 18 | - [Prerequisites](#prerequisites) 19 | - [Composer](#composer) 20 | 21 | - [Usage](#usage) 22 | - [Basic usage](#basic-usage) 23 | - [Advanced usage](#advanced-usage) 24 | - [Make commands](#make-commands) 25 | - [No facade usage](#no-facade-usage) 26 | - [Report Tracking](#report-tracking) 27 | - [Before After Hooks](#before-after-hooks) 28 | - [Override Timestamp](#override-timestamp) 29 | 30 | - [Bonus CLI Debounce](#bonus-cli-debounce) 31 | - [Debugging And Monitoring](#debugging-and-monitoring) 32 | - [Known Issues](#known-issues) 33 | - [Contributing](#contributing) 34 | - [License](#license) 35 | 36 | ## Introduction 37 | 38 | This laravel package uses UniqueJobs (atomic locks) and caching to run only one instance of a task in a debounced interval of x seconds delay. 39 | 40 | Everytime a new activity is recorded (occurrence), the execution is delayed by x seconds. 41 | 42 | ### Features 43 | 44 | - Debounce Notifications, Jobs and Artisan Commands [Basic usage](#basic-usage) & [Advanced usage](#advanced-usage) 45 | - [Report Tracking](#report-tracking) 46 | - [Bonus CLI Debounce](#bonus-cli-debounce) 47 | 48 | 49 | ### Demo 50 | 51 | A debounced notification to bulk notify users about new uploaded files. 52 | 53 | https://github.com/user-attachments/assets/b1d5aafd-256d-4f6f-b31a-0d6dc516793b 54 | 55 | 56 |
57 | See Code 58 | 59 | FileUploaded.php 60 | ```php 61 | $this->file->user->files() 84 | ->where('created_at', '>=', $this->file->created_at) 85 | ->get(), 86 | ]; 87 | } 88 | } 89 | 90 | ``` 91 | 92 | DemoController.php 93 | ```php 94 | user(); 110 | $file = File::factory()->create(['user_id' => $user->id]); 111 | $otherUsers = User::query()->whereNot('id', $user->id)->get(); 112 | 113 | Notification::send($otherUsers, new FileUploaded($file)); 114 | 115 | return back(); 116 | } 117 | 118 | public function debounceNotification(Request $request) 119 | { 120 | $user = $request->user(); 121 | $file = File::factory()->create(['user_id' => $user->id]); 122 | $otherUsers = User::query()->whereNot('id', $user->id)->get(); 123 | 124 | Debounce::notification( 125 | notifiables: $otherUsers, 126 | notification:new FileUploaded($file), 127 | delay: 5, 128 | uniqueKey:$user->id, 129 | ); 130 | 131 | return back(); 132 | } 133 | } 134 | ``` 135 |
136 | 137 | ## Installation 138 | 139 | ### Prerequisites 140 | - Laravel application (> 10.x) 141 | - Up and running cache system that supports [atomic locks](https://laravel.com/docs/11.x/cache#atomic-locks) 142 | - Up and running [queue worker](https://laravel.com/docs/11.x/queues) 143 | 144 | ### Composer 145 | 146 | ```bash 147 | composer require zackaj/laravel-debounce 148 | ``` 149 | 150 | ## Usage 151 | 152 | ### Basic usage 153 | You can debounce existing jobs, notifications and commands with zero setup. 154 | 155 | **Warning** you can't access [report tracking](#report-tracking) without extending the package's classes, see [Advanced usage](#advanced-usage). 156 | 157 | ```php 158 | use Zackaj\LaravelDebounce\Facades\Debounce; 159 | 160 | 161 | //job 162 | Debounce::job( 163 | job:new Job(),//replace 164 | delay:5,//delay in seconds 165 | uniqueKey:auth()->user()->id,//debounce per Job class name + uniqueKey 166 | sync:false, //optional, job will be fired to the queue 167 | ); 168 | 169 | //notification 170 | Debounce::notification( 171 | notifiables: auth()->user(), 172 | notification: new Notification(),//replace 173 | delay: 5, 174 | uniqueKey: auth()->user()->id, 175 | sendNow: false, 176 | ); 177 | 178 | //command 179 | Debounce::command( 180 | command: new Command(),//replace 181 | delay: 5, 182 | uniqueKey: $request->ip(), 183 | parameters: ['name' => 'zackaj'],//see Artisan::call() signature 184 | toQueue: false,//optional, send command to the queue when executed 185 | outputBuffer: null,//optional, //see Artisan::call() signature 186 | ); 187 | ``` 188 | 189 | ## Advanced usage 190 | In order to use: 191 | - [No Facade Usage](#no-facade-usage) 192 | - [Report Tracking](#report-tracking) 193 | - [before/after Hooks](#before-after-hooks) 194 | - [Debounce from custom timestamp](#override-timestamp) 195 | 196 | your existing jobs, notifications and commands must extend: 197 | 198 | ```php 199 | use Zackaj\LaravelDebounce\DebounceJob; 200 | use Zackaj\LaravelDebounce\DebounceNotification; 201 | use Zackaj\LaravelDebounce\DebounceCommand; 202 | ``` 203 | 204 | or just generate new ones using the available [make commands](#make-commands). 205 | 206 | ### Make commands 207 | 208 | - Notification 209 | ```bash 210 | php artisan make:debounce-notification TestNotification 211 | ``` 212 | 213 | - Job 214 | ```bash 215 | php artisan make:debounce-job TestJob 216 | ``` 217 | 218 | - Command 219 | ```bash 220 | php artisan make:debounce-command TestCommand 221 | ``` 222 | 223 | ### No facade usage 224 | Alternatively, now you can debounce from the job, notification and command instances directly without using the `Debounce facade` used in [Basic usage](#basic-usage) 225 | 226 | ```php 227 | (new Job())->debounce(...); 228 | 229 | (new Notification())->debounce(...); 230 | 231 | (new Command())->debounce(...); 232 | ``` 233 | 234 | ### Report Tracking 235 | Laravel-debounce uses the cache to store every request occurrence, use `getReport()` method within your debounceables to access the report chain that has a collection of occurrences. 236 | 237 | Every report will have one occurrence minimum. 238 | 239 | ```php 240 | getReport()->occurrences;//collection of occurrences 255 | $this->getReport()->occurrences->count(); 256 | $this->getReport()->occurrences->first()->happenedAt; 257 | $this->getReport()->occurrences->first()->ip; 258 | $this->getReport()->occurrences->first()->ips; 259 | $this->getReport()->occurrences->first()->requestHeaders;//HeaderBag 260 | $this->getReport()->occurrences->first()->user;//authenticated user | null 261 | } 262 | } 263 | 264 | ``` 265 | 266 | ### Before After Hooks 267 | If you wish to run some code before and/or after firing the `debounceables` you can use the available hooks. 268 | 269 | **Important:** `after()` hook could run before your `debounceable` is handled if it's `sent to the queue` when: 270 | - `sendNow==false` and your notification `implements ShouldQueue` 271 | - `sync==false` and your job `implements ShouldQueue` 272 | - `toQueue==true` (command) 273 | 274 | see: [Basic usage](#basic-usage) 275 | 276 | 277 | #### Debounce job 278 | 279 | ```php 280 | getReport()->occurrences->last()->happenedAt; 350 | } 351 | ``` 352 | 353 | You can override this method in your `debounceables` in order to debounce from a custom timestamp of your choice. If `null` is returned the debouncer will fallback to the default implementation above. 354 | 355 | #### Debounce job 356 | 357 | ```php 358 | first()?->seen_at; 366 | } 367 | } 368 | ``` 369 | 370 | #### Debounce notification 371 | You get the `$notifiables` injected into the method. 372 | 373 | ```php 374 | file->user->files->latest()->first()?->created_at; 383 | } 384 | } 385 | ``` 386 | 387 | #### Debounce command 388 | Due to limitations, the method must be `static`. 389 | 390 | ```php 391 | first()?->created_at; 400 | } 401 | } 402 | 403 | ``` 404 | 405 | ## Bonus CLI Debounce 406 | For fun, you can actually debounce commands from the CLI using the `debounce:command` Artisan command. 407 | 408 | ```php 409 | php artisan debounce:command 5 uniqueKey app:test 410 | ``` 411 | here's the signature for the command: 412 | `php artisan debounce:command {delay} {uniqueKey} {signature*}` 413 | 414 | ## Debugging And Monitoring 415 | I recommend using [Laravel telescope](https://laravel.com/docs/11.x/telescope) to see the debouncer live in the queues tab and to debug any failures. 416 | 417 | ## Known Issues 418 | 419 | 1. Unique lock gets stuck sometimes when jobs fail [github issue](https://github.com/laravel/framework/issues/37729), I made a fix to the laravel core framework about this give it a reaction: [PR (merged)](https://github.com/laravel/framework/pull/54000) 420 | - cause: this happens when deleted models are unserialized causing the job to fail without clearing the lock. 421 | - solution: don't use `SerializesModels` trait on Notifications/Jobs. (old temporary solution, now the bug is fixed) 422 | 423 | 424 | ## Contributing 425 | 426 | Contributions, issues and suggestions are always welcome! See `contributing.md` for ways to get started. 427 | 428 | 429 | ## License 430 | 431 | [MIT](https://choosealicense.com/licenses/mit/) 432 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zackaj/laravel-debounce", 3 | "description": "Debounce jobs ,notifications and artisan commands.", 4 | "license": "MIT", 5 | "minimum-stability": "dev", 6 | "autoload": { 7 | "psr-4": { 8 | "Zackaj\\LaravelDebounce\\": "src/" 9 | } 10 | }, 11 | "autoload-dev": { 12 | "psr-4": { 13 | "Zackaj\\LaravelDebounce\\Tests\\": "tests/", 14 | "Workbench\\App\\": "workbench/app/", 15 | "Workbench\\Database\\Factories\\": "workbench/database/factories/", 16 | "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" 17 | } 18 | }, 19 | "authors": [ 20 | { 21 | "name": "zackaj", 22 | "email": "codeartbtw@gmail.com" 23 | } 24 | ], 25 | "extra": { 26 | "laravel": { 27 | "providers": [ 28 | "Zackaj\\LaravelDebounce\\DebounceServiceProvider" 29 | ], 30 | "aliases": { 31 | "Debounce": "Zackaj\\LaravelDebounce\\Facades\\Debounce" 32 | } 33 | } 34 | }, 35 | "scripts": { 36 | "test": "vendor/bin/phpunit", 37 | "test:feature": "vendor/bin/phpunit tests/Feature", 38 | "test:unit": "vendor/bin/phpunit tests/Unit" 39 | }, 40 | "require-dev": { 41 | "shipmonk/composer-dependency-analyser": "dev-master", 42 | "orchestra/testbench": "9.x-dev" 43 | }, 44 | "require": { 45 | "php": "^8.1", 46 | "laravel/framework": "^10.0|^11.0|^12.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Commands/DebounceConsoleCommand.php: -------------------------------------------------------------------------------- 1 | getCommand(); 30 | $delay = $this->argument('delay'); 31 | 32 | if (! is_numeric($delay)) { 33 | $this->fail('argument delay must be a number'); 34 | } 35 | 36 | $uniqueKey = $this->argument('uniqueKey'); 37 | 38 | $uniqueId = $this->getSignature().'-'.$uniqueKey; 39 | 40 | CommandDebouncer::dispatch( 41 | $command, 42 | [], 43 | (int) $delay, 44 | $uniqueId 45 | ); 46 | } 47 | 48 | private function getCommand(): string 49 | { 50 | return implode(' ', $this->argument('signature')); 51 | } 52 | 53 | private function getSignature() 54 | { 55 | return $this->argument('signature')[0]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Commands/DebounceConsoleMakeCommand.php: -------------------------------------------------------------------------------- 1 | option('sync') 33 | ? $this->resolveStubPath('/stubs/debounceJob.stub') 34 | : $this->resolveStubPath('/stubs/debounceJob.queued.stub'); 35 | } 36 | 37 | /** 38 | * Resolve the fully-qualified path to the stub. 39 | * 40 | * @param string $stub 41 | * @return string 42 | */ 43 | protected function resolveStubPath($stub) 44 | { 45 | return __DIR__.$stub; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Commands/DebounceNotificationMakeCommand.php: -------------------------------------------------------------------------------- 1 | option('markdown') 44 | ? $this->resolveStubPath('/stubs/markdown-notification.stub') 45 | : $this->resolveStubPath('/stubs/debounceNotification.stub'); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Commands/stubs/debounceConsole.stub: -------------------------------------------------------------------------------- 1 | getReport()->occurrences; 30 | } 31 | 32 | public static function before(): void 33 | { 34 | //NOTE: remove the method if not needed 35 | //run before executing the command 36 | } 37 | 38 | public static function after(): void 39 | { 40 | //NOTE: remove the method if not needed 41 | //run after executing the command 42 | } 43 | 44 | public static function getLastActivityTimestamp(): ?Carbon 45 | { 46 | //NOTE: remove the method if not needed 47 | //manually set the last activity to debounce from 48 | 49 | return null; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Commands/stubs/debounceJob.queued.stub: -------------------------------------------------------------------------------- 1 | getReport()->occurrences; 28 | } 29 | 30 | public function before(): void 31 | { 32 | //NOTE: remove the method if not needed 33 | //run before dispatching the job 34 | } 35 | public function after(): void 36 | { 37 | //NOTE: remove the method if not needed 38 | //run after dispatching the job 39 | } 40 | 41 | public function getLastActivityTimestamp(): ?Carbon 42 | { 43 | //NOTE: remove the method if not needed 44 | //manually set the last activity to debounce from 45 | return null; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Commands/stubs/debounceJob.stub: -------------------------------------------------------------------------------- 1 | 2 | getReport()->occurrences; 28 | } 29 | 30 | public function before(): void 31 | { 32 | //NOTE: remove the method if not needed 33 | //run before dispatching the job 34 | } 35 | public function after(): void 36 | { 37 | //NOTE: remove the method if not needed 38 | //run after dispatching the job 39 | } 40 | 41 | public function getLastActivityTimestamp(): ?Carbon 42 | { 43 | //NOTE: remove the method if not needed 44 | //manually set the last activity to debounce from 45 | return null; 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /src/Commands/stubs/debounceNotification.stub: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | public function via(object $notifiable): array 29 | { 30 | return ['mail']; 31 | } 32 | 33 | /** 34 | * Get the mail representation of the notification. 35 | */ 36 | public function toMail(object $notifiable): MailMessage 37 | { 38 | return (new MailMessage) 39 | ->line('The introduction to the notification.') 40 | ->action('Notification Action', url('/')) 41 | ->line('Thank you for using our application!'); 42 | } 43 | 44 | /** 45 | * Get the array representation of the notification. 46 | * 47 | * @return array 48 | */ 49 | public function toArray(object $notifiable): array 50 | { 51 | return [ 52 | // 53 | ]; 54 | } 55 | 56 | public function before($notifiables): void 57 | { 58 | //NOTE: remove the method if not needed 59 | //run before sending the notification 60 | } 61 | 62 | public function after($notifiables): void 63 | { 64 | //NOTE: remove the method if not needed 65 | //run after sending the notification 66 | } 67 | 68 | /** 69 | * @param \Illuminate\Support\Collection|array|mixed $notifiables 70 | */ 71 | public function getLastActivityTimestamp(mixed $notifiables): ?Carbon 72 | { 73 | //NOTE: remove the method if not needed 74 | //manually set the last activity to debounce from 75 | 76 | return null; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Concerns/DebounceTrackable.php: -------------------------------------------------------------------------------- 1 | report; 14 | } 15 | 16 | public function setReport(Report $report): Report 17 | { 18 | return $this->report = $report; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Contracts/Debounceable.php: -------------------------------------------------------------------------------- 1 | getCommandSignature(), 30 | $delay, 31 | $uniqueKey, 32 | $parameters, 33 | $toQueue, 34 | $outputBuffer 35 | ); 36 | } 37 | 38 | /** 39 | * get the command signature without parameters 40 | */ 41 | public function getCommandSignature(): string 42 | { 43 | return collect(Artisan::all())->where(fn ($command) => $command::class === static::class) 44 | ->keys() 45 | ->firstOrFail(); 46 | } 47 | 48 | final public function getReport(): Report 49 | { 50 | return Context::getHidden('report'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/DebounceJob.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__.'/config/debounce.php', 'debounce'); 19 | } 20 | 21 | public function boot() 22 | { 23 | $this->commands([ 24 | DebounceConsoleCommand::class, 25 | DebounceNotificationMakeCommand::class, 26 | DebounceJobMakeCommand::class, 27 | DebounceConsoleMakeCommand::class, 28 | ]); 29 | 30 | $this->app->bind(Debouncer::class, Debouncer::class); 31 | 32 | $this->app->bind(Report::class, fn () => (new Report(collect([])))); 33 | 34 | // create with default driver config driver 35 | $this->app->bind(Trackable::class, fn () => TrackerManager::make()->tracker); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Debouncer.php: -------------------------------------------------------------------------------- 1 | $parameters 37 | */ 38 | public function command( 39 | string $command, 40 | int $delay, 41 | string $uniqueKey, 42 | array $parameters = [], 43 | bool $toQueue = false, 44 | ?OutputInterface $outputBuffer = null 45 | ): PendingDispatch|int { 46 | $commandClass = Artisan::all()[$command]::class; 47 | $uniqueKey = $commandClass.'-'.$uniqueKey; 48 | 49 | return CommandDebouncer::dispatch($command, $parameters, (int) $delay, $uniqueKey, $toQueue, $outputBuffer); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Debouncers/BaseDebouncer.php: -------------------------------------------------------------------------------- 1 | getTimestamp())) { 28 | return; 29 | } 30 | if ($this->withinInterval()) { 31 | $this::dispatch(...$this->getConstructorArgs()) 32 | ->delay( 33 | $this 34 | ->getTimestamp() 35 | ->copy() 36 | ->addSeconds($this->getOriginalDelay()) 37 | ); 38 | 39 | return; 40 | } 41 | 42 | $this->beforeExecute(); 43 | $this->execute(); 44 | $this->afterExecute(); 45 | } 46 | 47 | /** 48 | * check if activity is within the debounce interval 49 | */ 50 | final protected function withinInterval(): bool 51 | { 52 | if (is_null($this->getTimestamp())) { 53 | return false; 54 | } 55 | 56 | return $this->getTimestamp()->diffInSeconds(now()) <= $this->getOriginalDelay(); 57 | } 58 | 59 | /** 60 | * get the constructor arguments for the instance 61 | * used to dispatch another instance recursively 62 | */ 63 | final public function getConstructorArgs(): array 64 | { 65 | $reflection = new \ReflectionClass($this); 66 | $constructor_parameters = $reflection->getConstructor()->getParameters(); 67 | $constructor_args = array_reduce($constructor_parameters, function ($accumilator, $parameter) { 68 | if ($parameter->name === 'delay') { 69 | array_push($accumilator, $this->getOriginalDelay()); 70 | } else { 71 | array_push($accumilator, $this->{$parameter->name}); 72 | } 73 | 74 | return $accumilator; 75 | }, []); 76 | 77 | return $constructor_args; 78 | } 79 | 80 | /* 81 | * get the default original delay, needed because the delay of 82 | * new dispatched job can be a Carbon instance 83 | * this needs to be in seconds 84 | */ 85 | public function getOriginalDelay(): int 86 | { 87 | $defaultDelay = (new ReflectionProperty($this::class, 'delay')) 88 | ->getDefaultValue(); 89 | 90 | return $defaultDelay; 91 | } 92 | 93 | // source of truth of the last activity registered 94 | final public function getTimestamp(): ?Carbon 95 | { 96 | return $this->lastActivityTimesStamp ??= 97 | $this->getLastActivityTimestamp() ?? 98 | $this->getLastActivityTimestampFallback(); 99 | } 100 | 101 | final protected function uniqueKey(): string 102 | { 103 | return $this::class.':'.$this->uniqueId(); 104 | } 105 | 106 | // determine if debouncer is locked already, no param is required 107 | final public function isLocked(): bool 108 | { 109 | $lock = $this->getLock($this->getLockKey()); 110 | $acquired = $lock->get(); 111 | $lock->release(); 112 | 113 | return ! $acquired; 114 | } 115 | 116 | // get the lock key of the debouncer, no param is required 117 | final protected function getLockKey(?string $uniqueKey = null): string 118 | { 119 | if (is_null($uniqueKey)) { 120 | $uniqueKey = $this->uniqueId(); 121 | } 122 | $laravelPrefix = 'laravel_unique_job:'.get_class($this).':'; // The Laravel way of doing it 123 | 124 | return $laravelPrefix.$uniqueKey; 125 | } 126 | 127 | // get a lock 128 | final protected function getLock(string $key, int $seconds = 10): Lock 129 | { 130 | return Cache::lock($key, $seconds); 131 | } 132 | 133 | public function beforeExecute(): void {} 134 | 135 | public function afterExecute(): void {} 136 | } 137 | -------------------------------------------------------------------------------- /src/Debouncers/CommandDebouncer.php: -------------------------------------------------------------------------------- 1 | setReportByContext(); 27 | 28 | if ($this->toQueue) { 29 | Artisan::queue($this->command, $this->parameters); 30 | } else { 31 | Artisan::call($this->command, [...$this->parameters], $this->outputBuffer); 32 | } 33 | 34 | $this->forgetReport(); 35 | } 36 | 37 | public function uniqueId(): string 38 | { 39 | return $this->uniqueId; 40 | } 41 | 42 | public function getOriginalDelay(): int 43 | { 44 | return $this->originalDelay; 45 | } 46 | 47 | public function before() 48 | { 49 | $concreteCommand = $this->getCommandFromSignature(); 50 | 51 | if ($this->isDebounceable($concreteCommand)) { 52 | $concreteCommand::before(); 53 | } 54 | } 55 | 56 | public function after(): void 57 | { 58 | $concreteCommand = $this->getCommandFromSignature(); 59 | 60 | if ($this->isDebounceable($concreteCommand)) { 61 | $concreteCommand::after(); 62 | } 63 | 64 | } 65 | 66 | private function getCommandFromSignature(): Command 67 | { 68 | return collect(Artisan::all()) 69 | ->where(fn ($cmd, $signature) => $signature === $this->getPureSignature()) 70 | ->firstOrFail(); 71 | } 72 | 73 | private function setReportByContext(): void 74 | { 75 | Context::addHidden('report', $this->report); 76 | } 77 | 78 | private function forgetReport(): void 79 | { 80 | Context::forgetHidden('report'); 81 | } 82 | 83 | /** 84 | * make sure command has no parameters, return only the signature 85 | */ 86 | private function getPureSignature(): string 87 | { 88 | return explode(' ', $this->command)[0]; 89 | } 90 | 91 | public function getLastActivityTimestamp(): ?Carbon 92 | { 93 | 94 | if (! $this->isDebounceable($this->getCommandFromSignature())) { 95 | return parent::getLastActivityTimestamp(); 96 | } 97 | 98 | return 99 | $this->getCommandFromSignature()::getLastActivityTimestamp() ?? 100 | parent::getLastActivityTimestamp(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Debouncers/JobDebouncer.php: -------------------------------------------------------------------------------- 1 | isDebounceable($this->queuable)) { 26 | $this->queuable->setReport($this->getReport()); 27 | } 28 | 29 | if ($this->sync) { 30 | dispatch_sync($this->queuable); 31 | } else { 32 | dispatch($this->queuable); 33 | } 34 | } 35 | 36 | public function uniqueId(): string 37 | { 38 | return $this->uniqueId; 39 | } 40 | 41 | public function before() 42 | { 43 | if ($this->isDebounceable($this->queuable)) { 44 | $this->queuable->before(); 45 | } 46 | } 47 | 48 | public function after(): void 49 | { 50 | if ($this->isDebounceable($this->queuable)) { 51 | $this->queuable->after(); 52 | } 53 | } 54 | 55 | public function getOriginalDelay(): int 56 | { 57 | return $this->originalDelay; 58 | } 59 | 60 | public function getLastActivityTimestamp(): ?Carbon 61 | { 62 | if (! $this->isDebounceable($this->queuable)) { 63 | return parent::getLastActivityTimestamp(); 64 | } 65 | 66 | return 67 | $this->queuable->getLastActivityTimestamp() ?? 68 | parent::getLastActivityTimestamp(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Debouncers/NotificationDebouncer.php: -------------------------------------------------------------------------------- 1 | isDebounceable($this->notification)) { 25 | $this->notification->setReport($this->getReport()); 26 | } 27 | 28 | if ($this->sendNow) { 29 | FacadesNotification::sendNow($this->notifiables, $this->notification); 30 | } else { 31 | FacadesNotification::send($this->notifiables, $this->notification); 32 | } 33 | } 34 | 35 | public function getLastActivityTimestamp(): ?Carbon 36 | { 37 | if (! $this->isDebounceable($this->notification)) { 38 | return parent::getLastActivityTimestamp(); 39 | } 40 | 41 | return 42 | $this->notification->getLastActivityTimestamp($this->notifiables) ?? 43 | parent::getLastActivityTimestamp(); 44 | } 45 | 46 | public function uniqueId(): string 47 | { 48 | return $this->uniqueId; 49 | } 50 | 51 | public function before(): void 52 | { 53 | if ($this->isDebounceable($this->notification)) { 54 | $this->notification->before($this->notifiables); 55 | } 56 | } 57 | 58 | public function after(): void 59 | { 60 | if ($this->isDebounceable($this->notification)) { 61 | $this->notification->after($this->notifiables); 62 | } 63 | } 64 | 65 | public function getOriginalDelay(): int 66 | { 67 | return $this->originalDelay; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Debouncers/TrackerDebouncer.php: -------------------------------------------------------------------------------- 1 | tracker = TrackerManager::make(); 31 | $this->originalDelay = $delay; 32 | $this->occurrence = new Occurrence( 33 | now(), 34 | request()->headers, 35 | request()->ip(), 36 | request()->ips(), 37 | request()->user(), 38 | ); 39 | 40 | // register if first occurrence ever 41 | // if a lock instance already exits 42 | // not if self dispatched 43 | if ($this->isLocked() || is_null($this->getReport())) { 44 | $this->registerOccurrence(); 45 | } 46 | } 47 | 48 | public function getLastActivityTimestamp(): ?Carbon 49 | { 50 | return $this->getReport()?->occurrences->last()?->happenedAt; 51 | } 52 | 53 | public function getLastActivityTimestampFallback(): ?Carbon 54 | { 55 | return now()->subSeconds($this->getOriginalDelay()); 56 | } 57 | 58 | final public function beforeExecute(): void 59 | { 60 | $this->forgetReport(); 61 | $this->before(); 62 | } 63 | 64 | final public function afterExecute(): void 65 | { 66 | $this->after(); 67 | } 68 | 69 | final public function getReport(): ?Report 70 | { 71 | return $this 72 | ->tracker 73 | ->getReport($this->uniqueKey()) ?? 74 | $this->report; 75 | } 76 | 77 | // clear timestamp tracker 78 | private function forgetReport(): void 79 | { 80 | $this->report = $this->tracker->forgetReport($this->uniqueKey()); 81 | } 82 | 83 | private function registerOccurrence(): void 84 | { 85 | $this->report = $this->tracker->registerOccurrence($this->uniqueKey(), $this->occurrence); 86 | } 87 | 88 | /** 89 | * NOTE: I don't like this, but for now it's okay 90 | * 91 | * checks if the target extends one of my debounceables 92 | * determines if the target has implementations 93 | */ 94 | protected function isDebounceable(mixed $target): bool 95 | { 96 | $debounceables = [ 97 | DebounceNotification::class, 98 | DebounceJob::class, 99 | DebounceCommand::class, 100 | ]; 101 | 102 | foreach ($debounceables as $debounceable) { 103 | if ($target instanceof $debounceable) { 104 | return true; 105 | } 106 | } 107 | 108 | return false; 109 | } 110 | 111 | public function before() {} 112 | 113 | public function after() {} 114 | } 115 | -------------------------------------------------------------------------------- /src/Facades/Debounce.php: -------------------------------------------------------------------------------- 1 | value))->tracker; 20 | } 21 | 22 | public static function getDefaultDriver(): Driver 23 | { 24 | $debounceDriver = match (config('debounce.driver')) { 25 | 'cache' => Driver::CACHE, 26 | default => Driver::CACHE, 27 | }; 28 | 29 | return $debounceDriver; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Trackers/CacheTracker.php: -------------------------------------------------------------------------------- 1 | getReport($trackerKey); 18 | Cache::forget($trackerKey); 19 | 20 | return $report; 21 | } 22 | 23 | public function registerOccurrence(string $trackerKey, Occurrence $occurrence): ?Report 24 | { 25 | $report = $this->getReport($trackerKey) ?? app(Report::class); 26 | $report->occurrences->push($occurrence); 27 | Cache::set($trackerKey, $report); 28 | 29 | return $report; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Trackers/Driver.php: -------------------------------------------------------------------------------- 1 | requestHeaders; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Trackers/Report.php: -------------------------------------------------------------------------------- 1 | $occurrences 11 | */ 12 | public function __construct(public Collection $occurrences) {} 13 | } 14 | -------------------------------------------------------------------------------- /src/config/debounce.php: -------------------------------------------------------------------------------- 1 | 'cache', 5 | ]; 6 | --------------------------------------------------------------------------------