├── pint.json ├── resources └── views │ ├── js.blade.php │ └── components │ └── button.blade.php ├── src ├── Exceptions │ ├── PolarApiError.php │ ├── InvalidMetadataPayload.php │ ├── InvalidCustomer.php │ └── ReservedMetadataKeys.php ├── Events │ ├── WebhookEvent.php │ ├── BenefitCreated.php │ ├── BenefitUpdated.php │ ├── ProductCreated.php │ ├── ProductUpdated.php │ ├── CheckoutCreated.php │ ├── CheckoutUpdated.php │ ├── CustomerCreated.php │ ├── CustomerDeleted.php │ ├── CustomerUpdated.php │ ├── CustomerStateChanged.php │ ├── WebhookHandled.php │ ├── WebhookReceived.php │ ├── BenefitGrantCreated.php │ ├── BenefitGrantRevoked.php │ ├── BenefitGrantUpdated.php │ ├── OrderCreated.php │ ├── SubscriptionActive.php │ ├── SubscriptionCreated.php │ ├── SubscriptionRevoked.php │ ├── SubscriptionUpdated.php │ ├── SubscriptionCanceled.php │ └── OrderUpdated.php ├── Facades │ └── LaravelPolar.php ├── View │ └── Components │ │ └── Button.php ├── Billable.php ├── Handlers │ ├── PolarSignature.php │ └── ProcessWebhook.php ├── Concerns │ ├── ManagesOrders.php │ ├── ManagesBenefits.php │ ├── ManagesSubscription.php │ ├── ManagesCustomer.php │ ├── ManagesCustomerMeters.php │ └── ManagesCheckouts.php ├── LaravelPolarServiceProvider.php ├── Customer.php ├── Order.php ├── Commands │ └── ListProductsCommand.php ├── Checkout.php ├── Subscription.php └── LaravelPolar.php ├── routes └── web.php ├── .cursor └── commands │ └── deslop.md ├── database ├── migrations │ ├── create_polar_customers_table.php.stub │ ├── create_webhook_calls_table.php.stub │ ├── create_polar_subscriptions_table.php.stub │ └── create_polar_orders_table.php.stub └── factories │ ├── CustomerFactory.php │ ├── OrderFactory.php │ └── SubscriptionFactory.php ├── LICENSE.md ├── config ├── webhook-client.php └── polar.php ├── composer.json ├── CHANGELOG.md └── README.md /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "per" 3 | } 4 | -------------------------------------------------------------------------------- /resources/views/js.blade.php: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/Exceptions/PolarApiError.php: -------------------------------------------------------------------------------- 1 | 'light']) 2 | 3 | 4 | {{ $slot }} 5 | 6 | -------------------------------------------------------------------------------- /src/Events/WebhookEvent.php: -------------------------------------------------------------------------------- 1 | config('polar.path'), 8 | 'as' => 'polar.', 9 | ], function () { 10 | Route::post('webhook', WebhookController::class)->name('webhook-client-polar'); 11 | }); 12 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidCustomer.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public array $payload, 20 | ) {} 21 | } 22 | -------------------------------------------------------------------------------- /src/Events/WebhookReceived.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public array $payload, 20 | ) {} 21 | } 22 | -------------------------------------------------------------------------------- /.cursor/commands/deslop.md: -------------------------------------------------------------------------------- 1 | # Remove AI-generated code slop 2 | 3 | Check the diff against main, and remove all AI-generated slop introduced in this branch. 4 | 5 | This includes: 6 | - Extra comments that a human wouldn't add or is inconsistent with the rest of the file 7 | - Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths) 8 | - Casts to any to get around type issues 9 | - Any other style that is inconsistent with the file 10 | 11 | Report at the end with only a 1-3 sentence summary of what you changed 12 | -------------------------------------------------------------------------------- /src/Billable.php: -------------------------------------------------------------------------------- 1 | signingSecret); 14 | $wh = new \StandardWebhooks\Webhook($signingSecret); 15 | 16 | return (bool) ($wh->verify($request->getContent(), [ 17 | 'webhook-id' => $request->header('webhook-id'), 18 | 'webhook-signature' => $request->header('webhook-signature'), 19 | 'webhook-timestamp' => $request->header('webhook-timestamp'), 20 | ])); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Events/OrderCreated.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->morphs('billable'); 17 | $table->string('polar_id')->nullable()->unique(); 18 | $table->timestamp('trial_ends_at')->nullable(); 19 | $table->timestamps(); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | */ 26 | public function down(): void 27 | { 28 | Schema::dropIfExists('polar_customers'); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/Events/SubscriptionActive.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 16 | 17 | $table->string('name'); 18 | $table->string('url'); 19 | $table->json('headers')->nullable(); 20 | $table->json('payload')->nullable(); 21 | $table->text('exception')->nullable(); 22 | 23 | $table->timestamps(); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | */ 30 | public function down() 31 | { 32 | Schema::dropIfExists('webhook_calls'); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/Events/OrderUpdated.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | public function orders(): MorphMany 18 | { 19 | return $this->morphMany(LaravelPolar::$orderModel, 'billable')->orderByDesc('created_at'); 20 | } 21 | 22 | /** 23 | * Determine if the billable has purchased a specific product. 24 | */ 25 | public function hasPurchasedProduct(string $productId): bool 26 | { 27 | return $this->orders()->where('product_id', $productId)->where('status', OrderStatus::Paid)->exists(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /database/factories/CustomerFactory.php: -------------------------------------------------------------------------------- 1 | */ 9 | class CustomerFactory extends Factory 10 | { 11 | /** 12 | * The name of the factory's corresponding model. 13 | * 14 | * @var class-string 15 | */ 16 | protected $model = Customer::class; 17 | 18 | /** 19 | * Define the model's default state. 20 | * 21 | * @return array{ 22 | * billable_id: int, 23 | * billable_type: string, 24 | * polar_id: string, 25 | * trial_ends_at: \Carbon\CarbonInterface|null, 26 | * } 27 | */ 28 | public function definition(): array 29 | { 30 | return [ 31 | 'billable_id' => $this->faker->randomNumber(), 32 | 'billable_type' => 'App\\Models\\User', 33 | 'polar_id' => $this->faker->uuid, 34 | 'trial_ends_at' => null, 35 | ]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /database/migrations/create_polar_subscriptions_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->morphs('billable'); 17 | $table->string('type'); 18 | $table->string('polar_id')->unique(); 19 | $table->string('status'); 20 | $table->string('product_id'); 21 | $table->timestamp('current_period_end')->nullable(); 22 | $table->timestamp('trial_ends_at')->nullable(); 23 | $table->timestamp('ends_at')->nullable(); 24 | $table->timestamps(); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | */ 31 | public function down(): void 32 | { 33 | Schema::dropIfExists('polar_subscriptions'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) danestves 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 | -------------------------------------------------------------------------------- /database/migrations/create_polar_orders_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->morphs('billable'); 17 | $table->string('polar_id')->nullable(); 18 | $table->string('status'); 19 | $table->integer('amount'); 20 | $table->integer('tax_amount'); 21 | $table->integer('refunded_amount'); 22 | $table->integer('refunded_tax_amount'); 23 | $table->string('currency'); 24 | $table->string('billing_reason'); 25 | $table->string('customer_id'); 26 | $table->string('product_id')->index(); 27 | $table->timestamp('refunded_at')->nullable(); 28 | $table->timestamp('ordered_at'); 29 | $table->timestamps(); 30 | }); 31 | } 32 | 33 | /** 34 | * Reverse the migrations. 35 | */ 36 | public function down(): void 37 | { 38 | Schema::dropIfExists('polar_orders'); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/Concerns/ManagesBenefits.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public function subscriptions(): MorphMany 17 | { 18 | return $this->morphMany(LaravelPolar::$subscriptionModel, 'billable')->orderByDesc('created_at'); 19 | } 20 | 21 | /** 22 | * Get a subscription instance by type. 23 | */ 24 | public function subscription(string $type = 'default'): ?Subscription 25 | { 26 | return $this->subscriptions()->where('type', $type)->first(); 27 | } 28 | 29 | /** 30 | * Determine if the billable has a valid subscription. 31 | */ 32 | public function subscribed(string $type = 'default', ?string $productId = null): bool 33 | { 34 | $subscription = $this->subscription($type); 35 | 36 | if (! $subscription || ! $subscription->valid()) { 37 | return false; 38 | } 39 | 40 | return $productId !== null && $productId !== '' && $productId !== '0' ? $subscription->hasProduct($productId) : true; 41 | } 42 | 43 | /** 44 | * Determine if the billable has a valid subscription for the given variant. 45 | */ 46 | public function subscribedToProduct(string $productId, string $type = 'default'): bool 47 | { 48 | $subscription = $this->subscription($type); 49 | 50 | if (! $subscription || ! $subscription->valid()) { 51 | return false; 52 | } 53 | 54 | return $subscription->hasProduct($productId); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/LaravelPolarServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-polar') 18 | ->hasConfigFile(["polar", "webhook-client"]) 19 | ->hasViews() 20 | ->hasViewComponent('polar', Button::class) 21 | ->hasMigrations() 22 | ->discoversMigrations() 23 | ->hasRoute("web") 24 | ->hasCommands( 25 | ListProductsCommand::class, 26 | ) 27 | ->hasInstallCommand(function (InstallCommand $command) { 28 | $command 29 | ->publishConfigFile() 30 | ->publishAssets() 31 | ->publishMigrations() 32 | ->askToRunMigrations() 33 | ->copyAndRegisterServiceProviderInApp() 34 | ->askToStarRepoOnGitHub('danestves/laravel-polar'); 35 | }); 36 | } 37 | 38 | public function register(): void 39 | { 40 | parent::register(); 41 | 42 | $this->app->singleton(\Polar\Polar::class, function () { 43 | return LaravelPolar::sdk(); 44 | }); 45 | 46 | $this->app->alias(\Polar\Polar::class, 'polar.sdk'); 47 | } 48 | 49 | public function boot(): void 50 | { 51 | parent::boot(); 52 | 53 | $this->bootDirectives(); 54 | } 55 | 56 | protected function bootDirectives(): void 57 | { 58 | Blade::directive('polarEmbedScript', function () { 59 | return ""; 60 | }); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Customer.php: -------------------------------------------------------------------------------- 1 | */ 25 | use HasFactory; 26 | 27 | /** 28 | * The table associated with the model. 29 | */ 30 | protected $table = 'polar_customers'; 31 | 32 | /** 33 | * The attributes that are not mass assignable. 34 | * 35 | * @var array 36 | */ 37 | protected $guarded = []; 38 | 39 | /** 40 | * Get the billable model related to the customer. 41 | * 42 | * @return MorphTo 43 | */ 44 | public function billable(): MorphTo 45 | { 46 | return $this->morphTo(); 47 | } 48 | 49 | /** 50 | * Determine if the customer is on a "generic" trial at the model level. 51 | */ 52 | public function onGenericTrial(): bool 53 | { 54 | return $this->trial_ends_at && $this->trial_ends_at->isFuture(); 55 | } 56 | 57 | /** 58 | * Determine if the customer has an expired "generic" trial at the model level. 59 | */ 60 | public function hasExpiredGenericTrial(): bool 61 | { 62 | return $this->trial_ends_at && $this->trial_ends_at->isPast(); 63 | } 64 | 65 | /** 66 | * The attributes that should be cast. 67 | */ 68 | protected function casts(): array 69 | { 70 | return [ 71 | 'trial_ends_at' => 'datetime', 72 | ]; 73 | } 74 | 75 | protected static function newFactory(): CustomerFactory 76 | { 77 | return CustomerFactory::new(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Concerns/ManagesCustomer.php: -------------------------------------------------------------------------------- 1 | $attributes 20 | */ 21 | public function createAsCustomer(array $attributes = []): Customer 22 | { 23 | return $this->customer()->create($attributes); 24 | } 25 | 26 | /** 27 | * Get the customer related to the billable model. 28 | * 29 | * @return MorphOne 30 | */ 31 | public function customer(): MorphOne 32 | { 33 | return $this->morphOne(LaravelPolar::$customerModel, 'billable'); 34 | } 35 | 36 | /** 37 | * Get the billable's name to associate with Polar. 38 | */ 39 | public function polarName(): ?string 40 | { 41 | return $this->name ?? null; 42 | } 43 | 44 | /** 45 | * Get the billable's email address to associate with Polar. 46 | */ 47 | public function polarEmail(): ?string 48 | { 49 | return $this->email ?? null; 50 | } 51 | 52 | /** 53 | * Generate a redirect response to the billable's customer portal. 54 | */ 55 | public function redirectToCustomerPortal(): RedirectResponse 56 | { 57 | return new RedirectResponse($this->customerPortalUrl()); 58 | } 59 | 60 | /** 61 | * Get the customer portal url for this billable. 62 | * 63 | * @throws PolarApiError 64 | * @throws InvalidCustomer 65 | * @throws Errors\APIException 66 | * @throws Errors\HTTPValidationError 67 | */ 68 | public function customerPortalUrl(): string 69 | { 70 | if ($this->customer === null || $this->customer->polar_id === null) { 71 | throw InvalidCustomer::notYetCreated($this); 72 | } 73 | 74 | $request = new Components\CustomerSessionCustomerIDCreate( 75 | customerId: $this->customer->polar_id, 76 | ); 77 | 78 | $response = LaravelPolar::createCustomerSession($request); 79 | 80 | return $response->customerPortalUrl; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /config/webhook-client.php: -------------------------------------------------------------------------------- 1 | [ 5 | [ 6 | /* 7 | * This package supports multiple webhook receiving endpoints. If you only have 8 | * one endpoint receiving webhooks, you can use 'default'. 9 | */ 10 | 'name' => 'polar', 11 | 12 | /* 13 | * We expect that every webhook call will be signed using a secret. This secret 14 | * is used to verify that the payload has not been tampered with. 15 | */ 16 | 'signing_secret' => env('POLAR_WEBHOOK_SECRET'), 17 | 18 | /* 19 | * The name of the header containing the signature. 20 | */ 21 | 'signature_header_name' => 'webhook-signature', 22 | 23 | /* 24 | * This class will verify that the content of the signature header is valid. 25 | * 26 | * It should implement \Spatie\WebhookClient\SignatureValidator\SignatureValidator 27 | */ 28 | 'signature_validator' => Danestves\LaravelPolar\Handlers\PolarSignature::class, 29 | 30 | /* 31 | * This class determines if the webhook call should be stored and processed. 32 | */ 33 | 'webhook_profile' => Spatie\WebhookClient\WebhookProfile\ProcessEverythingWebhookProfile::class, 34 | 35 | /* 36 | * This class determines the response on a valid webhook call. 37 | */ 38 | 'webhook_response' => Spatie\WebhookClient\WebhookResponse\DefaultRespondsTo::class, 39 | 40 | /* 41 | * The classname of the model to be used to store webhook calls. The class should 42 | * be equal or extend Spatie\WebhookClient\Models\WebhookCall. 43 | */ 44 | 'webhook_model' => Spatie\WebhookClient\Models\WebhookCall::class, 45 | 46 | /* 47 | * In this array, you can pass the headers that should be stored on 48 | * the webhook call model when a webhook comes in. 49 | * 50 | * To store all headers, set this value to `*`. 51 | */ 52 | 'store_headers' => [ 53 | 54 | ], 55 | 56 | /* 57 | * The class name of the job that will process the webhook request. 58 | * 59 | * This should be set to a class that extends \Spatie\WebhookClient\Jobs\ProcessWebhookJob. 60 | */ 61 | 'process_webhook_job' => Danestves\LaravelPolar\Handlers\ProcessWebhook::class, 62 | ], 63 | ], 64 | 65 | /* 66 | * The integer amount of days after which models should be deleted. 67 | * 68 | * It deletes all records after 30 days. Set to null if no models should be deleted. 69 | */ 70 | 'delete_after_days' => 30, 71 | 72 | /* 73 | * Should a unique token be added to the route name 74 | */ 75 | 'add_unique_token_to_route_name' => false, 76 | ]; 77 | -------------------------------------------------------------------------------- /database/factories/OrderFactory.php: -------------------------------------------------------------------------------- 1 | */ 11 | class OrderFactory extends Factory 12 | { 13 | /** 14 | * The name of the factory's corresponding model. 15 | * 16 | * @var class-string 17 | */ 18 | protected $model = Order::class; 19 | 20 | /** 21 | * Define the model's default state. 22 | * 23 | * @return array{ 24 | * billable_id: int, 25 | * billable_type: string, 26 | * polar_id: string, 27 | * status: OrderStatus, 28 | * amount: int, 29 | * tax_amount: int, 30 | * refunded_amount: int, 31 | * refunded_tax_amount: int, 32 | * currency: string, 33 | * billing_reason: string, 34 | * customer_id: string, 35 | * product_id: string, 36 | * refunded_at: \Illuminate\Support\Carbon|null, 37 | * ordered_at: \Illuminate\Support\Carbon, 38 | * } 39 | */ 40 | public function definition(): array 41 | { 42 | return [ 43 | 'billable_id' => $this->faker->randomNumber(), 44 | 'billable_type' => 'App\\Models\\User', 45 | 'polar_id' => $this->faker->uuid, 46 | 'status' => OrderStatus::Paid, 47 | 'amount' => $this->faker->randomNumber(), 48 | 'tax_amount' => $this->faker->randomNumber(), 49 | 'refunded_amount' => $this->faker->randomNumber(), 50 | 'refunded_tax_amount' => $this->faker->randomNumber(), 51 | 'currency' => 'USD', 52 | 'billing_reason' => $this->faker->randomElement(['purchase', 'subscription_create', 'subscription_cycle', 'subscription_update']), 53 | 'customer_id' => $this->faker->uuid, 54 | 'product_id' => $this->faker->uuid, 55 | 'refunded_at' => null, 56 | 'ordered_at' => now(), 57 | ]; 58 | } 59 | 60 | /** 61 | * Configure the model factory. 62 | */ 63 | public function configure(): self 64 | { 65 | return $this->afterCreating(function ($order) { 66 | Customer::factory()->create([ 67 | 'billable_id' => $order->billable_id, 68 | 'billable_type' => $order->billable_type, 69 | ]); 70 | }); 71 | } 72 | 73 | /** 74 | * Mark the order as paid. 75 | */ 76 | public function paid(): self 77 | { 78 | return $this->state([ 79 | 'status' => OrderStatus::Paid, 80 | ]); 81 | } 82 | 83 | /** 84 | * Mark the order as refunded. 85 | */ 86 | public function refunded(): self 87 | { 88 | return $this->state([ 89 | 'status' => OrderStatus::Refunded, 90 | ]); 91 | } 92 | 93 | /** 94 | * Mark the order as partially refunded. 95 | */ 96 | public function partiallyRefunded(): self 97 | { 98 | return $this->state([ 99 | 'status' => OrderStatus::PartiallyRefunded, 100 | ]); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /config/polar.php: -------------------------------------------------------------------------------- 1 | Settings 11 | | under the "Developers" section. 12 | | 13 | */ 14 | 'access_token' => env('POLAR_ACCESS_TOKEN'), 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Polar Server 19 | |-------------------------------------------------------------------------- 20 | | 21 | | The Polar server environment to use for API requests. 22 | | Available options: "production" or "sandbox" 23 | | 24 | | - production: https://api.polar.sh (Production environment) 25 | | - sandbox: https://sandbox-api.polar.sh (Sandbox environment) 26 | | 27 | */ 28 | 'server' => env('POLAR_SERVER', 'sandbox'), 29 | 30 | /* 31 | |-------------------------------------------------------------------------- 32 | | Polar Webhook Secret 33 | |-------------------------------------------------------------------------- 34 | | 35 | | The Polar webhook secret is used to verify that the webhook requests 36 | | are coming from Polar. You can find your webhook secret in the Polar 37 | | dashboard > Settings > Webhooks on each registered webhook. 38 | | 39 | | We (the developers) recommend using a single webhook for all your 40 | | integrations. This way you can use the same secret for all your 41 | | integrations and you don't have to manage multiple webhooks. 42 | | 43 | */ 44 | 'webhook_secret' => env('POLAR_WEBHOOK_SECRET'), 45 | 46 | /* 47 | |-------------------------------------------------------------------------- 48 | | Polar Url Path 49 | |-------------------------------------------------------------------------- 50 | | 51 | | This is the base URI where routes from Polar will be served 52 | | from. The URL built into Polar is used by default; however, 53 | | you can modify this path as you see fit for your application. 54 | | 55 | */ 56 | 'path' => env('POLAR_PATH', 'polar'), 57 | 58 | /* 59 | |-------------------------------------------------------------------------- 60 | | Default Redirect URL 61 | |-------------------------------------------------------------------------- 62 | | 63 | | This is the default redirect URL that will be used when a customer 64 | | is redirected back to your application after completing a purchase 65 | | from a checkout session in your Polar account. 66 | | 67 | */ 68 | 'redirect_url' => null, 69 | 70 | /* 71 | |-------------------------------------------------------------------------- 72 | | Currency Locale 73 | |-------------------------------------------------------------------------- 74 | | 75 | | This is the default locale in which your money values are formatted in 76 | | for display. To utilize other locales besides the default "en" locale 77 | | verify you have to have the "intl" PHP extension installed on the system. 78 | | 79 | */ 80 | 'currency_locale' => env('POLAR_CURRENCY_LOCALE', 'en'), 81 | ]; 82 | -------------------------------------------------------------------------------- /src/Concerns/ManagesCustomerMeters.php: -------------------------------------------------------------------------------- 1 | $metadata 18 | * 19 | * @throws \Polar\Models\Errors\APIException 20 | * @throws \Exception 21 | */ 22 | public function ingestUsageEvent(string $eventName, array $metadata = []): void 23 | { 24 | if ($this->customer === null || $this->customer->polar_id === null) { 25 | return; 26 | } 27 | 28 | $event = new Components\EventCreateCustomer( 29 | name: $eventName, 30 | customerId: $this->customer->polar_id, 31 | timestamp: new \DateTime(), 32 | metadata: empty($metadata) ? null : $metadata, 33 | ); 34 | 35 | $request = new Components\EventsIngest( 36 | events: [$event], 37 | ); 38 | 39 | LaravelPolar::ingestEvents($request); 40 | } 41 | 42 | /** 43 | * Track multiple usage events for this customer in a batch. 44 | * 45 | * Note: Silently returns if customer is not yet created in Polar. 46 | * This allows fire-and-forget usage tracking without requiring customer setup. 47 | * 48 | * @param array, timestamp?: \DateTime}> $events 49 | * 50 | * @throws \Polar\Models\Errors\APIException 51 | * @throws \Exception 52 | */ 53 | public function ingestUsageEvents(array $events): void 54 | { 55 | if ($this->customer === null || $this->customer->polar_id === null) { 56 | return; 57 | } 58 | 59 | if (empty($events)) { 60 | return; 61 | } 62 | 63 | $eventObjects = []; 64 | 65 | foreach ($events as $event) { 66 | $eventObjects[] = new Components\EventCreateCustomer( 67 | name: $event['eventName'], 68 | customerId: $this->customer->polar_id, 69 | timestamp: $event['timestamp'] ?? new \DateTime(), 70 | metadata: $event['metadata'] ?? null, 71 | ); 72 | } 73 | 74 | $request = new Components\EventsIngest( 75 | events: $eventObjects, 76 | ); 77 | 78 | LaravelPolar::ingestEvents($request); 79 | } 80 | 81 | /** 82 | * List customer meters for this customer. 83 | * 84 | * @throws \Polar\Models\Errors\APIException 85 | * @throws \Exception 86 | */ 87 | public function listCustomerMeters(?string $meterId = null): Operations\CustomerMetersListResponse 88 | { 89 | if ($this->customer === null || $this->customer->polar_id === null) { 90 | throw new \Exception('Customer not yet created in Polar.'); 91 | } 92 | 93 | $request = new Operations\CustomerMetersListRequest( 94 | customerId: $this->customer->polar_id, 95 | meterId: $meterId !== null ? [$meterId] : null, 96 | ); 97 | 98 | return LaravelPolar::listCustomerMeters($request); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "danestves/laravel-polar", 3 | "description": "A package to easily integrate your Laravel application with Polar.sh", 4 | "keywords": [ 5 | "laravel", 6 | "polar", 7 | "billing", 8 | "subscription" 9 | ], 10 | "homepage": "https://github.com/danestves/laravel-polar", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "danestves", 15 | "email": "danestves@users.noreply.github.com", 16 | "role": "Developer" 17 | } 18 | ], 19 | "require": { 20 | "php": "^8.3", 21 | "illuminate/contracts": "^11.0||^12.0", 22 | "pinkary-project/type-guard": "0.1.0", 23 | "polar-sh/sdk": "^0.7.0", 24 | "spatie/laravel-data": "^4.0", 25 | "spatie/laravel-package-tools": "^1", 26 | "spatie/laravel-webhook-client": "^3.0", 27 | "standard-webhooks/standard-webhooks": "dev-main" 28 | }, 29 | "require-dev": { 30 | "larastan/larastan": "^3.0", 31 | "laravel/pint": "^1.2", 32 | "mockery/mockery": "^1.5", 33 | "nunomaduro/collision": "^8.1.1", 34 | "orchestra/testbench": "^9.0||^10.0", 35 | "pestphp/pest": "^4.0", 36 | "pestphp/pest-plugin-arch": "^4.0", 37 | "pestphp/pest-plugin-laravel": "^4.0", 38 | "phpstan/extension-installer": "^1.3||^2.0", 39 | "phpstan/phpstan-deprecation-rules": "^1.1||^2.0", 40 | "phpstan/phpstan-phpunit": "^1.3||^2.0", 41 | "spatie/laravel-ray": "^1.35" 42 | }, 43 | "autoload": { 44 | "psr-4": { 45 | "Danestves\\LaravelPolar\\": "src/", 46 | "Danestves\\LaravelPolar\\Database\\Factories\\": "database/factories/" 47 | } 48 | }, 49 | "autoload-dev": { 50 | "psr-4": { 51 | "Danestves\\LaravelPolar\\Tests\\": "tests/", 52 | "Workbench\\App\\": "workbench/app/", 53 | "Workbench\\Database\\Factories\\": "workbench/database/factories/", 54 | "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" 55 | } 56 | }, 57 | "scripts": { 58 | "post-autoload-dump": [ 59 | "@clear", 60 | "@prepare", 61 | "@composer run prepare" 62 | ], 63 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 64 | "analyse": "vendor/bin/phpstan analyse", 65 | "test": "vendor/bin/pest", 66 | "test-coverage": "vendor/bin/pest --coverage", 67 | "format": "vendor/bin/pint", 68 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 69 | "build": "@php vendor/bin/testbench workbench:build --ansi", 70 | "serve": [ 71 | "Composer\\Config::disableProcessTimeout", 72 | "@build", 73 | "@php vendor/bin/testbench serve --ansi" 74 | ], 75 | "lint": [ 76 | "@php vendor/bin/pint --ansi", 77 | "@php vendor/bin/phpstan analyse --verbose --ansi" 78 | ] 79 | }, 80 | "config": { 81 | "sort-packages": true, 82 | "allow-plugins": { 83 | "pestphp/pest-plugin": true, 84 | "phpstan/extension-installer": true 85 | } 86 | }, 87 | "extra": { 88 | "laravel": { 89 | "providers": [ 90 | "Danestves\\LaravelPolar\\LaravelPolarServiceProvider" 91 | ], 92 | "aliases": { 93 | "LaravelPolar": "Danestves\\LaravelPolar\\Facades\\LaravelPolar" 94 | } 95 | } 96 | }, 97 | "minimum-stability": "dev", 98 | "prefer-stable": true 99 | } 100 | -------------------------------------------------------------------------------- /database/factories/SubscriptionFactory.php: -------------------------------------------------------------------------------- 1 | */ 12 | class SubscriptionFactory extends Factory 13 | { 14 | /** 15 | * The name of the factory's corresponding model. 16 | * 17 | * @var class-string 18 | */ 19 | protected $model = Subscription::class; 20 | 21 | /** 22 | * Define the model's default state. 23 | * 24 | * @return array{ 25 | * billable_id: int, 26 | * billable_type: string, 27 | * type: string, 28 | * polar_id: string, 29 | * status: SubscriptionStatus, 30 | * product_id: string, 31 | * current_period_end: CarbonInterface 32 | * } 33 | */ 34 | public function definition(): array 35 | { 36 | return [ 37 | 'billable_id' => $this->faker->randomNumber(), 38 | 'billable_type' => 'App\\Models\\User', 39 | 'type' => 'default', 40 | 'polar_id' => $this->faker->uuid, 41 | 'status' => SubscriptionStatus::Active, 42 | 'product_id' => $this->faker->uuid, 43 | 'current_period_end' => now()->addDays(30), 44 | ]; 45 | } 46 | 47 | /** 48 | * Configure the model factory. 49 | */ 50 | public function configure(): self 51 | { 52 | return $this->afterCreating(function ($subscription) { 53 | Customer::factory()->create([ 54 | 'billable_id' => $subscription->billable_id, 55 | 'billable_type' => $subscription->billable_type, 56 | ]); 57 | }); 58 | } 59 | 60 | /** 61 | * Mark the subscription as active. 62 | */ 63 | public function active(): self 64 | { 65 | return $this->state([ 66 | 'status' => SubscriptionStatus::Active, 67 | ]); 68 | } 69 | 70 | /** 71 | * Mark the subscription as past due. 72 | */ 73 | public function pastDue(): self 74 | { 75 | return $this->state([ 76 | 'status' => SubscriptionStatus::PastDue, 77 | ]); 78 | } 79 | 80 | /** 81 | * Mark the subscription as unpaid. 82 | */ 83 | public function unpaid(): self 84 | { 85 | return $this->state([ 86 | 'status' => SubscriptionStatus::Unpaid, 87 | ]); 88 | } 89 | 90 | /** 91 | * Mark the subscription as cancelled. 92 | */ 93 | public function cancelled(): self 94 | { 95 | return $this->state([ 96 | 'status' => SubscriptionStatus::Canceled, 97 | 'ends_at' => now(), 98 | ]); 99 | } 100 | 101 | /** 102 | * Mark the subscription as trialing. 103 | */ 104 | public function trialing(): self 105 | { 106 | return $this->state([ 107 | 'status' => SubscriptionStatus::Trialing, 108 | ]); 109 | } 110 | 111 | /** 112 | * Mark the subscription as incomplete. 113 | */ 114 | public function incomplete(): self 115 | { 116 | return $this->state([ 117 | 'status' => SubscriptionStatus::Incomplete, 118 | ]); 119 | } 120 | 121 | /** 122 | * Mark the subscription as expired 123 | */ 124 | public function incompleteExpired(): self 125 | { 126 | return $this->state([ 127 | 'status' => SubscriptionStatus::IncompleteExpired, 128 | ]); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Concerns/ManagesCheckouts.php: -------------------------------------------------------------------------------- 1 | $products 15 | * @param array|null $options 16 | * @param array|null $customerMetadata 17 | * @param array|null $metadata 18 | */ 19 | public function checkout(array $products, ?array $options = [], ?array $customerMetadata = [], ?array $metadata = []): Checkout 20 | { 21 | /** @var string|int $key */ 22 | $key = $this->getKey(); 23 | 24 | // We'll need a way to identify the user in any webhook we're catching so before 25 | // we make an API request we'll attach the authentication identifier to this 26 | // checkout so we can match it back to a user when handling Polar webhooks. 27 | $customerMetadata = [...($customerMetadata ?? []), 'billable_id' => (string) $key, 28 | 'billable_type' => $this->getMorphClass()]; 29 | 30 | $billingAddress = null; 31 | if (isset($options['country'])) { 32 | $countryCode = (string) $options['country']; 33 | $upperCode = strtoupper($countryCode); 34 | if ($upperCode === 'UK') { 35 | $upperCode = 'GB'; 36 | } 37 | $country = Components\CountryAlpha2Input::tryFrom($upperCode); 38 | if ($country === null) { 39 | Log::warning("Invalid country code '{$upperCode}' provided, defaulting to US"); 40 | $country = Components\CountryAlpha2Input::Us; 41 | } 42 | 43 | $billingAddress = new Components\AddressInput( 44 | country: $country, 45 | line1: isset($options['line1']) ? (string) $options['line1'] : null, 46 | line2: isset($options['line2']) ? (string) $options['line2'] : null, 47 | postalCode: isset($options['zip']) ? (string) $options['zip'] : null, 48 | city: isset($options['city']) ? (string) $options['city'] : null, 49 | state: isset($options['state']) ? (string) $options['state'] : null, 50 | ); 51 | } 52 | 53 | $checkout = Checkout::make($products) 54 | ->withCustomerName((string) ($options['customer_name'] ?? $this->polarName() ?? '')) 55 | ->withCustomerEmail((string) ($options['customer_email'] ?? $this->polarEmail() ?? '')) 56 | ->withCustomerBillingAddress($billingAddress) 57 | ->withCustomerMetadata($customerMetadata) 58 | ->withMetadata($metadata); 59 | 60 | if (isset($options['tax_id'])) { 61 | $checkout->withCustomerTaxId((string) $options['tax_id']); 62 | } 63 | 64 | if (isset($options['discount_id'])) { 65 | $checkout->withDiscountId((string) $options['discount_id']); 66 | } 67 | 68 | if (isset($options['amount']) && is_numeric($options['amount'])) { 69 | $checkout->withAmount((int) $options['amount']); 70 | } 71 | 72 | return $checkout; 73 | } 74 | 75 | /** 76 | * Create a new checkout instance to sell a product with a custom price. 77 | * 78 | * @param array $products 79 | * @param array|null $options 80 | * @param array|null $customerMetadata 81 | * @param array|null $metadata 82 | */ 83 | public function charge(int $amount, array $products, ?array $options = [], ?array $customerMetadata = [], ?array $metadata = []): Checkout 84 | { 85 | return $this->checkout($products, [...($options ?? []), 'amount' => $amount], $customerMetadata, $metadata); 86 | } 87 | 88 | /** 89 | * Subscribe the customer to a new plan variant. 90 | * 91 | * @param array|null $options 92 | * @param array|null $customerMetadata 93 | * @param array|null $metadata 94 | */ 95 | public function subscribe(string $productId, string $type = "default", ?array $options = [], ?array $customerMetadata = [], ?array $metadata = []): Checkout 96 | { 97 | return $this->checkout([$productId], $options, [...($customerMetadata ?? []), 'subscription_type' => $type], $metadata); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Order.php: -------------------------------------------------------------------------------- 1 | */ 37 | use HasFactory; 38 | 39 | /** 40 | * The table associated with the model. 41 | */ 42 | protected $table = 'polar_orders'; 43 | 44 | /** 45 | * The attributes that are not mass assignable. 46 | * 47 | * @var array 48 | */ 49 | protected $guarded = []; 50 | 51 | /** 52 | * Get the billable model related to the customer. 53 | * 54 | * @return MorphTo 55 | */ 56 | public function billable(): MorphTo 57 | { 58 | return $this->morphTo(); 59 | } 60 | 61 | /** 62 | * Check if the order is paid. 63 | */ 64 | public function paid(): bool 65 | { 66 | return $this->status === OrderStatus::Paid; 67 | } 68 | 69 | /** 70 | * Filter query by paid. 71 | * 72 | * @param Builder $query 73 | */ 74 | public function scopePaid(Builder $query): void 75 | { 76 | $query->where('status', OrderStatus::Paid); 77 | } 78 | 79 | /** 80 | * Check if the order is refunded. 81 | */ 82 | public function refunded(): bool 83 | { 84 | return $this->status === OrderStatus::Refunded; 85 | } 86 | 87 | /** 88 | * Filter query by refunded. 89 | * 90 | * @param Builder $query 91 | */ 92 | public function scopeRefunded(Builder $query): void 93 | { 94 | $query->where('status', OrderStatus::Refunded); 95 | } 96 | 97 | /** 98 | * Check if the order is partially refunded. 99 | */ 100 | public function partiallyRefunded(): bool 101 | { 102 | return $this->status === OrderStatus::PartiallyRefunded; 103 | } 104 | 105 | /** 106 | * Filter query by partially refunded. 107 | * 108 | * @param Builder $query 109 | */ 110 | public function scopePartiallyRefunded(Builder $query): void 111 | { 112 | $query->where('status', OrderStatus::PartiallyRefunded); 113 | } 114 | 115 | /** 116 | * Determine if the order is for a specific product. 117 | */ 118 | public function hasProduct(string $productId): bool 119 | { 120 | return $this->product_id === $productId; 121 | } 122 | 123 | /** 124 | * Sync the order with the given attributes. 125 | * 126 | * @param array $attributes 127 | */ 128 | public function sync(array $attributes): self 129 | { 130 | $this->update([ 131 | 'polar_id' => $attributes['id'], 132 | 'status' => \is_string($attributes['status']) ? OrderStatus::from($attributes['status']) : $attributes['status'], 133 | 'amount' => $attributes['amount'], 134 | 'tax_amount' => $attributes['tax_amount'], 135 | 'refunded_amount' => $attributes['refunded_amount'], 136 | 'refunded_tax_amount' => $attributes['refunded_tax_amount'], 137 | 'currency' => $attributes['currency'], 138 | 'billing_reason' => $attributes['billing_reason'], 139 | 'customer_id' => $attributes['customer_id'], 140 | 'product_id' => $attributes['product_id'], 141 | 'refunded_at' => $attributes['refunded_at'], 142 | 'ordered_at' => $attributes['created_at'], 143 | ]); 144 | 145 | return $this; 146 | } 147 | 148 | /** 149 | * The attributes that should be cast to native types. 150 | */ 151 | protected function casts(): array 152 | { 153 | return [ 154 | 'status' => OrderStatus::class, 155 | 'ordered_at' => 'datetime', 156 | 'refunded_at' => 'datetime', 157 | ]; 158 | } 159 | 160 | protected static function newFactory(): OrderFactory 161 | { 162 | return OrderFactory::new(); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/Commands/ListProductsCommand.php: -------------------------------------------------------------------------------- 1 | validate()) { 45 | return static::FAILURE; 46 | } 47 | 48 | $options = $this->options(); 49 | $request = new Operations\ProductsListRequest( 50 | id: $this->normalizeArrayOption($options['id'] ?? []), 51 | organizationId: $this->normalizeArrayOption($options['organization-id'] ?? []), 52 | query: $options['query'] ?? null, 53 | isArchived: $options['archived'] ?? null ? true : null, 54 | isRecurring: $options['recurring'] ?? null ? true : null, 55 | benefitId: $this->normalizeArrayOption($options['benefit-id'] ?? []), 56 | sorting: !empty($options['sorting']) ? $this->mapSorting($options['sorting']) : null, 57 | metadata: null, 58 | page: isset($options['page']) && is_numeric($options['page']) ? (int) $options['page'] : null, 59 | limit: isset($options['limit']) && is_numeric($options['limit']) ? (int) $options['limit'] : null, 60 | ); 61 | 62 | return $this->handleProducts($request); 63 | } 64 | 65 | protected function validate(): bool 66 | { 67 | $validator = Validator::make([ 68 | ...config('polar'), 69 | ], [ 70 | 'access_token' => 'required', 71 | ], [ 72 | 'access_token.required' => 'Polar access token not set. You can add it to your .env file as POLAR_ACCESS_TOKEN.', 73 | ]); 74 | 75 | if ($validator->passes()) { 76 | return true; 77 | } 78 | 79 | $this->newLine(); 80 | 81 | foreach ($validator->errors()->all() as $error) { 82 | error($error); 83 | } 84 | 85 | return false; 86 | } 87 | 88 | protected function handleProducts(Operations\ProductsListRequest $request): int 89 | { 90 | $productsResponse = spin( 91 | fn() => LaravelPolar::listProducts($request), 92 | '⚪ Fetching products information...', 93 | ); 94 | 95 | if ($productsResponse->listResourceProduct === null) { 96 | $this->error('No products found.'); 97 | 98 | return static::FAILURE; 99 | } 100 | 101 | $products = collect($productsResponse->listResourceProduct->items); 102 | 103 | $this->newLine(); 104 | $this->displayTitle(); 105 | $this->newLine(); 106 | 107 | $products->each(function (Components\Product $product) { 108 | $this->displayProduct($product); 109 | 110 | $this->newLine(); 111 | }); 112 | 113 | return static::SUCCESS; 114 | } 115 | 116 | protected function displayTitle(): void 117 | { 118 | $this->components->twoColumnDetail('Product', 'ID'); 119 | } 120 | 121 | protected function displayProduct(Components\Product $product): void 122 | { 123 | $this->components->twoColumnDetail( 124 | sprintf('%s', $product->name), 125 | $product->id, 126 | ); 127 | } 128 | 129 | /** 130 | * Normalize array option to single value or array. 131 | * 132 | * @param array $values 133 | * @return string|array|null 134 | */ 135 | protected function normalizeArrayOption(array $values): string|array|null 136 | { 137 | if (empty($values)) { 138 | return null; 139 | } 140 | 141 | return count($values) === 1 ? $values[0] : $values; 142 | } 143 | 144 | /** 145 | * Map sorting strings to ProductSortProperty enum values. 146 | * 147 | * @param array $sorting 148 | * @return array 149 | */ 150 | protected function mapSorting(array $sorting): array 151 | { 152 | $mapped = []; 153 | 154 | foreach ($sorting as $sort) { 155 | $property = Components\ProductSortProperty::tryFrom($sort); 156 | 157 | if ($property !== null) { 158 | $mapped[] = $property; 159 | } else { 160 | $this->components->warn("Unknown sorting criterion ignored: {$sort}"); 161 | } 162 | } 163 | 164 | return $mapped; 165 | } 166 | 167 | /** 168 | * Get the console command options. 169 | * 170 | * @return array 171 | */ 172 | protected function getOptions() 173 | { 174 | return [ 175 | ['id', null, InputOption::VALUE_IS_ARRAY, 'Filter by a single product id or multiple product ids.'], 176 | ['organization-id', null, InputOption::VALUE_IS_ARRAY, 'Filter by a single organization id or multiple organization ids.'], 177 | ['query', null, InputOption::VALUE_REQUIRED, 'Filter by product name.'], 178 | ['archived', null, InputOption::VALUE_NONE, 'Filter on archived products.'], 179 | ['recurring', null, InputOption::VALUE_NONE, 'Filter on recurring products.'], 180 | ['benefit-id', null, InputOption::VALUE_IS_ARRAY, 'Filter by a single benefit id or multiple benefit ids.'], 181 | ['page', null, InputOption::VALUE_NONE, 'Page number, defaults to 1.'], 182 | ['limit', null, InputOption::VALUE_NONE, 'Size of a page, defaults to 10. Maximum is 100.'], 183 | ['sorting', null, InputOption::VALUE_IS_ARRAY, 'Sorting criterion. Several criteria can be used simultaneously and will be applied in order. Add a minus sign - before the criteria name to sort by descending order. Available options: created_at, -created_at, name, -name, price_amount_type, -price_amount_type, price_amount, -price_amount'], 184 | ]; 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/Checkout.php: -------------------------------------------------------------------------------- 1 | */ 15 | private ?array $metadata = null; 16 | 17 | /** @var ?array */ 18 | private ?array $customFieldData = null; 19 | 20 | /** @var ?array */ 21 | private ?array $customerMetadata = null; 22 | 23 | private ?string $discountId = null; 24 | 25 | private bool $allowDiscountCodes = true; 26 | 27 | private ?int $amount = null; 28 | 29 | private ?string $customerId = null; 30 | 31 | private ?string $customerExternalId = null; 32 | 33 | private ?string $customerName = null; 34 | 35 | private ?string $customerEmail = null; 36 | 37 | private ?string $customerIpAddress = null; 38 | 39 | private ?Components\AddressInput $customerBillingAddress = null; 40 | 41 | private ?string $customerTaxId = null; 42 | 43 | private ?string $subscriptionId = null; 44 | 45 | private ?string $successUrl = null; 46 | 47 | private ?string $embedOrigin = null; 48 | 49 | /** 50 | * @param array $products 51 | */ 52 | public function __construct(private readonly array $products) {} 53 | 54 | /** 55 | * @param array $products 56 | */ 57 | public static function make(array $products): self 58 | { 59 | return new self($products); 60 | } 61 | 62 | /** 63 | * Key-value object allowing you to store additional information. 64 | * 65 | * The key must be a string with a maximum length of **40 characters**. The value must be either: 66 | * 67 | * - A string with a maximum length of **500 characters** 68 | * - An integer 69 | * - A boolean 70 | * 71 | * You can store up to **50 key-value pairs**. 72 | * 73 | * @param ?array $metadata 74 | */ 75 | public function withMetadata(?array $metadata): self 76 | { 77 | $this->metadata = ($metadata === []) ? null : $metadata; 78 | 79 | return $this; 80 | } 81 | 82 | /** 83 | * Key-value object storing custom field values. 84 | * 85 | * @param ?array $customFieldData 86 | */ 87 | public function withCustomFieldData(?array $customFieldData): self 88 | { 89 | $this->customFieldData = ($customFieldData === []) ? null : $customFieldData; 90 | 91 | return $this; 92 | } 93 | 94 | /** 95 | * Key-value object allowing you to store additional information that'll be copied to the created customer. 96 | * 97 | * The key must be a string with a maximum length of **40 characters**. The value must be either: 98 | * 99 | * - A string with a maximum length of **500 characters** 100 | * - An integer 101 | * - A boolean 102 | * 103 | * You can store up to **50 key-value pairs**. 104 | * 105 | * @param ?array $customerMetadata 106 | */ 107 | public function withCustomerMetadata(?array $customerMetadata): self 108 | { 109 | // Process input: trim strings and filter out nulls (defensive programming) 110 | $processed = collect($customerMetadata) 111 | ->map(fn($value) => is_string($value) ? trim($value) : $value) 112 | /** @phpstan-ignore-next-line Defensive: filter out nulls even though type doesn't allow them */ 113 | ->filter(fn($value) => $value !== null) 114 | ->toArray(); 115 | 116 | // Convert empty array to null for SDK serialization 117 | $this->customerMetadata = ($processed === []) ? null : $processed; 118 | 119 | return $this; 120 | } 121 | 122 | /** 123 | * ID of the discount to apply to the checkout. 124 | */ 125 | public function withDiscountId(string $discountId): self 126 | { 127 | $this->discountId = $discountId; 128 | 129 | return $this; 130 | } 131 | 132 | /** 133 | * Whether to allow the customer to apply discount codes. If you apply a discount through `discount_id`, it'll still be applied, but the customer won't be able to change it. 134 | */ 135 | public function withoutDiscountCodes(): self 136 | { 137 | $this->allowDiscountCodes = false; 138 | 139 | return $this; 140 | } 141 | 142 | /** 143 | * The custom amount to charge the customer. 144 | */ 145 | public function withAmount(int $amount): self 146 | { 147 | $this->amount = $amount; 148 | 149 | return $this; 150 | } 151 | 152 | /** 153 | * ID of an existing customer in the organization. The customer data will be pre-filled in the checkout form. The resulting order will be linked to this customer. 154 | */ 155 | public function withCustomerId(string $customerId): self 156 | { 157 | $this->customerId = $customerId; 158 | 159 | return $this; 160 | } 161 | 162 | /** 163 | * ID of the customer in your system. If a matching customer exists on Polar, the resulting order will be linked to this customer. Otherwise, a new customer will be created with this external ID set. 164 | */ 165 | public function withCustomerExternalId(string $customerExternalId): self 166 | { 167 | $this->customerExternalId = $customerExternalId; 168 | 169 | return $this; 170 | } 171 | 172 | public function withCustomerName(string $customerName): self 173 | { 174 | $this->customerName = $customerName; 175 | 176 | return $this; 177 | } 178 | 179 | public function withCustomerEmail(string $customerEmail): self 180 | { 181 | $this->customerEmail = $customerEmail; 182 | 183 | return $this; 184 | } 185 | 186 | public function withCustomerIpAddress(string $customerIpAddress): self 187 | { 188 | $this->customerIpAddress = $customerIpAddress; 189 | 190 | return $this; 191 | } 192 | 193 | public function withCustomerBillingAddress(?Components\AddressInput $customerBillingAddress): self 194 | { 195 | $this->customerBillingAddress = $customerBillingAddress; 196 | 197 | return $this; 198 | } 199 | 200 | public function withCustomerTaxId(string $customerTaxId): self 201 | { 202 | $this->customerTaxId = $customerTaxId; 203 | 204 | return $this; 205 | } 206 | 207 | /** 208 | * ID of a subscription to upgrade. It must be on a free pricing. If checkout is successful, metadata set on this checkout will be copied to the subscription, and existing keys will be overwritten. 209 | */ 210 | public function withSubscriptionId(string $subscriptionId): self 211 | { 212 | $this->subscriptionId = $subscriptionId; 213 | 214 | return $this; 215 | } 216 | 217 | /** 218 | * URL where the customer will be redirected after a successful payment. You can add the `checkout_id={CHECKOUT_ID}` query parameter to retrieve the checkout session id. 219 | */ 220 | public function withSuccessUrl(string $successUrl): self 221 | { 222 | $this->successUrl = $successUrl; 223 | 224 | return $this; 225 | } 226 | 227 | /** 228 | * If you plan to embed the checkout session, set this to the Origin of the embedding page. It'll allow the Polar iframe to communicate with the parent page. 229 | */ 230 | public function withEmbedOrigin(string $embedOrigin): self 231 | { 232 | $this->embedOrigin = $embedOrigin; 233 | 234 | return $this; 235 | } 236 | 237 | public function toResponse($request): RedirectResponse 238 | { 239 | return $this->redirect(); 240 | } 241 | 242 | public function redirect(): RedirectResponse 243 | { 244 | return Redirect::to($this->url(), 303); 245 | } 246 | 247 | /** 248 | * URL where the customer can access the checkout session. 249 | * 250 | * @throws Errors\APIException 251 | * @throws Errors\HTTPValidationErrorThrowable 252 | */ 253 | public function url(): string 254 | { 255 | $billingAddress = $this->customerBillingAddress; 256 | 257 | $request = new Components\CheckoutCreate( 258 | products: $this->products, 259 | metadata: $this->metadata, 260 | customFieldData: $this->customFieldData, 261 | discountId: $this->discountId, 262 | allowDiscountCodes: $this->allowDiscountCodes, 263 | amount: $this->amount, 264 | customerId: $this->customerId, 265 | externalCustomerId: $this->customerExternalId, 266 | customerName: $this->customerName, 267 | customerEmail: $this->customerEmail, 268 | customerIpAddress: $this->customerIpAddress, 269 | customerBillingAddress: $billingAddress, 270 | customerTaxId: $this->customerTaxId, 271 | customerMetadata: $this->customerMetadata, 272 | subscriptionId: $this->subscriptionId, 273 | successUrl: $this->successUrl, 274 | embedOrigin: $this->embedOrigin, 275 | ); 276 | 277 | $checkout = LaravelPolar::createCheckoutSession($request); 278 | 279 | if (!$checkout->url) { 280 | throw new Errors\APIException('Failed to create checkout session', 500, '', null); 281 | } 282 | 283 | return $checkout->url; 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/Subscription.php: -------------------------------------------------------------------------------- 1 | */ 36 | use HasFactory; 37 | 38 | /** 39 | * The table associated with the model. 40 | */ 41 | protected $table = 'polar_subscriptions'; 42 | 43 | /** 44 | * The attributes that are not mass assignable. 45 | * 46 | * @var array 47 | */ 48 | protected $guarded = []; 49 | 50 | /** 51 | * Get the billable model related to the subscription. 52 | * 53 | * @return MorphTo 54 | */ 55 | public function billable(): MorphTo 56 | { 57 | return $this->morphTo(); 58 | } 59 | 60 | /** 61 | * Determine if the subscription is active, on trial, past due, or within its grace period. 62 | */ 63 | public function valid(): bool 64 | { 65 | return $this->active() || $this->onTrial() || $this->pastDue() || $this->onGracePeriod(); 66 | } 67 | 68 | /** 69 | * Determine if the subscription is incomplete. 70 | */ 71 | public function incomplete(): bool 72 | { 73 | return $this->status === SubscriptionStatus::Incomplete; 74 | } 75 | 76 | /** 77 | * Filter query by incomplete. 78 | * 79 | * @param Builder $query 80 | */ 81 | public function scopeIncomplete(Builder $query): void 82 | { 83 | $query->where('status', SubscriptionStatus::Incomplete); 84 | } 85 | 86 | /** 87 | * Determine if the subscription is incomplete expired. 88 | */ 89 | public function incompleteExpired(): bool 90 | { 91 | return $this->status === SubscriptionStatus::IncompleteExpired; 92 | } 93 | 94 | /** 95 | * Filter query by incomplete expired. 96 | * 97 | * @param Builder $query 98 | */ 99 | public function scopeIncompleteExpired(Builder $query): void 100 | { 101 | $query->where('status', SubscriptionStatus::IncompleteExpired); 102 | } 103 | 104 | /** 105 | * Determine if the subscription is trialing. 106 | */ 107 | public function onTrial(): bool 108 | { 109 | return $this->status === SubscriptionStatus::Trialing; 110 | } 111 | 112 | /** 113 | * Filter query by on trial. 114 | * 115 | * @param Builder $query 116 | */ 117 | public function scopeOnTrial(Builder $query): void 118 | { 119 | $query->where('status', SubscriptionStatus::Trialing); 120 | } 121 | 122 | /** 123 | * Determine if the subscription's trial has expired. 124 | */ 125 | public function hasExpiredTrial(): bool 126 | { 127 | return $this->trial_ends_at && $this->trial_ends_at->isPast(); 128 | } 129 | 130 | /** 131 | * Check if the subscription is active. 132 | */ 133 | public function active(): bool 134 | { 135 | return $this->status === SubscriptionStatus::Active; 136 | } 137 | 138 | /** 139 | * Filter query by active. 140 | * 141 | * @param Builder $query 142 | */ 143 | public function scopeActive(Builder $query): void 144 | { 145 | $query->where('status', SubscriptionStatus::Active); 146 | } 147 | 148 | /** 149 | * Check if the subscription is past due. 150 | */ 151 | public function pastDue(): bool 152 | { 153 | return $this->status === SubscriptionStatus::PastDue; 154 | } 155 | 156 | /** 157 | * Filter query by past due. 158 | * 159 | * @param Builder $query 160 | */ 161 | public function scopePastDue(Builder $query): void 162 | { 163 | $query->where('status', SubscriptionStatus::PastDue); 164 | } 165 | 166 | /** 167 | * Check if the subscription is unpaid. 168 | */ 169 | public function unpaid(): bool 170 | { 171 | return $this->status === SubscriptionStatus::Unpaid; 172 | } 173 | 174 | /** 175 | * Filter query by unpaid. 176 | * 177 | * @param Builder $query 178 | */ 179 | public function scopeUnpaid(Builder $query): void 180 | { 181 | $query->where('status', SubscriptionStatus::Unpaid); 182 | } 183 | 184 | /** 185 | * Check if the subscription is cancelled. 186 | */ 187 | public function cancelled(): bool 188 | { 189 | return $this->status === SubscriptionStatus::Canceled; 190 | } 191 | 192 | /** 193 | * Filter query by cancelled. 194 | * 195 | * @param Builder $query 196 | */ 197 | public function scopeCancelled(Builder $query): void 198 | { 199 | $query->where('status', SubscriptionStatus::Canceled); 200 | } 201 | 202 | /** 203 | * Determine if the subscription is within its grace period after cancellation. 204 | */ 205 | public function onGracePeriod(): bool 206 | { 207 | return $this->cancelled() && $this->ends_at?->isFuture(); 208 | } 209 | 210 | /** 211 | * Determine if the subscription is on a specific product. 212 | */ 213 | public function hasProduct(string $productId): bool 214 | { 215 | return $this->product_id === $productId; 216 | } 217 | 218 | /** 219 | * Swap the subscription to a new product. 220 | */ 221 | public function swap(string $productId, ?SubscriptionProrationBehavior $prorationBehavior = SubscriptionProrationBehavior::Prorate): self 222 | { 223 | $request = new Components\SubscriptionUpdateProduct( 224 | productId: $productId, 225 | prorationBehavior: $prorationBehavior ?? SubscriptionProrationBehavior::Prorate, 226 | ); 227 | 228 | return $this->updateAndSync($request); 229 | } 230 | 231 | /** 232 | * Swap the subscription to a new product plan and invoice immediately. 233 | */ 234 | public function swapAndInvoice(string $productId): self 235 | { 236 | return $this->swap($productId, SubscriptionProrationBehavior::Invoice); 237 | } 238 | 239 | /** 240 | * Cancel the subscription. 241 | */ 242 | public function cancel(): self 243 | { 244 | $request = new Components\SubscriptionCancel(cancelAtPeriodEnd: true); 245 | 246 | return $this->updateAndSync($request); 247 | } 248 | 249 | /** 250 | * Resume the subscription. 251 | */ 252 | public function resume(): self 253 | { 254 | if ($this->status === SubscriptionStatus::IncompleteExpired) { 255 | throw new PolarApiError('Subscription is incomplete and expired.'); 256 | } 257 | 258 | $request = new Components\SubscriptionCancel(cancelAtPeriodEnd: false); 259 | 260 | return $this->updateAndSync($request); 261 | } 262 | 263 | /** 264 | * Update the subscription and sync the changes. 265 | * 266 | * @param Components\SubscriptionUpdateProduct|Components\SubscriptionCancel|Components\SubscriptionUpdateDiscount|Components\SubscriptionUpdateTrial|Components\SubscriptionUpdateSeats|Components\SubscriptionRevoke $request 267 | */ 268 | private function updateAndSync(Components\SubscriptionUpdateProduct|Components\SubscriptionCancel|Components\SubscriptionUpdateDiscount|Components\SubscriptionUpdateTrial|Components\SubscriptionUpdateSeats|Components\SubscriptionRevoke $request): self 269 | { 270 | $response = LaravelPolar::updateSubscription( 271 | subscriptionId: $this->polar_id, 272 | request: $request, 273 | ); 274 | 275 | $this->syncFromSdkComponent($response); 276 | 277 | return $this; 278 | } 279 | 280 | /** 281 | * Sync the subscription from SDK component. 282 | */ 283 | private function syncFromSdkComponent(Components\Subscription $subscription): self 284 | { 285 | $this->update([ 286 | 'status' => $subscription->status, 287 | 'product_id' => $subscription->productId, 288 | 'current_period_end' => $subscription->currentPeriodEnd ? Carbon::make($subscription->currentPeriodEnd) : null, 289 | 'ends_at' => $subscription->endedAt ? Carbon::make($subscription->endedAt) : null, 290 | ]); 291 | 292 | return $this; 293 | } 294 | 295 | /** 296 | * Sync the subscription with the given attributes. 297 | * 298 | * @param array $attributes 299 | */ 300 | public function sync(array $attributes): self 301 | { 302 | $this->update([ 303 | 'status' => \is_string($attributes['status']) ? SubscriptionStatus::from($attributes['status']) : $attributes['status'], 304 | 'product_id' => $attributes['product_id'], 305 | 'current_period_end' => isset($attributes['current_period_end']) ? Carbon::make($attributes['current_period_end']) : null, 306 | 'ends_at' => isset($attributes['ends_at']) ? Carbon::make($attributes['ends_at']) : null, 307 | ]); 308 | 309 | return $this; 310 | } 311 | 312 | 313 | /** 314 | * The attributes that should be cast. 315 | */ 316 | protected function casts(): array 317 | { 318 | return [ 319 | 'status' => SubscriptionStatus::class, 320 | 'current_period_end' => 'datetime', 321 | 'ends_at' => 'datetime', 322 | ]; 323 | } 324 | 325 | protected static function newFactory(): SubscriptionFactory 326 | { 327 | return SubscriptionFactory::new(); 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /src/LaravelPolar.php: -------------------------------------------------------------------------------- 1 | checkouts->create(request: $request); 46 | 47 | if ($response->statusCode === 201 && $response->checkout !== null) { 48 | return $response->checkout; 49 | } 50 | 51 | throw new Errors\APIException('Failed to create checkout session', $response->statusCode ?? 500, '', null); 52 | } 53 | 54 | /** 55 | * Update a subscription. 56 | * 57 | * @param Components\SubscriptionUpdateProduct|Components\SubscriptionCancel|Components\SubscriptionUpdateDiscount|Components\SubscriptionUpdateTrial|Components\SubscriptionUpdateSeats|Components\SubscriptionRevoke $request 58 | * 59 | * @throws Errors\APIException 60 | * @throws Exception 61 | */ 62 | public static function updateSubscription(string $subscriptionId, Components\SubscriptionUpdateProduct|Components\SubscriptionCancel|Components\SubscriptionUpdateDiscount|Components\SubscriptionUpdateTrial|Components\SubscriptionUpdateSeats|Components\SubscriptionRevoke $request): Components\Subscription 63 | { 64 | $sdk = self::sdk(); 65 | 66 | $response = $sdk->subscriptions->update( 67 | id: $subscriptionId, 68 | subscriptionUpdate: $request, 69 | ); 70 | 71 | if ($response->statusCode === 200 && $response->subscription !== null) { 72 | return $response->subscription; 73 | } 74 | 75 | throw new Errors\APIException('Failed to update subscription', 500, '', null); 76 | } 77 | 78 | /** 79 | * List all products. 80 | * 81 | * @throws Errors\APIException 82 | * @throws Exception 83 | */ 84 | public static function listProducts(?Operations\ProductsListRequest $request = null): Operations\ProductsListResponse 85 | { 86 | $sdk = self::sdk(); 87 | 88 | if ($request === null) { 89 | $request = new Operations\ProductsListRequest(); 90 | } 91 | 92 | $generator = $sdk->products->list(request: $request); 93 | 94 | foreach ($generator as $response) { 95 | if ($response->statusCode === 200) { 96 | return $response; 97 | } 98 | } 99 | 100 | throw new Errors\APIException('Failed to list products', 500, '', null); 101 | } 102 | 103 | /** 104 | * Create a customer session. 105 | * 106 | * @param Components\CustomerSessionCustomerIDCreate|Components\CustomerSessionCustomerExternalIDCreate $request 107 | * 108 | * @throws Errors\APIException 109 | * @throws Exception 110 | */ 111 | public static function createCustomerSession(Components\CustomerSessionCustomerIDCreate|Components\CustomerSessionCustomerExternalIDCreate $request): Components\CustomerSession 112 | { 113 | $sdk = self::sdk(); 114 | 115 | $response = $sdk->customerSessions->create(request: $request); 116 | 117 | if ($response->statusCode === 201 && $response->customerSession !== null) { 118 | return $response->customerSession; 119 | } 120 | 121 | throw new Errors\APIException('Failed to create customer session', 500, '', null); 122 | } 123 | 124 | /** 125 | * Create a benefit. 126 | * 127 | * @param Components\BenefitCustomCreate|Components\BenefitDiscordCreate|Components\BenefitGitHubRepositoryCreate|Components\BenefitDownloadablesCreate|Components\BenefitLicenseKeysCreate|Components\BenefitMeterCreditCreate $request 128 | * 129 | * @throws Errors\APIException 130 | * @throws Exception 131 | */ 132 | public static function createBenefit(Components\BenefitCustomCreate|Components\BenefitDiscordCreate|Components\BenefitGitHubRepositoryCreate|Components\BenefitDownloadablesCreate|Components\BenefitLicenseKeysCreate|Components\BenefitMeterCreditCreate $request): Components\BenefitCustom|Components\BenefitDiscord|Components\BenefitGitHubRepository|Components\BenefitDownloadables|Components\BenefitLicenseKeys|Components\BenefitMeterCredit 133 | { 134 | $sdk = self::sdk(); 135 | 136 | $response = $sdk->benefits->create(request: $request); 137 | 138 | if ($response->statusCode === 201 && $response->benefit !== null) { 139 | return $response->benefit; 140 | } 141 | 142 | throw new Errors\APIException('Failed to create benefit', 500, '', null); 143 | } 144 | 145 | /** 146 | * Update a benefit. 147 | * 148 | * @param Components\BenefitCustomUpdate|Components\BenefitDiscordUpdate|Components\BenefitGitHubRepositoryUpdate|Components\BenefitDownloadablesUpdate|Components\BenefitLicenseKeysUpdate|Components\BenefitMeterCreditUpdate $request 149 | * 150 | * @throws Errors\APIException 151 | * @throws Exception 152 | */ 153 | public static function updateBenefit(string $benefitId, Components\BenefitCustomUpdate|Components\BenefitDiscordUpdate|Components\BenefitGitHubRepositoryUpdate|Components\BenefitDownloadablesUpdate|Components\BenefitLicenseKeysUpdate|Components\BenefitMeterCreditUpdate $request): Components\BenefitCustom|Components\BenefitDiscord|Components\BenefitGitHubRepository|Components\BenefitDownloadables|Components\BenefitLicenseKeys|Components\BenefitMeterCredit 154 | { 155 | $sdk = self::sdk(); 156 | 157 | $response = $sdk->benefits->update(id: $benefitId, requestBody: $request); 158 | 159 | if ($response->statusCode === 200 && $response->benefit !== null) { 160 | return $response->benefit; 161 | } 162 | 163 | throw new Errors\APIException('Failed to update benefit', 500, '', null); 164 | } 165 | 166 | /** 167 | * Delete a benefit. 168 | * 169 | * @throws Errors\APIException 170 | * @throws Exception 171 | */ 172 | public static function deleteBenefit(string $benefitId): void 173 | { 174 | $sdk = self::sdk(); 175 | 176 | $response = $sdk->benefits->delete(id: $benefitId); 177 | 178 | if ($response->statusCode !== 200 && $response->statusCode !== 204) { 179 | throw new Errors\APIException('Failed to delete benefit', 500, '', null); 180 | } 181 | } 182 | 183 | /** 184 | * List all benefits. 185 | * 186 | * @throws Errors\APIException 187 | * @throws Exception 188 | */ 189 | public static function listBenefits(Operations\BenefitsListRequest $request): Operations\BenefitsListResponse 190 | { 191 | $sdk = self::sdk(); 192 | 193 | $generator = $sdk->benefits->list(request: $request); 194 | 195 | foreach ($generator as $response) { 196 | if ($response->statusCode === 200) { 197 | return $response; 198 | } 199 | } 200 | 201 | throw new Errors\APIException('Failed to list benefits', 500, '', null); 202 | } 203 | 204 | /** 205 | * Get a specific benefit by ID. 206 | * 207 | * @throws Errors\APIException 208 | * @throws Exception 209 | */ 210 | public static function getBenefit(string $benefitId): Components\BenefitCustom|Components\BenefitDiscord|Components\BenefitGitHubRepository|Components\BenefitDownloadables|Components\BenefitLicenseKeys|Components\BenefitMeterCredit 211 | { 212 | $sdk = self::sdk(); 213 | 214 | $response = $sdk->benefits->get(id: $benefitId); 215 | 216 | if ($response->statusCode === 200 && $response->benefit !== null) { 217 | return $response->benefit; 218 | } 219 | 220 | throw new Errors\APIException('Failed to get benefit', 500, '', null); 221 | } 222 | 223 | /** 224 | * List all grants for a specific benefit. 225 | * 226 | * @throws Errors\APIException 227 | * @throws Exception 228 | */ 229 | public static function listBenefitGrants(Operations\BenefitsGrantsRequest $request): Operations\BenefitsGrantsResponse 230 | { 231 | $sdk = self::sdk(); 232 | 233 | $generator = $sdk->benefits->grants(request: $request); 234 | 235 | foreach ($generator as $response) { 236 | if ($response->statusCode === 200) { 237 | return $response; 238 | } 239 | } 240 | 241 | throw new Errors\APIException('Failed to list benefit grants', 500, '', null); 242 | } 243 | 244 | /** 245 | * Ingest usage events for metered billing. 246 | * 247 | * @throws Errors\APIException 248 | * @throws Exception 249 | */ 250 | public static function ingestEvents(Components\EventsIngest $request): void 251 | { 252 | $sdk = self::sdk(); 253 | 254 | $response = $sdk->events->ingest(request: $request); 255 | 256 | if ($response->statusCode !== 202) { 257 | throw new Errors\APIException('Failed to ingest events', 500, '', null); 258 | } 259 | } 260 | 261 | /** 262 | * List customer meters. 263 | * 264 | * @throws Errors\APIException 265 | * @throws Exception 266 | */ 267 | public static function listCustomerMeters(Operations\CustomerMetersListRequest $request): Operations\CustomerMetersListResponse 268 | { 269 | $sdk = self::sdk(); 270 | 271 | $generator = $sdk->customerMeters->list(request: $request); 272 | 273 | foreach ($generator as $response) { 274 | if ($response->statusCode === 200) { 275 | return $response; 276 | } 277 | } 278 | 279 | throw new Errors\APIException('Failed to list customer meters', 500, '', null); 280 | } 281 | 282 | /** 283 | * Get a specific customer meter by ID. 284 | * 285 | * @throws Errors\APIException 286 | * @throws Exception 287 | */ 288 | public static function getCustomerMeter(string $meterId): Components\CustomerMeter 289 | { 290 | $sdk = self::sdk(); 291 | 292 | $response = $sdk->customerMeters->get(id: $meterId); 293 | 294 | if ($response->statusCode === 200 && $response->customerMeter !== null) { 295 | return $response->customerMeter; 296 | } 297 | 298 | throw new Errors\APIException('Failed to get customer meter', 500, '', null); 299 | } 300 | 301 | /** 302 | * Reset the cached SDK instance (useful for testing). 303 | */ 304 | public static function resetSdk(): void 305 | { 306 | self::$sdkInstance = null; 307 | } 308 | 309 | /** 310 | * Set the SDK instance (useful for testing). 311 | */ 312 | public static function setSdk(?Polar $sdk): void 313 | { 314 | self::$sdkInstance = $sdk; 315 | } 316 | 317 | /** 318 | * Get or create a cached Polar SDK instance. 319 | * 320 | * @throws Exception 321 | */ 322 | public static function sdk(): Polar 323 | { 324 | if (self::$sdkInstance !== null) { 325 | return self::$sdkInstance; 326 | } 327 | 328 | if (empty($apiKey = config('polar.access_token'))) { 329 | throw new Exception('Polar API key not set.'); 330 | } 331 | 332 | self::$sdkInstance = Polar::builder() 333 | ->setSecurity($apiKey) 334 | ->setServer(config('polar.server', 'sandbox')) 335 | ->build(); 336 | 337 | return self::$sdkInstance; 338 | } 339 | 340 | /** 341 | * Set the customer model class name. 342 | */ 343 | public static function useCustomerModel(string $customerModel): void 344 | { 345 | static::$customerModel = $customerModel; 346 | } 347 | 348 | /** 349 | * Set the subscription model class name. 350 | */ 351 | public static function useSubscriptionModel(string $subscriptionModel): void 352 | { 353 | static::$subscriptionModel = $subscriptionModel; 354 | } 355 | 356 | /** 357 | * Set the order model class name. 358 | */ 359 | public static function useOrderModel(string $orderModel): void 360 | { 361 | static::$orderModel = $orderModel; 362 | } 363 | } 364 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-polar` will be documented in this file. 4 | 5 | ## v2.0.3 - 2025-12-10 6 | 7 | ### What's Changed 8 | 9 | * fix: `createCustomerSession` status code by @andrzejchmura in https://github.com/danestves/laravel-polar/pull/52 10 | * fix documentation about embedded checkout by @einenlum in https://github.com/danestves/laravel-polar/pull/53 11 | * fix: specify factories for models by @einenlum in https://github.com/danestves/laravel-polar/pull/51 12 | 13 | ### New Contributors 14 | 15 | * @andrzejchmura made their first contribution in https://github.com/danestves/laravel-polar/pull/52 16 | * @einenlum made their first contribution in https://github.com/danestves/laravel-polar/pull/53 17 | 18 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v2.0.2...v2.0.3 19 | 20 | ## v2.0.2 - 2025-12-03 21 | 22 | ### What's Changed 23 | 24 | * fix: handle null parameters spread by @adiologydev in https://github.com/danestves/laravel-polar/pull/49 25 | 26 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v2.0.1...v2.0.2 27 | 28 | ## v2.0.1 - 2025-12-02 29 | 30 | ### What's Changed 31 | 32 | * refactor: convert empty arrays to null in metadata handling methods by @adiologydev in https://github.com/danestves/laravel-polar/pull/48 33 | 34 | ### New Contributors 35 | 36 | * @adiologydev made their first contribution in https://github.com/danestves/laravel-polar/pull/48 37 | 38 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v2.0.0...v2.0.1 39 | 40 | ## v2.0.0 - 2025-12-02 41 | 42 | ### What's Changed 43 | 44 | 🎉 **We're excited to announce Laravel Polar v2.0.0!** This major release brings significant improvements, new features, and important breaking changes to align with the latest Polar API and modern PHP/Laravel standards. 45 | 46 | ### 🚀 What's New 47 | 48 | #### Major Features 49 | 50 | - **✨ Laravel 11 & 12 Support**: Full support for Laravel 11.x and 12.x with modern Laravel features 51 | - **🔌 Enhanced Webhook Support**: Added 10 new webhook event types for better integration capabilities 52 | - **📊 Benefits Management**: Complete support for Polar Benefits API 53 | - **📈 Customer Meters**: Full support for usage-based billing and customer meters 54 | - **🎯 Improved Checkout API**: Enhanced checkout functionality with custom fields and discount code controls 55 | - **🔧 Polar SDK Integration**: Migrated to use Polar SDK Components for better type safety and API alignment 56 | 57 | #### New Webhook Events 58 | 59 | This release introduces support for 10 new webhook event types: 60 | 61 | - `checkout.created` - Fired when a checkout session is created 62 | - `checkout.updated` - Fired when a checkout session is updated 63 | - `customer.created` - Fired when a customer is created 64 | - `customer.updated` - Fired when a customer is updated 65 | - `customer.deleted` - Fired when a customer is deleted 66 | - `customer.state_changed` - Fired when a customer's state changes 67 | - `product.created` - Fired when a product is created 68 | - `product.updated` - Fired when a product is updated 69 | - `benefit.created` - Fired when a benefit is created 70 | - `benefit.updated` - Fired when a benefit is updated 71 | 72 | #### Enhanced Checkout Features 73 | 74 | The checkout API now supports additional features: 75 | 76 | - **Custom Field Data**: Use `withCustomFieldData()` to pass custom data to checkout sessions 77 | - **Discount Code Control**: Use `withoutDiscountCodes()` to disable discount code input 78 | - **Enhanced Billing Address**: Improved billing address support 79 | 80 | #### Code Quality Improvements 81 | 82 | - Refactored webhook processing for better error handling 83 | - Improved timestamp parsing and status handling 84 | - Streamlined JSON serialization 85 | - Enhanced test coverage 86 | - Removed redundant code and comments 87 | - Better type safety with Polar SDK Components 88 | 89 | ### ⚠️ Breaking Changes 90 | 91 | #### PHP Version Requirement 92 | 93 | **🚨 BREAKING**: Laravel Polar v2 requires **PHP 8.3 or higher**. 94 | 95 | If you're running PHP 8.2 or lower, you must upgrade before installing v2.0.0. 96 | 97 | #### Updated Dependencies 98 | 99 | The following dependencies have been updated: 100 | 101 | - `polar-sh/sdk`: `^0.7.0` (previously `^0.6.0`) 102 | - `spatie/laravel-data`: `^4.0` (previously `^3.0`) 103 | - `spatie/laravel-webhook-client`: `^3.0` (previously `^2.0`) 104 | 105 | #### Model Casts Method 106 | 107 | Laravel Polar v2 uses Laravel 11's new `casts()` method. If you've extended any models (`Order`, `Subscription`, or `Customer`), ensure your custom casts are compatible. 108 | 109 | #### Enum to SDK Components Migration 110 | 111 | Internal enums have been replaced with Polar SDK Components for better type safety and API alignment. This change is mostly internal, but if you've extended or referenced internal enums, you may need to update your code. 112 | 113 | ### 📦 Installation 114 | 115 | To upgrade to v2.0.0: 116 | 117 | ```bash 118 | composer require danestves/laravel-polar:^2.0 119 | 120 | 121 | 122 | 123 | ``` 124 | After installation: 125 | 126 | 1. **Republish configuration**: 127 | 128 | ```bash 129 | php artisan vendor:publish --tag="polar-config" --force 130 | 131 | 132 | 133 | 134 | ``` 135 | 2. **Run migrations** (if any new ones exist): 136 | 137 | ```bash 138 | php artisan migrate 139 | 140 | 141 | 142 | 143 | ``` 144 | 145 | ### 🔄 Migration Guide 146 | 147 | For detailed migration instructions, please see our comprehensive [Migration Guide](docs/migration-v1-to-v2.md). 148 | 149 | #### Quick Migration Checklist 150 | 151 | - [ ] Verify PHP 8.3+ is installed 152 | - [ ] Confirm Laravel 11.x or 12.x 153 | - [ ] Update `composer.json` to require `^2.0` 154 | - [ ] Run `composer update` 155 | - [ ] Republish configuration files 156 | - [ ] Run database migrations 157 | - [ ] Test checkout flow 158 | - [ ] Test subscription management 159 | - [ ] Test webhook handling 160 | - [ ] Review and update any custom code 161 | 162 | ### 🐛 Bug Fixes 163 | 164 | - Fixed typo in README regarding embedded checkout attribute 165 | - Improved error handling in Checkout and LaravelPolar classes 166 | - Enhanced timestamp parsing in webhook processing 167 | - Fixed status assignment in Subscription model 168 | - Improved benefit type handling in webhook processing 169 | 170 | ### 🔧 Improvements 171 | 172 | - Streamlined JSON serialization in webhook processing 173 | - Enhanced error handling throughout the package 174 | - Improved test coverage 175 | - Better code organization and structure 176 | - Updated GitHub Actions workflows 177 | - Removed unused code and dependencies 178 | 179 | ### 📚 Documentation 180 | 181 | - Enhanced README with new webhook events documentation 182 | - Added comprehensive migration guide 183 | - Updated API server configuration documentation 184 | - Improved inline code documentation 185 | 186 | ### 🙏 Contributors 187 | 188 | Thank you to all contributors who helped make this release possible! 189 | 190 | ### 📖 Full Changelog 191 | 192 | For a complete list of changes, see the [CHANGELOG.md](CHANGELOG.md). 193 | 194 | ### 🔗 Links 195 | 196 | - [Documentation](README.md) 197 | - [Migration Guide](docs/migration-v1-to-v2.md) 198 | - [GitHub Repository](https://github.com/danestves/laravel-polar) 199 | - [Polar API Documentation](https://docs.polar.sh) 200 | 201 | ### 💬 Support 202 | 203 | If you encounter any issues during migration: 204 | 205 | 1. Check the [GitHub Issues](https://github.com/danestves/laravel-polar/issues) 206 | 2. Review the [Migration Guide](docs/migration-v1-to-v2.md) 207 | 3. Open a new issue with details about your problem 208 | 209 | 210 | --- 211 | 212 | **Note**: This is a major release with breaking changes. Please review the migration guide carefully before upgrading in production environments. 213 | 214 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v1.2.4...v2.0.0 215 | 216 | ## v1.2.4 - 2025-10-17 217 | 218 | ### What's Changed 219 | 220 | * chore(deps): bump aglipanci/laravel-pint-action from 2.5 to 2.6 by @dependabot[bot] in https://github.com/danestves/laravel-polar/pull/40 221 | 222 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v.1.2.4...v1.2.4 223 | 224 | ## v.1.2.4 - 2025-08-11 225 | 226 | ### What's Changed 227 | 228 | * Update subscription API method and subscriptionData to match Polar API by @jbardnz in https://github.com/danestves/laravel-polar/pull/38 229 | 230 | ### New Contributors 231 | 232 | * @jbardnz made their first contribution in https://github.com/danestves/laravel-polar/pull/38 233 | 234 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v1.2.3...v.1.2.4 235 | 236 | ## v1.2.3 - 2025-07-23 237 | 238 | ### What's Changed 239 | 240 | * fix: ordered_at value by @jmaekki in https://github.com/danestves/laravel-polar/pull/36 241 | 242 | ### New Contributors 243 | 244 | * @jmaekki made their first contribution in https://github.com/danestves/laravel-polar/pull/36 245 | 246 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v1.2.2...v1.2.3 247 | 248 | ## v1.2.2 - 2025-07-02 249 | 250 | ### What's Changed 251 | 252 | * fix: correctly transform data to array on subscription by @danestves in https://github.com/danestves/laravel-polar/pull/33 253 | 254 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v1.2.1...v1.2.2 255 | 256 | ## v1.2.1 - 2025-06-04 257 | 258 | ### What's Changed 259 | 260 | * fix: undefined subscription type by @danestves in https://github.com/danestves/laravel-polar/pull/30 261 | 262 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v1.2.0...v1.2.1 263 | 264 | ## v1.2.0 - 2025-06-04 265 | 266 | ### What's Changed 267 | 268 | * chore(deps): bump dependabot/fetch-metadata from 2.3.0 to 2.4.0 by @dependabot in https://github.com/danestves/laravel-polar/pull/27 269 | * feat: latest polar schema by @danestves in https://github.com/danestves/laravel-polar/pull/28 270 | * chore: update dependencies by @danestves in https://github.com/danestves/laravel-polar/pull/29 271 | 272 | ### New Contributors 273 | 274 | * @dependabot made their first contribution in https://github.com/danestves/laravel-polar/pull/27 275 | 276 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v1.1.2...v1.2.0 277 | 278 | ## v1.1.2 - 2025-04-10 279 | 280 | ### What's Changed 281 | 282 | * feat: add Pending status to OrderStatus enum by @danestves in https://github.com/danestves/laravel-polar/pull/21 283 | * fix: update taxId property to allow null values by @danestves in https://github.com/danestves/laravel-polar/pull/22 284 | 285 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v1.1.1...v1.1.2 286 | 287 | ## v1.1.1 - 2025-03-16 288 | 289 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v1.1.0...v1.1.1 290 | 291 | ## v1.1.0 - 2025-03-11 292 | 293 | ### What's Changed 294 | 295 | * feat: webhook parse and data by @danestves in https://github.com/danestves/laravel-polar/pull/17 296 | 297 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v1.0.1...v1.1.0 298 | 299 | ## v1.0.1 - 2025-03-10 300 | 301 | ### What's Changed 302 | 303 | * fix: checkout payload mapping the values by @danestves in https://github.com/danestves/laravel-polar/pull/16 304 | 305 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v1.0.0...v1.0.1 306 | 307 | ## v1.0.0 - 2025-03-09 308 | 309 | As of now, we have rewritten the package to entirely use API calls, it should be working like before, only the core has changed with same functionality 310 | 311 | ### What's Changed 312 | 313 | * feat: rewrite queries to use API calls by @danestves in https://github.com/danestves/laravel-polar/pull/15 314 | 315 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v0.3.2...v1.0.0 316 | 317 | ## v0.3.2 - 2025-03-07 318 | 319 | ### What's Changed 320 | 321 | * fix: read all config files by @danestves in https://github.com/danestves/laravel-polar/pull/14 322 | 323 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v0.3.1...v0.3.2 324 | 325 | ## v0.3.1 - 2025-03-07 326 | 327 | ### What's Changed 328 | 329 | * fix: do not throw on customer metadata by @danestves in https://github.com/danestves/laravel-polar/pull/12 330 | 331 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v0.3.0...v0.3.1 332 | 333 | ## v0.3.0 - 2025-03-07 334 | 335 | ### What's Changed 336 | 337 | * feat: update sdk to latest version by @danestves in https://github.com/danestves/laravel-polar/pull/10 338 | * feat: support customer external id by @danestves in https://github.com/danestves/laravel-polar/pull/11 339 | 340 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v0.2.0...v0.3.0 341 | 342 | ## v0.2.0 - 2025-03-03 343 | 344 | ### What's Changed 345 | 346 | * fix: customer metadata wrong assumption by @danestves in https://github.com/danestves/laravel-polar/pull/8 347 | * fix: correct handling of webhooks by @danestves in https://github.com/danestves/laravel-polar/pull/9 348 | 349 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v0.1.3...v0.2.0 350 | 351 | ## v0.1.3 - 2025-02-24 352 | 353 | ### What's Changed 354 | 355 | * feat: descriptive name for embed script by @danestves in https://github.com/danestves/laravel-polar/pull/3 356 | 357 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v0.1.2...v0.1.3 358 | 359 | ## v0.1.2 - 2025-02-24 360 | 361 | ### What's Changed 362 | 363 | * fix: scape the at character on js link by @danestves in https://github.com/danestves/laravel-polar/pull/2 364 | 365 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v0.1.1...v0.1.2 366 | 367 | ## v0.1.1 - 2025-02-24 368 | 369 | ### What's Changed 370 | 371 | * fix: namespaces and add install command by @danestves in https://github.com/danestves/laravel-polar/pull/1 372 | 373 | ### New Contributors 374 | 375 | * @danestves made their first contribution in https://github.com/danestves/laravel-polar/pull/1 376 | 377 | **Full Changelog**: https://github.com/danestves/laravel-polar/compare/v0.1.0...v0.1.1 378 | 379 | ## v0.1.0 - 2025-02-23 380 | 381 | 🍾 First version of the package, for docs, please refer to the README 382 | 383 | **Full Changelog**: https://github.com/danestves/laravel-polar/commits/v0.1.0 384 | -------------------------------------------------------------------------------- /src/Handlers/ProcessWebhook.php: -------------------------------------------------------------------------------- 1 | serializer === null) { 45 | $this->serializer = \Polar\Utils\JSON::createSerializer(); 46 | } 47 | 48 | return $this->serializer; 49 | } 50 | 51 | public function handle(): void 52 | { 53 | $decoded = json_decode($this->webhookCall, true); 54 | if ($decoded === null || !isset($decoded['payload'])) { 55 | Log::error('Invalid webhook payload: failed to decode JSON or missing payload'); 56 | return; 57 | } 58 | $payload = $decoded['payload']; 59 | $type = $payload['type']; 60 | $data = $payload['data']; 61 | $timestamp = $this->parseTimestamp($payload['timestamp'] ?? null); 62 | 63 | WebhookReceived::dispatch($payload); 64 | 65 | match ($type) { 66 | 'order.created' => $this->handleOrderCreated($data, $timestamp, $type), 67 | 'order.updated' => $this->handleOrderUpdated($data, $timestamp, $type), 68 | 'subscription.created' => $this->handleSubscriptionCreated($data, $timestamp, $type), 69 | 'subscription.updated' => $this->handleSubscriptionUpdated($data, $timestamp, $type), 70 | 'subscription.active' => $this->handleSubscriptionActive($data, $timestamp, $type), 71 | 'subscription.canceled' => $this->handleSubscriptionCanceled($data, $timestamp, $type), 72 | 'subscription.revoked' => $this->handleSubscriptionRevoked($data, $timestamp, $type), 73 | 'benefit_grant.created' => $this->handleBenefitGrantCreated($data, $timestamp, $type), 74 | 'benefit_grant.updated' => $this->handleBenefitGrantUpdated($data, $timestamp, $type), 75 | 'benefit_grant.revoked' => $this->handleBenefitGrantRevoked($data, $timestamp, $type), 76 | 'checkout.created' => $this->handleCheckoutCreated($data, $timestamp, $type), 77 | 'checkout.updated' => $this->handleCheckoutUpdated($data, $timestamp, $type), 78 | 'customer.created' => $this->handleCustomerCreated($data, $timestamp, $type), 79 | 'customer.updated' => $this->handleCustomerUpdated($data, $timestamp, $type), 80 | 'customer.deleted' => $this->handleCustomerDeleted($data, $timestamp, $type), 81 | 'customer.state_changed' => $this->handleCustomerStateChanged($data, $timestamp, $type), 82 | 'product.created' => $this->handleProductCreated($data, $timestamp, $type), 83 | 'product.updated' => $this->handleProductUpdated($data, $timestamp, $type), 84 | 'benefit.created' => $this->handleBenefitCreated($data, $timestamp, $type), 85 | 'benefit.updated' => $this->handleBenefitUpdated($data, $timestamp, $type), 86 | default => Log::info("Unknown event type: $type"), 87 | }; 88 | 89 | WebhookHandled::dispatch($payload); 90 | } 91 | 92 | /** 93 | * Handle the order created event. 94 | * 95 | * @param array $data 96 | */ 97 | private function handleOrderCreated(array $data, \DateTime $timestamp, string $type): void 98 | { 99 | $billable = $this->resolveBillable($data); 100 | 101 | $order = $billable->orders()->create([ // @phpstan-ignore-line class.notFound - the property is found in the billable model 102 | 'polar_id' => $data['id'], 103 | 'status' => \is_string($data['status']) ? OrderStatus::from($data['status']) : $data['status'], 104 | 'amount' => $data['amount'], 105 | 'tax_amount' => $data['tax_amount'], 106 | 'refunded_amount' => $data['refunded_amount'], 107 | 'refunded_tax_amount' => $data['refunded_tax_amount'], 108 | 'currency' => $data['currency'], 109 | 'billing_reason' => $data['billing_reason'], 110 | 'customer_id' => $data['customer_id'], 111 | 'product_id' => $data['product_id'], 112 | 'ordered_at' => Carbon::make($data['created_at']), 113 | ]); 114 | 115 | $payload = $this->createOrderCreatedPayload($data, $timestamp, $type); 116 | OrderCreated::dispatch($billable, $order, $payload); // @phpstan-ignore-line argument.type - Billable is a instance of a model 117 | } 118 | 119 | /** 120 | * Handle the order updated event. 121 | * 122 | * @param array $data 123 | */ 124 | private function handleOrderUpdated(array $data, \DateTime $timestamp, string $type): void 125 | { 126 | $billable = $this->resolveBillable($data); 127 | 128 | if (!($order = $this->findOrder($data['id'])) instanceof EloquentOrder) { 129 | Log::warning('Order not found for webhook update', [ 130 | 'order_id' => $data['id'], 131 | 'event_type' => $type, 132 | ]); 133 | return; 134 | } 135 | 136 | $status = $data['status']; 137 | $isRefunded = $status === OrderStatus::Refunded->value || $status === OrderStatus::PartiallyRefunded->value; 138 | 139 | $order->sync([ 140 | ...$data, 141 | 'status' => $status, 142 | 'refunded_at' => $isRefunded ? Carbon::make($data['refunded_at']) : null, 143 | ]); 144 | 145 | $payload = $this->createOrderUpdatedPayload($data, $timestamp, $type); 146 | OrderUpdated::dispatch($billable, $order, $payload, $isRefunded); // @phpstan-ignore-line argument.type - Billable is a instance of a model 147 | } 148 | 149 | /** 150 | * Handle the subscription created event. 151 | * 152 | * @param array $data 153 | */ 154 | private function handleSubscriptionCreated(array $data, \DateTime $timestamp, string $type): void 155 | { 156 | $customerMetadata = $data['customer']['metadata']; 157 | $billable = $this->resolveBillable($data); 158 | 159 | $subscription = $billable->subscriptions()->create([ // @phpstan-ignore-line class.notFound - the property is found in the billable model 160 | 'type' => $customerMetadata['subscription_type'] ?? 'default', 161 | 'polar_id' => $data['id'], 162 | 'status' => \is_string($data['status']) ? SubscriptionStatus::from($data['status']) : $data['status'], 163 | 'product_id' => $data['product_id'], 164 | 'current_period_end' => $data['current_period_end'] ? Carbon::make($data['current_period_end']) : null, 165 | 'ends_at' => $data['ends_at'] ? Carbon::make($data['ends_at']) : null, 166 | ]); 167 | 168 | if ($billable->customer->polar_id === null) { // @phpstan-ignore-line property.notFound - the property is found in the billable model 169 | $billable->customer->update(['polar_id' => $data['customer_id']]); // @phpstan-ignore-line property.notFound - the property is found in the billable model 170 | } 171 | 172 | $payload = $this->createSubscriptionCreatedPayload($data, $timestamp, $type); 173 | SubscriptionCreated::dispatch($billable, $subscription, $payload); // @phpstan-ignore-line argument.type - Billable is a instance of a model 174 | } 175 | 176 | /** 177 | * Handle the subscription updated event. 178 | * 179 | * @param array $data 180 | */ 181 | private function handleSubscriptionUpdated(array $data, \DateTime $timestamp, string $type): void 182 | { 183 | if (!($subscription = $this->findSubscription($data['id'])) instanceof EloquentSubscription) { 184 | Log::warning('Subscription not found for webhook update', [ 185 | 'subscription_id' => $data['id'], 186 | 'event_type' => $type, 187 | ]); 188 | return; 189 | } 190 | 191 | $subscription->sync($data); 192 | 193 | $payload = $this->createSubscriptionUpdatedPayload($data, $timestamp, $type); 194 | SubscriptionUpdated::dispatch($subscription->billable, $subscription, $payload); // @phpstan-ignore-line argument.type - Billable is a instance of a model 195 | } 196 | 197 | /** 198 | * Handle the subscription active event. 199 | * 200 | * @param array $data 201 | */ 202 | private function handleSubscriptionActive(array $data, \DateTime $timestamp, string $type): void 203 | { 204 | if (!($subscription = $this->findSubscription($data['id'])) instanceof EloquentSubscription) { 205 | Log::warning('Subscription not found for webhook active event', [ 206 | 'subscription_id' => $data['id'], 207 | 'event_type' => $type, 208 | ]); 209 | return; 210 | } 211 | 212 | $subscription->sync($data); 213 | 214 | $payload = $this->createSubscriptionActivePayload($data, $timestamp, $type); 215 | SubscriptionActive::dispatch($subscription->billable, $subscription, $payload); // @phpstan-ignore-line argument.type - Billable is a instance of a model 216 | } 217 | 218 | /** 219 | * Handle the subscription canceled event. 220 | * 221 | * @param array $data 222 | */ 223 | private function handleSubscriptionCanceled(array $data, \DateTime $timestamp, string $type): void 224 | { 225 | if (!($subscription = $this->findSubscription($data['id'])) instanceof EloquentSubscription) { 226 | Log::warning('Subscription not found for webhook canceled event', [ 227 | 'subscription_id' => $data['id'], 228 | 'event_type' => $type, 229 | ]); 230 | return; 231 | } 232 | 233 | $subscription->sync($data); 234 | 235 | $payload = $this->createSubscriptionCanceledPayload($data, $timestamp, $type); 236 | SubscriptionCanceled::dispatch($subscription->billable, $subscription, $payload); // @phpstan-ignore-line argument.type - Billable is a instance of a model 237 | } 238 | 239 | /** 240 | * Handle the subscription revoked event. 241 | * 242 | * @param array $data 243 | */ 244 | private function handleSubscriptionRevoked(array $data, \DateTime $timestamp, string $type): void 245 | { 246 | if (!($subscription = $this->findSubscription($data['id'])) instanceof EloquentSubscription) { 247 | Log::warning('Subscription not found for webhook revoked event', [ 248 | 'subscription_id' => $data['id'], 249 | 'event_type' => $type, 250 | ]); 251 | return; 252 | } 253 | 254 | $subscription->sync($data); 255 | 256 | $payload = $this->createSubscriptionRevokedPayload($data, $timestamp, $type); 257 | SubscriptionRevoked::dispatch($subscription->billable, $subscription, $payload); // @phpstan-ignore-line argument.type - Billable is a instance of a model 258 | } 259 | 260 | /** 261 | * Handle the benefit grant created event. 262 | * 263 | * @param array $data 264 | */ 265 | private function handleBenefitGrantCreated(array $data, \DateTime $timestamp, string $type): void 266 | { 267 | $billable = $this->resolveBillable($data); 268 | 269 | $payload = $this->createBenefitGrantCreatedPayload($data, $timestamp, $type); 270 | BenefitGrantCreated::dispatch($billable, $payload); // @phpstan-ignore-line argument.type - Billable is a instance of a model 271 | } 272 | 273 | /** 274 | * Handle the benefit grant updated event. 275 | * 276 | * @param array $data 277 | */ 278 | private function handleBenefitGrantUpdated(array $data, \DateTime $timestamp, string $type): void 279 | { 280 | $billable = $this->resolveBillable($data); 281 | 282 | $payload = $this->createBenefitGrantUpdatedPayload($data, $timestamp, $type); 283 | BenefitGrantUpdated::dispatch($billable, $payload); // @phpstan-ignore-line argument.type - Billable is a instance of a model 284 | } 285 | 286 | /** 287 | * Handle the benefit grant revoked event. 288 | * 289 | * @param array $data 290 | */ 291 | private function handleBenefitGrantRevoked(array $data, \DateTime $timestamp, string $type): void 292 | { 293 | $billable = $this->resolveBillable($data); 294 | 295 | $payload = $this->createBenefitGrantRevokedPayload($data, $timestamp, $type); 296 | BenefitGrantRevoked::dispatch($billable, $payload); // @phpstan-ignore-line argument.type - Billable is a instance of a model 297 | } 298 | 299 | /** 300 | * Resolve the billable from the payload. 301 | * 302 | * @param array $payload 303 | * @return \Danestves\LaravelPolar\Billable 304 | * 305 | * @throws InvalidMetadataPayload 306 | */ 307 | private function resolveBillable(array $payload) // @phpstan-ignore-line return.trait - Billable is used in the user final code 308 | { 309 | $customerMetadata = $payload['customer']['metadata'] ?? null; 310 | 311 | if (!isset($customerMetadata) || !is_array($customerMetadata) || !isset($customerMetadata['billable_id'], $customerMetadata['billable_type'])) { 312 | throw new InvalidMetadataPayload(); 313 | } 314 | 315 | return $this->findOrCreateCustomer( 316 | $customerMetadata['billable_id'], 317 | (string) $customerMetadata['billable_type'], 318 | (string) $payload['customer_id'], 319 | ); 320 | } 321 | 322 | /** 323 | * Find or create a customer. 324 | * 325 | * @return \Danestves\LaravelPolar\Billable 326 | */ 327 | private function findOrCreateCustomer(int|string $billableId, string $billableType, string $customerId) // @phpstan-ignore-line return.trait - Billable is used in the user final code 328 | { 329 | return LaravelPolar::$customerModel::firstOrCreate([ 330 | 'billable_id' => $billableId, 331 | 'billable_type' => $billableType, 332 | ], [ 333 | 'polar_id' => $customerId, 334 | ])->billable; 335 | } 336 | 337 | private function findSubscription(string $subscriptionId): ?EloquentSubscription 338 | { 339 | return LaravelPolar::$subscriptionModel::firstWhere('polar_id', $subscriptionId); 340 | } 341 | 342 | private function findOrder(string $orderId): ?EloquentOrder 343 | { 344 | return LaravelPolar::$orderModel::firstWhere('polar_id', $orderId); 345 | } 346 | 347 | private function parseTimestamp($timestampValue): \DateTime 348 | { 349 | if ($timestampValue === null) { 350 | return new \DateTime(); 351 | } 352 | 353 | $parsed = \DateTime::createFromFormat(\DateTime::ATOM, $timestampValue); 354 | if ($parsed !== false) { 355 | return $parsed; 356 | } 357 | 358 | $parsed = \DateTime::createFromFormat('Y-m-d\TH:i:s.u\Z', $timestampValue); 359 | if ($parsed !== false) { 360 | return $parsed; 361 | } 362 | 363 | $timestamp = strtotime($timestampValue); 364 | if ($timestamp !== false) { 365 | $dateTime = new \DateTime(); 366 | $dateTime->setTimestamp($timestamp); 367 | return $dateTime; 368 | } 369 | 370 | try { 371 | return new \DateTime($timestampValue); 372 | } catch (\Exception $e) { 373 | Log::warning('Failed to parse webhook timestamp', [ 374 | 'timestamp' => $timestampValue, 375 | 'error' => $e->getMessage(), 376 | ]); 377 | 378 | return new \DateTime(); 379 | } 380 | } 381 | 382 | /** 383 | * Create WebhookOrderCreatedPayload from array data. 384 | */ 385 | private function createOrderCreatedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookOrderCreatedPayload 386 | { 387 | $order = $this->arrayToOrder($data); 388 | return new Components\WebhookOrderCreatedPayload($timestamp, $order, $type); 389 | } 390 | 391 | /** 392 | * Create WebhookOrderUpdatedPayload from array data. 393 | */ 394 | private function createOrderUpdatedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookOrderUpdatedPayload 395 | { 396 | $order = $this->arrayToOrder($data); 397 | return new Components\WebhookOrderUpdatedPayload($timestamp, $order, $type); 398 | } 399 | 400 | /** 401 | * Create WebhookSubscriptionCreatedPayload from array data. 402 | */ 403 | private function createSubscriptionCreatedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookSubscriptionCreatedPayload 404 | { 405 | $subscription = $this->arrayToSubscription($data); 406 | return new Components\WebhookSubscriptionCreatedPayload($timestamp, $subscription, $type); 407 | } 408 | 409 | /** 410 | * Create WebhookSubscriptionUpdatedPayload from array data. 411 | */ 412 | private function createSubscriptionUpdatedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookSubscriptionUpdatedPayload 413 | { 414 | $subscription = $this->arrayToSubscription($data); 415 | return new Components\WebhookSubscriptionUpdatedPayload($timestamp, $subscription, $type); 416 | } 417 | 418 | /** 419 | * Create WebhookSubscriptionActivePayload from array data. 420 | */ 421 | private function createSubscriptionActivePayload(array $data, \DateTime $timestamp, string $type): Components\WebhookSubscriptionActivePayload 422 | { 423 | $subscription = $this->arrayToSubscription($data); 424 | return new Components\WebhookSubscriptionActivePayload($timestamp, $subscription, $type); 425 | } 426 | 427 | /** 428 | * Create WebhookSubscriptionCanceledPayload from array data. 429 | */ 430 | private function createSubscriptionCanceledPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookSubscriptionCanceledPayload 431 | { 432 | $subscription = $this->arrayToSubscription($data); 433 | return new Components\WebhookSubscriptionCanceledPayload($timestamp, $subscription, $type); 434 | } 435 | 436 | /** 437 | * Create WebhookSubscriptionRevokedPayload from array data. 438 | */ 439 | private function createSubscriptionRevokedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookSubscriptionRevokedPayload 440 | { 441 | $subscription = $this->arrayToSubscription($data); 442 | return new Components\WebhookSubscriptionRevokedPayload($timestamp, $subscription, $type); 443 | } 444 | 445 | /** 446 | * Create WebhookBenefitGrantCreatedPayload from array data. 447 | */ 448 | private function createBenefitGrantCreatedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookBenefitGrantCreatedPayload 449 | { 450 | $benefitGrant = $this->arrayToBenefitGrant($data); 451 | return new Components\WebhookBenefitGrantCreatedPayload($timestamp, $benefitGrant, $type); 452 | } 453 | 454 | /** 455 | * Create WebhookBenefitGrantUpdatedPayload from array data. 456 | */ 457 | private function createBenefitGrantUpdatedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookBenefitGrantUpdatedPayload 458 | { 459 | $benefitGrant = $this->arrayToBenefitGrant($data); 460 | return new Components\WebhookBenefitGrantUpdatedPayload($timestamp, $benefitGrant, $type); 461 | } 462 | 463 | /** 464 | * Create WebhookBenefitGrantRevokedPayload from array data. 465 | */ 466 | private function createBenefitGrantRevokedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookBenefitGrantRevokedPayload 467 | { 468 | $benefitGrant = $this->arrayToBenefitGrant($data); 469 | return new Components\WebhookBenefitGrantRevokedPayload($timestamp, $benefitGrant, $type); 470 | } 471 | 472 | private function arrayToComponent(array $data, string $class): mixed 473 | { 474 | $json = json_encode($data); 475 | if ($json === false) { 476 | throw new \RuntimeException("Failed to encode data to JSON for {$class}: " . json_last_error_msg()); 477 | } 478 | return $this->getSerializer()->deserialize($json, $class, 'json'); 479 | } 480 | 481 | private function arrayToOrder(array $data): Components\Order 482 | { 483 | return $this->arrayToComponent($data, Components\Order::class); 484 | } 485 | 486 | private function arrayToSubscription(array $data): Components\Subscription 487 | { 488 | return $this->arrayToComponent($data, Components\Subscription::class); 489 | } 490 | 491 | private function arrayToBenefitGrant(array $data): Components\BenefitGrantDiscordWebhook|Components\BenefitGrantCustomWebhook|Components\BenefitGrantGitHubRepositoryWebhook|Components\BenefitGrantDownloadablesWebhook|Components\BenefitGrantLicenseKeysWebhook|Components\BenefitGrantMeterCreditWebhook 492 | { 493 | $type = $data['type'] ?? $data['benefit']['type'] ?? 'custom'; 494 | $json = json_encode($data); 495 | if ($json === false) { 496 | throw new \RuntimeException('Failed to encode benefit grant data to JSON: ' . json_last_error_msg()); 497 | } 498 | 499 | $serializer = $this->getSerializer(); 500 | 501 | return match ($type) { 502 | 'discord' => $serializer->deserialize($json, Components\BenefitGrantDiscordWebhook::class, 'json'), 503 | 'custom' => $serializer->deserialize($json, Components\BenefitGrantCustomWebhook::class, 'json'), 504 | 'github_repository' => $serializer->deserialize($json, Components\BenefitGrantGitHubRepositoryWebhook::class, 'json'), 505 | 'downloadables' => $serializer->deserialize($json, Components\BenefitGrantDownloadablesWebhook::class, 'json'), 506 | 'license_keys' => $serializer->deserialize($json, Components\BenefitGrantLicenseKeysWebhook::class, 'json'), 507 | 'meter_credit' => $serializer->deserialize($json, Components\BenefitGrantMeterCreditWebhook::class, 'json'), 508 | default => $serializer->deserialize($json, Components\BenefitGrantCustomWebhook::class, 'json'), 509 | }; 510 | } 511 | 512 | /** 513 | * Handle the checkout created event. 514 | * 515 | * @param array $data 516 | */ 517 | private function handleCheckoutCreated(array $data, \DateTime $timestamp, string $type): void 518 | { 519 | $payload = $this->createCheckoutCreatedPayload($data, $timestamp, $type); 520 | CheckoutCreated::dispatch($payload); 521 | } 522 | 523 | /** 524 | * Handle the checkout updated event. 525 | * 526 | * @param array $data 527 | */ 528 | private function handleCheckoutUpdated(array $data, \DateTime $timestamp, string $type): void 529 | { 530 | $payload = $this->createCheckoutUpdatedPayload($data, $timestamp, $type); 531 | CheckoutUpdated::dispatch($payload); 532 | } 533 | 534 | /** 535 | * Handle the customer created event. 536 | * 537 | * @param array $data 538 | */ 539 | private function handleCustomerCreated(array $data, \DateTime $timestamp, string $type): void 540 | { 541 | $payload = $this->createCustomerCreatedPayload($data, $timestamp, $type); 542 | CustomerCreated::dispatch($payload); 543 | } 544 | 545 | /** 546 | * Handle the customer updated event. 547 | * 548 | * @param array $data 549 | */ 550 | private function handleCustomerUpdated(array $data, \DateTime $timestamp, string $type): void 551 | { 552 | $payload = $this->createCustomerUpdatedPayload($data, $timestamp, $type); 553 | CustomerUpdated::dispatch($payload); 554 | } 555 | 556 | /** 557 | * Handle the customer deleted event. 558 | * 559 | * @param array $data 560 | */ 561 | private function handleCustomerDeleted(array $data, \DateTime $timestamp, string $type): void 562 | { 563 | $payload = $this->createCustomerDeletedPayload($data, $timestamp, $type); 564 | CustomerDeleted::dispatch($payload); 565 | } 566 | 567 | /** 568 | * Handle the customer state changed event. 569 | * 570 | * @param array $data 571 | */ 572 | private function handleCustomerStateChanged(array $data, \DateTime $timestamp, string $type): void 573 | { 574 | $payload = $this->createCustomerStateChangedPayload($data, $timestamp, $type); 575 | CustomerStateChanged::dispatch($payload); 576 | } 577 | 578 | /** 579 | * Handle the product created event. 580 | * 581 | * @param array $data 582 | */ 583 | private function handleProductCreated(array $data, \DateTime $timestamp, string $type): void 584 | { 585 | $payload = $this->createProductCreatedPayload($data, $timestamp, $type); 586 | ProductCreated::dispatch($payload); 587 | } 588 | 589 | /** 590 | * Handle the product updated event. 591 | * 592 | * @param array $data 593 | */ 594 | private function handleProductUpdated(array $data, \DateTime $timestamp, string $type): void 595 | { 596 | $payload = $this->createProductUpdatedPayload($data, $timestamp, $type); 597 | ProductUpdated::dispatch($payload); 598 | } 599 | 600 | /** 601 | * Handle the benefit created event. 602 | * 603 | * @param array $data 604 | */ 605 | private function handleBenefitCreated(array $data, \DateTime $timestamp, string $type): void 606 | { 607 | $payload = $this->createBenefitCreatedPayload($data, $timestamp, $type); 608 | BenefitCreated::dispatch($payload); 609 | } 610 | 611 | /** 612 | * Handle the benefit updated event. 613 | * 614 | * @param array $data 615 | */ 616 | private function handleBenefitUpdated(array $data, \DateTime $timestamp, string $type): void 617 | { 618 | $payload = $this->createBenefitUpdatedPayload($data, $timestamp, $type); 619 | BenefitUpdated::dispatch($payload); 620 | } 621 | 622 | /** 623 | * Create WebhookCheckoutCreatedPayload from array data. 624 | */ 625 | private function createCheckoutCreatedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookCheckoutCreatedPayload 626 | { 627 | $checkout = $this->arrayToCheckout($data); 628 | return new Components\WebhookCheckoutCreatedPayload($timestamp, $checkout, $type); 629 | } 630 | 631 | /** 632 | * Create WebhookCheckoutUpdatedPayload from array data. 633 | */ 634 | private function createCheckoutUpdatedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookCheckoutUpdatedPayload 635 | { 636 | $checkout = $this->arrayToCheckout($data); 637 | return new Components\WebhookCheckoutUpdatedPayload($timestamp, $checkout, $type); 638 | } 639 | 640 | /** 641 | * Create WebhookCustomerCreatedPayload from array data. 642 | */ 643 | private function createCustomerCreatedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookCustomerCreatedPayload 644 | { 645 | $customer = $this->arrayToCustomer($data); 646 | return new Components\WebhookCustomerCreatedPayload($timestamp, $customer, $type); 647 | } 648 | 649 | /** 650 | * Create WebhookCustomerUpdatedPayload from array data. 651 | */ 652 | private function createCustomerUpdatedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookCustomerUpdatedPayload 653 | { 654 | $customer = $this->arrayToCustomer($data); 655 | return new Components\WebhookCustomerUpdatedPayload($timestamp, $customer, $type); 656 | } 657 | 658 | /** 659 | * Create WebhookCustomerDeletedPayload from array data. 660 | */ 661 | private function createCustomerDeletedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookCustomerDeletedPayload 662 | { 663 | $customer = $this->arrayToCustomer($data); 664 | return new Components\WebhookCustomerDeletedPayload($timestamp, $customer, $type); 665 | } 666 | 667 | /** 668 | * Create WebhookCustomerStateChangedPayload from array data. 669 | */ 670 | private function createCustomerStateChangedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookCustomerStateChangedPayload 671 | { 672 | $customerState = $this->arrayToCustomerState($data); 673 | return new Components\WebhookCustomerStateChangedPayload($timestamp, $customerState, $type); 674 | } 675 | 676 | /** 677 | * Create WebhookProductCreatedPayload from array data. 678 | */ 679 | private function createProductCreatedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookProductCreatedPayload 680 | { 681 | $product = $this->arrayToProduct($data); 682 | return new Components\WebhookProductCreatedPayload($timestamp, $product, $type); 683 | } 684 | 685 | /** 686 | * Create WebhookProductUpdatedPayload from array data. 687 | */ 688 | private function createProductUpdatedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookProductUpdatedPayload 689 | { 690 | $product = $this->arrayToProduct($data); 691 | return new Components\WebhookProductUpdatedPayload($timestamp, $product, $type); 692 | } 693 | 694 | /** 695 | * Create WebhookBenefitCreatedPayload from array data. 696 | */ 697 | private function createBenefitCreatedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookBenefitCreatedPayload 698 | { 699 | $benefit = $this->arrayToBenefit($data); 700 | return new Components\WebhookBenefitCreatedPayload($timestamp, $benefit, $type); 701 | } 702 | 703 | /** 704 | * Create WebhookBenefitUpdatedPayload from array data. 705 | */ 706 | private function createBenefitUpdatedPayload(array $data, \DateTime $timestamp, string $type): Components\WebhookBenefitUpdatedPayload 707 | { 708 | $benefit = $this->arrayToBenefit($data); 709 | return new Components\WebhookBenefitUpdatedPayload($timestamp, $benefit, $type); 710 | } 711 | 712 | private function arrayToCheckout(array $data): Components\Checkout 713 | { 714 | return $this->arrayToComponent($data, Components\Checkout::class); 715 | } 716 | 717 | private function arrayToCustomer(array $data): Components\Customer 718 | { 719 | return $this->arrayToComponent($data, Components\Customer::class); 720 | } 721 | 722 | private function arrayToCustomerState(array $data): Components\CustomerState 723 | { 724 | return $this->arrayToComponent($data, Components\CustomerState::class); 725 | } 726 | 727 | private function arrayToProduct(array $data): Components\Product 728 | { 729 | return $this->arrayToComponent($data, Components\Product::class); 730 | } 731 | 732 | private function arrayToBenefit(array $data): Components\BenefitCustom|Components\BenefitDiscord|Components\BenefitGitHubRepository|Components\BenefitDownloadables|Components\BenefitLicenseKeys|Components\BenefitMeterCredit 733 | { 734 | $type = $data['type'] ?? 'custom'; 735 | $json = json_encode($data); 736 | if ($json === false) { 737 | throw new \RuntimeException('Failed to encode benefit data to JSON: ' . json_last_error_msg()); 738 | } 739 | 740 | $serializer = $this->getSerializer(); 741 | 742 | return match ($type) { 743 | 'discord' => $serializer->deserialize($json, Components\BenefitDiscord::class, 'json'), 744 | 'custom' => $serializer->deserialize($json, Components\BenefitCustom::class, 'json'), 745 | 'github_repository' => $serializer->deserialize($json, Components\BenefitGitHubRepository::class, 'json'), 746 | 'downloadables' => $serializer->deserialize($json, Components\BenefitDownloadables::class, 'json'), 747 | 'license_keys' => $serializer->deserialize($json, Components\BenefitLicenseKeys::class, 'json'), 748 | 'meter_credit' => $serializer->deserialize($json, Components\BenefitMeterCredit::class, 'json'), 749 | default => $serializer->deserialize($json, Components\BenefitCustom::class, 'json'), 750 | }; 751 | } 752 | } 753 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://banners.beyondco.de/laravel-polar.png?theme=dark&packageManager=composer+require&packageName=danestves%2Flaravel-polar&pattern=pieFactory&style=style_1&description=Easily+integrate+your+Laravel+application+with+Polar.sh&md=1&showWatermark=1&fontSize=100px&images=https%3A%2F%2Flaravel.com%2Fimg%2Flogomark.min.svg "Laravel Polar") 2 | 3 | # Laravel Polar 4 | 5 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/danestves/laravel-polar.svg?style=flat-square)](https://packagist.org/packages/danestves/laravel-polar) 6 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/danestves/laravel-polar/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/danestves/laravel-polar/actions?query=workflow%3Arun-tests+branch%3Amain) 7 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/danestves/laravel-polar/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/danestves/laravel-polar/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 8 | [![Total Downloads](https://img.shields.io/packagist/dt/danestves/laravel-polar.svg?style=flat-square)](https://packagist.org/packages/danestves/laravel-polar) 9 | 10 | 11 | 12 | Subscribe on Polar 13 | 14 | 15 | Seamlessly integrate Polar.sh subscriptions and payments into your Laravel application. This package provides an elegant way to handle subscriptions, manage recurring payments, and interact with Polar's API. With built-in support for webhooks, subscription management, and a fluent API, you can focus on building your application while we handle the complexities of subscription billing. 16 | 17 | ## Installation 18 | 19 | **Step 1:** You can install the package via composer: 20 | 21 | ```bash 22 | composer require danestves/laravel-polar 23 | ``` 24 | 25 | **Step 2:** Run `:install`: 26 | 27 | ```bash 28 | php artisan polar:install 29 | ``` 30 | 31 | This will publish the config, migrations and views, and ask to run the migrations. 32 | 33 | Or publish and run the migrations individually: 34 | 35 | ```bash 36 | php artisan vendor:publish --tag="polar-migrations" 37 | ``` 38 | 39 | ```bash 40 | php artisan vendor:publish --tag="polar-config" 41 | ``` 42 | 43 | ```bash 44 | php artisan vendor:publish --tag="polar-views" 45 | ``` 46 | 47 | ```bash 48 | php artisan migrate 49 | ``` 50 | 51 | This is the contents of the published config file: 52 | 53 | ```php 54 | Settings 64 | | under the "Developers" section. 65 | | 66 | */ 67 | 'access_token' => env('POLAR_ACCESS_TOKEN'), 68 | 69 | /* 70 | |-------------------------------------------------------------------------- 71 | | Polar Server 72 | |-------------------------------------------------------------------------- 73 | | 74 | | The Polar server environment to use for API requests. 75 | | Available options: "production" or "sandbox" 76 | | 77 | | - production: https://api.polar.sh (Production environment) 78 | | - sandbox: https://sandbox-api.polar.sh (Sandbox environment) 79 | | 80 | */ 81 | 'server' => env('POLAR_SERVER', 'sandbox'), 82 | 83 | /* 84 | |-------------------------------------------------------------------------- 85 | | Polar Webhook Secret 86 | |-------------------------------------------------------------------------- 87 | | 88 | | The Polar webhook secret is used to verify that the webhook requests 89 | | are coming from Polar. You can find your webhook secret in the Polar 90 | | dashboard > Settings > Webhooks on each registered webhook. 91 | | 92 | | We (the developers) recommend using a single webhook for all your 93 | | integrations. This way you can use the same secret for all your 94 | | integrations and you don't have to manage multiple webhooks. 95 | | 96 | */ 97 | 'webhook_secret' => env('POLAR_WEBHOOK_SECRET'), 98 | 99 | /* 100 | |-------------------------------------------------------------------------- 101 | | Polar Url Path 102 | |-------------------------------------------------------------------------- 103 | | 104 | | This is the base URI where routes from Polar will be served 105 | | from. The URL built into Polar is used by default; however, 106 | | you can modify this path as you see fit for your application. 107 | | 108 | */ 109 | 'path' => env('POLAR_PATH', 'polar'), 110 | 111 | /* 112 | |-------------------------------------------------------------------------- 113 | | Default Redirect URL 114 | |-------------------------------------------------------------------------- 115 | | 116 | | This is the default redirect URL that will be used when a customer 117 | | is redirected back to your application after completing a purchase 118 | | from a checkout session in your Polar account. 119 | | 120 | */ 121 | 'redirect_url' => null, 122 | 123 | /* 124 | |-------------------------------------------------------------------------- 125 | | Currency Locale 126 | |-------------------------------------------------------------------------- 127 | | 128 | | This is the default locale in which your money values are formatted in 129 | | for display. To utilize other locales besides the default "en" locale 130 | | verify you have to have the "intl" PHP extension installed on the system. 131 | | 132 | */ 133 | 'currency_locale' => env('POLAR_CURRENCY_LOCALE', 'en'), 134 | ]; 135 | ``` 136 | 137 | ## Usage 138 | 139 | ### Access Token 140 | 141 | Configure your access token. Create a new token in the Polar Dashboard > Settings > Developers and paste it in the `.env` file. 142 | 143 | - https://sandbox.polar.sh/dashboard//settings (Sandbox) 144 | - https://polar.sh/dashboard//settings (Production) 145 | 146 | ```bash 147 | POLAR_ACCESS_TOKEN="" 148 | ``` 149 | 150 | ### Webhook Secret 151 | 152 | Configure your webhook secret. Create a new webhook in the Polar Dashboard > Settings > Webhooks. 153 | 154 | - https://sandbox.polar.sh/dashboard//settings/webhooks (Sandbox) 155 | - https://polar.sh/dashboard//settings/webhooks (Production) 156 | 157 | Configure the webhook for the following events that this package supports: 158 | 159 | - `order.created` 160 | - `order.updated` 161 | - `subscription.created` 162 | - `subscription.updated` 163 | - `subscription.active` 164 | - `subscription.canceled` 165 | - `subscription.revoked` 166 | - `benefit_grant.created` 167 | - `benefit_grant.updated` 168 | - `benefit_grant.revoked` 169 | - `checkout.created` 170 | - `checkout.updated` 171 | - `customer.created` 172 | - `customer.updated` 173 | - `customer.deleted` 174 | - `customer.state_changed` 175 | - `product.created` 176 | - `product.updated` 177 | - `benefit.created` 178 | - `benefit.updated` 179 | 180 | ```bash 181 | POLAR_WEBHOOK_SECRET="" 182 | ``` 183 | 184 | ### Billable Trait 185 | 186 | Let’s make sure everything’s ready for your customers to checkout smoothly. 🛒 187 | 188 | First, we’ll need to set up a model to handle billing—don’t worry, it’s super simple! In most cases, this will be your app’s User model. Just add the Billable trait to your model like this (you’ll import it from the package first, of course): 189 | 190 | ```php 191 | use Danestves\LaravelPolar\Billable; 192 | 193 | class User extends Authenticatable 194 | { 195 | use Billable; 196 | } 197 | ``` 198 | 199 | Now the user model will have access to the methods provided by the package. You can make any model billable by adding the trait to it, not just the User model. 200 | 201 | ### Polar Script 202 | 203 | Polar includes a JavaScript script that you can use to initialize the [Polar Embedded Checkout](https://docs.polar.sh/features/checkout/embed). If you going to use this functionality, you can use the `@polarEmbedScript` directive to include the script in your views inside the `` tag. 204 | 205 | ```blade 206 | 207 | ... 208 | 209 | @polarEmbedScript 210 | 211 | ``` 212 | 213 | ### Webhooks 214 | 215 | This package includes a webhook handler that will handle the webhooks from Polar. 216 | 217 | #### Webhooks & CSRF Protection 218 | 219 | Incoming webhooks should not be affected by [CSRF protection](https://laravel.com/docs/csrf). To prevent this, exclude `polar/*` in your application's `bootstrap/app.php` file: 220 | 221 | ```php 222 | ->withMiddleware(function (Middleware $middleware) { 223 | $middleware->validateCsrfTokens(except: [ 224 | 'polar/*', 225 | ]); 226 | }) 227 | ``` 228 | 229 | ### Commands 230 | 231 | This package includes a list of commands that you can use to retrieve information about your Polar account. 232 | 233 | | Command | Description | 234 | |---------|-------------| 235 | | `php artisan polar:products` | List all available products with their ids | 236 | | `php artisan polar:products --id=123` | List a specific product by id | 237 | | `php artisan polar:products --id=123 --id=321` | List a specific products by ids | 238 | 239 | ### Checkouts 240 | 241 | #### Single Payments 242 | 243 | To create a checkout to show only a single payment, pass a single items to the array of products when creating the checkout. 244 | 245 | ```php 246 | use Illuminate\Http\Request; 247 | 248 | Route::get('/subscribe', function (Request $request) { 249 | return $request->user()->checkout(['product_id_123']); 250 | }); 251 | ``` 252 | 253 | If you want to show multiple products that the user can choose from, you can pass an array of product ids to the `checkout` method. 254 | 255 | ```php 256 | use Illuminate\Http\Request; 257 | 258 | Route::get('/subscribe', function (Request $request) { 259 | return $request->user()->checkout(['product_id_123', 'product_id_456']); 260 | }); 261 | ``` 262 | 263 | This could be useful if you want to offer monthly, yearly, and lifetime plans for example. 264 | 265 | > [!NOTE] 266 | > If you are requesting the checkout a lot of times we recommend you to cache the URL returned by the `checkout` method. 267 | 268 | #### Custom Price 269 | 270 | You can override the price of a product using the `charge` method. 271 | 272 | ```php 273 | use Illuminate\Http\Request; 274 | 275 | Route::get('/subscribe', function (Request $request) { 276 | return $request->user()->charge(1000, ['product_id_123']); 277 | }); 278 | ``` 279 | 280 | #### Embedded Checkout 281 | 282 | Instead of redirecting the user you can create the checkout link, pass it to the page and use our blade component: 283 | 284 | ```php 285 | use Illuminate\Http\Request; 286 | 287 | Route::get('/billing', function (Request $request) { 288 | $checkout = $request->user()->checkout(['product_id_123']) 289 | ->withEmbedOrigin(config('app.url')); 290 | 291 | return view('billing', ['checkout' => $checkout]); 292 | }); 293 | ``` 294 | 295 | Now we can use the button like this: 296 | 297 | ```blade 298 | 299 | ``` 300 | 301 | The component accepts the normal props that a link element accepts. You can change the theme of the embedded checkout by using the following prop: 302 | 303 | ```blade 304 | 305 | ``` 306 | 307 | It defaults to light theme, so you only need to pass the prop if you want to change it. 308 | 309 | ##### Inertia 310 | 311 | For projects usin Inertia you can render the button adding `data-polar-checkout` to the link in the following way: 312 | 313 | `button.vue` 314 | ```vue 315 | 318 | ``` 319 | 320 | ```jsx 321 | // button.{jsx,tsx} 322 | 323 | export function Button() { 324 | return ( 325 | Buy now 326 | ); 327 | } 328 | ``` 329 | 330 | At the end is just a normal link but using an special attribute for the script to render the embedded checkout. 331 | 332 | > [!NOTE] 333 | > Remember that you can use the theme attribute too to change the color system in the checkout 334 | 335 | ### Prefill Customer Information 336 | 337 | You can override the user data using the following methods in your models provided by the `Billable` trait. 338 | 339 | ```php 340 | public function polarName(): ?string; // default: $model->name 341 | public function polarEmail(): ?string; // default: $model->email 342 | ``` 343 | 344 | ### Redirects After Purchase 345 | 346 | You can redirect the user to a custom page after the purchase using the `withSuccessUrl` method: 347 | 348 | ```php 349 | $request->user()->checkout('variant-id') 350 | ->withSuccessUrl(url('/success')); 351 | ``` 352 | 353 | You can also add the `checkout_id={CHECKOUT_ID}` query parameter to the URL to retrieve the checkout session id: 354 | 355 | ```php 356 | $request->user()->checkout('variant-id') 357 | ->withSuccessUrl(url('/success?checkout_id={CHECKOUT_ID}')); 358 | ``` 359 | 360 | ### Custom metadata and customer metadata 361 | 362 | You can add custom metadata to the checkout session using the `withMetadata` method: 363 | 364 | ```php 365 | $request->user()->checkout('variant-id') 366 | ->withMetadata(['key' => 'value']); 367 | ``` 368 | 369 | You can also add customer metadata to the checkout session using the `withCustomerMetadata` method: 370 | 371 | ```php 372 | $request->user()->checkout('variant-id') 373 | ->withCustomerMetadata(['key' => 'value']); 374 | ``` 375 | 376 | These will then be available in the relevant webhooks for you. 377 | 378 | #### Reserved Keywords 379 | 380 | When working with custom data, this library has a few reserved terms. 381 | 382 | - `billable_id` 383 | - `billable_type` 384 | - `subscription_type` 385 | 386 | Using any of these will result in an exception being thrown. 387 | 388 | ### Customers 389 | 390 | #### Customer Portal 391 | 392 | Customers can update their personal information (e.g., name, email address) by accessing their [self-service customer portal](https://docs.polar.sh/features/customer-portal). To redirect customers to this portal, call the `redirectToCustomerPortal()` method on your billable model (e.g., the User model). 393 | 394 | ```php 395 | use Illuminate\Http\Request; 396 | 397 | Route::get('/customer-portal', function (Request $request) { 398 | return $request->user()->redirectToCustomerPortal(); 399 | }); 400 | ``` 401 | 402 | Optionally, you can obtain the signed customer portal URL directly: 403 | 404 | ```php 405 | $url = $user->customerPortalUrl(); 406 | ``` 407 | 408 | ### Orders 409 | 410 | #### Retrieving Orders 411 | 412 | You can retrieve orders by using the `orders` relationship on the billable model: 413 | 414 | ```blade 415 | 416 | @foreach ($user->orders as $order) 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | @endforeach 426 |
{{ $order->ordered_at->toFormattedDateString() }}{{ $order->polar_id }}{{ $order->amount }}{{ $order->tax_amount }}{{ $order->refunded_amount }}{{ $order->refunded_tax_amount }}{{ $order->currency }}
427 | ``` 428 | 429 | #### Check order status 430 | 431 | You can check the status of an order by using the `status` attribute: 432 | 433 | ```php 434 | $order->status; 435 | ``` 436 | 437 | Or you can use some of the helper methods offers by the `Order` model: 438 | 439 | ```php 440 | $order->paid(); 441 | ``` 442 | 443 | Aside from that, you can run two other checks: refunded, and partially refunded. If the order is refunded, you can utilize the refunded_at timestamp: 444 | 445 | ```blade 446 | @if ($order->refunded()) 447 | Order {{ $order->polar_id }} was refunded on {{ $order->refunded_at->toFormattedDateString() }} 448 | @endif 449 | ``` 450 | 451 | You may also see if an order was for a certain product: 452 | 453 | ```php 454 | if ($order->hasProduct('product_id_123')) { 455 | // ... 456 | } 457 | ``` 458 | 459 | Furthermore, you can check if a consumer has purchased a specific product: 460 | 461 | ```php 462 | if ($user->hasPurchasedProduct('product_id_123')) { 463 | // ... 464 | } 465 | ``` 466 | 467 | ### Subscriptions 468 | 469 | #### Creating Subscriptions 470 | 471 | Starting a subscription is simple. For this, we require our product's variant id. Copy the product id and start a new subscription checkout using your billable model: 472 | 473 | ```php 474 | use Illuminate\Http\Request; 475 | 476 | Route::get('/subscribe', function (Request $request) { 477 | return $request->user()->subscribe('product_id_123'); 478 | }); 479 | ``` 480 | 481 | When a customer completes their checkout, the incoming `SubscriptionCreated` event webhook connects it to your billable model in the database. You may then get the subscription from your billable model: 482 | 483 | ```php 484 | $subscription = $user->subscription(); 485 | ``` 486 | 487 | #### Checking Subscription Status 488 | 489 | Once a consumer has subscribed to your services, you can use a variety of methods to check on the status of their subscription. The most basic example is to check if a customer has a valid subscription. 490 | 491 | ```php 492 | if ($user->subscribed()) { 493 | // ... 494 | } 495 | ``` 496 | 497 | You can utilize this in a variety of locations in your app, such as middleware, rules, and so on, to provide services. To determine whether an individual subscription is valid, you can use the `valid` method: 498 | 499 | ```php 500 | if ($user->subscription()->valid()) { 501 | // ... 502 | } 503 | ``` 504 | 505 | This method, like the subscribed method, returns true if your membership is active, on trial, past due, or cancelled during its grace period. 506 | 507 | You may also check if a subscription is for a certain product: 508 | 509 | ```php 510 | if ($user->subscription()->hasProduct('product_id_123')) { 511 | // ... 512 | } 513 | ``` 514 | 515 | If you wish to check if a subscription is on a specific product while being valid, you can use: 516 | 517 | ```php 518 | if ($user->subscribedToProduct('product_id_123')) { 519 | // ... 520 | } 521 | ``` 522 | 523 | Alternatively, if you use different [subscription types](#multiple-subscriptions), you can pass a type as an additional parameter: 524 | 525 | ```php 526 | if ($user->subscribed('swimming')) { 527 | // ... 528 | } 529 | 530 | if ($user->subscribedToProduct('product_id_123', 'swimming')) { 531 | // ... 532 | } 533 | ``` 534 | 535 | #### Cancelled Status 536 | 537 | To see if a user has cancelled their subscription, you can use the cancelled method: 538 | 539 | ```php 540 | if ($user->subscription()->cancelled()) { 541 | // ... 542 | } 543 | ``` 544 | 545 | When they are in their grace period, you can utilize the `onGracePeriod` check. 546 | 547 | ```php 548 | if ($user->subscription()->onGracePeriod()) { 549 | // ... 550 | } 551 | ``` 552 | 553 | #### Past Due Status 554 | 555 | If a recurring payment fails, the subscription will become past due. This indicates that the subscription is still valid, but your customer's payments will be retried in two weeks. 556 | 557 | ```php 558 | if ($user->subscription()->pastDue()) { 559 | // ... 560 | } 561 | ``` 562 | 563 | #### Subscription Scopes 564 | 565 | There are several subscription scopes available for querying subscriptions in specific states: 566 | 567 | ```php 568 | // Get all active subscriptions... 569 | $subscriptions = Subscription::query()->active()->get(); 570 | 571 | // Get all of the cancelled subscriptions for a specific user... 572 | $subscriptions = $user->subscriptions()->cancelled()->get(); 573 | ``` 574 | 575 | Here's all available scopes: 576 | 577 | ```php 578 | Subscription::query()->incomplete(); 579 | Subscription::query()->incompleteExpired(); 580 | Subscription::query()->onTrial(); 581 | Subscription::query()->active(); 582 | Subscription::query()->pastDue(); 583 | Subscription::query()->unpaid(); 584 | Subscription::query()->cancelled(); 585 | ``` 586 | 587 | #### Changing Plans 588 | 589 | When a consumer is on a monthly plan, they may desire to upgrade to a better plan, alter their payments to an annual plan, or drop to a lower-cost plan. In these cases, you can allow them to swap plans by giving a different product id to the `swap` method: 590 | 591 | ```php 592 | use App\Models\User; 593 | 594 | $user = User::find(1); 595 | 596 | $user->subscription()->swap('product_id_123'); 597 | ``` 598 | 599 | This will change the customer's subscription plan, however billing will not occur until the next payment cycle. If you want to immediately invoice the customer, you can use the `swapAndInvoice` method instead. 600 | 601 | ```php 602 | $user = User::find(1); 603 | 604 | $user->subscription()->swapAndInvoice('product_id_123'); 605 | ``` 606 | 607 | #### Multiple Subscriptions 608 | 609 | In certain situations, you may wish to allow your consumer to subscribe to numerous subscription kinds. For example, a gym may provide a swimming and weight lifting subscription. You can let your customers subscribe to one or both. 610 | 611 | To handle the various subscriptions, you can offer a type of subscription as the second argument when creating a new one: 612 | 613 | ```php 614 | $user = User::find(1); 615 | 616 | $checkout = $user->subscribe('product_id_123', 'swimming'); 617 | ``` 618 | 619 | You can now always refer to this specific subscription type by passing the type argument when getting it: 620 | 621 | ```php 622 | $user = User::find(1); 623 | 624 | // Retrieve the swimming subscription type... 625 | $subscription = $user->subscription('swimming'); 626 | 627 | // Swap plans for the gym subscription type... 628 | $user->subscription('gym')->swap('product_id_123'); 629 | 630 | // Cancel the swimming subscription... 631 | $user->subscription('swimming')->cancel(); 632 | ``` 633 | 634 | #### Cancelling Subscriptions 635 | 636 | To cancel a subscription, call the `cancel` method. 637 | 638 | ```php 639 | $user = User::find(1); 640 | 641 | $user->subscription()->cancel(); 642 | ``` 643 | 644 | This will cause your subscription to be cancelled. If you cancel your subscription in the middle of the cycle, it will enter a grace period, and the ends_at column will be updated. The customer will continue to have access to the services offered for the duration of the period. You may check the grace period by calling the `onGracePeriod` method: 645 | 646 | ```php 647 | if ($user->subscription()->onGracePeriod()) { 648 | // ... 649 | } 650 | ``` 651 | 652 | Polar does not offer immediate cancellation. To resume a subscription while it is still in its grace period, use the resume method. 653 | 654 | ```php 655 | $user->subscription()->resume(); 656 | ``` 657 | 658 | When a cancelled subscription approaches the end of its grace period, it becomes expired and cannot be resumed. 659 | 660 | #### Subscription Trials 661 | 662 | > [!NOTE] 663 | > Coming soon. 664 | 665 | ### Benefits 666 | 667 | Benefits are automated features that are granted to customers when they purchase your products. You can manage benefits using both the `LaravelPolar` facade (for create/update/delete operations) and methods on your billable model (for listing and retrieving benefits). 668 | 669 | #### Creating Benefits 670 | 671 | Create benefits programmatically using the `LaravelPolar` facade: 672 | 673 | ```php 674 | use Danestves\LaravelPolar\LaravelPolar; 675 | use Polar\Models\Components; 676 | 677 | $benefit = LaravelPolar::createBenefit( 678 | new Components\BenefitCustomCreate( 679 | description: 'Premium Support', 680 | organizationId: 'your-org-id', 681 | properties: new Components\BenefitCustomCreateProperties(), 682 | ) 683 | ); 684 | ``` 685 | 686 | #### Listing Benefits 687 | 688 | List all benefits for an organization using your billable model: 689 | 690 | ```php 691 | $benefits = $user->listBenefits('your-org-id'); 692 | ``` 693 | 694 | #### Getting a Specific Benefit 695 | 696 | Retrieve a specific benefit by ID using your billable model: 697 | 698 | ```php 699 | $benefit = $user->getBenefit('benefit-id-123'); 700 | ``` 701 | 702 | #### Listing Benefit Grants 703 | 704 | Get all grants for a specific benefit using your billable model: 705 | 706 | ```php 707 | $grants = $user->listBenefitGrants('benefit-id-123'); 708 | ``` 709 | 710 | #### Updating Benefits 711 | 712 | Update an existing benefit using the `LaravelPolar` facade: 713 | 714 | ```php 715 | use Danestves\LaravelPolar\LaravelPolar; 716 | use Polar\Models\Components; 717 | 718 | $benefit = LaravelPolar::updateBenefit( 719 | 'benefit-id-123', 720 | new Components\BenefitCustomUpdate( 721 | description: 'Updated Premium Support', 722 | properties: new Components\BenefitCustomUpdateProperties(), 723 | ) 724 | ); 725 | ``` 726 | 727 | #### Deleting Benefits 728 | 729 | Delete a benefit using the `LaravelPolar` facade: 730 | 731 | ```php 732 | LaravelPolar::deleteBenefit('benefit-id-123'); 733 | ``` 734 | 735 | ### Usage-Based Billing 736 | 737 | Track customer usage events for metered billing. This allows you to charge customers based on their actual usage of your service. 738 | 739 | #### Tracking Usage Events 740 | 741 | Track a single usage event for a customer: 742 | 743 | ```php 744 | $user->ingestUsageEvent('api_request', [ 745 | 'endpoint' => '/api/v1/data', 746 | 'method' => 'GET', 747 | 'duration_ms' => 145, 748 | ]); 749 | ``` 750 | 751 | #### Batch Event Ingestion 752 | 753 | For usage-based billing, you can track multiple events at once: 754 | 755 | ```php 756 | $user->ingestUsageEvents([ 757 | [ 758 | 'eventName' => 'api_request', 759 | 'properties' => [ 760 | 'endpoint' => '/api/v1/data', 761 | 'method' => 'GET', 762 | ], 763 | ], 764 | [ 765 | 'eventName' => 'storage_used', 766 | 'properties' => [ 767 | 'bytes' => 1048576, 768 | ], 769 | 'timestamp' => time(), 770 | ], 771 | ]); 772 | ``` 773 | 774 | #### Listing Customer Meters 775 | 776 | List all meters for a customer: 777 | 778 | ```php 779 | $meters = $user->listCustomerMeters(); 780 | ``` 781 | 782 | #### Getting a Specific Customer Meter 783 | 784 | Retrieve a specific customer meter by ID using the `LaravelPolar` facade: 785 | 786 | ```php 787 | use Danestves\LaravelPolar\LaravelPolar; 788 | 789 | $meter = LaravelPolar::getCustomerMeter('meter-id-123'); 790 | ``` 791 | 792 | > [!NOTE] 793 | > Usage events are sent to Polar for processing. They are not stored locally in your database. Use Polar's dashboard or API to view processed usage data. 794 | 795 | ### Handling Webhooks 796 | 797 | Polar can send webhooks to your app, allowing you to react. By default, this package handles the majority of the work for you. If you have properly configured webhooks, it will listen for incoming events and update your database accordingly. We recommend activating all event kinds so you may easily upgrade in the future. 798 | 799 | #### Webhook Events 800 | 801 | The package dispatches the following webhook events: 802 | 803 | **Order Events:** 804 | - `Danestves\LaravelPolar\Events\OrderCreated` 805 | - `Danestves\LaravelPolar\Events\OrderUpdated` 806 | 807 | **Subscription Events:** 808 | - `Danestves\LaravelPolar\Events\SubscriptionCreated` 809 | - `Danestves\LaravelPolar\Events\SubscriptionUpdated` 810 | - `Danestves\LaravelPolar\Events\SubscriptionActive` 811 | - `Danestves\LaravelPolar\Events\SubscriptionCanceled` 812 | - `Danestves\LaravelPolar\Events\SubscriptionRevoked` 813 | 814 | **Benefit Grant Events:** 815 | - `Danestves\LaravelPolar\Events\BenefitGrantCreated` 816 | - `Danestves\LaravelPolar\Events\BenefitGrantUpdated` 817 | - `Danestves\LaravelPolar\Events\BenefitGrantRevoked` 818 | 819 | **Checkout Events:** 820 | - `Danestves\LaravelPolar\Events\CheckoutCreated` 821 | - `Danestves\LaravelPolar\Events\CheckoutUpdated` 822 | 823 | **Customer Events:** 824 | - `Danestves\LaravelPolar\Events\CustomerCreated` 825 | - `Danestves\LaravelPolar\Events\CustomerUpdated` 826 | - `Danestves\LaravelPolar\Events\CustomerDeleted` 827 | - `Danestves\LaravelPolar\Events\CustomerStateChanged` 828 | 829 | **Product Events:** 830 | - `Danestves\LaravelPolar\Events\ProductCreated` 831 | - `Danestves\LaravelPolar\Events\ProductUpdated` 832 | 833 | **Benefit Events:** 834 | - `Danestves\LaravelPolar\Events\BenefitCreated` 835 | - `Danestves\LaravelPolar\Events\BenefitUpdated` 836 | 837 | Each of these events has a `$payload` property containing the webhook payload. Some events also expose convenience properties for direct access to related models: 838 | 839 | **Events with Convenience Properties:** 840 | 841 | | Event | Convenience Properties | 842 | |-------|----------------------| 843 | | `OrderCreated`, `OrderUpdated` | `$billable`, `$order` | 844 | | `SubscriptionCreated`, `SubscriptionUpdated`, `SubscriptionActive`, `SubscriptionCanceled`, `SubscriptionRevoked` | `$billable`, `$subscription` | 845 | | `BenefitGrantCreated`, `BenefitGrantUpdated`, `BenefitGrantRevoked` | `$billable` | 846 | 847 | **Events with Only `$payload`:** 848 | 849 | | Event | Access Pattern | 850 | |-------|----------------| 851 | | `CheckoutCreated`, `CheckoutUpdated` | `$event->payload->checkout` | 852 | | `CustomerCreated`, `CustomerUpdated`, `CustomerDeleted`, `CustomerStateChanged` | `$event->payload->customer` | 853 | | `ProductCreated`, `ProductUpdated` | `$event->payload->product` | 854 | | `BenefitCreated`, `BenefitUpdated` | `$event->payload->benefit` | 855 | 856 | **Example Usage:** 857 | 858 | ```php 859 | // Events with convenience properties 860 | public function handle(OrderCreated $event): void 861 | { 862 | $order = $event->order; // Direct access 863 | $billable = $event->billable; // Direct access 864 | } 865 | 866 | // Events with only payload 867 | public function handle(CheckoutCreated $event): void 868 | { 869 | $checkout = $event->payload->checkout; // Access via payload 870 | } 871 | ``` 872 | 873 | If you wish to respond to these events, you must establish listeners for them. You can create separate listener classes for each event type, or use a single listener class with multiple methods. 874 | 875 | #### Using Separate Listener Classes 876 | 877 | Create individual listener classes for each event: 878 | 879 | ```php 880 | payload->checkout; 891 | // Handle checkout creation... 892 | } 893 | } 894 | ``` 895 | 896 | ```php 897 | subscription; 908 | // Handle subscription update... 909 | } 910 | } 911 | ``` 912 | 913 | #### Using a Single Listener Class 914 | 915 | Alternatively, you can use a single listener class with multiple methods. For this approach, you'll need to register the listener as an event subscriber: 916 | 917 | ```php 918 | payload['type'] === 'subscription.updated') { 935 | // Handle the incoming event... 936 | } 937 | } 938 | 939 | /** 940 | * Handle checkout created events. 941 | */ 942 | public function handleCheckoutCreated(CheckoutCreated $event): void 943 | { 944 | $checkout = $event->payload->checkout; 945 | // Handle checkout creation... 946 | } 947 | 948 | /** 949 | * Handle subscription updated events. 950 | */ 951 | public function handleSubscriptionUpdated(SubscriptionUpdated $event): void 952 | { 953 | $subscription = $event->subscription; 954 | // Handle subscription update... 955 | } 956 | 957 | /** 958 | * Register the listeners for the subscriber. 959 | */ 960 | public function subscribe(Dispatcher $events): void 961 | { 962 | $events->listen( 963 | WebhookHandled::class, 964 | [self::class, 'handleWebhookHandled'] 965 | ); 966 | 967 | $events->listen( 968 | CheckoutCreated::class, 969 | [self::class, 'handleCheckoutCreated'] 970 | ); 971 | 972 | $events->listen( 973 | SubscriptionUpdated::class, 974 | [self::class, 'handleSubscriptionUpdated'] 975 | ); 976 | } 977 | } 978 | ``` 979 | 980 | The [Polar documentation](https://docs.polar.sh/integrate/webhooks/events) includes an example payload. 981 | 982 | #### Registering Listeners 983 | 984 | **For separate listener classes**, register them in your `EventServiceProvider`: 985 | 986 | ```php 987 | [ 1002 | // Add your listeners here 1003 | ], 1004 | CheckoutCreated::class => [ 1005 | HandleCheckoutCreated::class, 1006 | ], 1007 | SubscriptionUpdated::class => [ 1008 | HandleSubscriptionUpdated::class, 1009 | ], 1010 | ]; 1011 | } 1012 | ``` 1013 | 1014 | **For event subscribers**, register the subscriber in your `EventServiceProvider`: 1015 | 1016 | ```php 1017 |