Selling Downloads with Stripe and Laravel

Tweet

Digital goods are an increasingly valuable commodity. Whether you're a designer selling templates or font files, a developer charging for packages of code or a musician selling MP3s, selling digital goods online is often far easier than physical goods – with much lower production costs and no delivery charges.

In this article I'll show how you can implement a simple store selling digital goods using PHP along with Stripe, a payment provider who aims to make it easier than ever to take online payments since you don't need to set up special merchant accounts or deal with complex payment gateways.

Before you Begin

You'll first need to set up an account with Stripe. Please note that the service is currently only available in the US, UK, Ireland and Canada. You may wish to check the blog or follow them on Twitter to be kept up to date in regards to avalability in other countries.

Setting up an account for testing takes no time at all, and doesn't require any complex financial questionnaires or information. You'll need to take a note of your test API key and publishable key for later.

Setting Up

For this example application I'm going to use Laravel. It'll take care of some of the bread-and-butter stuff such as routing, templating and initiating the downloads, but the example shouldn't be too difficult to adapt for other frameworks (or indeed, no framework at all). Note that you can clone the entire example from Github.

Get started by installing Laravel via Composer:

composer create-project laravel/laravel stripe-downloads --prefer-dist

Include the following in the require section of your composer.json file, and run composer update:

"abodeo/laravel-stripe": "dev-master"

This provides a simple wrapper to the Stripe SDK, allowing it to be used from within Laravel without having to worry about require'ing the appropriate files.

Publish the configuration file using:

php artisan config:publish abodeo/laravel-stripe

Then enter your API key and publishable key in app/config/packages/abodeo/laravel-stripe/stripe.php

Finally add the package to your list of service providers (app/config/app.php):

'Abodeo\LaravelStripe\LaravelStripeServiceProvider',

Setting up the Database

Configure and create your database, then run the following to create a migration:

php artisan migrate:make create_downloads_table

Here's the relevant section (of the up() function), to create a table for the downloads we're going to sell:

Schema::create('downloads', function($table)
{
    $table->increments('id')->unsigned();        
    $table->string('name', 255);      
    $table->string('filepath', 255);      
    $table->integer('price');            
    $table->timestamps();
});    

Note that price is an integer, because internally we're only going to deal with cents / pence. Filepath will refer to the location of the file relative to the app/storage directory.

The corresponding model is very simple:

// app/models/Download.php
class Download extends Eloquent {
    protected $fillable = array('name', 'filepath', 'price');
}

Finally, let's seed the database with some sample downloads:

class DownloadsSeeder extends Seeder {

    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        $data = array(
            array(
                'name'            =>    'Sample download 1',
                'filepath'    =>    'downloads/one.zip',
                'price'            =>    500,
            ),
            array(
                'name'            =>    'Sample download 2',
                'filepath'    =>    'downloads/two.zip',
                'price'            =>    1000,
            ),
        );

        foreach ($data as $properties) {

            $download = new Download($properties);
            $download->save();            

        }
    }

}    

A Simple Homepage

Let's create a simple homepage which allows people to select which download they'd like to purchase:

Route::get('/', function()
{
    $downloads = Download::get();    
    return View::make('index', array('downloads' => $downloads));
});

And the view:

// app/views/index.blade.php
<h1>Select your download:</h1>

<table class="table table-bordered">
@foreach ($downloads as $download)
    <tr>
        <td>{{ $download->name }}</td>
        <td>&pound;{{ round($download->price/100) }}</td>
        <td><a href="/buy/{{ $download->id }}" class="btn btn-primary">Buy</a></td>
    </tr>
@endforeach
</table>

Taking Payment

Stripe provides two methods for taking card details. The first is a simple button, generated using Javascript which launches a payment form in a popup like so:

image

The second and more complex method allows to create the form yourself. That's the method I'm going to use in this example. Here's the route:

Route::get('/buy/{id}', function($id)
{
    $download = Download::find($id);    
    return View::make('buy', array('download' => $download));
});

And the view:

// app/views/buy.blade.php
<form action="" method="POST" id="payment-form" role="form">

  <div class="payment-errors alert alert-danger" style="display:none;"></div>

  <input type="hidden" name="did" value="{{ $download->id }}" />

  <div class="form-group">
    <label>
      <span>Card Number</span>
      <input type="text" size="20" data-stripe="number" class="form-control input-lg" />
    </label>
  </div>

  <div class="form-group">
    <label>
      <span>CVC</span>
      <input type="text" size="4" data-stripe="cvc" class="form-control input-lg" />
    </label>
  </div>

  <div class="form-group">  
    <label>
      <span>Expires</span>      
    </label>
    <div class="row">
      <div class="col-lg-1 col-md-1 col-sm-2 col-xs-3">
        <input type="text" size="2" data-stripe="exp-month" class="input-lg" placeholder="MM" />
      </div>  
      <div class="col-lg-1 col-md-1 col-sm-2 col-xs-3">
        <input type="text" size="4" data-stripe="exp-year" class="input-lg" placeholder="YYYY" />
      </div>
    </div>
  </div>

    <div class="form-group">
      <button type="submit" class="btn btn-primary btn-lg">Submit Payment</button>
  </div>
</form>

The eagle-eyed among you will notice that the card-related inputs are missing one crucial attribute – name. Without this, no data can possibly be passed to our server when the form gets submitted. How can this be?

Actually, this is deliberate. We don't want any credit card data being sent to our application.

What we're going to do is intercept the submission of this form using Javascript. The card information will be extracted from the form – notice the data-stripe attributes – and securely sent to the Stripe servers along with our publishable key. Stripe are then responsible for validating those details and all being well, return to us a special token. Once we have received that token, we can inject it into the form and POST the results as normal – minus the card details.

Back on the server, we can then use this token in conjunction with our private (API) key to actually charge the user's card. Should that code get intercepted up to this point, it's useless without our private key so there's very little a hacker could do with it.

We start by initialising the Stripe Javascript:

Stripe.setPublishableKey('@stripeKey');

In the code above, @stripeKey is a special Blade extension which outputs the publishable key. If you're not using the blade templating library, you may wish to do something like this instead:

Stripe.setPublishableKey('<?php print Config::get('stripe.publishableKey') ?>');

Next up, here's the submit handler for our form:

$('#payment-form').submit(function(e) {
  var $form = $(this);

  $form.find('.payment-errors').hide();

  $form.find('button').prop('disabled', true);

  Stripe.createToken($form, stripeResponseHandler);

  return false;
});

The function to handle the response from Stripe:

    function stripeResponseHandler(status, response) {
      var $form = $('#payment-form');

      if (response.error) {

        $form.find('.payment-errors').text(response.error.message).show();
        $form.find('button').prop('disabled', false);
      } else {

        var token = response.id;

        $form.append($('<input type="hidden" name="stripeToken" />').val(token));

        $form.get(0).submit();
      }
  };

Note how the token gets injected into the form by dynamically creating a new hidden form element with the name stripeToken. This is the only payment-related data that gets submitted.

Should you examine the response from the Stripe server, you'll notice that in addition to the token – the id property – there is a card dictionary object. Obviously this does not contain the card number or CVC, but it does include the type – Visa, Mastercard etc – and the last 4 digits of the card number. This is useful for generating invoices or receipts; it's quite common to include this information to indicate to the customer what card they used to pay.

Let's build the POST form handler:

    Route::post('/buy/{id}', function($id)
    {
        // Set the API key    
        Stripe::setApiKey(Config::get('laravel-stripe::stripe.api_key'));

        $download = Download::find($id);

        // Get the credit card details submitted by the form
        $token = Input::get('stripeToken');

        // Charge the card
        try {
            $charge = Stripe_Charge::create(array(
                "amount" => $download->price,
                "currency" => "gbp",
                "card" => $token,
                "description" => 'Order: ' . $download->name)
            );

            // If we get this far, we've charged the user successfully

        Session::put('purchased_download_id', $download->id);
        return Redirect::to('confirmed');

        } catch(Stripe_CardError $e) {
          // Payment failed
        return Redirect::to('buy/'.$id)->with('message', 'Your payment has failed.');        
        }
    }

Let's go through this.

First we're setting the API key for Stripe. When we handled the form client-side we were okay with exposing the publishable key, because the token is useless without the private API key.

Next we get the download being purchased from its ID, so we can get the name (used in the transaction reference) and its price (in cents / pence etc).

The value of $token is what we got from Stripe in the Javascript above, which we then injected into the form.

Next we use Stripe_Charge::create to actually charge the card. The $currency setting isn't required, but it will default to USD even if you set up an account outside of the US.

If the payment succeeds, we put the ID of the purchased item in the session. We'll use this to control access in a moment.

If the payment fails – usually because the bank declined the transaction – a Stripe_CardError exception gets thrown.

You can test the payment process using the dummy card number 4242-4242-4242-4242, along with any three-digit CVC and any expiry date – provided it's in the future.

The confirmation route is simple:

Route::get('/confirmed', function()
{
    $download = Download::find(Session::get('purchased_download_id'));
    return View::make('confirmed', array('download' => $download));
});

The view, which allows the buyer to download their file by clicking the download button:

// app/views/confirmed.blade.php
<h1>Your Order has been Confirmed</h1>

<p>You can now download your file using the button below:</p>

<p><a href="/download/{{ $download->id }}" class="btn btn-lg btn-primary">Download</a></p>

Delivering the File

Finally, we need to implement the download link. We don't want to simply link to a public file, since we're charging for access. Instead, we'll take the file from the application's storage directory – which isn't web accessible – and deliver if, and only if, the current user has successfully paid for it.

Route::get('/download/{id}', function($id)
{
    $download = Download::find($id);        
    if ((Session::has('purchased_download_id') && (Session::get('purchased_download_id') == $id))) {
        Session::forget('purchased_download_id');
        return Response::download(storage_path().'/'.$download->filepath);    
    } else {
        App::abort(401, 'Access denied');
    }
});

This link can only be used once; in practice you'd probably want to make it possible to download a file again at a later date, but I'll leave you to come up with ways of doing that.

Summary

In this article I've shown how simple it is to take payments using Stripe, without having to worry about merchant accounts, payment gateways or storing sensitive card information. We've implemented a working, albeit simple purchasing process for digital downloads. Hopefully you'll have seen enough to get started implementing it in your projects.

Free book: Jump Start HTML5 Basics

Grab a free copy of one our latest ebooks! Packed with hints and tips on HTML5's most powerful new features.

  • Hunter

    great timing. just started a new site using Laravel that needs to sell site credits and was planning on using Stripe. This should help immensely.

  • Anonymous

    Since you’re using sessions to authenticate, couldn’t that be manipulated by the client? Not sure if it matters in this example, but didn’t know if you had other recommendations for 1-time links or repeated download access without requiring backend authentication.

    • chris

      You could always make a hash of something that identifies the user and the name of the file (not the full path) and have the ID be that and just interpret it on the other side. Alternatively, you could do something like adding a new record to a table that links the hash to the file ID and user and pull from there.

  • Anonymous

    According to the laravel-stripe README on Github (https://github.com/Abodeo/laravel-stripe), there’s no need to to configure it with your API key if you set it in the config.

    However, I noticed that the repository on Github is requiring 1.8.1 of the stripe-php library. I’d like to use 1.9.x. I tried simply adding a second line in my composer.json of “stripe/stripe-php”: “1.9.*” but it ended up requiring “easybib/stripe-php” instead. Any clue on how to get the package to use the latest version of Stripe? I submitted a pull request but it doesn’t look like abodeo is active on Github.

    • Lukas White

      As far as I can tell without delving too deep, the Laravel package looks like little more than a wrapper to the library – with an autoloader and not a great deal else. So it should be pretty simple to modify. In terms, then, of referencing your modified package – if just temporarily, until you get a response from the author – you could try forking it and following these instructions to point your composer.json to your version: http://getcomposer.org/doc/05-repositories.md#loading-a-package-from-a-vcs-repository

  • Maks Surguy

    Very good detailed tutorial! Thanks for posting! If your files are large you might experience some troubles downloading the items using Laravel’s built in Response::download method. In Laravel 3 I used the following code to deal with files of any size, this code could be modified for Laravel 4 as well : http://paste.laravel.com/12PB

  • Brendan owens

    Interesting for sure. I’d like to see the same but useing Paypal – I’m already using PayPal and would like to have my own selling routine instead of using payloadz.com because I want to also store the purchase history of the user on my joomla site. Any chance you might show how to incorporate PayPal? Thanks for all your work on our behalf.

  • Ed Shaw

    Thank you. Good timing. I’m in.

  • Anonymous

    People generally happy to go with a Stripe only payment option or use it in conjunction with PayPal etc?

  • Anonymous

    Great tutorial by the way