DEV Community

Marvin Rabe
Marvin Rabe

Posted on

Set up Paddle Subscriptions for your Laravel App in 5 Minutes

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 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

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"
Enter fullscreen mode Exit fullscreen mode

And set up your Paddle credentials:

PADDLE_VENDOR_ID=123
PADDLE_VENDOR_AUTH_CODE=456
PADDLE_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----"
Enter fullscreen mode Exit fullscreen mode

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:

    'product_id' => env('PADDLE_PRODUCT_ID'),
Enter fullscreen mode Exit fullscreen mode

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

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();
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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:

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

And the Billing.vuecomponent does this:

<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>
Enter fullscreen mode Exit fullscreen mode

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

<html>
<body>
  <!-- your template -->
  @paddle
</body>
Enter fullscreen mode Exit fullscreen mode

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:

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

And another listener for the cancellation event called CancelSubscription:

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

Add those listeners to your EventServiceProvider:

ProtoneMedia\LaravelPaddle\Events\SubscriptionCreated::class => [
    Listeners\SaveSubscription::class
],
ProtoneMedia\LaravelPaddle\Events\SubscriptionUpdated::class => [
    Listeners\SaveSubscription::class
],
ProtoneMedia\LaravelPaddle\Events\SubscriptionCancelled::class => [
    Listeners\CancelSubscription::class
]
Enter fullscreen mode Exit fullscreen mode

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

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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

protected $casts = [
    // ...
    'subscription_ends_at' => 'date'
];
Enter fullscreen mode Exit fullscreen mode

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

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

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
Enter fullscreen mode Exit fullscreen mode

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. 😊

Oldest comments (0)