├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── config └── laravel-subscriptions.php ├── database └── migrations │ ├── create_plan_features_table.php │ ├── create_plan_subscription_usage_table.php │ ├── create_plan_subscriptions_table.php │ ├── create_plans_table.php │ ├── remove_unique_slug_on_subscriptions_table.php │ └── update_unique_keys_on_features_table.php ├── phpstan.neon ├── pint.json └── src ├── Interval.php ├── Models ├── Feature.php ├── Plan.php ├── Subscription.php └── SubscriptionUsage.php ├── Services └── Period.php ├── SubscriptionServiceProvider.php └── Traits ├── BelongsToPlan.php ├── HasPlanSubscriptions.php ├── HasSlug.php └── HasTranslations.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Laravel Subscriptions Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | This project adheres to [Semantic Versioning](CONTRIBUTING.md). 6 | 7 | ## [v1.5](https://github.com/rinvex/laravel-subscriptions/compare/v1.4.1...v1.5) - 2025-04-09 8 | 9 | ### What's Changed 10 | 11 | * laravel 12 by @mckenziearts in https://github.com/laravelcm/laravel-subscriptions/pull/36 12 | 13 | **Full Changelog**: https://github.com/laravelcm/laravel-subscriptions/compare/v1.4.1...v1.5 14 | 15 | ## [v1.4.1](https://github.com/rinvex/laravel-subscriptions/compare/v1.4...v1.4.1) - 2025-01-07 16 | 17 | ### What's Changed 18 | 19 | * Fixed duplicate feature slugs. by @mnkincir in https://github.com/laravelcm/laravel-subscriptions/pull/32 20 | 21 | ### New Contributors 22 | 23 | * @mnkincir made their first contribution in https://github.com/laravelcm/laravel-subscriptions/pull/32 24 | 25 | **Full Changelog**: https://github.com/laravelcm/laravel-subscriptions/compare/v1.4...v1.4.1 26 | 27 | ## [v1.4](https://github.com/rinvex/laravel-subscriptions/compare/v1.3.2...v1.4) - 2024-12-03 28 | 29 | ### What's Changed 30 | 31 | * Multiple features can now be used with the same slug by @chikenare in https://github.com/laravelcm/laravel-subscriptions/pull/28 32 | * feat: rename `usage` relation to `usages` on Feature Model and refactor Models by @mckenziearts in https://github.com/laravelcm/laravel-subscriptions/pull/29 33 | 34 | ### New Contributors 35 | 36 | * @chikenare made their first contribution in https://github.com/laravelcm/laravel-subscriptions/pull/28 37 | 38 | **Full Changelog**: https://github.com/laravelcm/laravel-subscriptions/compare/v1.3.2...v1.4 39 | 40 | ## [v1.3.2](https://github.com/rinvex/laravel-subscriptions/compare/v1.3.1...v1.3.2) - 2024-11-01 41 | 42 | ### What's Changed 43 | 44 | * feat: Disable relation integer casting to allow UUIDs with custom Models by @DanielSpravtsev in https://github.com/laravelcm/laravel-subscriptions/pull/19 45 | * feat: fix slug generation and add new tests by @mckenziearts in https://github.com/laravelcm/laravel-subscriptions/pull/25 46 | 47 | ### New Contributors 48 | 49 | * @DanielSpravtsev made their first contribution in https://github.com/laravelcm/laravel-subscriptions/pull/19 50 | 51 | **Full Changelog**: https://github.com/laravelcm/laravel-subscriptions/compare/v1.3.1...v1.3.2 52 | 53 | ## [v1.3.1](https://github.com/rinvex/laravel-subscriptions/compare/v1.3...v1.3.1) - 2024-07-24 54 | 55 | ### What's Changed 56 | 57 | * feat: Remove laravel 9 support and update github actions test by @mckenziearts in https://github.com/laravelcm/laravel-subscriptions/pull/17 58 | 59 | **Full Changelog**: https://github.com/laravelcm/laravel-subscriptions/compare/v1.3...v1.3.1 60 | 61 | ## [v1.3](https://github.com/rinvex/laravel-subscriptions/compare/v6.1.0...v1.3) - 2024-07-24 62 | 63 | ### What's Changed 64 | 65 | * Fixing the shell command to publish resources by @lauspadafora in https://github.com/laravelcm/laravel-subscriptions/pull/14 66 | * fix: Error on undefined method planSubscriptions by @mckenziearts in https://github.com/laravelcm/laravel-subscriptions/pull/15 67 | * feat: Add support for Laravel 11 by @mckenziearts in https://github.com/laravelcm/laravel-subscriptions/pull/16 68 | 69 | ### New Contributors 70 | 71 | * @lauspadafora made their first contribution in https://github.com/laravelcm/laravel-subscriptions/pull/14 72 | 73 | **Full Changelog**: https://github.com/laravelcm/laravel-subscriptions/compare/v1.2.2...v1.3 74 | 75 | ## [v6.1.0](https://github.com/rinvex/laravel-subscriptions/compare/v6.0.1...v6.1.0) - 2022-02-14 76 | 77 | - Update composer dependencies to Laravel v9 78 | - Add support for model HasFactory 79 | - Feature to find active subscriptions for a user (#173) 80 | 81 | ## [v6.0.1](https://github.com/rinvex/laravel-subscriptions/compare/v6.0.0...v6.0.1) - 2021-12-15 82 | 83 | - Soft deleting children models on soft deleting parent models 84 | - Update the required packages 85 | 86 | ## [v6.0.0](https://github.com/rinvex/laravel-subscriptions/compare/v5.0.3...v6.0.0) - 2021-08-22 87 | 88 | - Drop PHP v7 support, and upgrade rinvex package dependencies to next major version 89 | - Update composer dependencies 90 | - Merge rules instead of resetting, to allow adequate model override 91 | - Fix constructor initialization order (fill attributes should come next after merging fillables & rules) 92 | - Drop old MySQL versions support that doesn't support json columns 93 | - Upgrade to GitHub-native Dependabot 94 | 95 | ## [v5.0.3](https://github.com/rinvex/laravel-subscriptions/compare/v5.0.2...v5.0.3) - 2021-03-15 96 | 97 | - Changes in doc to reflect new ofSubscriber breaking change 98 | - Utilize `SoftDeletes` functionality (fix #142) 99 | - Update hardcoded model to use service container IoC 100 | - Add period regardless if it's 0 or more, this should be fine 101 | - Check if there's usage or not (fix #26 & #138) 102 | 103 | ## [v5.0.2](https://github.com/rinvex/laravel-subscriptions/compare/v5.0.1...v5.0.2) - 2021-02-19 104 | 105 | - Define morphMany parameters explicitly 106 | - Simplify service provider model registration into IoC 107 | - Add startDate optional parameter to new subscription creation (fix #79) 108 | - Fix FeatureSlug confused with FeatureName by mistake (fix #43 #48 #62 #65 #136 #137) 109 | - Breaking Change: Rename "User" to "Subscriber" for more generic naming convention (fix #63) 110 | 111 | ## [v5.0.1](https://github.com/rinvex/laravel-subscriptions/compare/v5.0.0...v5.0.1) - 2020-12-25 112 | 113 | - Add support for PHP v8 114 | 115 | ## [v5.0.0](https://github.com/rinvex/laravel-subscriptions/compare/v4.1.0...v5.0.0) - 2020-12-22 116 | 117 | - Upgrade to Laravel v8 118 | - Update validation rules 119 | 120 | ## [v4.1.0](https://github.com/rinvex/laravel-subscriptions/compare/v4.0.6...v4.1.0) - 2020-06-15 121 | 122 | - Update validation rules 123 | 124 | - Drop using rinvex/laravel-cacheable from core packages for more flexibility 125 | 126 | - Caching should be handled on the application layer, not enforced from the core packages 127 | 128 | - Drop PHP 7.2 & 7.3 support from travis 129 | 130 | 131 | ## [v4.0.6](https://github.com/rinvex/laravel-subscriptions/compare/v4.0.5...v4.0.6) - 2020-05-30 132 | 133 | - Remove default indent size config 134 | - Add strip_tags validation rule to string fields 135 | - Specify events queue 136 | - Explicitly specify relationship attributes 137 | - Add strip_tags validation rule 138 | - Explicitly define relationship name 139 | 140 | ## [v4.0.5](https://github.com/rinvex/laravel-subscriptions/compare/v4.0.4...v4.0.5) - 2020-04-12 141 | 142 | - Fix ServiceProvider registerCommands method compatibility 143 | 144 | ## [v4.0.4](https://github.com/rinvex/laravel-subscriptions/compare/v4.0.3...v4.0.4) - 2020-04-09 145 | 146 | - Tweak artisan command registration 147 | - Reverse commit "Convert database int fields into bigInteger" 148 | - Refactor publish command and allow multiple resource values 149 | 150 | ## [v4.0.3](https://github.com/rinvex/laravel-subscriptions/compare/v4.0.2...v4.0.3) - 2020-04-04 151 | 152 | - Fix namespace issue 153 | 154 | ## [v4.0.2](https://github.com/rinvex/laravel-subscriptions/compare/v4.0.1...v4.0.2) - 2020-04-04 155 | 156 | - Enforce consistent artisan command tag namespacing 157 | - Enforce consistent package namespace 158 | - Drop laravel/helpers usage as it's no longer used 159 | 160 | ## [v4.0.1](https://github.com/rinvex/laravel-subscriptions/compare/v4.0.0...v4.0.1) - 2020-03-20 161 | 162 | - Convert into bigInteger database fields 163 | - Add shortcut -f (force) for artisan publish commands 164 | - Fix migrations path 165 | 166 | ## [v4.0.0](https://github.com/rinvex/laravel-subscriptions/compare/v3.0.2...v4.0.0) - 2020-03-15 167 | 168 | - Upgrade to Laravel v7.1.x & PHP v7.4.x 169 | 170 | ## [v3.0.2](https://github.com/rinvex/laravel-subscriptions/compare/v3.0.1...v3.0.2) - 2020-03-13 171 | 172 | - Tweak TravisCI config 173 | - Add migrations autoload option to the package 174 | - Tweak service provider `publishesResources` 175 | - Remove indirect composer dependency 176 | - Drop using global helpers 177 | - Update StyleCI config 178 | 179 | ## [v3.0.1](https://github.com/rinvex/laravel-subscriptions/compare/v3.0.0...v3.0.1) - 2019-12-18 180 | 181 | - Fix `migrate:reset` args as it doesn't accept --step 182 | 183 | ## [v3.0.0](https://github.com/rinvex/laravel-subscriptions/compare/v2.1.1...v3.0.0) - 2019-09-23 184 | 185 | - Upgrade to Laravel v6 and update dependencies 186 | 187 | ## [v2.1.1](https://github.com/rinvex/laravel-subscriptions/compare/v2.1.0...v2.1.1) - 2019-06-03 188 | 189 | - Enforce latest composer package versions 190 | 191 | ## [v2.1.0](https://github.com/rinvex/laravel-subscriptions/compare/v2.0.0...v2.1.0) - 2019-06-02 192 | 193 | - Update composer deps 194 | - Drop PHP 7.1 travis test 195 | - Refactor migrations and artisan commands, and tweak service provider publishes functionality 196 | - Fix wrong container binding: 197 | - app('rinvex.subscriptions.plan_features') => app('rinvex.subscriptions.plan_feature') 198 | - app('rinvex.subscriptions.plan_subscriptions') => app('rinvex.subscriptions.plan_subscription') 199 | 200 | 201 | ## [v2.0.0](https://github.com/rinvex/laravel-subscriptions/compare/v1.0.2...v2.0.0) - 2019-03-03 202 | 203 | - Require PHP 7.2 & Laravel 5.8 204 | 205 | ## [v1.0.2](https://github.com/rinvex/laravel-subscriptions/compare/v1.0.1...v1.0.2) - 2018-12-30 206 | 207 | - Rinvex\Subscriptions\Services\Period: adding interval received as parameter in constructor to property ->interval 208 | 209 | ## [v1.0.1](https://github.com/rinvex/laravel-subscriptions/compare/v1.0.0...v1.0.1) - 2018-12-22 210 | 211 | - Update composer dependencies 212 | - Add PHP 7.3 support to travis 213 | - Fix MySQL / PostgreSQL json column compatibility 214 | 215 | ## [v1.0.0](https://github.com/rinvex/laravel-subscriptions/compare/v0.0.4...v1.0.0) - 2018-10-01 216 | 217 | - Enforce Consistency 218 | - Support Laravel 5.7+ 219 | - Rename package to rinvex/laravel-subscriptions 220 | 221 | ## [v0.0.4](https://github.com/rinvex/laravel-subscriptions/compare/v0.0.3...v0.0.4) - 2018-09-21 222 | 223 | - Update travis php versions 224 | - Define polymorphic relationship parameters explicitly 225 | - Fix fully qualified booking unit methods (fix #20) 226 | - Convert timestamps into datetime fields and add timezone 227 | - Tweak validation rules 228 | - Drop StyleCI multi-language support (paid feature now!) 229 | - Update composer dependencies 230 | - Prepare and tweak testing configuration 231 | - Update StyleCI options 232 | - Update PHPUnit options 233 | - Rename subscription model activation and deactivation methods 234 | 235 | ## [v0.0.3](https://github.com/rinvex/laravel-subscriptions/compare/v0.0.2...v0.0.3) - 2018-02-18 236 | 237 | - Add PublishCommand to artisan 238 | - Move slug auto generation to the custom HasSlug trait 239 | - Add Rollback Console Command 240 | - Add missing composer dependencies 241 | - Remove useless scopes 242 | - Add PHPUnitPrettyResultPrinter 243 | - Use Carbon global helper 244 | - Update composer dependencies 245 | - Update supplementary files 246 | - Use ->getKey() method instead of ->id 247 | - Typehint method returns 248 | - Drop useless model contracts (models already swappable through IoC) 249 | - Add Laravel v5.6 support 250 | - Simplify IoC binding 251 | - Add force option to artisan commands 252 | - Refactor user_id to a polymorphic relation 253 | - Rename PlanSubscriber trait to HasSubscriptions 254 | - Rename polymorphic relation customer to user 255 | - Rename polymorphic relation customer to user 256 | - Convert interval column data type into string from character 257 | 258 | ## [v0.0.2](https://github.com/rinvex/laravel-subscriptions/compare/v0.0.1...v0.0.2) - 2017-09-08 259 | 260 | - Fix many issues and apply many enhancements 261 | - Rename package rinvex/laravel-subscriptions from rinvex/subscribable 262 | 263 | ## v0.0.1 - 2017-06-29 264 | 265 | - Tag first release 266 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [help@rinvex.com](mailto:help@rinvex.com). All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | This project adheres to the following standards and practices. 4 | 5 | 6 | ## Versioning 7 | 8 | This project is versioned under the [Semantic Versioning](http://semver.org/) guidelines as much as possible. 9 | 10 | Releases will be numbered with the following format: 11 | 12 | - `..` 13 | - `..` 14 | 15 | And constructed with the following guidelines: 16 | 17 | - Breaking backward compatibility bumps the major and resets the minor and patch. 18 | - New additions without breaking backward compatibility bump the minor and reset the patch. 19 | - Bug fixes and misc changes bump the patch. 20 | 21 | 22 | ## Pull Requests 23 | 24 | The pull request process differs for new features and bugs. 25 | 26 | Pull requests for bugs may be sent without creating any proposal issue. If you believe that you know of a solution for a bug that has been filed, please leave a comment detailing your proposed fix or create a pull request with the fix mentioning that issue id. 27 | 28 | 29 | ## Coding Standards 30 | 31 | This project follows the FIG PHP Standards Recommendations compliant with the [PSR-1: Basic Coding Standard](http://www.php-fig.org/psr/psr-1/), [PSR-2: Coding Style Guide](http://www.php-fig.org/psr/psr-2/) and [PSR-4: Autoloader](http://www.php-fig.org/psr/psr-4/) to ensure a high level of interoperability between shared PHP code. If you notice any compliance oversights, please send a patch via pull request. 32 | 33 | 34 | ## Feature Requests 35 | 36 | If you have a proposal or a feature request, you may create an issue with `[Proposal]` in the title. 37 | 38 | The proposal should also describe the new feature, as well as implementation ideas. The proposal will then be reviewed and either approved or denied. Once a proposal is approved, a pull request may be created implementing the new feature. 39 | 40 | 41 | ## Git Flow 42 | 43 | This project follows [Git-Flow](http://nvie.com/posts/a-successful-git-branching-model/), and as such has `master` (latest stable releases), `develop` (latest WIP development) and X.Y support branches (when there's multiple major versions). 44 | 45 | Accordingly all pull requests MUST be sent to the `develop` branch. 46 | 47 | > **Note:** Pull requests which do not follow these guidelines will be closed without any further notice. 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2021, Rinvex LLC, 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 |

2 | 3 |

4 | 5 |

6 | 7 | Laravel v10.x 8 | 9 | 10 | Build Status 11 | 12 | 13 | Coding Standards 14 | 15 | 16 | Total Downloads 17 | 18 | 19 | Packagist 20 | 21 | 22 | Packagist 23 | 24 |

25 | 26 | # Laravel Subscriptions 27 | 28 | **Laravel Subscriptions** is a flexible plans and subscription management system for Laravel, with the required tools to run your SAAS like services efficiently. 29 | It's simple architecture, accompanied by powerful underlying to afford solid platform for your business. 30 | 31 | ## Considerations 32 | 33 | - Payments are out of scope for this package. 34 | - You may want to extend some of the core models, in case you need to override the logic behind some helper methods like `renew()`, `cancel()` etc. E.g.: when cancelling a subscription you may want to also cancel the recurring payment attached. 35 | 36 | 37 | ## Installation 38 | 39 | 1. Install the package via composer: 40 | ```shell 41 | composer require laravelcm/laravel-subscriptions 42 | ``` 43 | 44 | 2. Publish resources (migrations and config files): 45 | ```shell 46 | php artisan vendor:publish --provider="Laravelcm\Subscriptions\SubscriptionServiceProvider" 47 | ``` 48 | 49 | 3. Execute migrations via the following command: 50 | ```shell 51 | php artisan migrate 52 | ``` 53 | 54 | 4. Done! 55 | 56 | 57 | ## Usage 58 | 59 | ### Add Subscriptions to User model 60 | 61 | **Laravel Subscriptions** has been specially made for Eloquent and simplicity has been taken very serious as in any other Laravel related aspect. To add Subscription functionality to your User model just use the `\Rinvex\Subscriptions\Traits\HasPlanSubscriptions` trait like this: 62 | 63 | ```php 64 | namespace App\Models; 65 | 66 | use Laravelcm\Subscriptions\Traits\HasPlanSubscriptions; 67 | use Illuminate\Foundation\Auth\User as Authenticatable; 68 | 69 | class User extends Authenticatable 70 | { 71 | use HasPlanSubscriptions; 72 | } 73 | ``` 74 | 75 | That's it, we only have to use that trait in our User model! Now your users may subscribe to plans. 76 | 77 | > **Note:** you can use `HasPlanSubscriptions` trait on any subscriber model, it doesn't have to be the user model, in fact any model will do. 78 | 79 | ### Create a Plan 80 | 81 | ```php 82 | use Laravelcm\Subscriptions\Models\Plan; 83 | use Laravelcm\Subscriptions\Models\Feature; 84 | use Laravelcm\Subscriptions\Interval; 85 | 86 | $plan = Plan::create([ 87 | 'name' => 'Pro', 88 | 'description' => 'Pro plan', 89 | 'price' => 9.99, 90 | 'signup_fee' => 1.99, 91 | 'invoice_period' => 1, 92 | 'invoice_interval' => Interval::MONTH->value, 93 | 'trial_period' => 15, 94 | 'trial_interval' => Interval::DAY->value, 95 | 'sort_order' => 1, 96 | 'currency' => 'USD', 97 | ]); 98 | 99 | // Create multiple plan features at once 100 | $plan->features()->saveMany([ 101 | new Feature(['name' => 'listings', 'value' => 50, 'sort_order' => 1]), 102 | new Feature(['name' => 'pictures_per_listing', 'value' => 10, 'sort_order' => 5]), 103 | new Feature(['name' => 'listing_duration_days', 'value' => 30, 'sort_order' => 10, 'resettable_period' => 1, 'resettable_interval' => 'month']), 104 | new Feature(['name' => 'listing_title_bold', 'value' => 'Y', 'sort_order' => 15]) 105 | ]); 106 | ``` 107 | 108 | ### Get Plan Details 109 | 110 | You can query the plan for further details, using the intuitive API as follows: 111 | 112 | ```php 113 | use Laravelcm\Subscriptions\Models\Plan; 114 | 115 | $plan = Plan::find(1); 116 | 117 | // Get all plan features 118 | $plan->features; 119 | 120 | // Get all plan subscriptions 121 | $plan->subscriptions; 122 | 123 | // Check if the plan is free 124 | $plan->isFree(); 125 | 126 | // Check if the plan has trial period 127 | $plan->hasTrial(); 128 | 129 | // Check if the plan has grace period 130 | $plan->hasGrace(); 131 | ``` 132 | 133 | Both `$plan->features` and `$plan->subscriptions` are collections, driven from relationships, and thus you can query these relations as any normal Eloquent relationship. E.g. `$plan->features()->where('name', 'listing_title_bold')->first()`. 134 | 135 | ### Get Feature Value 136 | 137 | Say you want to show the value of the feature _pictures_per_listing_ from above. You can do so in many ways: 138 | 139 | ```php 140 | use Laravelcm\Subscriptions\Models\Feature; 141 | use Laravelcm\Subscriptions\Models\Subscription; 142 | 143 | // Use the plan instance to get feature's value 144 | $amountOfPictures = $plan->getFeatureBySlug('pictures_per_listing')->value; 145 | 146 | // Query the feature itself directly 147 | $amountOfPictures = Feature::where('slug', 'pictures_per_listing')->first()->value; 148 | 149 | // Get feature value through the subscription instance 150 | $amountOfPictures = Subscription::find(1)->getFeatureValue('pictures_per_listing'); 151 | ``` 152 | 153 | ### Create a Subscription 154 | 155 | You can subscribe a user to a plan by using the `newSubscription()` function available in the `HasPlanSubscriptions` trait. First, retrieve an instance of your subscriber model, which typically will be your user model and an instance of the plan your user is subscribing to. Once you have retrieved the model instance, you may use the `newSubscription` method to create the model's subscription. 156 | 157 | ```php 158 | use Laravelcm\Subscriptions\Models\Plan; 159 | use App\Models\User; 160 | 161 | $user = User::find(1); 162 | $plan = Plan::find(1); 163 | 164 | $user->newPlanSubscription('main', $plan); 165 | ``` 166 | 167 | The first argument passed to `newSubscription` method should be the title of the subscription. If your application offer a single subscription, you might call this `main` or `primary`, while the second argument is the plan instance your user is subscribing to, and there's an optional third parameter to specify custom start date as an instance of `Carbon\Carbon` (by default if not provided, it will start now). 168 | 169 | ### Change the Plan 170 | 171 | You can change subscription plan easily as follows: 172 | 173 | ```php 174 | use Laravelcm\Subscriptions\Models\Plan; 175 | use Laravelcm\Subscriptions\Models\Subscription; 176 | 177 | $plan = Plan::find(2); 178 | $subscription = Subscription::find(1); 179 | 180 | // Change subscription plan 181 | $subscription->changePlan($plan); 182 | ``` 183 | 184 | If both plans (current and new plan) have the same billing frequency (e.g., `invoice_period` and `invoice_interval`) the subscription will retain the same billing dates. If the plans don't have the same billing frequency, the subscription will have the new plan billing frequency, starting on the day of the change and _the subscription usage data will be cleared_. Also, if the new plan has a trial period, and it's a new subscription, the trial period will be applied. 185 | 186 | ### Feature Options 187 | 188 | Plan features are great for fine-tuning subscriptions, you can top-up certain feature for X times of usage, so users may then use it only for that amount. Features also have the ability to be resettable and then it's usage could be expired too. See the following examples: 189 | 190 | ```php 191 | use Laravelcm\Subscriptions\Models\Feature; 192 | 193 | // Find plan feature 194 | $feature = Feature::where('name', 'listing_duration_days')->first(); 195 | 196 | // Get feature reset date 197 | $feature->getResetDate(new \Carbon\Carbon()); 198 | ``` 199 | 200 | ### Subscription Feature Usage 201 | 202 | There's multiple ways to determine the usage and ability of a particular feature in the user subscription, the most common one is `canUseFeature`: 203 | 204 | The `canUseFeature` method returns `true` or `false` depending on multiple factors: 205 | 206 | - Feature _is enabled_. 207 | - Feature value isn't `0`/`false`/`NULL`. 208 | - Or feature has remaining uses available. 209 | 210 | ```php 211 | $user->planSubscription('main')->canUseFeature('listings'); 212 | ``` 213 | 214 | Other feature methods on the user subscription instance are: 215 | 216 | - `getFeatureUsage`: returns how many times the user has used a particular feature. 217 | - `getFeatureRemainings`: returns available uses for a particular feature. 218 | - `getFeatureValue`: returns the feature value. 219 | 220 | > All methods share the same signature: e.g. `$user->planSubscription('main')->getFeatureUsage('listings');`. 221 | 222 | ### Record Feature Usage 223 | 224 | In order to effectively use the ability methods you will need to keep track of every usage of each feature (or at least those that require it). You may use the `recordFeatureUsage` method available through the user `subscription()` method: 225 | 226 | ```php 227 | $user->planSubscription('main')->recordFeatureUsage('listings'); 228 | ``` 229 | 230 | The `recordFeatureUsage` method accept 3 parameters: the first one is the feature's name, the second one is the quantity of uses to add (default is `1`), and the third one indicates if the addition should be incremental (default behavior), when disabled the usage will be override by the quantity provided. E.g.: 231 | 232 | ```php 233 | // Increment by 2 234 | $user->planSubscription('main')->recordFeatureUsage('listings', 2); 235 | 236 | // Override with 9 237 | $user->planSubscription('main')->recordFeatureUsage('listings', 9, false); 238 | ``` 239 | 240 | ### Reduce Feature Usage 241 | 242 | Reducing the feature usage is _almost_ the same as incrementing it. Here we only _substract_ a given quantity (default is `1`) to the actual usage: 243 | 244 | ```php 245 | $user->planSubscription('main')->reduceFeatureUsage('listings', 2); 246 | ``` 247 | 248 | ### Clear The Subscription Usage Data 249 | 250 | ```php 251 | $user->planSubscription('main')->usage()->delete(); 252 | ``` 253 | 254 | ### Check Subscription Status 255 | 256 | For a subscription to be considered active _one of the following must be `true`_: 257 | 258 | - Subscription has an active trial. 259 | - Subscription `ends_at` is in the future. 260 | 261 | ```php 262 | $user->subscribedTo($planId); 263 | ``` 264 | 265 | Alternatively you can use the following methods available in the subscription model: 266 | 267 | ```php 268 | $user->planSubscription('main')->active(); 269 | $user->planSubscription('main')->canceled(); 270 | $user->planSubscription('main')->ended(); 271 | $user->planSubscription('main')->onTrial(); 272 | ``` 273 | 274 | > Canceled subscriptions with an active trial or `ends_at` in the future are considered active. 275 | 276 | ### Renew a Subscription 277 | 278 | To renew a subscription you may use the `renew` method available in the subscription model. This will set a new `ends_at` date based on the selected plan and _will clear the usage data_ of the subscription. 279 | 280 | ```php 281 | $user->planSubscription('main')->renew(); 282 | ``` 283 | 284 | _Canceled subscriptions with an ended period can't be renewed._ 285 | 286 | ### Cancel a Subscription 287 | 288 | To cancel a subscription, simply use the `cancel` method on the user's subscription: 289 | 290 | ```php 291 | $user->planSubscription('main')->cancel(); 292 | ``` 293 | 294 | By default the subscription will remain active until the end of the period, you may pass `true` to end the subscription _immediately_: 295 | 296 | ```php 297 | $user->planSubscription('main')->cancel(true); 298 | ``` 299 | 300 | ### Scopes 301 | 302 | #### Subscription Model 303 | 304 | ```php 305 | use Laravelcm\Subscriptions\Models\Subscription; 306 | use App\Models\User; 307 | 308 | // Get subscriptions by plan 309 | $subscriptions = Subscription::byPlanId($plan_id)->get(); 310 | 311 | // Get bookings of the given user 312 | $user = User::find(1); 313 | $bookingsOfSubscriber = Subscription::ofSubscriber($user)->get(); 314 | 315 | // Get subscriptions with trial ending in 3 days 316 | $subscriptions = Subscription::findEndingTrial(3)->get(); 317 | 318 | // Get subscriptions with ended trial 319 | $subscriptions = Subscription::findEndedTrial()->get(); 320 | 321 | // Get subscriptions with period ending in 3 days 322 | $subscriptions = Subscription::findEndingPeriod(3)->get(); 323 | 324 | // Get subscriptions with ended period 325 | $subscriptions = Subscription::findEndedPeriod()->get(); 326 | ``` 327 | 328 | ### Models 329 | 330 | **Laravel Subscriptions** uses 4 models: 331 | 332 | ```php 333 | Laravelcm\Subscriptions\Models\Plan; 334 | Laravelcm\Subscriptions\Models\Feature; 335 | Laravelcm\Subscriptions\Models\Subscription; 336 | Laravelcm\Subscriptions\Models\SubscriptionUsage; 337 | ``` 338 | 339 | ## Changelog 340 | 341 | Refer to the [Changelog](CHANGELOG.md) for a full history of the project. 342 | 343 | 344 | ## Support 345 | 346 | The following support channels are available at your fingertips: 347 | 348 | - [Chat on Telegram](https://laravel.cm/telegram) 349 | - [Help on Email](mailto:developers@laravel.cm) 350 | - [Follow on Twitter](https://twitter.com/laravelcm) 351 | 352 | 353 | ## Contributing & Protocols 354 | 355 | Thank you for considering contributing to this project! The contribution guide can be found in [CONTRIBUTING.md](CONTRIBUTING.md). 356 | 357 | Bug reports, feature requests, and pull requests are very welcome. 358 | 359 | - [Versioning](CONTRIBUTING.md#versioning) 360 | - [Pull Requests](CONTRIBUTING.md#pull-requests) 361 | - [Coding Standards](CONTRIBUTING.md#coding-standards) 362 | - [Feature Requests](CONTRIBUTING.md#feature-requests) 363 | - [Git Flow](CONTRIBUTING.md#git-flow) 364 | 365 | 366 | ## Security Vulnerabilities 367 | 368 | If you discover a security vulnerability within this project, please send an e-mail to [developers@laravel.cm](help@rinvex.com). All security vulnerabilities will be promptly addressed. 369 | 370 | 371 | ## About Laravel Cameroon 372 | 373 | The community of PHP and Laravel developers in Cameroon, the largest gathering of developers in Cameroon. 374 | 375 | 376 | ## License 377 | 378 | This software is released under [The MIT License (MIT)](LICENSE). 379 | 380 | (c) 2018-2023 Laravel Cameroun, Some rights reserved. 381 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravelcm/laravel-subscriptions", 3 | "description": "Laravel Subscriptions is a flexible plans and subscription management system for Laravel, with the required tools to run your SAAS like services efficiently. It's simple architecture, accompanied by powerful underlying to afford solid platform for your business.", 4 | "type": "library", 5 | "keywords": [ 6 | "plan", 7 | "value", 8 | "laravel", 9 | "feature", 10 | "database", 11 | "recurring", 12 | "subscription" 13 | ], 14 | "license": "MIT", 15 | "homepage": "https://laravel.cm", 16 | "support": { 17 | "email": "support@laravel.cm", 18 | "issues": "https://github.com/laravelcm/laravel-subscriptions/issues", 19 | "source": "https://github.com/laravelcm/laravel-subscriptions", 20 | "docs": "https://github.com/laravelcm/laravel-subscriptions/blob/main/README.md" 21 | }, 22 | "authors": [ 23 | { 24 | "name": "Arthur Monney", 25 | "homepage": "https://arthurmonney.me", 26 | "email": "arthur@laravel.cm" 27 | }, 28 | { 29 | "name": "The Generous Laravel Community", 30 | "homepage": "https://github.com/laravelcm/laravel-subscriptions/contributors" 31 | } 32 | ], 33 | "require": { 34 | "php": "^8.2", 35 | "illuminate/console": "^10.0|^11.0|^12.0", 36 | "illuminate/container": "^10.0|^11.0|^12.0", 37 | "illuminate/database": "^10.0|^11.0|^12.0", 38 | "illuminate/support": "^10.0|^11.0|^12.0", 39 | "spatie/eloquent-sortable": "^4.0.0", 40 | "spatie/laravel-package-tools": "^1.16", 41 | "spatie/laravel-sluggable": "^3.4.2", 42 | "spatie/laravel-translatable": "^6.5.0" 43 | }, 44 | "require-dev": { 45 | "larastan/larastan": "^2.0|^3.0", 46 | "laravel/pint": "^1.13", 47 | "orchestra/testbench": "^8.0|^9.0|^10.0", 48 | "pestphp/pest": "^2.18|^3.7", 49 | "spatie/test-time": "^1.3" 50 | }, 51 | "autoload": { 52 | "psr-4": { 53 | "Laravelcm\\Subscriptions\\": "src" 54 | } 55 | }, 56 | "autoload-dev": { 57 | "psr-4": { 58 | "Tests\\": "tests/src", 59 | "Tests\\Database\\Factories\\": "tests/database/factories" 60 | } 61 | }, 62 | "scripts": { 63 | "test": "./vendor/bin/pest", 64 | "lint": "./vendor/bin/pint", 65 | "types": "./vendor/bin/phpstan analyse --memory-limit=2g" 66 | }, 67 | "config": { 68 | "sort-packages": true, 69 | "preferred-install": "dist", 70 | "optimize-autoloader": true, 71 | "allow-plugins": { 72 | "pestphp/pest-plugin": false 73 | } 74 | }, 75 | "extra": { 76 | "laravel": { 77 | "providers": [ 78 | "Laravelcm\\Subscriptions\\SubscriptionServiceProvider" 79 | ] 80 | } 81 | }, 82 | "minimum-stability": "dev", 83 | "prefer-stable": true 84 | } 85 | -------------------------------------------------------------------------------- /config/laravel-subscriptions.php: -------------------------------------------------------------------------------- 1 | [ 21 | 'plans' => 'plans', 22 | 'features' => 'features', 23 | 'subscriptions' => 'subscriptions', 24 | 'subscription_usage' => 'subscription_usage', 25 | ], 26 | 27 | /* 28 | |-------------------------------------------------------------------------- 29 | | Subscription Models 30 | |-------------------------------------------------------------------------- 31 | | 32 | | Models used to manage subscriptions. You can replace to use your own models, 33 | | but make sure that you have the same functionalities or that your models 34 | | extend from each model that you are going to replace. 35 | | 36 | */ 37 | 38 | 'models' => [ 39 | 'plan' => Plan::class, 40 | 'feature' => Feature::class, 41 | 'subscription' => Subscription::class, 42 | 'subscription_usage' => SubscriptionUsage::class, 43 | ], 44 | 45 | ]; 46 | -------------------------------------------------------------------------------- /database/migrations/create_plan_features_table.php: -------------------------------------------------------------------------------- 1 | id(); 15 | 16 | $table->foreignIdFor(config('laravel-subscriptions.models.plan')); 17 | $table->json('name'); 18 | $table->string('slug')->unique(); 19 | $table->json('description')->nullable(); 20 | $table->string('value'); 21 | $table->unsignedSmallInteger('resettable_period')->default(0); 22 | $table->string('resettable_interval')->default('month'); 23 | $table->unsignedMediumInteger('sort_order')->default(0); 24 | 25 | $table->timestamps(); 26 | $table->softDeletes(); 27 | }); 28 | } 29 | 30 | public function down(): void 31 | { 32 | Schema::dropIfExists(config('laravel-subscriptions.tables.features')); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /database/migrations/create_plan_subscription_usage_table.php: -------------------------------------------------------------------------------- 1 | id(); 15 | 16 | $table->foreignIdFor(config('laravel-subscriptions.models.subscription')); 17 | $table->foreignIdFor(config('laravel-subscriptions.models.feature')); 18 | $table->unsignedSmallInteger('used'); 19 | $table->string('timezone')->nullable(); 20 | 21 | $table->dateTime('valid_until')->nullable(); 22 | $table->timestamps(); 23 | $table->softDeletes(); 24 | }); 25 | } 26 | 27 | public function down(): void 28 | { 29 | Schema::dropIfExists(config('laravel-subscriptions.tables.subscription_usage')); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /database/migrations/create_plan_subscriptions_table.php: -------------------------------------------------------------------------------- 1 | id(); 15 | 16 | $table->morphs('subscriber'); 17 | $table->foreignIdFor(config('laravel-subscriptions.models.plan')); 18 | $table->json('name'); 19 | $table->string('slug')->unique(); 20 | $table->json('description')->nullable(); 21 | $table->string('timezone')->nullable(); 22 | 23 | $table->dateTime('trial_ends_at')->nullable(); 24 | $table->dateTime('starts_at')->nullable(); 25 | $table->dateTime('ends_at')->nullable(); 26 | $table->dateTime('cancels_at')->nullable(); 27 | $table->dateTime('canceled_at')->nullable(); 28 | $table->timestamps(); 29 | $table->softDeletes(); 30 | }); 31 | } 32 | 33 | public function down(): void 34 | { 35 | Schema::dropIfExists(config('laravel-subscriptions.tables.subscriptions')); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /database/migrations/create_plans_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | 17 | $table->json('name'); 18 | $table->string('slug')->unique(); 19 | $table->json('description')->nullable(); 20 | $table->boolean('is_active')->default(true); 21 | $table->decimal('price')->default('0.00'); 22 | $table->decimal('signup_fee')->default('0.00'); 23 | $table->string('currency', 3); 24 | $table->unsignedSmallInteger('trial_period')->default(0); 25 | $table->string('trial_interval')->default(Interval::DAY->value); 26 | $table->unsignedSmallInteger('invoice_period')->default(0); 27 | $table->string('invoice_interval')->default(Interval::MONTH->value); 28 | $table->unsignedSmallInteger('grace_period')->default(0); 29 | $table->string('grace_interval')->default(Interval::DAY->value); 30 | $table->unsignedTinyInteger('prorate_day')->nullable(); 31 | $table->unsignedTinyInteger('prorate_period')->nullable(); 32 | $table->unsignedTinyInteger('prorate_extend_due')->nullable(); 33 | $table->unsignedSmallInteger('active_subscribers_limit')->nullable(); 34 | $table->unsignedSmallInteger('sort_order')->default(0); 35 | 36 | $table->timestamps(); 37 | $table->softDeletes(); 38 | }); 39 | } 40 | 41 | public function down(): void 42 | { 43 | Schema::dropIfExists(config('laravel-subscriptions.tables.plans')); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /database/migrations/remove_unique_slug_on_subscriptions_table.php: -------------------------------------------------------------------------------- 1 | dropUnique(config('laravel-subscriptions.tables.subscriptions') . '_slug_unique'); 15 | }); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /database/migrations/update_unique_keys_on_features_table.php: -------------------------------------------------------------------------------- 1 | dropUnique(config('laravel-subscriptions.tables.features') . '_slug_unique'); 15 | $table->unique(['plan_id', 'slug']); 16 | }); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/larastan/larastan/extension.neon 3 | 4 | parameters: 5 | 6 | level: 0 7 | 8 | paths: 9 | - src 10 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "concat_space": { 5 | "spacing": "one" 6 | }, 7 | "array_indentation": true, 8 | "array_syntax": true, 9 | "blank_line_before_statement": true, 10 | "declare_strict_types": true, 11 | "declare_parentheses": true, 12 | "ordered_traits": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Interval.php: -------------------------------------------------------------------------------- 1 | 'string', 73 | 'value' => 'string', 74 | 'resettable_period' => 'integer', 75 | 'resettable_interval' => 'string', 76 | 'sort_order' => 'integer', 77 | 'deleted_at' => 'datetime', 78 | ]; 79 | 80 | /** 81 | * The attributes that are translatable. 82 | * 83 | * @var array 84 | */ 85 | public $translatable = [ 86 | 'name', 87 | 'description', 88 | ]; 89 | 90 | public array $sortable = [ 91 | 'order_column_name' => 'sort_order', 92 | ]; 93 | 94 | public function getTable(): string 95 | { 96 | return config('laravel-subscriptions.tables.features', 'features'); 97 | } 98 | 99 | protected static function boot(): void 100 | { 101 | parent::boot(); 102 | 103 | static::deleted(function (Feature $feature): void { 104 | $feature->usage()->delete(); 105 | }); 106 | 107 | static::creating(function (Feature $feature) { 108 | if (static::where('plan_id', $feature->plan_id)->where('slug', $feature->slug)->exists()) { 109 | throw new InvalidArgumentException('Each plan should only have one feature with the same slug'); 110 | } 111 | }); 112 | } 113 | 114 | public function getSlugOptions(): SlugOptions 115 | { 116 | return SlugOptions::create() 117 | ->doNotGenerateSlugsOnUpdate() 118 | ->generateSlugsFrom('name') 119 | ->allowDuplicateSlugs() 120 | ->saveSlugsTo('slug'); 121 | } 122 | 123 | public function usages(): HasMany 124 | { 125 | return $this->hasMany(config('laravel-subscriptions.models.subscription_usage')); 126 | } 127 | 128 | public function getResetDate(?Carbon $dateFrom = null): Carbon 129 | { 130 | $period = new Period($this->resettable_interval, $this->resettable_period, $dateFrom ?? Carbon::now()); 131 | 132 | return $period->getEndDate(); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Models/Plan.php: -------------------------------------------------------------------------------- 1 | 'boolean', 98 | 'price' => 'float', 99 | 'signup_fee' => 'float', 100 | 'deleted_at' => 'datetime', 101 | ]; 102 | 103 | /** 104 | * The attributes that are translatable. 105 | * 106 | * @var array 107 | */ 108 | public $translatable = [ 109 | 'name', 110 | 'description', 111 | ]; 112 | 113 | public array $sortable = [ 114 | 'order_column_name' => 'sort_order', 115 | ]; 116 | 117 | public function getTable(): string 118 | { 119 | return config('laravel-subscriptions.tables.plans'); 120 | } 121 | 122 | protected static function boot(): void 123 | { 124 | parent::boot(); 125 | 126 | static::deleted(function ($plan): void { 127 | $plan->features()->delete(); 128 | $plan->subscriptions()->delete(); 129 | }); 130 | } 131 | 132 | public function getSlugOptions(): SlugOptions 133 | { 134 | return SlugOptions::create() 135 | ->doNotGenerateSlugsOnUpdate() 136 | ->generateSlugsFrom('name') 137 | ->saveSlugsTo('slug') 138 | ->allowDuplicateSlugs(); 139 | } 140 | 141 | public function features(): HasMany 142 | { 143 | return $this->hasMany(config('laravel-subscriptions.models.feature')); 144 | } 145 | 146 | public function subscriptions(): HasMany 147 | { 148 | return $this->hasMany(config('laravel-subscriptions.models.subscription')); 149 | } 150 | 151 | public function isFree(): bool 152 | { 153 | return $this->price <= 0.00; 154 | } 155 | 156 | public function hasTrial(): bool 157 | { 158 | return $this->trial_period && $this->trial_interval; 159 | } 160 | 161 | public function hasGrace(): bool 162 | { 163 | return $this->grace_period && $this->grace_interval; 164 | } 165 | 166 | public function getFeatureBySlug(string $featureSlug): ?Feature 167 | { 168 | return $this->features()->where('slug', $featureSlug)->first(); 169 | } 170 | 171 | public function activate(): self 172 | { 173 | $this->update(['is_active' => true]); 174 | 175 | return $this; 176 | } 177 | 178 | public function deactivate(): self 179 | { 180 | $this->update(['is_active' => false]); 181 | 182 | return $this; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Models/Subscription.php: -------------------------------------------------------------------------------- 1 | 'string', 86 | 'slug' => 'string', 87 | 'trial_ends_at' => 'datetime', 88 | 'starts_at' => 'datetime', 89 | 'ends_at' => 'datetime', 90 | 'cancels_at' => 'datetime', 91 | 'canceled_at' => 'datetime', 92 | 'deleted_at' => 'datetime', 93 | ]; 94 | 95 | /** 96 | * The attributes that are translatable. 97 | * 98 | * @var array 99 | */ 100 | public $translatable = [ 101 | 'name', 102 | 'description', 103 | ]; 104 | 105 | public function getTable() 106 | { 107 | return config('laravel-subscriptions.tables.subscriptions'); 108 | } 109 | 110 | protected static function boot(): void 111 | { 112 | parent::boot(); 113 | 114 | static::creating(function (self $model): void { 115 | if (! $model->starts_at || ! $model->ends_at) { 116 | $model->setNewPeriod(); 117 | } 118 | }); 119 | 120 | static::deleted(function (self $subscription): void { 121 | $subscription->usage()->delete(); 122 | }); 123 | } 124 | 125 | public function getSlugOptions(): SlugOptions 126 | { 127 | return SlugOptions::create() 128 | ->doNotGenerateSlugsOnUpdate() 129 | ->generateSlugsFrom('name') 130 | ->saveSlugsTo('slug'); 131 | } 132 | 133 | public function subscriber(): MorphTo 134 | { 135 | return $this->morphTo('subscriber', 'subscriber_type', 'subscriber_id', 'id'); 136 | } 137 | 138 | public function usage(): HasMany 139 | { 140 | return $this->hasMany(config('laravel-subscriptions.models.subscription_usage')); 141 | } 142 | 143 | public function active(): bool 144 | { 145 | return ! $this->ended() || $this->onTrial(); 146 | } 147 | 148 | public function inactive(): bool 149 | { 150 | return ! $this->active(); 151 | } 152 | 153 | public function onTrial(): bool 154 | { 155 | return $this->trial_ends_at && Carbon::now()->lt($this->trial_ends_at); 156 | } 157 | 158 | public function canceled(): bool 159 | { 160 | return $this->canceled_at && Carbon::now()->gte($this->canceled_at); 161 | } 162 | 163 | public function ended(): bool 164 | { 165 | return $this->ends_at && Carbon::now()->gte($this->ends_at); 166 | } 167 | 168 | public function cancel(bool $immediately = false): self 169 | { 170 | $this->canceled_at = Carbon::now(); 171 | 172 | if ($immediately) { 173 | $this->ends_at = $this->canceled_at; 174 | } 175 | 176 | $this->save(); 177 | 178 | return $this; 179 | } 180 | 181 | public function changePlan(Plan $plan): self 182 | { 183 | // If plans does not have the same billing frequency 184 | // (e.g., invoice_interval and invoice_period) we will update 185 | // the billing dates starting today, and since we are basically creating 186 | // a new billing cycle, the usage data will be cleared. 187 | if ($this->plan->invoice_interval !== $plan->invoice_interval || $this->plan->invoice_period !== $plan->invoice_period) { 188 | $this->setNewPeriod($plan->invoice_interval, $plan->invoice_period); 189 | $this->usage()->delete(); 190 | } 191 | 192 | // Attach new plan to subscription 193 | $this->plan_id = $plan->getKey(); 194 | $this->save(); 195 | 196 | return $this; 197 | } 198 | 199 | /** 200 | * Renew subscription period. 201 | * 202 | * @return $this 203 | * 204 | * @throws LogicException 205 | */ 206 | public function renew(): self 207 | { 208 | if ($this->ended() && $this->canceled()) { 209 | throw new LogicException('Unable to renew canceled ended subscription.'); 210 | } 211 | 212 | $subscription = $this; 213 | 214 | DB::transaction(function () use ($subscription): void { 215 | // Clear usage data 216 | $subscription->usage()->delete(); 217 | 218 | // Renew period 219 | $subscription->setNewPeriod(); 220 | $subscription->canceled_at = null; 221 | $subscription->save(); 222 | }); 223 | 224 | return $this; 225 | } 226 | 227 | /** 228 | * Get bookings of the given subscriber. 229 | */ 230 | public function scopeOfSubscriber(Builder $builder, Model $subscriber): Builder 231 | { 232 | return $builder->where('subscriber_type', $subscriber->getMorphClass()) 233 | ->where('subscriber_id', $subscriber->getKey()); 234 | } 235 | 236 | /** 237 | * Scope subscriptions with ending trial. 238 | */ 239 | public function scopeFindEndingTrial(Builder $builder, int $dayRange = 3): Builder 240 | { 241 | $from = Carbon::now(); 242 | $to = Carbon::now()->addDays($dayRange); 243 | 244 | return $builder->whereBetween('trial_ends_at', [$from, $to]); 245 | } 246 | 247 | /** 248 | * Scope subscriptions with ended trial. 249 | */ 250 | public function scopeFindEndedTrial(Builder $builder): Builder 251 | { 252 | return $builder->where('trial_ends_at', '<=', Carbon::now()); 253 | } 254 | 255 | /** 256 | * Scope subscriptions with ending periods. 257 | */ 258 | public function scopeFindEndingPeriod(Builder $builder, int $dayRange = 3): Builder 259 | { 260 | $from = Carbon::now(); 261 | $to = Carbon::now()->addDays($dayRange); 262 | 263 | return $builder->whereBetween('ends_at', [$from, $to]); 264 | } 265 | 266 | /** 267 | * Scope subscriptions with ended periods. 268 | */ 269 | public function scopeFindEndedPeriod(Builder $builder): Builder 270 | { 271 | return $builder->where('ends_at', '<=', Carbon::now()); 272 | } 273 | 274 | /** 275 | * Scope all active subscriptions for a user. 276 | */ 277 | public function scopeFindActive(Builder $builder): Builder 278 | { 279 | return $builder->where('ends_at', '>', Carbon::now()); 280 | } 281 | 282 | /** 283 | * Set new subscription period. 284 | * 285 | * @return $this 286 | */ 287 | protected function setNewPeriod(string $invoice_interval = '', ?int $invoice_period = null, ?Carbon $start = null): self 288 | { 289 | if (empty($invoice_interval)) { 290 | $invoice_interval = $this->plan->invoice_interval; 291 | } 292 | 293 | if (empty($invoice_period)) { 294 | $invoice_period = $this->plan->invoice_period; 295 | } 296 | 297 | $period = new Period( 298 | interval: $invoice_interval, 299 | count: $invoice_period, 300 | start: $start ?? Carbon::now() 301 | ); 302 | 303 | $this->starts_at = $period->getStartDate(); 304 | $this->ends_at = $period->getEndDate(); 305 | 306 | return $this; 307 | } 308 | 309 | public function recordFeatureUsage(string $featureSlug, int $uses = 1, bool $incremental = true): SubscriptionUsage 310 | { 311 | $feature = $this->plan->features()->where('slug', $featureSlug)->first(); 312 | 313 | $usage = $this->usage()->firstOrNew([ 314 | 'subscription_id' => $this->getKey(), 315 | 'feature_id' => $feature->getKey(), 316 | ]); 317 | 318 | if ($feature->resettable_period) { 319 | // Set expiration date when the usage record is new or doesn't have one. 320 | if ($usage->valid_until === null) { 321 | // Set date from subscription creation date so the reset 322 | // period match the period specified by the subscription's plan. 323 | $usage->valid_until = $feature->getResetDate($this->created_at); 324 | } elseif ($usage->expired()) { 325 | // If the usage record has been expired, let's assign 326 | // a new expiration date and reset the uses to zero. 327 | $usage->valid_until = $feature->getResetDate($usage->valid_until); 328 | $usage->used = 0; 329 | } 330 | } 331 | 332 | $usage->used = $incremental ? $usage->used + $uses : $uses; 333 | 334 | $usage->save(); 335 | 336 | return $usage; 337 | } 338 | 339 | public function reduceFeatureUsage(string $featureSlug, int $uses = 1): ?SubscriptionUsage 340 | { 341 | $usage = $this->usage()->byFeatureSlug($featureSlug, $this->plan_id)->first(); 342 | 343 | if ($usage === null) { 344 | return null; 345 | } 346 | 347 | $usage->used = max($usage->used - $uses, 0); 348 | 349 | $usage->save(); 350 | 351 | return $usage; 352 | } 353 | 354 | /** 355 | * Determine if the feature can be used. 356 | */ 357 | public function canUseFeature(string $featureSlug): bool 358 | { 359 | $featureValue = $this->getFeatureValue($featureSlug); 360 | $usage = $this->usage()->byFeatureSlug($featureSlug, $this->plan_id)->first(); 361 | 362 | if ($featureValue === 'true') { 363 | return true; 364 | } 365 | 366 | // If the feature value is zero, let's return false since 367 | // there's no uses available. (useful to disable countable features) 368 | if (! $usage || $usage->expired() || $featureValue === null || $featureValue === '0' || $featureValue === 'false') { 369 | return false; 370 | } 371 | 372 | // Check for available uses 373 | return $this->getFeatureRemainings($featureSlug) > 0; 374 | } 375 | 376 | /** 377 | * Get how many times the feature has been used. 378 | */ 379 | public function getFeatureUsage(string $featureSlug): int 380 | { 381 | $usage = $this->usage()->byFeatureSlug($featureSlug, $this->plan_id)->first(); 382 | 383 | return (! $usage || $usage->expired()) ? 0 : $usage->used; 384 | } 385 | 386 | /** 387 | * Get the available uses. 388 | */ 389 | public function getFeatureRemainings(string $featureSlug): int 390 | { 391 | return $this->getFeatureValue($featureSlug) - $this->getFeatureUsage($featureSlug); 392 | } 393 | 394 | public function getFeatureValue(string $featureSlug): ?string 395 | { 396 | $feature = $this->plan->features()->where('slug', $featureSlug)->first(); 397 | 398 | return $feature->value ?? null; 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /src/Models/SubscriptionUsage.php: -------------------------------------------------------------------------------- 1 | 'integer', 48 | 'valid_until' => 'datetime', 49 | 'deleted_at' => 'datetime', 50 | ]; 51 | 52 | public function getTable(): string 53 | { 54 | return config('laravel-subscriptions.tables.subscription_usage'); 55 | } 56 | 57 | public function feature(): BelongsTo 58 | { 59 | return $this->belongsTo(config('laravel-subscriptions.models.feature'), 'feature_id', 'id', 'feature'); 60 | } 61 | 62 | public function subscription(): BelongsTo 63 | { 64 | return $this->belongsTo(config('laravel-subscriptions.models.subscription'), 'subscription_id', 'id', 'subscription'); 65 | } 66 | 67 | public function scopeByFeatureSlug(Builder $builder, string $featureSlug, int $planId): Builder 68 | { 69 | $model = config('laravel-subscriptions.models.feature', Feature::class); 70 | $feature = $model::where('plan_id', $planId)->where('slug', $featureSlug)->first(); 71 | 72 | return $builder->where('feature_id', $feature ? $feature->getKey() : null); 73 | } 74 | 75 | public function expired(): bool 76 | { 77 | if (! $this->valid_until) { 78 | return false; 79 | } 80 | 81 | return Carbon::now()->gte($this->valid_until); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Services/Period.php: -------------------------------------------------------------------------------- 1 | interval = $interval; 28 | 29 | if (empty($start)) { 30 | $this->start = Carbon::now(); 31 | } elseif (! $start instanceof Carbon) { 32 | $this->start = new Carbon($start); 33 | } else { 34 | $this->start = $start; 35 | } 36 | 37 | $this->period = $count; 38 | $start = clone $this->start; 39 | $method = 'add' . ucfirst($this->interval) . 's'; 40 | $this->end = $start->{$method}($this->period); 41 | } 42 | 43 | public function getStartDate(): Carbon 44 | { 45 | return $this->start; 46 | } 47 | 48 | public function getEndDate(): Carbon 49 | { 50 | return $this->end; 51 | } 52 | 53 | public function getInterval(): string 54 | { 55 | return $this->interval; 56 | } 57 | 58 | public function getIntervalCount(): int 59 | { 60 | return $this->period; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/SubscriptionServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-subscriptions') 16 | ->hasConfigFile('laravel-subscriptions') 17 | ->hasMigrations([ 18 | 'create_plans_table', 19 | 'create_plan_features_table', 20 | 'create_plan_subscriptions_table', 21 | 'create_plan_subscription_usage_table', 22 | 'remove_unique_slug_on_subscriptions_table', 23 | 'update_unique_keys_on_features_table', 24 | ]) 25 | ->hasInstallCommand(function (InstallCommand $command): void { 26 | $command 27 | ->publishConfigFile() 28 | ->publishMigrations() 29 | ->askToStarRepoOnGitHub('laravelcm/laravel-subscriptions'); 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Traits/BelongsToPlan.php: -------------------------------------------------------------------------------- 1 | belongsTo(config('laravel-subscriptions.models.plan'), 'plan_id', 'id', 'plan'); 15 | } 16 | 17 | public function scopeByPlanId(Builder $builder, int $planId): Builder 18 | { 19 | return $builder->where('plan_id', $planId); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Traits/HasPlanSubscriptions.php: -------------------------------------------------------------------------------- 1 | subscriptions()->delete(); 20 | }); 21 | } 22 | 23 | /** 24 | * The subscriber may have many plan subscriptions. 25 | */ 26 | public function planSubscriptions(): MorphMany 27 | { 28 | return $this->morphMany( 29 | related: config('laravel-subscriptions.models.subscription'), 30 | name: 'subscriber', 31 | type: 'subscriber_type', 32 | id: 'subscriber_id' 33 | ); 34 | } 35 | 36 | public function activePlanSubscriptions(): Collection 37 | { 38 | return $this->planSubscriptions->reject->inactive(); 39 | } 40 | 41 | public function planSubscription(string $subscriptionSlug): ?Subscription 42 | { 43 | return $this->planSubscriptions()->where('slug', 'like', '%' . $subscriptionSlug . '%')->first(); 44 | } 45 | 46 | public function subscribedPlans(): Collection 47 | { 48 | $planIds = $this->planSubscriptions->reject 49 | ->inactive() 50 | ->pluck('plan_id') 51 | ->unique(); 52 | 53 | return tap(new (config('laravel-subscriptions.models.plan')))->whereIn('id', $planIds)->get(); 54 | } 55 | 56 | public function subscribedTo(int $planId): bool 57 | { 58 | $subscription = $this->planSubscriptions() 59 | ->where('plan_id', $planId) 60 | ->first(); 61 | 62 | return $subscription && $subscription->active(); 63 | } 64 | 65 | public function newPlanSubscription(string $subscription, Plan $plan, ?Carbon $startDate = null): Subscription 66 | { 67 | $trial = new Period( 68 | interval: $plan->trial_interval, 69 | count: $plan->trial_period, 70 | start: $startDate ?? Carbon::now() 71 | ); 72 | $period = new Period( 73 | interval: $plan->invoice_interval, 74 | count: $plan->invoice_period, 75 | start: $trial->getEndDate() 76 | ); 77 | 78 | return $this->planSubscriptions()->create([ 79 | 'name' => $subscription, 80 | 'plan_id' => $plan->getKey(), 81 | 'trial_ends_at' => $trial->getEndDate(), 82 | 'starts_at' => $period->getStartDate(), 83 | 'ends_at' => $period->getEndDate(), 84 | ]); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Traits/HasSlug.php: -------------------------------------------------------------------------------- 1 | exists && $model->getSlugOptions()->generateSlugsOnUpdate) { 19 | $model->generateSlugOnUpdate(); 20 | } elseif (! $model->exists && $model->getSlugOptions()->generateSlugsOnCreate) { 21 | $model->generateSlugOnCreate(); 22 | } 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Traits/HasTranslations.php: -------------------------------------------------------------------------------- 1 | isTranslatableAttribute($key)) { 20 | return parent::getAttributeValue($key); 21 | } 22 | 23 | return $this->getTranslation($key, config('app.locale')) ?: Arr::first($this->getTranslations($key)); 24 | } 25 | 26 | /** 27 | * @throws AttributeIsNotTranslatable 28 | */ 29 | public function getTranslations(?string $key = null): array 30 | { 31 | if ($key !== null) { 32 | $this->guardAgainstNonTranslatableAttribute($key); 33 | 34 | $value = array_filter( 35 | json_decode($this->getAttributes()[$key] ?? '' ?: '{}', true) ?: [], 36 | fn ($value) => $value !== null && $value !== '' 37 | ); 38 | 39 | // Inject default translation if none supplied 40 | if (! is_array($value)) { 41 | $oldValue = $value; 42 | 43 | if ($this->hasSetMutator($key)) { 44 | $method = 'set' . Str::studly($key) . 'Attribute'; 45 | $value = $this->{$method}($value); 46 | } 47 | 48 | $value = [$locale = app()->getLocale() => $value]; 49 | 50 | $this->attributes[$key] = $this->asJson($value); 51 | event(new TranslationHasBeenSetEvent($this, $key, $locale, $oldValue, $value)); 52 | } 53 | 54 | return $value; 55 | } 56 | 57 | return array_reduce($this->getTranslatableAttributes(), function ($result, $item) { 58 | $result[$item] = $this->getTranslations($item); 59 | 60 | return $result; 61 | }); 62 | } 63 | 64 | public function attributesToArray(): array 65 | { 66 | $values = array_map(fn ($attribute) => $this->getTranslation($attribute, config('app.locale')) ?: null, $keys = $this->getTranslatableAttributes()); 67 | 68 | return array_replace(parent::attributesToArray(), array_combine($keys, $values)); 69 | } 70 | 71 | public function mergeTranslatable(array $translatable): void 72 | { 73 | $this->translatable = array_merge($this->translatable, $translatable); 74 | } 75 | } 76 | --------------------------------------------------------------------------------