Selling Downloads with Stripe and Laravel
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>£{{ 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:
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.