How to Accept Payments with Laravel Cashier and Paddle

March 11, 2025
Written by
Anumadu Udodiri Moses
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Implementing Payment with Laravel Cashier and Paddle

For online businesses, having a reliable payment system is critical since most transactions happen online. As a developer, understanding how to implement online payment systems is an important skill, as every business needs an efficient way to handle payments. In this tutorial, we’ll set up a subscription-based payment system using Laravel, Laravel Cashier, and Paddle.

To demonstrate this, we’ll build a basic time-tracking web application with subscription payments. While anyone can sign up, only users with active subscriptions can access the time-tracking dashboard. Now, let's dive into building this!

Prerequisites

The following are required to follow along effectively with this tutorial

Set up the project

To get started creating the project, we need to create a new Laravel application, and change into the new project directory, by using the commands below:

composer create-project laravel/laravel cashier-paddle-subscription
cd cashier-paddle-subscription

Once we’ve set up our Laravel project, the next steps are configuring the database, and implementing authentication for the application. This will ensure that users can securely register, log in, and access the relevant features of the app.

For this tutorial, we'll be using MySQL as our database. In your .env file, update the following fields with your database's credentials.

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=<<DB_NAME>>
DB_USERNAME=<<DB_USER_NAME>>
DB_PASSWORD=<<DB_PASSWORD>>

Next, we’ll set up authentication using Laravel Breeze. You can install it by running the following command.

composer require laravel/breeze --dev

Then, run the following commands to complete the installation.

php artisan breeze:install
php artisan migrate
npm install
npm run dev

When you run the command, you'll be asked to pick a frontend stack. Choose Blade. After that, the rest of the setup will continue. The php artisan migrate command runs your database migrations, npm install installs frontend dependencies, and npm run dev starts the development server.

To make sure everything is functioning properly at this point, you can use the command below to launch the application.

php artisan serve

Add time tracking functionality

Subscription-based applications typically offer a service that users must subscribe to in order to access them. In our case, the service will be a simple time-tracking feature. Only users with active subscriptions will be able to use its functionality.

Let’s begin by implementing it. To start, we’ll need to create a model, controller, and migration. You can do this by running the following command:

php artisan make:model TimeTracker -cm

After creating the files above, head over to app/Http/Controllers/TimeTrackerController.php file and update it to match the following code.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\TimeTracker;

class TimeTrackerController extends Controller
{
    public function index() 
    {
        $entries = TimeTracker::all();
        return view('time-tracker', ['entries' => $entries]);
    }

    public function start()
    {
        $entry = TimeTracker::create(['start_time' => now()]);
        return redirect()->back();
    }

    public function stop(TimeTracker $entry)
    {
        $entry->update(['end_time' => now()]);
        return redirect()->back();
    }
}

In the code above, the index() function returns the time-tracker.blade.php file along with all the time entries in a variable named $entries.

The start() method starts a new time-tracker entry by recording the current time as start_time. It creates a new record in the TimeTracker model and redirects the user back to the previous page.

The stop() method stops an active time-tracker entry by updating the end_time field to the current time for the specified entry. Like the start() method, it also redirects the user back to the previous page.

Add the accompanying Blade template

Next, we need to create the Blade template for our front end. In the resources\view directory, create a file and name it time-tracker.blade.php. Then, add the following code to the newly created file:

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Dashboard') }}
        </h2>
    </x-slot>
    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                <div class="p-6 text-gray-900">
                    <div class="container mx-auto">
                        <h1 class="text-2xl font-bold mb-5">Simple Time Tracker</h1>
                        <!-- Start Time Tracking -->
                        <form action="{{ route('time-tracker.start') }}" method="POST">
                            @csrf
                            <button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded">
                                Start Tracking
                            </button>
                        </form>
                        <!-- Display Active Time Entries -->
                        <div class="mt-10">
                            <h2 class="text-xl font-semibold mb-3">Active Time Entries</h2>
                            @foreach($entries as $entry)
                                @if(!$entry->end_time)
                                    <div class="bg-white p-4 rounded shadow mb-3">
                                        <p>Started at: {{ $entry->start_time }}</p>
                                        <form action="{{ route('time-tracker.stop', $entry->id) }}" method="POST" class="mt-3">
                                            @csrf
                                            <button type="submit" class="bg-red-500 text-white px-4 py-2 rounded">
                                                Stop Tracking
                                            </button>
                                        </form>
                                    </div>
                                @endif
                            @endforeach
                        </div>
                        <!-- Display All Time Entries -->
                        <div class="mt-10">
                            <h2 class="text-xl font-semibold mb-3">All Time Entries</h2>
                            @foreach($entries as $entry)
                                <div class="bg-white p-4 rounded shadow mb-3">
                                    <p>Start Time: {{ $entry->start_time }}</p>
                                    <p>End Time: {{ $entry->end_time ?? 'In Progress' }}</p>
                                    <p>Duration: {{ $entry->end_time ? $entry->start_time->diffForHumans($entry->end_time, true) : 'In Progress' }}</p>
                                </div>
                            @endforeach
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</x-app-layout>

We also need to modify the migration file we created earlier. Then, in database/migrations,open the file ending in _create_time_trackers_table.php and update the up() function to match the following.

public function up(): void
{
    Schema::create('time_trackers', function (Blueprint $table) {
        $table->id();
        $table->timestamp('start_time')->nullable();
        $table->timestamp('end_time')->nullable();
        $table->timestamps();
    });
}

Next, update the TimeTracker model by opening the App\Models\TimeTracker.php file. Then, replace the existing content with the following code.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class TimeTracker extends Model
{
    use HasFactory;

    protected $fillable = ['start_time','end_time'];
    protected $casts = [
        'start_time' => 'datetime',
        'end_time' => 'datetime',
    ];
}

We also need route links, so let's create routes for our time tracker. To do that, add the following to routes\web.php file.

Route::get('/dashboard', [TimeTrackerController::class, 'index'])->middleware(['auth', 'verified'])->name('dashboard');
Route::prefix('/time')->group(function(){
    Route::post('/start', [TimeTrackerController::class, 'start'])->name('time-tracker.start');
    Route::post('/stop/{entry}', [TimeTrackerController::class, 'stop'])->name('time-tracker.stop');
});

After that, add the following use statement to the top of the file.

use App\Http\Controllers\TimeTrackerController;

Then, run migration using the command below.

php artisan migrate

If everything works correctly, you should now be able to register and log in. After doing so, your dashboard should also look similar to the screenshot below.

Screenshot of a Laravel dashboard with a Start Tracking button for a simple time tracker.

Set up Laravel Cashier

We will handle all payments using the Paddle payment gateway. But, instead of interacting directly with Paddle's API, we will use Laravel Cashier to streamline the process.

Here are the steps that we will follow to set everything up:

  • Install Laravel Cashier for Paddle
  • Create a Paddle sandbox account
  • Generate the necessary API keys
  • Set up different products and pricing plans
  • Create a webhook to handle payment events
  • Initiate payments and restrict access for non-subscribers

Install Laravel Cashier

Similar to many other Laravel packages, Composer can be used to install Laravel Cashier. Run the command below to install it.

composer require laravel/cashier-paddle

Once the installation is done, we need to publish the Cashier migration file using the Artisan command below.

php artisan vendor:publish --tag="cashier-migrations"

And finally, we need to re-run our migration to make sure all the necessary database tables are created.

php artisan migrate

Set up the Paddle Sandbox

The Paddle Sandbox is Paddle's development environment, allowing us to test our application's payment functionality using test API keys without processing real transactions. To begin, you need to connect the application to Paddle.

Before you do though, add the following configuration to your .env file.

PADDLE_API_KEY="<<PADDLE_API_KEY>>"
PADDLE_CLIENT_SIDE_TOKEN="<<PADDLE_CLIENT_SIDE_TOKEN>>"
PADDLE_WEBHOOK_SECRET="<<PADDLE_WEBHOOK_SECRET>>"
PADDLE_SANDBOX=true

Now, login to your Paddle Sandbox dashboard and click on Developer Tools > Authentication.

There, on the right-hand side of the API keys section, click the three dot button and click Copy key. Then, paste the key in place of <<PADDLE_API_KEY>> in .env.

You can also create a new API key by clicking on the Generate API key button on the top right corner of the screen.
Client-side tokens page indicating no tokens generated with a button to generate new tokens.

Next, in the Client-side tokens section, click Generate client-side token.

Screen showing API keys dashboard and form to generate a new token with name and description fields.

In the Generate token form that appears, add a name, such as "Laravel Cashier Integration", and click Generate.

Interface displaying client-side tokens with options to copy, show, or revoke a Laravel Cashier Integration token.

After that, click the three dot button and click "Copy key". Finally, in .env, paste the key in place of <<PADDLE_CLIENT_SIDE_TOKEN>>.

Later, you'll need to add the PADDLE_WEBHOOK_SECRET along with its value. This will be covered in the upcoming section where we configure the webhook.

Configure the Paddle Webhook

Next, we need to set up the Paddle webhook for communication between our application and Paddle. Laravel Cashier already took care of the webhook implementation and route in our Laravel codebase. The route paddle/webhook is already defined and works.

You can confirm this by running the following command to list all the available routes in the application.

php artisan route:list

To connect this route to Paddle, we'll need to expose our local development environment to the Internet. Since we're developing locally, we can use a tunneling service like Ngrok for this, by running the following command.

ngrok http 8000
If you're unfamiliar with ngrok, here is a link to a simple tutorial to help you get started.
Screenshot of an ngrok dashboard displaying session status, account info, region, latency, and connection details.

In the ngrok output, take note of the Forwarding URL. In my case it was https://2bf9-169-159-98-231.ngrok-free.app, it will be different for you.

Using the ngrok Forwarding URL, we can create our webhook in the Paddle sandbox dashboard, by clicking on Developer Tools > Notifications. On the notification page click the New Destination button in the top right corner of the screen.

There:

  • Set the URL field as your <<NGROK_FORWARDING_URL>>/paddle/webhook. For example, in my case I will have https://2bf9-169-159-98-231.ngrok-free.app/paddle/webhook.
  • Set the Description field to "Paddle Webhook"
  • Under Events, check: subscription.canceled, subscription.created, subscription.paused, subscription.updated, transaction.completed, and transaction.updated
  • Click Save destination
Screenshot of a webhook management interface with edit, view logs, copy ID, and deactivate options.

After saving the webhook, you'll see the new notification in the list. Click the three dot button to the right of the new notification and click Edit destination.

Screen showing Paddle webhook configuration with URL, secret key, and notifications settings.

Then, copy the Secret key value and paste it in place of <<PADDLE_WEBHOOK_SECRET>> in .env.

In .env, PADDLE_SANDBOX=true, as you saw earlier, is already set to true. It informs Laravel Cashier that we are in development and are working with the sandbox. This value would be set to false in production.

Create a Paddle product

In the final step of the Paddle sandbox setup, we’ll define products with fixed prices in the Paddle dashboard. This is where you’ll set up the pricing structure for your subscription services.

Paddle allows you to create products with multiple pricing options. For instance, let’s say you have a subscription service with a "Basic" plan. On the Paddle dashboard, you could define this product with two pricing tiers:

  • One for a basic monthly subscription (price_basic_monthly)
  • nother for a basic yearly subscription (price_basic_yearly)

Similarly, you could offer a "Pro" plan with both monthly and yearly pricing options. This flexibility allows you to offer different pricing options.

For this tutorial, we will create two products (Basic and Pro) with monthly and yearly prices for each of them. To get started, from your Paddle sandbox dashboard, navigate to Catalog > Products, then click the New product button on the top right corner of the page. For more clarity, check the image below

An online dashboard displaying a products page with no data and a New product button.

 

When the New product button is clicked, it opens a modal dialog that contains the form to create a product. Fill out the form by providing the required details (product name, tax category, etc). After that, click on the Save button in the top right corner of the page to save the details for the products. Check the image below for further clarification.

After saving the new product, the next step is to create the different prices for the Basic plan. To do this, navigate to Catalog > Products page from the Paddle sandbox dashboard. The product page should look like the image below.

Screenshot of a web interface showing the form to add a new product with fields for name, category, description, and icon URL.
Dashboard showing a list of active products with a balance of $247.96 and navigation menu on the left side.

Get started by clicking the button at the far right of Basic on the Products page, then clicking "View". You should be redirected to a page that looks like the screen below. Click the New price button on this page.

Screen showing basic account details with a balance of $173.52 and prompt to set prices to start selling.

This opens up a modal window with the form needed to create the different prices. If everything is done right, your screen should look just like the image below at this stage.

Screenshot of a pricing management interface showing price setup for a new recurring subscription.

Now, we can add different prices to the basic product by filling out the form. Add the amount to the Base price form field. Select the billing type (Type). In our case, it should be set to Recurring as we are building a billing system. Then, set Billing period to "monthly", and set values for Trial period, Price name, and Internal description respectively. After that, save the changes by clicking the Save button.

Setting the Billing period value to "monthly" creates a price for the monthly plan with the price name set to price_basic_monthly.

You need to repeat the process the second time. This time, set Billing period to "Yearly" and Price name to "price_basic_yearly" to create a yearly plan.

Now, we have created our Basic product and added prices for monthly subscriptions and yearly subscriptions respectively. So, we need to do the same for our Pro product plan. Follow the steps above again to do so with a monthly price named "price_pro_monthly" and a yearly price named "price_pro_yearly" respectively.

If done correctly, you should have two plans with four prices. With that done, let us refactor our time tracker to allow only subscribed users to access the dashboard.

Before we proceed any further, let's add the following to the end of our .env file and add values for the Paddle price IDs to keep things organized and simple.

PADDLE_BASIC_MONTHLY="<<PADDLE_BASIC_MONTHLY>>"
PADDLE_BASIC_YEARLY="<<PADDLE_BASIC_YEARLY>>"
PADDLE_PRO_MONTHLY="<<PADDLE_PRO_MONTHLY>>"
PADDLE_PRO_YEARLY="<<PADDLE_PRO_YEARLY>>"

The final step in the Paddle sandbox configuration is setting up a default payment link. To do this, head to Paddle > Checkout > Checkout Settings and add your default payment link under the "Default payment link" heading. Since we are developing locally, our default link is https://localhost:8000. After that, click the save button and it should save. Check the image below for a visual reference.

A settings page for configuring online payment methods including PayPal, Google Pay, and Apple Pay options.

If you are not building locally or have a domain name, this should be the complete link to your payment. For example, link should pay https://example.com/pay not https://example.com.

Set up a billable model

To start using Cashier, we need to add the Billable trait to our target model. In this case our user model. This trait gives us access to methods for handling billing tasks, like setting up subscriptions and updating payment details. You can add the Billable trait to any models that need to be billable.

Since we want to make our User model billable in this case, let's add the Billable trait to App\Models\User.php like so.

<?php

use Laravel\Paddle\Billable;

class User extends Authenticatable
{
    use <…existing traits>, Billable;
    // …existing code
}

Add the ability to handle payments

To load the Paddle checkout widget, we need to import Paddle's JavaScript library into our project. To do that, add the following to the bottom of the <head> section of resources/views/layouts/app.blade.php.

<head>
    ...
    @paddleJS
</head>

For a user to subscribe to any of our payment plans, they need to click a button that opens a Paddle payment checkout overlay for the given price and allows them to pay. The payment overlay will be triggered using a Blade component button named paddle-button.

Gladly, we don't need to create this as Cashier already offers a paddle-button Blade component out of the box. You can also choose to render an overlay checkout manually if you prefer. But, for this tutorial, we will use the paddle-button blade component.

Before showing the checkout overlay widget, we need to create a checkout session using Cashier. This session provides the checkout widget with the details of the billing process that needs to be carried out.

The code below is an example of a checkout session instantiated from a /buy route. Let's take a closer look at it. A good understanding of how this code works will come in handy in our own implementation.

use Illuminate\Http\Request;

Route::get('/buy', function (Request $request) {
    $checkout = $user->checkout('pri_34567')
        ->returnTo(route('dashboard'));
    return view('billing', ['checkout' => $checkout]);
});

The checkout method accepts the price_id, and returns a view where the Cashier Blade component button would accept $checkout as an argument to trigger the checkout overlay.

Let's modify our TimeTrackerController to enable users to pay before accessing the tracker. Update App/Http/Controllers/TimeTrackerController.php with the following code .

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\TimeTracker;

class TimeTrackerController extends Controller
{
    public $basicMonthly;
    public $basicYearly;
    public $proMonthly;
    public $proYearly;

    public function __construct()
    {
        $this->basicMonthly = env('PADDLE_BASIC_MONTHLY');
        $this->basicYearly = env('PADDLE_BASIC_YEARLY');
        $this->proMonthly = env('PADDLE_PRO_MONTHLY');
        $this->proYearly = env('PADDLE_PRO_YEARLY');
    }
    
    public function index(Request $request) 
    {
        if (auth()->user()->subscribed()) {
            $entries = TimeTracker::all();
            return view('time-tracker', ['entries' => $entries]);
        } else {
            $basicMonthly = $this->paymentInit($this->basicMonthly);
            $basicYearly = $this->paymentInit($this->basicYearly);
            $proMonthly = $this->paymentInit($this->proMonthly);
            $proYearly = $this->paymentInit($this->proYearly);
            return view('dashboard', [
                'basicMonthly' => $basicMonthly,
                'basicYearly' => $basicYearly,
                'proMonthly' => $proMonthly,
                'proYearly' => $proYearly,
            ]);
        }
    }

    public function paymentInit($paymentPlan) 
    {
        return $checkout = auth()->user()->checkout($paymentPlan)->returnTo(route('dashboard'));
    }

    public function start()
    {
        $entry = TimeTracker::create(['start_time' => now()]);
        return redirect()->back();
    }

    public function stop(TimeTracker $entry)
    {
        $entry->update(['end_time' => now()]);
        return redirect()->back();
    }
}

We made a few changes to the App/Http/Controllers/TimeTrackerController.php file to send subscribed users to a different view and unsubscribed users to the subscription page.

$basicMonthly, $basicYearly, $proMonthly, $proYearly are variables used to store different subscription plans (monthly and yearly for both Basic and Pro) pulled from .env.

The __construct() method initializes these properties by retrieving the subscription plan IDs from the environment file.

index() is the main entry point for users accessing the time tracker. We modified it to check if the user is subscribed using auth()->user()->subscribed(). If the user is subscribed, it fetches all the time-tracker entries and returns the time-tracker view with these values.

If the user is not subscribed, the method prepares checkout URLs for different subscription plans ( Basic Monthly, Basic Yearly, Pro Monthly, and Pro Yearly) using the paymentInit() method, and returns the dashboard view with these checkout options.

paymentInit() handles payment initialization by generating a Paddle checkout link for a given subscription plan. It uses the user's checkout() method and redirects them back to the dashboard route after a successful payment.

Let's modify the content of the dashboard to display the various payment links for unsubscribed users. To do this, navigate to resources/views/dashboard.blade.php and replace the code in it with the code below.

<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight"> {{ __('Subscription') }} </h2> </x-slot>
<div class="py-12">
    <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="p-6 text-gray-900">
                <div class="mt-4">
                    <div class="grid grid-cols-4 gap-4">
                        <div class="shadow-md border rounded-2xl p-8 bg-white">
                            <div class="flex justify-between">
                                <h2 class="font-bold text-2xl">Basic monthly plan</h2>
                                <h2>$10.00/month</h2>
                            </div>
                            <div class="mt-4">
                                <x-paddle-button :checkout="$basicMonthly" class="p-4 w-full rounded-xl bg-sky-500 text-white">
                                    Subscribe
                                </x-paddle-button>
                            </div>
                        </div>
                        <div class="shadow-md border rounded-2xl p-8 w-full bg-white">
                            <div class="flex justify-between">
                                <h2 class="font-bold text-2xl">Basic yearly plan</h2>
                                <h2>$8.00/month</h2>
                            </div>
                            <div class="mt-4">
                                <x-paddle-button :checkout="$basicYearly" class="p-4 w-full rounded-xl bg-sky-500 text-white">
                                    Subscribe
                                </x-paddle-button>
                            </div>
                        </div>
                        <div class="shadow-md border rounded-2xl p-8 w-full bg-white">
                            <div class="flex justify-between">
                                <h2 class="font-bold text-2xl">Pro Monthly plan</h2>
                                <h2>25.00/month</h2>
                            </div>
                            <div class="mt-4">
                                <x-paddle-button :checkout="$proMonthly" class="p-4 w-full rounded-xl bg-sky-500 text-white">
                                    Subscribe
                                </x-paddle-button>
                            </div>
                        </div>
                        <div class="shadow-md border rounded-2xl p-8 w-full bg-white">
                            <div class="flex justify-between">
                                <h2 class="font-bold text-2xl">Pro yearly plan</h2>
                                <h2>$20.00/year</h2>
                            </div>
                            <div class="mt-4">
                                <x-paddle-button :checkout="$proYearly" class="p-4 w-full rounded-xl bg-sky-500 text-white">
                                    Subscribe
                                </x-paddle-button>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
    </div>
</div>
</x-app-layout>

If you have everything set up correctly, your home screen should look like mine in the screenshot below, and your subscription payment should work using Paddle test cards.

Four subscription plans including basic monthly, basic yearly, pro monthly, and pro yearly with prices and subscribe buttons.

Test the payment flow

Before testing, make sure that your Paddle webhook is set up correctly and that your application is served using ngrok to tunnel it to the internet. The ngrok URL must be the same as the one in your Paddle webhook configuration.

If you have to restart ngrok for any reason, you will have to update the webhook URL to the newly generated ngrok URL.

Now, to test the payment flow, register a new user, log in to the application, and pay for any of the subscription plans using the Paddle test card details. After a successful payment, you should be redirected to the dashboard, where you can access the time tracker features, similar to the image below.

Screenshot of a dashboard displaying a simple time tracker with active and all time entries

That's how to accept payments with Laravel Cashier and Paddle

A thumbs up if you read to the end and follow along with the code. You've learned how to integrate subscription payments in a Laravel application using Laravel Cashier and Paddle.

In this article, we built a very simple time tracker in Laravel, backed by MySQL. We also went through the steps of implementing payments using Laravel Cashier and the Paddle payment gateway.

We explored some of the core features of Paddle like:

  • Creating a sandbox environment account
  • Creating different products and adding prices to them
  • Configuring webhook on Paddle using ngrok to tunnel our application to the internet
  • Modified our User model into a billable model able to make payments
  • Ensured that only subscribed users can access the core features of the application

This should be enough to get you started, however, there is still a whole lot we did not cover. Take a look at the Laravel Cashier documentation for Paddle for further reading.

Moses Anumadu is a software developer and online educator who loves to write clean, maintainable code. He's creating something awesome for Laravel developers with Laravel. You can get in touch with him here.

The Laravel and Paddle logos are copyright of their respective owners.