├── src ├── StatsServiceProvider.php ├── DataPoint.php ├── Models │ └── StatsEvent.php ├── Traits │ └── HasStats.php ├── BaseStats.php ├── StatsWriter.php └── StatsQuery.php ├── database └── migrations │ └── create_stats_tables.php.stub ├── UPGRADING.md ├── LICENSE.md ├── .php-cs-fixer.dist.php ├── composer.json ├── CHANGELOG.md └── README.md /src/StatsServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('stats') 14 | ->hasMigration('create_stats_tables'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /database/migrations/create_stats_tables.php.stub: -------------------------------------------------------------------------------- 1 | id(); 13 | 14 | $table->string('name'); 15 | $table->string('type'); 16 | $table->bigInteger('value'); 17 | 18 | $table->timestamps(); 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/DataPoint.php: -------------------------------------------------------------------------------- 1 | 'integer', 25 | ]; 26 | 27 | protected $guarded = []; 28 | } 29 | -------------------------------------------------------------------------------- /src/Traits/HasStats.php: -------------------------------------------------------------------------------- 1 | groupByRaw($periodGroupBy)->selectRaw("{$periodGroupBy} as period"); 14 | } 15 | 16 | public function scopeIncrements(Builder $query): void 17 | { 18 | $query->where('value', '>', 0); 19 | } 20 | 21 | public function scopeDecrements(Builder $query): void 22 | { 23 | $query->where('value', '<', 0); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrading 2 | 3 | If you come across any edge cases that this guide does not cover, please send in a PR! 4 | 5 | ## From v1 to v2 6 | 7 | ### Breaking changes 8 | 9 | - Replaced `StatsQuery::for($model)->getStatistic()` with `StatsQuery::for($model)->getAttributes()` 10 | - Removed `BaseStats->createEvent()` 11 | 12 | - Changed visibility of `StatsQuery::for($model)->generatePeriods()` from `public` to `protected` 13 | - Changed visibility of `StatsQuery::getPeriodTimestampFormat()` from `public` to `protected` 14 | 15 | These methods are only used internally by the package. You can no longer use these methods in your own code. 16 | 17 | ### Migrations 18 | 19 | - Replace `StatsQuery::for(OrderStats::class)` with `OrderStats::query()` 20 | - Replace `StatsEvent::TYPE_SET` with `DataPoint::TYPE_SET` 21 | - Replace `StatsEvent::TYPE_CHANGE` with `DataPoint::TYPE_CHANGE` 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Spatie bvba 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 | -------------------------------------------------------------------------------- /src/BaseStats.php: -------------------------------------------------------------------------------- 1 | (new static)->getName(), 19 | ]); 20 | } 21 | 22 | public static function writer(): StatsWriter 23 | { 24 | return StatsWriter::for(StatsEvent::class, [ 25 | 'name' => (new static)->getName(), 26 | ]); 27 | } 28 | 29 | public static function increase(mixed $number = 1, ?DateTimeInterface $timestamp = null) 30 | { 31 | static::writer()->increase($number, $timestamp); 32 | } 33 | 34 | public static function decrease(mixed $number = 1, ?DateTimeInterface $timestamp = null) 35 | { 36 | static::writer()->decrease($number, $timestamp); 37 | } 38 | 39 | public static function set(int $value, ?DateTimeInterface $timestamp = null) 40 | { 41 | static::writer()->set($value, $timestamp); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | notPath('bootstrap/*') 5 | ->notPath('storage/*') 6 | ->notPath('resources/view/mail/*') 7 | ->in([ 8 | __DIR__ . '/src', 9 | __DIR__ . '/tests', 10 | ]) 11 | ->name('*.php') 12 | ->notName('*.blade.php') 13 | ->ignoreDotFiles(true) 14 | ->ignoreVCS(true); 15 | 16 | return (new PhpCsFixer\Config) 17 | ->setRules([ 18 | '@PSR2' => true, 19 | 'array_syntax' => ['syntax' => 'short'], 20 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 21 | 'no_unused_imports' => true, 22 | 'not_operator_with_successor_space' => true, 23 | 'trailing_comma_in_multiline' => true, 24 | 'phpdoc_scalar' => true, 25 | 'unary_operator_spaces' => true, 26 | 'binary_operator_spaces' => true, 27 | 'blank_line_before_statement' => [ 28 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 29 | ], 30 | 'phpdoc_single_line_var_spacing' => true, 31 | 'phpdoc_var_without_name' => true, 32 | 'method_argument_space' => [ 33 | 'on_multiline' => 'ensure_fully_multiline', 34 | 'keep_multiple_spaces_after_comma' => true, 35 | ] 36 | ]) 37 | ->setFinder($finder); 38 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/laravel-stats", 3 | "description": "Track application stat changes over time", 4 | "keywords": [ 5 | "spatie", 6 | "laravel-stats" 7 | ], 8 | "homepage": "https://github.com/spatie/laravel-stats", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Alex Vanderbist", 13 | "email": "alex@spatie.be", 14 | "homepage": "https://spatie.be", 15 | "role": "Developer" 16 | }, 17 | { 18 | "name": "Freek Van der Herten", 19 | "email": "freek@spatie.be", 20 | "homepage": "https://spatie.be", 21 | "role": "Developer" 22 | } 23 | ], 24 | "require": { 25 | "php": "^8.2", 26 | "illuminate/contracts": "^11.0|^12.0", 27 | "spatie/laravel-package-tools": "^1.19.0" 28 | }, 29 | "require-dev": { 30 | "nesbot/carbon": "^2.63|^3.8.6", 31 | "doctrine/dbal": "^3.9.4", 32 | "orchestra/testbench": "^9.0|^10.1", 33 | "phpunit/phpunit": "^10.0|^11.5.12" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Spatie\\Stats\\": "src" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Spatie\\Stats\\Tests\\": "tests" 43 | } 44 | }, 45 | "scripts": { 46 | "psalm": "vendor/bin/psalm", 47 | "test": "vendor/bin/phpunit --colors=always", 48 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 49 | }, 50 | "config": { 51 | "sort-packages": true 52 | }, 53 | "extra": { 54 | "laravel": { 55 | "providers": [ 56 | "Spatie\\Stats\\StatsServiceProvider" 57 | ] 58 | } 59 | }, 60 | "minimum-stability": "dev", 61 | "prefer-stable": true, 62 | "funding": [ 63 | { 64 | "type": "github", 65 | "url": "https://github.com/sponsors/spatie" 66 | }, 67 | { 68 | "type": "other", 69 | "url": "https://spatie.be/open-source/support-us" 70 | } 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /src/StatsWriter.php: -------------------------------------------------------------------------------- 1 | subject = $subject; 17 | $this->attributes = $attributes; 18 | } 19 | 20 | public static function for(Model|Relation|string $subject, array $attributes = []) 21 | { 22 | return new static($subject, $attributes); 23 | } 24 | 25 | public function increase(mixed $number = 1, ?DateTimeInterface $timestamp = null) 26 | { 27 | $number = is_int($number) ? $number : 1; 28 | 29 | $this->createEvent(DataPoint::TYPE_CHANGE, $number, $timestamp); 30 | } 31 | 32 | public function decrease(mixed $number = 1, ?DateTimeInterface $timestamp = null) 33 | { 34 | $number = is_int($number) ? $number : 1; 35 | 36 | $this->createEvent(DataPoint::TYPE_CHANGE, -$number, $timestamp); 37 | } 38 | 39 | public function set(int $value, ?DateTimeInterface $timestamp = null) 40 | { 41 | $this->createEvent(DataPoint::TYPE_SET, $value, $timestamp); 42 | } 43 | 44 | public function getAttributes(): array 45 | { 46 | return $this->attributes; 47 | } 48 | 49 | protected function createEvent($type, $value, ?DateTimeInterface $timestamp = null): Model 50 | { 51 | if ($this->subject instanceof Relation) { 52 | return $this->subject->create(array_merge($this->attributes, [ 53 | 'type' => $type, 54 | 'value' => $value, 55 | 'created_at' => $timestamp ?? now(), 56 | ])); 57 | } 58 | 59 | $subject = $this->subject; 60 | if ($subject instanceof Model) { 61 | $subject = get_class($subject); 62 | } 63 | 64 | return $subject::create(array_merge($this->attributes, [ 65 | 'type' => $type, 66 | 'value' => $value, 67 | 'created_at' => $timestamp ?? now(), 68 | ])); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-stats` will be documented in this file 4 | 5 | ## 2.3.1 - 2025-03-08 6 | 7 | **Full Changelog**: https://github.com/spatie/laravel-stats/compare/2.3.0...2.3.1 8 | 9 | ## 2.3.0 - 2024-03-12 10 | 11 | ### What's Changed 12 | 13 | * l11 support by @finndev in https://github.com/spatie/laravel-stats/pull/38 14 | 15 | ### New Contributors 16 | 17 | * @finndev made their first contribution in https://github.com/spatie/laravel-stats/pull/38 18 | 19 | **Full Changelog**: https://github.com/spatie/laravel-stats/compare/2.2.0...2.3.0 20 | 21 | ## 2.2.0 - 2024-02-29 22 | 23 | ### What's Changed 24 | 25 | * Update README.md by @gauravmak in https://github.com/spatie/laravel-stats/pull/33 26 | * Fix method name in docs by @ManojKiranA in https://github.com/spatie/laravel-stats/pull/35 27 | * Completing SQLite Support by @abishekrsrikaanth in https://github.com/spatie/laravel-stats/pull/37 28 | 29 | ### New Contributors 30 | 31 | * @gauravmak made their first contribution in https://github.com/spatie/laravel-stats/pull/33 32 | * @ManojKiranA made their first contribution in https://github.com/spatie/laravel-stats/pull/35 33 | * @abishekrsrikaanth made their first contribution in https://github.com/spatie/laravel-stats/pull/37 34 | 35 | **Full Changelog**: https://github.com/spatie/laravel-stats/compare/2.1.1...2.2.0 36 | 37 | ## 2.1.1 - 2023-02-10 38 | 39 | - Support Laravel 10 40 | - Support PHP 8.2 41 | 42 | ## 2.1.0 - 2022-12-09 43 | 44 | ### What's Changed 45 | 46 | - Add Group By Minute Period by @DexterHarrison in https://github.com/spatie/laravel-stats/pull/31 47 | 48 | ### New Contributors 49 | 50 | - @DexterHarrison made their first contribution in https://github.com/spatie/laravel-stats/pull/31 51 | 52 | **Full Changelog**: https://github.com/spatie/laravel-stats/compare/2.0.4...2.1.0 53 | 54 | ## 2.0.4 - 2022-08-31 55 | 56 | ### What's Changed 57 | 58 | - Performance Improvement by removing get queries by @noamanahmed-omniful in https://github.com/spatie/laravel-stats/pull/28 59 | 60 | ### New Contributors 61 | 62 | - @noamanahmed-omniful made their first contribution in https://github.com/spatie/laravel-stats/pull/28 63 | 64 | **Full Changelog**: https://github.com/spatie/laravel-stats/compare/2.0.3...2.0.4 65 | 66 | ## 2.0.3 - 2022-07-29 67 | 68 | ### What's Changed 69 | 70 | - fix typo in README.md. by @fsamapoor in https://github.com/spatie/laravel-stats/pull/23 71 | - Support PostgreSQL by @skollro in https://github.com/spatie/laravel-stats/pull/27 72 | 73 | ### New Contributors 74 | 75 | - @fsamapoor made their first contribution in https://github.com/spatie/laravel-stats/pull/23 76 | - @skollro made their first contribution in https://github.com/spatie/laravel-stats/pull/27 77 | 78 | **Full Changelog**: https://github.com/spatie/laravel-stats/compare/2.0.2...2.0.3 79 | 80 | ## 2.0.2 - 2022-06-01 81 | 82 | ### What's Changed 83 | 84 | - Add table prefix to StatsQuery by @digitalkreativ in https://github.com/spatie/laravel-stats/pull/22 85 | 86 | ### New Contributors 87 | 88 | - @digitalkreativ made their first contribution in https://github.com/spatie/laravel-stats/pull/22 89 | 90 | **Full Changelog**: https://github.com/spatie/laravel-stats/compare/2.0.1...2.0.2 91 | 92 | ## 2.0.1 - 2022-04-06 93 | 94 | ## What's Changed 95 | 96 | - Support SQLite by @bumbummen99 in https://github.com/spatie/laravel-stats/pull/19 97 | 98 | ## New Contributors 99 | 100 | - @bumbummen99 made their first contribution in https://github.com/spatie/laravel-stats/pull/19 101 | 102 | **Full Changelog**: https://github.com/spatie/laravel-stats/compare/2.0.0...2.0.1 103 | 104 | ## 2.0.0 - 2022-03-04 105 | 106 | ## What's Changed 107 | 108 | Add support for relationships by @christoph-kluge in https://github.com/spatie/laravel-stats/pull/17 109 | 110 | See [upgrading.md](./upgrading.md) for an upgrading guide. 111 | 112 | ### Added 113 | 114 | - Added `StatsWriter` with classname support (`StatsWriter::for(MyModel::class)`) 115 | - Added `StatsWriter` with eloquent-model support (`StatsWriter::for($eloquent)`) 116 | - Added `StatsWriter` with "has-many"-relationship support (`StatsWriter::for($model->relationship())`) - other relationships are untested yet 117 | - Added `StatsWriter` with custom-attribute support (`StatsWriter::for(MyModel::class, ['custom_column' => 'orders])`) 118 | - Extended `StatsQuery` with relationship-support (`StatsQuery::for($model->relationship())`) 119 | - Extended `StatsQuery` with additional attributes (`StatsQuery::for(StatsEvent::class, ['name' => 'OrderStats'])`) 120 | - Extended `BaseStats` with direct writer access (`OrderStats::writer()` as addition to `OrderStats::query()`) 121 | 122 | ### Breaking changes 123 | 124 | - Changed visibility of `StatsQuery::for($model)->generatePeriods()` from `public` to `protected` 125 | - Changed visibility of `StatsQuery::getPeriodTimestampFormat()` from `public` to `protected` 126 | - Replaced `StatsQuery::for($model)->getStatistic()` with `StatsQuery::for($model)->getAttributes()` 127 | - Removed `BaseStats->createEvent()` 128 | 129 | ### Migrations 130 | 131 | - Replace `StatsQuery::for(OrderStats::class)` with `OrderStats::query()` 132 | - Replace `StatsEvent::TYPE_SET` with `DataPoint::TYPE_SET` 133 | - Replace `StatsEvent::TYPE_CHANGE` with `DataPoint::TYPE_CHANGE` 134 | 135 | ## New Contributors 136 | 137 | - @christoph-kluge made their first contribution in https://github.com/spatie/laravel-stats/pull/17 138 | 139 | **Full Changelog**: https://github.com/spatie/laravel-stats/compare/1.0.1...2.0.0 140 | 141 | ## 2.0.0 - 2022-03-04 142 | 143 | ### Added 144 | 145 | - Added `StatsWriter` with classname support (`StatsWriter::for(MyModel::class)`) 146 | - Added `StatsWriter` with eloquent-model support (`StatsWriter::for($eloquent)`) 147 | - Added `StatsWriter` with "has-many"-relationship support (`StatsWriter::for($model->relationship())`) - other relationships are untested yet 148 | - Added `StatsWriter` with custom-attribute support (`StatsWriter::for(MyModel::class, ['custom_column' => 'orders])`) 149 | - Extended `StatsQuery` with relationship-support (`StatsQuery::for($model->relationship())`) 150 | - Extended `StatsQuery` with additional attributes (`StatsQuery::for(StatsEvent::class, ['name' => 'OrderStats'])`) 151 | - Extended `BaseStats` with direct writer access (`OrderStats::writer()` as addition to `OrderStats::query()`) 152 | 153 | ### Breaking changes 154 | 155 | - Changed visibility of `StatsQuery::for($model)->generatePeriods()` from `public` to `protected` 156 | - Changed visibility of `StatsQuery::getPeriodTimestampFormat()` from `public` to `protected` 157 | - Replaced `StatsQuery::for($model)->getStatistic()` with `StatsQuery::for($model)->getAttributes()` 158 | - Removed `BaseStats->createEvent()` 159 | 160 | ### Migrations 161 | 162 | - Replace `StatsQuery::for(OrderStats::class)` with `OrderStats::query()` 163 | - Replace `StatsEvent::TYPE_SET` with `DataPoint::TYPE_SET` 164 | - Replace `StatsEvent::TYPE_CHANGE` with `DataPoint::TYPE_CHANGE` 165 | 166 | ## 1.0.1 - 2022-02-02 167 | 168 | - Add support for Laravel 9 169 | 170 | ## 1.0.0 - 2021-04-14 171 | 172 | - initial release 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Track application stat changes over time 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/laravel-stats.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-stats) 4 | [![Tests](https://github.com/spatie/laravel-stats/actions/workflows/run-tests.yml/badge.svg)](https://github.com/spatie/laravel-stats/actions/workflows/run-tests.yml) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/laravel-stats.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-stats) 6 | 7 | This package is a lightweight solution to summarize changes in your database over time. Here's a quick example where we are going to track the number of subscriptions and cancellations over time. 8 | 9 | First, you should create a stats class. 10 | 11 | ```php 12 | use Spatie\Stats\BaseStats; 13 | 14 | class SubscriptionStats extends BaseStats {} 15 | ``` 16 | 17 | Next, you can call `increase` on it when somebody subscribes, and `decrease` when somebody cancels their plan. 18 | 19 | ```php 20 | SubscriptionStats::increase(); // execute whenever somebody subscribes 21 | SubscriptionStats::decrease() // execute whenever somebody cancels the subscription; 22 | ``` 23 | 24 | With this in place, you can query the stats. Here's how you can get the subscription stats for the past two months, 25 | grouped by week. 26 | 27 | ```php 28 | use Spatie\Stats\StatsQuery; 29 | 30 | $stats = SubscriptionStats::query() 31 | ->start(now()->subMonths(2)) 32 | ->end(now()->subSecond()) 33 | ->groupByWeek() 34 | ->get(); 35 | ``` 36 | 37 | This will return an array like this one: 38 | 39 | ```php 40 | [ 41 | [ 42 | 'start' => '2020-01-01', 43 | 'end' => '2020-01-08', 44 | 'value' => 102, 45 | 'increments' => 32, 46 | 'decrements' => 20, 47 | 'difference' => 12, 48 | ], 49 | [ 50 | 'start' => '2020-01-08', 51 | 'end' => '2020-01-15', 52 | 'value' => 114, 53 | 'increments' => 63, 54 | 'decrements' => 30, 55 | 'difference' => 33, 56 | ], 57 | ] 58 | ``` 59 | 60 | ## Support us 61 | 62 | [](https://spatie.be/github-ad-click/laravel-stats) 63 | 64 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can 65 | support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 66 | 67 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. 68 | You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards 69 | on [our virtual postcard wall](https://spatie.be/open-source/postcards). 70 | 71 | ## Installation 72 | 73 | You can install the package via composer: 74 | 75 | ```bash 76 | composer require spatie/laravel-stats 77 | ``` 78 | 79 | You must publish and run the migrations with: 80 | 81 | ```bash 82 | php artisan vendor:publish --provider="Spatie\Stats\StatsServiceProvider" --tag="stats-migrations" 83 | php artisan migrate 84 | ``` 85 | 86 | ## Usage 87 | 88 | ### Step 1: create a stats class 89 | 90 | First, you should create a stats class. This class is responsible for configuration how a particular statistic is 91 | stored. By default, it needs no configuration at all. 92 | 93 | ```php 94 | use Spatie\Stats\BaseStats; 95 | 96 | class SubscriptionStats extends BaseStats {} 97 | ``` 98 | 99 | By default, the name of the class will be used to store the statistics in the database. To customize the used key, use `getName` 100 | 101 | ```php 102 | use Spatie\Stats\BaseStats; 103 | 104 | class SubscriptionStats extends BaseStats 105 | { 106 | public function getName() : string{ 107 | return 'my-custom-name'; // stats will be stored with using name `my-custom-name` 108 | } 109 | } 110 | ``` 111 | 112 | ## Step 2: call increase and decrease or set a fixed value 113 | 114 | Next, you can call `increase`, `decrease` when the stat should change. In this particular case, you should call `increase` on it when somebody subscribes, and `decrease` when somebody cancels their plan. 115 | 116 | ```php 117 | SubscriptionStats::increase(); // execute whenever somebody subscribes 118 | SubscriptionStats::decrease(); // execute whenever somebody cancels the subscription; 119 | ``` 120 | 121 | Instead of manually increasing and decreasing the stat, you can directly set it. This is useful when your particular stat does not get calculated by your own app, but lives elsewhere. Using the subscription example, let's image that subscriptions live elsewhere, and that there's an API call to get the count. 122 | 123 | ```php 124 | $count = AnAPi::getSubscriptionCount(); 125 | 126 | SubscriptionStats::set($count); 127 | ``` 128 | 129 | By default, that `increase`, `decrease` and `set` methods assume that the event that caused your stats to change, happened right now. Optionally, you can pass a date time as a second parameter to these methods. Your stat change will be recorded as if it happened on that moment. 130 | 131 | ```php 132 | SubscriptionStats::increase(1, $subscription->created_at); 133 | ``` 134 | 135 | ### Step 3: query the stats 136 | 137 | With this in place, you can query the stats. You can fetch stats for a certain period and group them by minute, hour, day, week, month, or year. 138 | 139 | Here's how you can get the subscription stats for the past two months, 140 | grouped by week. 141 | 142 | ```php 143 | $stats = SubscriptionStats::query() 144 | ->start(now()->subMonths(2)) 145 | ->end(now()->subSecond()) 146 | ->groupByWeek() 147 | ->get(); 148 | ``` 149 | 150 | This will return an array containing arrayable `Spatie\Stats\DataPoint` objects. These objects can be cast to arrays like this: 151 | 152 | ```php 153 | // output of $stats->toArray(): 154 | [ 155 | [ 156 | 'start' => '2020-01-01', 157 | 'end' => '2020-01-08', 158 | 'value' => 102, 159 | 'increments' => 32, 160 | 'decrements' => 20, 161 | 'difference' => 12, 162 | ], 163 | [ 164 | 'start' => '2020-01-08', 165 | 'end' => '2020-01-15', 166 | 'value' => 114, 167 | 'increments' => 63, 168 | 'decrements' => 30, 169 | 'difference' => 33, 170 | ], 171 | ] 172 | ``` 173 | 174 | ## Extended Use-Cases 175 | 176 | ### Read and Write from a custom Model 177 | 178 | * Create a new table with `type (string)`, `value (bigInt)`, `created_at`, `updated_at` fields 179 | * Create a model and add `HasStats`-trait 180 | 181 | ```php 182 | StatsWriter::for(MyCustomModel::class)->set(123) 183 | StatsWriter::for(MyCustomModel::class, ['custom_column' => '123'])->increase(1) 184 | StatsWriter::for(MyCustomModel::class, ['another_column' => '234'])->decrease(1, now()->subDay()) 185 | 186 | $stats = StatsQuery::for(MyCustomModel::class) 187 | ->start(now()->subMonths(2)) 188 | ->end(now()->subSecond()) 189 | ->groupByWeek() 190 | ->get(); 191 | 192 | // OR 193 | 194 | $stats = StatsQuery::for(MyCustomModel::class, ['additional_column' => '123']) 195 | ->start(now()->subMonths(2)) 196 | ->end(now()->subSecond()) 197 | ->groupByWeek() 198 | ->get(); 199 | ``` 200 | 201 | ### Read and Write from a HasMany-Relationship 202 | 203 | ```php 204 | $tenant = Tenant::find(1) 205 | 206 | StatsWriter::for($tenant->orderStats(), ['payment_type_column' => 'recurring'])->increment(1) 207 | 208 | $stats = StatsQuery::for($tenant->orderStats(), , ['payment_type_column' => 'recurring']) 209 | ->start(now()->subMonths(2)) 210 | ->end(now()->subSecond()) 211 | ->groupByWeek() 212 | ->get(); 213 | ``` 214 | 215 | ## Testing 216 | 217 | ``` bash 218 | composer test 219 | ``` 220 | 221 | ## Changelog 222 | 223 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 224 | 225 | ## Contributing 226 | 227 | Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. 228 | 229 | ## Security Vulnerabilities 230 | 231 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 232 | 233 | ## Credits 234 | 235 | - [Alex Vanderbist](https://github.com/AlexVanderbist) 236 | - [Freek Van der Herten](https://github.com/freekmurze) 237 | - [All Contributors](../../contributors) 238 | 239 | ## License 240 | 241 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 242 | -------------------------------------------------------------------------------- /src/StatsQuery.php: -------------------------------------------------------------------------------- 1 | subject = $subject; 29 | 30 | $this->attributes = $attributes; 31 | 32 | $this->period = 'week'; 33 | 34 | $this->start = now()->subMonth(); 35 | 36 | $this->end = now(); 37 | } 38 | 39 | public static function for(Model|Relation|string $subject, array $attributes = []): self 40 | { 41 | return new self($subject, $attributes); 42 | } 43 | 44 | public function groupByYear(): self 45 | { 46 | $this->period = 'year'; 47 | 48 | return $this; 49 | } 50 | 51 | public function groupByMonth(): self 52 | { 53 | $this->period = 'month'; 54 | 55 | return $this; 56 | } 57 | 58 | public function groupByWeek(): self 59 | { 60 | $this->period = 'week'; 61 | 62 | return $this; 63 | } 64 | 65 | public function groupByDay(): self 66 | { 67 | $this->period = 'day'; 68 | 69 | return $this; 70 | } 71 | 72 | public function groupByHour(): self 73 | { 74 | $this->period = 'hour'; 75 | 76 | return $this; 77 | } 78 | 79 | public function groupByMinute(): self 80 | { 81 | $this->period = 'minute'; 82 | 83 | return $this; 84 | } 85 | 86 | public function start(DateTimeInterface $start): self 87 | { 88 | $this->start = $start; 89 | 90 | return $this; 91 | } 92 | 93 | public function end(DateTimeInterface $end): self 94 | { 95 | $this->end = $end; 96 | 97 | return $this; 98 | } 99 | 100 | /** @return Collection|DataPoint[] */ 101 | public function get(): Collection 102 | { 103 | $periods = $this->generatePeriods(); 104 | 105 | $differencesPerPeriod = $this->getDifferencesPerPeriod(); 106 | 107 | $latestSetPerPeriod = $this->getLatestSetPerPeriod(); 108 | 109 | $lastPeriodValue = $this->getValue($this->start); 110 | 111 | return $periods->map(function (array $periodBoundaries) use ($latestSetPerPeriod, $differencesPerPeriod, &$lastPeriodValue) { 112 | [$periodStart, $periodEnd, $periodKey] = $periodBoundaries; 113 | 114 | $setEvent = $latestSetPerPeriod->where('period', $periodKey)->first(); 115 | 116 | $startValue = $setEvent['value'] ?? $lastPeriodValue; 117 | 118 | $applyChangesAfter = $setEvent['created_at'] ?? $periodStart; 119 | 120 | $difference = $this->queryStats() 121 | ->where('type', DataPoint::TYPE_CHANGE) 122 | ->where('created_at', '>=', $applyChangesAfter) 123 | ->where('created_at', '<', $periodEnd) 124 | ->sum('value'); 125 | 126 | $value = $startValue + $difference; 127 | $lastPeriodValue = $value; 128 | 129 | return new DataPoint( 130 | start: $periodStart, 131 | end: $periodEnd, 132 | value: (int) $value, 133 | increments: (int) ($differencesPerPeriod[$periodKey]['increments'] ?? 0), 134 | decrements: (int) ($differencesPerPeriod[$periodKey]['decrements'] ?? 0), 135 | difference: (int) ($differencesPerPeriod[$periodKey]['difference'] ?? 0), 136 | ); 137 | }); 138 | } 139 | 140 | /** 141 | * Gets the value at a point in time by using the previous 142 | * snapshot and the changes since that snapshot. 143 | * 144 | * @param DateTimeInterface $dateTime 145 | * 146 | * @return int 147 | */ 148 | public function getValue(DateTimeInterface $dateTime): int 149 | { 150 | $nearestSet = $this->queryStats() 151 | ->where('type', DataPoint::TYPE_SET) 152 | ->where('created_at', '<', $dateTime) 153 | ->orderByDesc('created_at') 154 | ->first(); 155 | 156 | $startId = optional($nearestSet)->id ?? 0; 157 | $startValue = optional($nearestSet)->value ?? 0; 158 | 159 | $differenceSinceSet = $this->queryStats() 160 | ->where('type', DataPoint::TYPE_CHANGE) 161 | ->where($this->getStatsKey(), '>', $startId) 162 | ->where('created_at', '<', $dateTime) 163 | ->sum('value'); 164 | 165 | return $startValue + $differenceSinceSet; 166 | } 167 | 168 | public function getAttributes(): array 169 | { 170 | return $this->attributes; 171 | } 172 | 173 | public static function getPeriodDateFormat(string $period): string 174 | { 175 | $dbDriver = Config::get('database.connections.'.Config::get('database.default', 'mysql').'.driver', 'mysql'); 176 | 177 | if ($dbDriver === 'pgsql') { 178 | return match ($period) { 179 | 'year' => "to_char(created_at, 'YYYY')", 180 | 'month' => "to_char(created_at, 'YYYY-MM')", 181 | 'week' => "to_char(created_at, 'IYYYIW')", 182 | 'day' => "to_char(created_at, 'YYYY-MM-DD')", 183 | 'hour' => "to_char(created_at, 'YYYY-MM-DD HH24')", 184 | 'minute' => "to_char(created_at, 'YYYY-MM-DD HH24:MI')", 185 | }; 186 | } 187 | 188 | if ($dbDriver === 'sqlite') { 189 | return match ($period) { 190 | 'year' => "strftime('%Y', created_at)", 191 | 'month' => "strftime('%Y-%m', created_at)", 192 | 'week' => "strftime('%Y%W', created_at)", 193 | 'day' => "strftime('%Y-%m-%d', created_at)", 194 | 'hour' => "strftime('%Y-%m-%d %H', created_at)", 195 | 'minute' => "strftime('%Y-%m-%d %H:%M', created_at)", 196 | }; 197 | } 198 | 199 | return match ($period) { 200 | 'year' => "date_format(created_at,'%Y')", 201 | 'month' => "date_format(created_at,'%Y-%m')", 202 | 'week' => "yearweek(created_at, 3)", 203 | 'day' => "date_format(created_at,'%Y-%m-%d')", 204 | 'hour' => "date_format(created_at,'%Y-%m-%d %H')", 205 | 'minute' => "date_format(created_at,'%Y-%m-%d %H:%i')", 206 | }; 207 | } 208 | 209 | protected function generatePeriods(): Collection 210 | { 211 | $data = collect(); 212 | $currentDateTime = (new Carbon($this->start))->startOf($this->period); 213 | 214 | do { 215 | $data->push([ 216 | $currentDateTime->copy(), 217 | $currentDateTime->copy()->add(1, $this->period), 218 | $currentDateTime->format($this->getPeriodTimestampFormat()), 219 | ]); 220 | 221 | $currentDateTime->add(1, $this->period); 222 | } while ($currentDateTime->lt($this->end)); 223 | 224 | return $data; 225 | } 226 | 227 | protected function getPeriodTimestampFormat(): string 228 | { 229 | return match ($this->period) { 230 | 'year' => 'Y', 231 | 'month' => 'Y-m', 232 | 'week' => 'oW', // see https://stackoverflow.com/questions/15562270/php-datew-vs-mysql-yearweeknow 233 | 'day' => 'Y-m-d', 234 | 'hour' => 'Y-m-d H', 235 | 'minute' => 'Y-m-d H:i', 236 | }; 237 | } 238 | 239 | protected function queryStats(): Builder 240 | { 241 | if ($this->subject instanceof Relation) { 242 | return $this->subject->getQuery()->clone()->where($this->attributes); 243 | } 244 | 245 | /** @var Model $subject */ 246 | $subject = $this->subject; 247 | if (is_string($subject) && class_exists($subject)) { 248 | $subject = new $subject; 249 | } 250 | 251 | return $subject->newQuery()->where($this->attributes); 252 | } 253 | 254 | protected function getDifferencesPerPeriod(): EloquentCollection 255 | { 256 | return $this->queryStats() 257 | ->where('type', DataPoint::TYPE_CHANGE) 258 | ->where('created_at', '>=', $this->start) 259 | ->where('created_at', '<', $this->end) 260 | ->selectRaw('sum(case when value > 0 then value else 0 end) as increments') 261 | ->selectRaw('abs(sum(case when value < 0 then value else 0 end)) as decrements') 262 | ->selectRaw('sum(value) as difference') 263 | ->groupByPeriod($this->period) 264 | ->get() 265 | ->keyBy('period'); 266 | } 267 | 268 | protected function getLatestSetPerPeriod(): EloquentCollection 269 | { 270 | $periodDateFormat = static::getPeriodDateFormat($this->period); 271 | 272 | $query = $this->queryStats(); 273 | 274 | $statsTable = $query->getGrammar()->wrap($this->getStatsTableName()); 275 | $statsKey = $query->getGrammar()->wrap($this->getStatsKey()); 276 | 277 | $rankedSets = $query 278 | ->selectRaw("ROW_NUMBER() OVER (PARTITION BY {$periodDateFormat} ORDER BY {$statsKey} DESC) AS rn, {$statsTable}.*, {$periodDateFormat} as period") 279 | ->where('type', DataPoint::TYPE_SET) 280 | ->where('created_at', '>=', $this->start) 281 | ->where('created_at', '<', $this->end) 282 | ->get(); 283 | 284 | $latestSetPerPeriod = $rankedSets->where('rn', 1); 285 | 286 | return $latestSetPerPeriod; 287 | } 288 | 289 | protected function getStatsKey(): string 290 | { 291 | if ($this->subject instanceof Relation) { 292 | return $this->subject->getRelated()->getKeyName(); 293 | } 294 | 295 | /** @var Model $subject */ 296 | $subject = $this->subject; 297 | if (is_string($subject) && class_exists($subject)) { 298 | $subject = new $subject; 299 | } 300 | 301 | return $subject->getKeyName(); 302 | } 303 | 304 | protected function getStatsTableName(): string 305 | { 306 | if ($this->subject instanceof Relation) { 307 | return $this->subject->getQuery()->getGrammar()->getTablePrefix().$this->subject->getRelated()->getTable(); 308 | } 309 | 310 | /** @var Model $subject */ 311 | $subject = $this->subject; 312 | if (is_string($subject) && class_exists($subject)) { 313 | $subject = new $subject; 314 | } 315 | 316 | return $subject->getQuery()->getGrammar()->getTablePrefix().$subject->getTable(); 317 | } 318 | } 319 | --------------------------------------------------------------------------------