Send PDF Invoices via WhatsApp using Laravel, PHP, Stripe, and the Twilio API for WhatsApp

February 11, 2020
Written by
Michael Okoko
Contributor
Opinions expressed by Twilio contributors are their own

Generate and Send PDF Invoices via WhatsApp using Laravel, PHP, Stripe, and the Twilio API for WhatsApp

Stripe is a payment gateway that provides developer APIs to help you receive payments from your application/website.

The Twilio API for WhatsApp provides a platform that helps you send any type of business message via WhatsApp through a streamlined API.

In this tutorial, we will be exploring how to send payment invoices and notifications to our users via WhatsApp when they make payments on our website.

Pre-requisites

To follow along with this post, you will need the following:

Getting Started

To get started, create a new Laravel application and assign the folder name.

$ laravel new twilio-commerce && cd twilio-commerce

Next, install our application dependencies using composer. These dependencies include the Twilio SDK and the Stripe PHP library (to communicate with the Twilio and Stripe API respectively) as well as Laravel DomPDF (to generate PDFs from HTML/Blade templates).

$ composer require twilio/sdk stripe/stripe-php barryvdh/laravel-dompdf

The Stripe library uses a pair of publishable and secret keys (which can be retrieved from the Developer Section on your Stripe dashboard) for authentication. Similarly, the Twilio SDK uses your Account SID and Auth token (which can also be found on your Twilio console). Grab those credentials and append them to your .env file.

STRIPE_PUB_KEY="pk_test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
STRIPE_SECRET_KEY="sk_test_XXXXXXXXXXXXXXXXXXXXXXX"

TWILIO_ACCOUNT_SID="ACXXXXXXXXXXXXXXXXXXXXXXXX"
TWILIO_AUTH_TOKEN="412XXXXXXXXXXXXXXXXXXXXXXX"

Our application requires two tables in our database, products and users. Open the users migration file created by Laravel (YOUR-APP-NAME/database/migrations/2014_10_12_000000_create_users_table.php) and change theup method to the following:

public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name');
            $table->string('email')->unique();
            $table->string('phone_number')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

Then, create a new migration for the products table with the command below:

$ php artisan make:migration create_products_table

And paste in the following in the newly created file.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateProductsTable extends Migration
{
        public function up()
        {
            Schema::create('products', function (Blueprint $table) {
                $table->bigIncrements('id');
                $table->string('name');
                $table->string('image_path');
                $table->string('description');
                $table->decimal('price');
                $table->timestamps();
            });
        }

        public function down()
        {
            Schema::dropIfExists('products');
        }
}

We need to seed both of the tables with sample data. Let’s generate Seeder classes for the tables we just created.

$ php artisan make:seeder UsersTableSeeder
$ php artisan make:seeder ProductsTableSeeder

Next, we need to change the code in the app/database/seeds/UsersTableSeeder class to the one below:

<?php

use Illuminate\Database\Seeder;
use Faker\Generator as Faker;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;

class UsersTableSeeder extends Seeder
{
        public function run(Faker $faker)
        {
            DB::table("users")->insert([
                [
                    'name' => $faker->name,
                    'email' => $faker->unique()->safeEmail,
                    'email_verified_at' => now(),
                    'phone_number' =>  $faker->e164PhoneNumber, // use a real phone number to see this work
                    'password' => Hash::make($faker->password()),
                ]
            ]);
        }
}

In the same way, let’s paste the following code into our app/database/seeds/ProductsTableSeeder class.

<?php

use Illuminate\Database\Seeder;
use Faker\Generator as Faker;
use Illuminate\Support\Facades\DB;

class ProductsTableSeeder extends Seeder
{
        public function run(Faker $faker)
        {
            DB::table("products")->insert([
                [
                    'name' => $faker->text(15),
                    'image_path' => $faker->image('public/images',
                        640,480,null, false),
                    'description' => $faker->word,
                    'price' =>  $faker->numberBetween(400, 600)
                ]
            ]);
        }
}

NOTE: You will need to create the `public/images` folder before you proceed to the next step.

Run the following commands to apply the database migrations and populate the tables with the seed data.

$ php artisan migrate
$ php artisan db:seed --class=UsersTableSeeder
$ php artisan db:seed --class=ProductsTableSeeder

NOTE: You will need to update the .env file with your database credentials before you run the migrations.

Since the User model class was generated when we created the application, we only need to create a single model for our products. This is achieved with Artisan’s make:model command.

$ php artisan make:model Product

Setting up the Controller

For simplicity, we will let the auto-generated Controller file handle all of our application logic. For now, the controller only needs three methods:

  • An index method that renders the home page.
  • A checkout method that renders the checkout page (which we will re-use later to send our users their invoices) and
  • A charge method to charge our users using the Stripe API.

Below is the controller file and the implementation of the methods listed above. Open up app/Http/Controllers/Controller.php and replace the code with the content below:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
use Twilio\Rest\Client;
Use App\User;
use Barryvdh\DomPDF\Facade as PDF;

class Controller extends BaseController
{
        use AuthorizesRequests, DispatchesJobs, ValidatesRequests;

        public function index() {
            $products = \App\Product::all();
            $data = [
                    'products' => $products
            ];
            return view('welcome', $data);
        }

        public function checkout ($id) {
            $product = \App\Product::findOrFail($id);
            $data = [
                'product' => $product
            ];
            return view('checkout', $data);
        }

        public function charge(Request $request) {
            $product = \App\Product::find($request->get('product_id'));
            try {
                Stripe::setApiKey(env('STRIPE_SECRET_KEY'));

                $amount = ($product->price) * 100;
                $customer = \Stripe\Customer::create(array(
                    'email' => $request->get('stripeEmail'),
                    'source' => $request->get('stripeToken')
                ));
\Stripe\Charge::create(array(
                    'customer' => $customer->id,
                    'amount' => $amount,
                    'currency' => 'usd'
                ));
                return 'Thanks for your purchase!';
            } catch (\Exception $ex) {
                return $ex->getMessage();
            }
        }
}

Setting up the Views

Our sample application provides two views for our users to interact with our application. The home page (rendered by welcome.blade.php) displays a list of products from which our users pick a product to buy. Upon selection, they are redirected to the checkout page (rendered by checkout.blade.php) which allows the user to pay and complete their purchase. Since we are using the default welcome template, open resources/views/welcome.blade.php and paste in the following:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
        <head>
            <meta charset="utf-8">
            <meta name="viewport" content="width=device-width, initial-scale=1">
            <title>Twilio Commerce</title>
            <link href="https://fonts.googleapis.com/css?family=Nunito:200,600" rel="stylesheet">
            <link href='https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css' rel="stylesheet"
                type="text/css">
            <link href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.0.3/css/font-awesome.css' rel="stylesheet"
                type="text/css">
            <base href="/">
        </head>
        <body>
        <div class="container py-5">
            <div class="row text-center mb-5">
                <div class="col-lg-7 mx-auto">
                    <h1 class="display-4">Twilio Commerce</h1>
                </div>
            </div>
            <div class="row">
                <div class="col-lg-8 mx-auto">
                    <ul class="list-group shadow">
                        @foreach ($products as $product)
                            <li class="list-group-item">
                                <div class="media align-items-lg-center flex-column flex-lg-row p-3">
                                    <div class="media-body order-2 order-lg-1">
                                        <h5 class="mt-0 font-weight-bold mb-2">{{$product->name}}</h5>
                                        <p class="font-italic text-muted mb-0 small">{{$product->description}}</p>
                                        <div class="d-flex align-items-center justify-content-between mt-1">
                                            <h6 class="font-weight-bold my-2">${{$product->price}}</h6>
                                            <a href="{{route('checkout', $product->id)}}" class="btn btn-primary">Buy</a>
                                        </div>
                                    </div><img src="images/{{$product->image_path}}" alt="Generic placeholder image" width="200" class="ml-lg-5 order-1 order-lg-2">
                                </div>
                            </li>
                        @endforeach
                    </ul>
                </div>
            </div>
        </div>
        </body>
</html>

Also, create our checkout template at resources/views/checkout.blade.php and add the code below:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Checkout - Twilio Commerce</title>
        <link href="https://fonts.googleapis.com/css?family=Nunito:200,600" rel="stylesheet">
        <link href='https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css' rel="stylesheet"
              type="text/css">
        <link href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.0.3/css/font-awesome.css' rel="stylesheet"
              type="text/css">
        <base href="/">
</head>
<body>
<div class="container py-5">
        <div class="row text-center mb-5">
            <div class="col-lg-7 mx-auto">
                <p>Order Summary</p>
            </div>
        </div>
        <div class="row">
            <div class="col-lg-8 mx-auto" style="border-top: 1px solid #007bff">
                <ul class="list-group mb-2">
                    <li class="list-group-item">
                        <div class="media align-items-lg-center flex-column flex-lg-row p-2">
                            <div class="media-body order-2 order-lg-1">
                                <h5 class="mt-0 font-weight-bold mb-2">{{$product->name}}</h5>
                                <div class="d-flex align-items-center justify-content-between mt-1">
                                    <h6 class="font-weight-bold my-2">${{$product->price}}</h6>
                                </div>
                            </div>
                            <img src="images/{{$product->image_path}}" alt="Generic placeholder image" width="200"
                                 class="ml-lg-5 order-1 order-lg-2">
                        </div>
                    </li>
                </ul>
                <form action="/charge" method="POST">
                    <input name="product_id" value="1" type="hidden"/>
                    {{csrf_field()}}
                    <script
                        src="https://checkout.stripe.com/checkout.js" class="stripe-button"
                        data-key="{{env('STRIPE_PUB_KEY')}}"
                        data-amount="{{$product->price * 100}}"
                        data-name="Stripe Demo"
                        data-description="Twilio Commerce: {{$product->description}}"
                        data-image="https://stripe.com/img/documentation/checkout/marketplace.png"
                        data-locale="auto"
                        data-currency="usd">
                    </script>
                </form>
            </div>
        </div>
</div>
</body>
</html>

Now that our application’s logic and views are up, let’s inform the routes file of the changes we have made by adding the code below to routes/web.php.

<?php
Route::get('/', 'Controller@index')->name('home');
Route::get('/checkout/{id}', 'Controller@checkout')->name('checkout');
Route::post('/charge', 'Controller@charge')->name('charge');

Sending our First WhatsApp Message via the Twilio SDK

To use the Twilio API for WhatsApp in production, we need a WhatsApp-approved account. However, for this tutorial we’ll only need the Twilio sandbox for us to test our application in a development environment. Grab your Twilio Sandbox phone number by following the instructions in the related Twilio Docs and append it to your .env file.

TWILIO_SANDBOX_NUMBER="+1415XXXXXXX"

Next, we modify the checkout method of our Controller class to send a WhatsApp message at the point of checkout. Replace the method with the code below:

…

public function checkout ($id) {
            $product = \App\Product::findOrFail($id);
            $data = [
                'product' => $product
            ];
            $twilio = new Client(env('TWILIO_ACCOUNT_SID'), env('TWILIO_AUTH_TOKEN'));
            $message = $twilio->messages->create(
                "whatsapp:YOUR_WHATSAPP_NUMBER", [
"from" => "whatsapp:".env('TWILIO_SANDBOX_NUMBER'),
                    "body" => "Hi from Twilio Commerce!"
                ]
            );
            Log::info($message->sid);
            return view('checkout', $data);
        }
…

NOTE: Make sure to replace the phone number placeholder with the E.164 format of your WhatsApp phone number i.e, both the “from” and “recipient” fields should be of the form “whatsapp:+14155238886”

Click the “Buy” button for a product from the home page and you should receive a message in the WhatsApp app on your mobile device just before the checkout page is rendered.

Attaching the Invoice as PDF

The Twilio API for WhatsApp allows us to attach media files to our WhatsApp message as long as the file sizes are less than 5MB and they are hosted on a publicly accessible URL. Create the invoice blade template at resources/views/invoice.blade.php and add the following:

<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
        <meta charset="utf-8">
        <title>Bill</title>
        <style>
            body{
                max-width: 46%;
                padding: 1.5%;
                border: 1px solid black;
            }
        </style>
</head>
<body>
<div class="body">
        <div style="text-align: center">
            <h3 style="margin: 0;">Twilio Commerce</h3>
            <h5 style="margin: 0;">supersales@example.com</h5>
        </div>
        <div class="content">
            <div style="margin: -2px;">
                <div style="float: left;">
                    <p>Invoice Number:- TWC-{{$product->id}} </p>
                </div>
                <div style="float: right;">
                    <p style="margin-right: 20px;">
                        Date: {{\Carbon\Carbon::now()->toFormattedDateString()}}
                    </p>
                </div>
            </div>
            <div style="clear: both;"></div>
            <div class="centre" style="text-align: center;">
                <h3>Invoice</h3>
            </div>
            <div>
                <p>Name: {{$user->name}}</p>
            </div>
            <hr/>
            <div style="padding: 30px 0px 30px 0px;">
                <p class="amount">{{$product->name}}
                    <span style="float: right;"> {{$product->price}}</span>
                </p>
            </div>
            <hr/>
            <p><a href="#">Pay with Stripe</a></p>
        </div>
</div>
</body>
</html>

Additionally, add a mediaUrl field to the parameters being passed to the Twilio client as shown below:

<?php
public function checkout ($id) {
            $product = \App\Product::findOrFail($id);
            $user = User::find(1); // replace this with the authenticated user
            $data = [
                'product' => $product,
                'user' => $user
            ];
            $invoiceFile = "invoice-".$product->id.".pdf";
            $invoicePath = public_path("invoices/".$invoiceFile);
            PDF::loadView('invoice', $data)
                ->save($invoicePath);
            $twilio = new Client(env('TWILIO_ACCOUNT_SID'), env('TWILIO_AUTH_TOKEN'));
            $twilio->messages->create(
                "whatsapp:".$user->phone_number, [
                    "from" => "whatsapp:".env('TWILIO_SANDBOX_NUMBER'),
                    "body" => "Here's your invoice!",
"mediaUrl" => [env("NGROK_URL")."/invoices/".$invoiceFile]
                ]
            );
            return view('checkout', $data);
        }

NOTE: You will need to create the invoices folder in the public folder. You will also need to update the user’s phone number (directly in the database) to your WhatsApp phone number in E.164 format.

The NGROK_URL environment variable is the forwarding URL provided to us by ngrok when we start the tunnel. In a terminal window, start up the Laravel server (if it isn’t already running) and the ngrok tunnel with the following commands:

$ php artisan serve
$ ngrok http 8000

Next, copy the ngrok forwarding URL and append it to your .env file .

… 
NGROK_URL="https://XXXXX.ngrok.io"
… 

Go ahead and click the “Buy” button for a product on the home page, if everything goes well, you should receive a WhatsApp message with the invoice PDF as an attachment on your WhatsApp phone number.

Conclusion

WhatsApp is one of the world’s most popular messaging applications and the Twilio API for WhatsApp gives you the ability to send conversational messages and notifications directly to your users on there. You can find the complete source code for this tutorial on Github and if you have an issue or a question, please feel free to create a new issue on the repository.

Michael Okoko is a student currently pursuing a degree in Computer Science and Mathematics at Obafemi Awolowo University, Nigeria. He loves open source and is mostly interested in Linux, Golang, PHP, and fantasy novels! You can reach him via:

Additional Reading

If you’d like to learn more about building with the Twilio API for WhatsApp, check out the following documentation: