【Laravel5.8+Stripe13】ショッピングカートの実装

f:id:nekorokkekun:20190827102831p:plain:w1000
こちらの連載記事では、LaravelとStripeを使用して企業サイト兼Eコマース(ECサイト)を作成していきます。

Laravelのプロジェクト作成からStripeの実装まで行い、最終的に単発決済・サブスクリプションの実装までを目指します。

シリーズ

【Laravel5.8+Stripe⓪】ECサイト作成チュートリアル概要 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe①】ベースプロジェクトの作成 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe②】メールフォームの実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe③】ユーザー認証機能のカスタマイズ - Laravelとねころっけくん5.8
【Laravel5.8+Stripe④】ディスカウントページを作成する - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑤】Laravel CasherとStripeを導入して管理者権限を設定する その1 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑥】Laravel CasherとStripeを導入して管理者権限を設定する その2 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑦】サブスクリプション決済の作成 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑧】請求書ダウンロード機能の実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑨】サブスクリプションプラン変更機能の実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑩】サブスクリプション中止機能の実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe11】Webhookの実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe12】クーポン機能を実装する - Laravelとねころっけくん5.8
【Laravel5.8+Stripe13】ショッピングカートの実装 - Laravelとねころっけくん5.8


ちなみにこちらの記事は、Easy E-Commerce Using Laravel and Stripeという書籍をもとに執筆しています。

こちらの記事では、サイトにショッピングカート機能を実装していきます。

商品追加機能の作成

こちらの記事で管理者権限の作成を途中まで行っていましたが、まずは管理者権限として商品を追加できるようにしていきましょう。

まずはルーティングから行います。
/routes/web.php

Route::group(['prefix' => 'admin', 'namespace' => 'Admin', 'middleware' => 'admin'], function()
{
    Route::resource('products', 'ProductController');
    Route::post('admin/store', 'ProductController@store')->name('admin.store'); //追加
});


次にresource/viewsディレクトリ内にadminディレクトリを作成し、その中にindex.blade.phpを作成します。

/resources/views/admin/index.blade.php

@extends('layouts.app')
@section('content')
<h1>Admin Page</h1>
  {!! Form::open([
    'route' => 'admin.store',
    'class' => 'form',

  ]) !!}
<div class="form-group">
    <label for="name">Product Name</label><br>
    <input type='text' name='name' id='name'>
</div>
<div class="form-group">
    <label for="description">Description</label><br>
    <textarea name='description' id='description'></textarea>
</div>
<div class="form-group">
    <label for="sku">SKU</label>
    <input type='text' name='sku' id='sku'>
    <label for="price">Price</label>
    <input type='text' name='price' id='price'>
    <label for="is_downloadble">Downloadble</label>
    <input type='checkbox' name='is_downloadble' id='is_downloadble'>
</div>
<div class="center-block form-actions">
  <button type="submit" class="submit-button btn btn-primary btn-lg">
    Add Product
  </button>
</div>
  {!! Form::close() !!}
@endsection

mysqlに入り、管理者ユーザーになるためにis_adminカラムをtrueにしておきましょう。

mysql> update users set is_admin=true where id=1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

先ほど作成したadmin/index.blade.phpにアクセスできるか確認してみましょう。
/admin/products というファイルパスでアクセスしてみてください。

以下のような管理者ページ兼商品追加ページが表示されたら成功です。
f:id:nekorokkekun:20190826102838p:plain

次に商品追加のためのアクションを追加します。
/app/Http/Controllers/Admin/ProductController.php

    public function store(Request $request) {
        $product = new Product($request->all());
        if($request->is_downloadble == true){
            $product->is_downloadble = true;
        }
        $product->save();
        return view ('admin/index');
    }

これでテーブルに商品が登録できるようになりました。
ESサイトのため、当然画像保存などもできることが理想的ですが、本旨とはズレるため今回は割愛します。

テーブル・モデルの作成

まずはショッピングカートを構成するテーブル・モデルを作成していきましょう。

$ php artisan make:model Cart -m

cartテーブルのカラムは以下のような設定になります。
/database/migrations/2019_08_25_233853_create_carts_table.php

    public function up()
    {
      Schema::create('cart', function(Blueprint $table)
      {
        $table->increments('id');
        $table->unsignedBigInteger('user_id');
        $table->unsignedInteger('order_id')->nullable();
        $table->unsignedInteger('product_id');
        $table->integer('complete')->default(0);
        $table->integer('qty');
        $table->decimal('price', 8, 2);
        $table->timestamps();
        $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
        $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade');
      });
    }

次にモデルのリレーションメソッドを作成していきましょう。
/app/Cart.php

class Cart extends Model
{
    protected $fillable = ['product_id', 'qty', 'price'];

    public function product()
    {
        return $this->belongsTo('App\Product');
    }

    public function user()
    {
        return $this->belongsTo('App\User');
    }
}

商品一覧・商品詳細ページの作成

登録した商品を一覧で見ることができるページと、詳細情報が記載されているページをそれぞれ作成していきます。

こちらも以前の記事ですでに作成済みのルーティングとコントローラーを使用する形になります。

作成済みのルーティング
/routes/web.php

Route::get('products', 'ProductController@index');
Route::get('products/{id}', 'ProductController@show');

作成済みのコントローラー
/app/Http/Controllers/ProductController.php

class ProductController extends Controller
{
    public function index(){
        $products = Product::orderBy('name', 'asc')->get();
        return view('product.index', compact('products'));
    }
    
    public function show($id){
        $product = Product::findOrFail($id);
        return view('product.show', compact('product'));
    }
}

まずは商品一覧ページを作成していきましょう。
resources/viewsディレクトリにproductディレクトリを作成し、その中にindex.blade.phpを作成します。
/resources/views/product/index.blade.php

@extends('app')
@section('content')
    <h2>All Products</h2>
    <ul>
        @foreach($products as $product)
        <li><a href='products/{{ $product->id }}'>{{ $product->name }}</a></li>
        @endforeach
    </ul>
@endsection

次にproductディレクトリ内にshow.blade.phpを作成します。
/resources/views/product/show.blade.php

@extends('app')
@section('content')
    <h2>{{ $product->name }}</h2>
    <p>{{ $product->description }}</p>
	{!! Form::open(['url' => '/cart/store']) !!}
	<input
	  type="hidden"
	  name="product_id" 
	  value="{{ $product->id }}"/>
	<button 
	  type="submit" 
	  class="btn btn-primary">Add to Cart
	</button>
	{!! Form::close() !!}
@endsection

最後にルーティングを作成します。
/routes/web.php

Route::post('cart/store', 'CartsController@store');

CartsControllerの作成

次にショッピングカート専用のControllerを作成していきます。

その前にUser.phpの中にCartsControllerで使用するリレーションメソッドを作成しておきましょう。
/app/User.php

    public function cart()
    {
        return $this->hasMany('App\Cart')
          ->where('complete', 0);
    }

仕組みは簡単で、決済が終わっていない(complete!=0)cartレコードを取得するメソッドになっています。


次にコマンドプロンプトでCartsControllerを作成します。

$ php artisan make:controller CartsController

/app/Http/Controllers/CartsController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Cart;
use App\Product;

class CartsController extends Controller
{
    public function __construct(){
        $this->middleware('auth');
    }
    
    public function store(Request $request){
    
        $product = Product::find($request->get('product_id'));
    
    $cart = new Cart([
        'product_id' => $product->id,
        'qty' => $request->get('qty', 1),
        'price' => $product->price
    ]);
    Auth::user()->cart()->save($cart);
    return redirect('/cart');
    }

先ほどUser.phpで作成したcart()が後半で使用されています。まだ決済済みではない商品のみをショッピングカートに表示できるように取得しています。

また、Laravelのsaveメソッドには引数を入れることができます。

Auth::user()->cart()->save($cart);

という書き方にすることで、「ログインユーザーのみがカートに商品を追加できる」ことになります。また、cartsテーブルカラムのuser_idにAuth::userのidが紐付けられます。


こちらのアクションでは、

return redirect('/cart');

と書かれている通り、ショッピングカートに商品を追加した後、ユーザーは/cartへリダイレクトされることになります。

ショッピングカートの作成

次にショッピングカートを作成していきましょう。

まずはルーティングからです。
/routes/web.php

Route::get('cart', 'CartsController@index');

次にCartsControllerへindexアクションを追加しましょう。

/app/Http/Controllers/CartsController.php

    public function index(){
        $cart = Auth::user()->cart;
        return view('cart.index', compact('cart'));
    }

次にViewを作成します。
/resources/views/cart/index.blade.php

@extends('app')
@section('content')
@if (count($cart) == 0)
    <p>Your cart is currently empty</p>
@else
    <table class="table table-border">
      <thead>
      <tr>
          <th></th>
          <th>Name</th>
          <th>Price</th>
      </tr>
      </thead>
      <tbody>
      @foreach ($cart as $item)
        <tr>
        <td><a href="/cart/remove/{{ $item->id }}">x</a></td>
        <td>{{ $item->product->name }}</td>
        <td>${{ $item->product->price }}</td>
        </tr>
      @endforeach
      </tbody>
    </table>

@endif
@endsection

まずはif文でカートが空かどうかによって表示内容を出し分けています。
また未設定ではありますが、以下の部分で削除ボタンの配置もしています。

<td><a href="/cart/remove/{{ $item->id }}">x</a></td>

削除機能も実装していきましょう。

/routes/web.php

Route::get('cart/remove/{id}', 'CartsController@remove');

/app/Http/Controllers/CartsController.php

    public function remove($id){
        Auth::user()->cart()
          ->where('id', $id)->firstOrFail()->delete();
        return redirect('/cart');
    }


最後にStripeのCheckoutを使用した決済機能を作成していきます。

まずはルーティングからです。
/routes/web.php

Route::post('cart/complete', 'CartController@complete')->name('cart.complete');

次にView側を整えていきましょう。
/resources/views/cart/index.blade.php

@extends('app')
@section('content')
@if (count($cart) == 0)
    <p>Your cart is currently empty</p>
@else
    <table class="table table-border">
      <thead>
      <tr>
          <th></th>
          <th>Name</th>
          <th>Price</th>
      </tr>
      </thead>
      <tbody>
      @foreach ($cart as $item)
        <tr>
        <td><a href="/cart/remove/{{ $item->id }}">x</a></td>
        <td>{{ $item->product->name }}</td>
        <td>${{ $item->product->price }}</td>
        </tr>
      @endforeach
      </tbody>
    </table>
  <div class="payment-errors alert alert-danger"
    style="display: none;">
  </div>
  {!! Form::open([
    'route' => 'cart.complete',
    'class' => 'form',
    'id' => 'purchase-form'
  ]) !!}
    <div class="form-group">
      <div class="row">
        <div class="col-xs-12">
          <label for="card-number" class="control-label">
            Credit Card Number
          </label>
        </div>
        <div class="col-sm-4">
          <input type="text"
            class="form-control"
            id="card-number"
            name="cardnumber"
            placeholder="Valid Card Number"
            required autofocus data-stripe="number"
            value="
            {{ App::environment() == 'local' ? '4242424242424242' : '' }}
            ">
        </div>
      </div>
    </div>
    <div class="form-group">
      <div class="row">
        <div class="col-xs-4">
          <label for="card-month">Expiration Date</label>
        </div>
        <div class="col-xs-8">
          <label for="card-cvc">Security Code</label>
        </div>
      </div>
      <div class="row">
        <div class="col-xs-2">
          <input type="text" size="3"
            class="form-control"
            name="exp_month"
            data-stripe="exp-month"
            placeholder="MM"
            id="card-month"
            value="{{ App::environment() == 'local' ? '12' : '' }}"
            required>
        </div>
        <div class="col-xs-2">
          <input type="text" size="4"
            class="form-control"
            name="exp_year" data-stripe="exp-year"
            placeholder="YYYY" id="card-year"
            value="{{ App::environment() == 'local' ? '2020' : '' }}"
            required>
        </div>
        <div class="col-xs-2">
          <input type="text"
            class="form-control" id="card-cvc"
            placeholder=""
            size="6"
            value="{{ App::environment() == 'local' ? '111' : '' }}"
            >
        </div>
      </div>
      <div class="row">
        <div class="col-xs-12">
          <label for="billing_name" class="control-label">
            Billing Name
          </label>
        </div>
        <div class="col-sm-4">
          <input type="text"
            class="form-control"
            id="billing_name"
            placeholder="John Williams"
            autofocus data-stripe="number"
            name="billing_name"
            value="
            {{ App::environment() == 'local' ? 'John Williams' : '' }}
            ">
        </div>
      </div>
      <div class="row">
        <div class="col-xs-12">
          <label for="billing_address" class="control-label">
            Billing Address
          </label>
        </div>
        <div class="col-sm-4">
          <input type="text"
            class="form-control"
            id="billing_address"
            placeholder="24"
            autofocus data-stripe="number"
            name="billing_address"
            value="
            {{ App::environment() == 'local' ? '24' : '' }}
            ">
        </div>
      </div>
      <div class="row">
        <div class="col-xs-12">
          <label for="billing_city" class="control-label">
            Billing City
          </label>
        </div>
        <div class="col-sm-4">
          <input type="text"
            class="form-control"
            id="billing_city"
            placeholder="Bayswater Perth"
            autofocus data-stripe="number"
            name="billing_city"
            value="
            {{ App::environment() == 'local' ? 'Bayswater Perth' : '' }}
            ">
        </div>
      </div>
      <div class="row">
        <div class="col-xs-12">
          <label for="billing_state" class="control-label">
            Billing State
          </label>
        </div>
        <div class="col-sm-4">
          <input type="text"
            class="form-control"
            id="billing_state"
            placeholder="Westan Australlia"
            autofocus data-stripe="number"
            name="billing_state"
            value="
            {{ App::environment() == 'local' ? 'Westan Australlia' : '' }}
            ">
        </div>
      </div>
      <div class="row">
        <div class="col-xs-12">
          <label for="billing_zip" class="control-label">
            Billing Zip
          </label>
        </div>
        <div class="col-sm-4">
          <input type="text"
            class="form-control"
            id="billing_zip"
            placeholder="6053"
            autofocus data-stripe="number"
            name="billing_zip"
            value="
            {{ App::environment() == 'local' ? '6053' : '' }}
            ">
        </div>
      </div>
      <div class="row">
        <div class="col-xs-12">
          <label for="billing_country" class="control-label">
            Billing Country
          </label>
        </div>
        <div class="col-sm-4">
          <input type="text"
            class="form-control"
            id="billing_country"
            placeholder="Australlia"
            autofocus data-stripe="number"
            name="billing_country"
            value="
            {{ App::environment() == 'local' ? 'Australlia' : '' }}
            ">
        </div>
      </div>
    </div>
    <div class="center-block form-actions">
      <button type="submit" class="submit-button btn btn-primary btn-lg">
        Complete Order
      </button>
    </div>
  {!! Form::close() !!}
  @include('subscriptions.form')
@endif
@endsection

分割して、コードを解説していきます。

      $total = $user->cart->sum(function($item){
          return $item->product->price;
      });

まずこちらですが、カート内の商品価格を合算しています。
少し癖のある書き方ですが、sumメソッドを使用してproductのpriceを合わせることが可能です。

      $charge = $user->charge($total, [
          'source' => $request->get('stripe_token'),
          'receipt_email' => $user->email,
          'metadata' => [
              'name' => $user->name,
          ],
      ]);

こちらも複雑に見えますが、まず前提としてLaravel Cashierのchargeメソッドが使用されていることを念頭に置きましょう。

chargeメソッドの最も簡単な書き方は以下のようなものです。

$user->charge(100);

これで100円の請求が行われます。引数に設定した金額によって請求される額が異なります。
その後の数値はオプションです。

以下が参考になります。
Laravel Cashier 5.8 Laravel
Stripe API Reference - The charge object

      // Add the order
      $order = new Order();
      $order->order_number = $charge->id;
      $order->email = $user->email;
      $order->billing_name = $request->input('billing_name');
      $order->billing_address = $request->input('billing_address');
      $order->billing_city = $request->input('billing_city');
      $order->billing_state = $request->input('billing_state');
      $order->billing_zip = $request->input('billing_zip');
      $order->billing_country = $request->input('billing_country');
    
      $order->shipping_name = 
        $request->input('shipping_name');
      $order->shipping_address = 
        $request->input('shipping_address');
      $order->shipping_city = 
        $request->input('shipping_city');
      $order->shipping_state = 
        $request->input('shipping_state');
      $order->shipping_zip = 
        $request->input('shipping_zip');
      $order->shipping_country = 
        $request->input('shipping_country');
      $order->save();

この辺りは、ordersテーブルへのinsertを行なっています。
View側には作成していませんが、請求先と送付先が異なることも多いため、Formを2つ作成しても良いかと思います。
また、ユーザー登録時点ですでに住所などを登録する設定になっているため、そちらを引用するとユーザビリティが向上します。

      // Update the old cart
      foreach ($user->cart as $cart) {
          $cart->order_id = $order->id;
          $cart->complete = 1;
          $cart->save();
      }

カートに入っていたproductをアップデートしています。order_idを登録することで、注文ごとに商品を管理できます。また、completeを1にすることで実質的にカートの中身を空にすることができます。注文したproductを削除してしまうと履歴に残らないため、このような仕様になっています。

よくある注文履歴などは、complete=1のproductだけを抽出して表示することで実現するはずです。

最後に注文完了画面を作成しましょう。
/resources/views/checkout/thankyou.blade.php

@extends('app')
@section('content')
<p>Thank you for your order.</p>
<p>Your order completed!</p>
@endsection

これでStripeを使用したECサイトの基本機能を作成することができました。
後はヘッダーやフッターなどを作成し、ユーザーがアクセスしやすいサイトへ作り直すことで、本格的なECサイトを作成できるはずです。

連載記事

【Laravel5.8+Stripe⓪】ECサイト作成チュートリアル概要 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe①】ベースプロジェクトの作成 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe②】メールフォームの実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe③】ユーザー認証機能のカスタマイズ - Laravelとねころっけくん5.8
【Laravel5.8+Stripe④】ディスカウントページを作成する - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑤】Laravel CasherとStripeを導入して管理者権限を設定する その1 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑥】Laravel CasherとStripeを導入して管理者権限を設定する その2 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑦】サブスクリプション決済の作成 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑧】請求書ダウンロード機能の実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑨】サブスクリプションプラン変更機能の実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe⑩】サブスクリプション中止機能の実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe11】Webhookの実装 - Laravelとねころっけくん5.8
【Laravel5.8+Stripe12】クーポン機能を実装する - Laravelとねころっけくん5.8
【Laravel5.8+Stripe13】ショッピングカートの実装 - Laravelとねころっけくん5.8