Set up Paddle Subscriptions for your Laravel App in 5 Minutes
Tags: laravel
Published: 2020-03-16 16:00:53

I want to monetize my web app, but I don't want to spend too much time on coding a billing infrastructure. Because I am lazy. In this article, I show you how you can set up subscriptions with Laravel and [Paddle](https://paddle.com/) in less than 5 minutes. Please note that I cut many corners (see below), you probably want to spend more time on the subscription process.

This is how my final result looks:

![Billing UI](https://dev-to-uploads.s3.amazonaws.com/i/7l9df84v69qrh3mp0qrd.png)

Very bleak, but gets the job done.

## Lets go!

After you set up your Subscription Plan in Paddle you pull in a Laravel wrapper for the Paddle API.

```
composer require protonemedia/laravel-paddle
php artisan vendor:publish --provider="ProtoneMedia\LaravelPaddle\PaddleServiceProvider"
```

And set up your Paddle credentials:

```
PADDLE_VENDOR_ID=123
PADDLE_VENDOR_AUTH_CODE=456
PADDLE_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----"
```

This library already integrates Paddle webhooks into the Laravel event service, so you only need to write code that combines those events with your user model.

I decided to add my subscription product id in `config/paddle.php`  as welll:

```php
    'product_id' => env('PADDLE_PRODUCT_ID'),
```

Add the required fields to the user by creating a migration:

```php
class AddPaddleToUsers extends Migration
{
    public function up()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('subscription_state');
            $table->date('subscription_ends_at');
            $table->string('paddle_cancel_url')->nullable();
            $table->string('paddle_update_url')->nullable();
        });
    }
}
```

Newly registered users get a 14-day free trial. Add this to the default Laravel `RegisterController`:

```php
protected function create(array $data)
{
    return User::create([
        // ...
        'subscription_state' => 'trial',
        'subscription_ends_at' => now()->addDays(14)
    ]);
}
```

Create a `/billing` route so that users can manage their subscriptions. I use Inertia.js for my front-end logic. You probably have to adjust this. The controller looks for me like this:

```php
class BillingController extends Controller
{
    public function index()
    {
        return Inertia::render('Billing', [
            'product' => config('paddle.product_id'),
            'user' => auth()->user()
        ]);
    }
}
```

And the `Billing.vue`component does this:

```vue
<template>
  <div class="card">
    <div class="card-header">
      <h1>Subscription</h1>
    </div>
    <div class="card-body">
      <p v-if="subscriptionEnded">
        Your subscription has run out at:
        <AppDateFormat :value="user.subscription_ends_at"/>
      </p>
      <p v-else-if="user.subscription_state === 'trial'">
        You are currently on trial. Your trial ends at:
        <AppDateFormat :value="user.subscription_ends_at"/>
      </p>
      <p v-else-if="user.subscription_state === 'active'">
        You are currently subscribed. Next billing is due at:
        <AppDateFormat :value="user.subscription_ends_at"/>
        <span class="block mt-2">
          <a :href="user.paddle_cancel_url">Cancel Subscription</a>
          &middot;
          <a :href="user.paddle_update_url">Update Billing Details</a>
        </span>
      </p>
      <p v-else-if="user.subscription_state === 'cancelled'">
        Your subscription has been canceled and will end at:
        <AppDateFormat :value="user.subscription_ends_at"/>
        <span class="block text-gray-6 mt-2">
          You can resubscribe when your subscription phase has ran out.
        </span>
      </p>

      <div class="mt-4" v-if="canCheckout">
        <AppButton
          @click="subscribe"
          variant="primary"
          label="Subscribe now"
        />

        <p class="text-gray-6 mt-8">
          It might take a few seconds for your subscription to process.
          Refresh this page to see when your subscription has been processed.
          If you experience issues after purchase please contact us.
        </p>
      </div>
    </div>
  </div>
</template>

<script>
  import AppButton from '@/Components/AppButton'
  import AppDateFormat from '@/Components/AppDateFormat'
  import dayjs from 'dayjs'

  export default {
    components: {
      AppButton,
      AppDateFormat,
      Layout
    },
    props: {
      product: String,
      user: Object
    },
    computed: {
      subscriptionEnded () {
        return dayjs(this.user.subscription_ends_at).isBefore()
      },
      canCheckout () {
        return this.user.subscription_state === 'trial' || this.subscriptionEnded
      }
    },
    methods: {
      subscribe () {
        Paddle.Checkout
          .open({
            product: this.product,
            email: this.user.email,
            passthrough: JSON.stringify({ 'user_id': this.user.id })
          })
          .then(() => {
            window.location.reload()
          })
      }
    }
  }
</script>
```

And finally set up the Paddle checkout JavaScript code by adding the paddle directive into your `app.blade.php`:

```php
<html>
<body>
  <!-- your template -->
  @paddle
</body>
```

At this point, you are able to open the Checkout form without any issues. Now lets wire up the required webhook logic. I start with the listener for the subscription creation/update process and simply call it `SaveSubscription`:

```php
use ProtoneMedia\LaravelPaddle\Events\SubscriptionCreated;
use ProtoneMedia\LaravelPaddle\Events\SubscriptionUpdated;

class SaveSubscription
{
    /**
     * @param  SubscriptionCreated|SubscriptionUpdated  $event
     */
    public function handle($event)
    {
        if ($event->states !== 'active') {
            // I decided to handle other states manually.
            return;
        }

        $user = User::findOrFail($event->passthrough['user_id']);
        $user->subscription_state = 'active';
        $user->subscription_ends_at = $event->next_bill_date;
        $user->paddle_cancel_url = $event->cancel_url;
        $user->paddle_update_url = $event->update_url;
        $user->save();
    }
}
```

And another listener for the cancellation event called `CancelSubscription`:

```php
class CancelSubscription
{
    public function handle(SubscriptionCancelled $event)
    {
        $user = User::findOrFail($event->passthrough['user_id']);
        $user->subscription_state = 'cancelled';
        $user->subscription_ends_at = $event->cancellation_effective_date;
        $user->paddle_cancel_url = null;
        $user->paddle_update_url = null;
        $user->save();
    }
}
```

Add those listeners to your `EventServiceProvider`:

```php
ProtoneMedia\LaravelPaddle\Events\SubscriptionCreated::class => [
    Listeners\SaveSubscription::class
],
ProtoneMedia\LaravelPaddle\Events\SubscriptionUpdated::class => [
    Listeners\SaveSubscription::class
],
ProtoneMedia\LaravelPaddle\Events\SubscriptionCancelled::class => [
    Listeners\CancelSubscription::class
]
```

Finally, you have to restrict access to the app for users without a valid subscription. You can do this by adding a custom middleware:

```php
class RequiresSubscription
{
    public function handle(Request $request, Closure $next)
    {
        if ($request->user()
            && $request->user()->subscription_ends_at->isBefore(now()->subDay())) {
            return Redirect::to('/billing');
        }
        return $next($request);
    }
}
```

Please make sure that `subscription_ends_at` is casted to a Carbon object. In your `User` model add this:

```php
protected $casts = [
    // ...
    'subscription_ends_at' => 'date'
];
```

And use the middleware on your routes that require a subscription like this:

```php
Route::group(['middleware' => \App\Http\Middleware\RequiresSubscription::class], function () {
	// your routes
});
```

This is all you need to do to set up Paddle subscriptions in Laravel.

## Testing the Process

You can write functional tests for the Listeners in Laravel like you are used to.

To test your webhooks in development get a public URL:

```
valet share
```

Set up http://YOURID.ngrok.io/paddle/webhook as your webhook in the Paddle Configuration.

To test the whole checkout process Paddle recommends doing a real transaction. You can fully refund this transaction without any cost.

## Cut Corners

I simplified the subscription process immensely. By taking these shortcuts:

* The user can only resubscribe when the previous subscription has ran out. Thus preventing any issues for the user on being billed multiple times for the same time period.
* Telling the user it takes some moments for the subscription to get into effect. Maybe you want to implement some polling so that the user sees when the subscription has been processed by your system.
* Subscription cancellation and billing details are fully managed by Paddle. So I don't need to write my own UIs for that.
* I don't store any subscription history. It might be nice for the user to see previous payments.
* Currently, I don't handle failed payments automatically. When a payment has failed, I have to manually disable the user account - which is alright for the moment.
* I only have one subscription plan.
* I don't have any marketing material on my billing page. Maybe displaying at least the prices. But I would have to make sure to keep them in sync with my Paddle settings.

Maybe you need those features. But for me, the current process works just fine. Especially when you consider the amount of time that went into it. I can refine it when I really need to later.

And If you find any errors please tell me. 😊