PHPでショッピングカートを手軽に実装したい (モダンなフレームワーク入れずに)

2020/7/12更新 注意点追加
2018/7/6更新、SSLページへの遷移など
#ある日カートがほしいと言われた

>商品数が増えてきたのでカートは必要だけど、EC-CUBEとか本格的なやつを入れるほどの規模の改修を望んではいない。
>決済機能とかいらないし、最後は注文メールが飛んでくればいいから。

との声を受け、定番のライブラリくらいあるだろうと思って、
手軽に実装できないか探してみます、と答えてしまう。

#PHPでショッピングカートを手軽に実装できるライブラリはないものか

軽く探した程度じゃこれぞというのが見つからなかった。
Laravel？CodeIgniter？のプラグイン？
いやいや、そういうモダンなフレームワーク使ってないから。
そもそもDBとか設置できる情報も貰ってないし、、商品管理はどうしようか、、ってとこからボンヤリと探しているんだこっちは。

ちらほら見つかっても、10年前くらいに更新が止まっていたり。

#seikan/cartが良かった

日本語で探すのは諦めて、英語で探す。
GitHubでよさげなのが見つかった。
> seikan/Cart
> This is a very simple PHP cart library. Cart data can either be saved in PHP session or browser cookie.
> [GitHub](https://github.com/seikan/Cart)

シンプルな配布ファイルの構成で、サンプルがとても見やすい。
商品データはJSON管理で、sessionかcookieに情報を保存するらしい。

##PHPが5.3以下のバージョンのサーバーに設置する場合は注意が必要
ちょっと躓いた。
PHP5.4以降の配列の省略表記`[]`を、`array()`に書き換える必要があるらしい。
本番環境でも動くとは限らないので早めに気付いてよかった。

##良い点
###JSONでノンDBで使える
なるべく工数かけずに、コンパクトに実装したいという今回の要件に合ってると思った。
~~JSONメンテは意外に書式が細かいのでお客さんには難しいかもしれないけど、結局のちのち管理画面とか必要になりそうだけども・・~~

###合計数・円とか出す方法がかなり柔軟な設計
金額関係はややこしい設計になってないので、ドル→円表示はnumber_format()の引数を書き換えてやるだけでいけた。
合計数だす関数も、引数でカウントする列を柔軟に変えられる。

###決済組み込むのも面倒じゃなさそう
このライブラリが提供してくれるのは
- カートに商品入れる
- カート内商品の編集
- カート内商品の各種計算材料の提供
と、カート内管理だけなのでその後の会計処理はご自分でどうぞという姿勢。
サンプルだけでだいたいの機能は実装しているし、ライブラリクラスも短くて把握は容易なので
けっこう使い出がありそうな感触。

###カートに入れるボタンはカート画面へsubmitする形式だけど、、
この辺はご要望に応じてAjax使って画面遷移しないようにとか拡張できそう。
今回は要望なしなのでやりません。WPの場合はajax機能が用意されてたりするので実装は容易。

## 使ってて気づいた注意点
このライブラリ、色などの属性オプション系が設定できるが、カートに入れた後に属性は変更できないので注意。
あくまで商品に初めから持たせる属性のためのオプションなので、カートに入れたあとにやっぱり違う色のスニーカーが欲しい、などユーザーが思った際は、カートから商品を削除しカートに入れなおしする必要がある。

ダメな属性オプションの例。
例えば、item1に納品日付指定などの属性オプションを持たせて、カートに入れた後に変更させて、、などのように便利には使えないのです。
その場合はseikan/cartとは別途に`$_SESSION`に保存して注文ページで紐づけよう。`$_SESSION`のcart領域はseikan/cartで予約されてたりするので、格納名には注意が必要。反映されない！とならないように。

開発中は、こまめにcart->destroy();。
これを忘れると、うまく変更が反映されないのでこれも注意。

```php:aaa.php
        if ($cmd == 'add') {
            $cart->add($ind_item->ID, $_POST['product-qty'], array(
                'price' => $ind_price,
            ));
            $_SESSION['nouhindate'][$ind_item->ID] = "";
        }
        if ($cmd == 'update') {
            $cart->update($ind_item->ID, $_POST['product-qty'], array(
                'price' => $ind_price,
            ));
            $_SESSION['nouhindate'][$ind_item->ID] = isset($_POST['product-date'])? $_POST['product-date']: "";
        }
        if ($cmd == 'remove') {
            $cart->remove($ind_item->ID, array(
                'price' => $ind_price,
            ));
            unset($_SESSION['nouhindate'][$ind_item->ID]);
        }
```


###実際のとこ、問題なさげ　ただSSLページに飛ばすには一工夫必要
実際組んで稼働させてみてしばらく経ったのでまとめ。
通常ページから共用SSLのお客様情報入力画面へ飛ばすと、当然のことながらドメインが異なるのでカート内容は保持されない。
カート内容を詰めなおす必要が出てくるので追記。
（※追記、seikan cartのcookie方式のほうを使えばいいのかもしれないとふと思ったり）

今回のサーバーでは、同じsetting.phpファイルを参照できたのでマスタの二重管理の問題はなかったけど、SSLが完全に別サービスの場合はマスタの二重管理は避けられないかも。
(情報をぜんぶpostしてやれば別ですが、何かあると怖いので)

とりあえずproduct_idと選択個数だけをSSLページに再postしてやって、
SSLページ側で再度マスタからカート内容を復元する形に落ち着く。

```php:cart/index.phpのjs

$('a.enter_ssl').on('click', function(e) {
    e.preventDefault();
    var $param = "";
    <?php
    foreach ($chkout_products as $id=>$item) {
        $quantity = $item["usr_item"]["quantity"];
        $param = "{$id}|{$quantity}";
    ?>
    $param += '<input type="hidden" name="items[]" value="<?php echo "$param"; ?>" />';
    <?php } ?>
    var $form = $('<form action="https://xx.xx.xx/cart/checkout.php" method="post" />').html($param);
    $('body').append($form);
    $form.submit();
});
```

```php:SSLページ側のcheckout.php

// POSTでカートのデータを受け取って、sessionに再構築する
global $products;
$items = $_POST["items"];
if ($items != null) {
    $cart->clear();
    foreach ($items as $item) {
        $ary_tmp = explode("|", $item); // この辺でis_numericとか値に応じて適切に処理したほうがいい
        $item_id  = $ary_tmp[0];
        $item_qty = $ary_tmp[1];
        $ind_product = $get_product_by_id($item_id, $products);
        $ind_attrb = array();
        $allItems = $cart->getItems();
        foreach ($allItems as $id => $items) {
            foreach ($items as $item) {
                if ($ind_product["id"]==$id) {
                    $ind_attrb = $item['attributes'];
                    break;
                }
            }
        }
        if ($cart->isItemExists($ind_product["id"],$ind_attrb)) {
            $cart->update($ind_product["id"], $item_qty, array(
                'price' => $ind_product["price"]
            ));
        } else {
            $cart->add($ind_product["id"], $item_qty, array(
                'price' => $ind_product["price"]
            ));
        }
    }
}
```

####チェックアウト終わったときのカートをカラにする処理はどうしましょうか
かなり泥臭い方法だけど、thanksページのすべてのリンクの末尾にget引数付けて元ページに飛ばしてカラにしてもらうしかないんじゃないかなあ・・

```php:SSLページ側のthanks.php

<a href="<?php echo "http://xxx.xxx.xx/?cart_end=true"; ?>"> 買い物を終了する</a>
```

```php:通常ページ側のindex.phpとか共通headerとかで

if (isset($_GET["cart_end"])) {
    $cart->clear();
}
```


#実際の実装に役立つかもしれない情報

##設定ファイル例
これが実際の実装コードになりそう。
まずは設定ファイル。

```php:setting.php
// 商品マスタ
$products = json_decode('[
   {
      "id":1010,
      "name":"ほげほげパン",
      "price":"100",
      "sale_price":"50",
      "picture_name":"hogehoge.png",
      "link_url":"/hogehoge/shouhin.php"
   },{
      "id":1020,
      "name":"あらあらパン",
      "price":"200",
      "sale_price":"100",
      "picture_name":"araara.png",
      "link_url":"/araara/shouhin.php"
   }
]', true);
// ID決め打ちで商品情報をとる関数を作る必要があったり
$get_product_by_id = function($product_id) {
   global $products;
    foreach ($products as $product) {
        if ($product_id == $product["id"]) {
            return $product;
        }
    }
    return array();
};
```

これ、よく考えたら別にJSONで記述する必要もないですね・・
別途マスタファイルで管理するならともかく、普通にPHPの配列でよさげ。
一人でやってると思い込みで進めてしまいがちなので反省です。


##各商品ページ

呼び出す側の商品ページでは、こんな感じ。
JS側でカートにsubmitしてるので先頭行以外はコピペか共通化できる。

```php:page.php
<?php $tmp_product = $get_product_by_id("1010"); ?>
<form>
<input value="<?php echo $tmp_product["id"]; ?>" class="product-id" type="hidden">
<input value="1" class="product-qty" type="hidden">
<dl class="buy">
    <dt><?php echo $tmp_product["name"]; ?></dt>
    <dd><?php echo $tmp_product["price"]; ?> 円</dd>
    <dd>
        <button class="add-to-cart">カートに入れる</button>
    </dd>
</dl>
</form>
```

```javascript:各商品ページのjs

// sei_cart function
var funcFormGene = function (cmd, product_id, qty) {
	var $form = $('<form action="/cart/" method="post" />').html('<input type="hidden" name="cmd" value="' + cmd + '"><input type="hidden" name="id" value="' + product_id + '"><input type="hidden" name="qty" value="' + qty + '">');
	return $form;
};
$('.add-to-cart').on('click', function(e) {
	e.preventDefault();
	var $btn = $(this);
    // タグ構造に応じてparent()は数の調整必要
	var id = $btn.parent().parent().parent().find('.product-id').val();
	var qty = $btn.parent().parent().parent().find('.product-qty').val();
	var $form = funcFormGene("add", id, qty);
    if(isNaN(qty) || qty < 1){
        $form = $('<form action="/cart/" method="post" />').html('');
    }
	$('body').append($form);
	$form.submit();
});
```

##カートのページ

カートの画面では、商品マスタの情報とカート内商品の情報を紐づけるとやりやすい。
数量とか、色とかのユーザー選択オプション系はカート内商品情報から取る。

```php:cart.php
<?php
$chkout_products = array();
if (!$cart->isEmpty()) {
    $allItems = $cart->getItems();
    foreach ($allItems as $id => $items) {
        foreach ($items as $item) {
            $ind_product = $get_product_by_id($id);
            $chkout_products[$id]["product"] = $ind_product;
            $chkout_products[$id]["usr_item"] = $item;
        }
    }
}
?>
<?php
$tmp_total = 0;
foreach ($chkout_products as $id=>$item) {
    $tmp_total .= $item["product"]["price"] * $item["usr_item"]["quantity"];
?>
<tr>
    <td class="">
        <dl class="">
            <dt class=""><?php echo $item["product"]["picture_name"]; ?></dt>
            <dd class=""><?php echo $item["product"]["name"]; ?></dd>
        </dl>
    </td>
    <td class="">
        <div class="">
            <input value="<?php echo $item["usr_item"]["quantity"]; ?>" type="number">
        </div>
    </td>
    <td class="">
        <?php echo $item["product"]["price"]; ?>
    </td>
</tr>
<?php } ?>
<tr>
    <td colspan="3">
        <?php
        echo "合計：".number_format($tmp_total)."円";
        ?>
    </td>
</tr>
```

```javascript:カートページのjs、全ページに置く必要はない
$('.btn-update').on('click', function() {
	var $btn = $(this);
	var id = $btn.attr('data-id');
	var qty = $btn.parent().find('.product-qty').val() || 1;
	var $form = funcFormGene("update", id, qty);
	$('body').append($form);
	$form.submit();
});
$('.btn-remove').on('click', function() {
	var $btn = $(this);
	var id = $btn.attr('data-id');
	// 削除の時はユーザー選択の属性を渡して消す必要がある
	// var color = $btn.attr('data-color');
	var $form = funcFormGene("remove", id, 0);
	$('body').append($form);
	$form.submit();
});
$('.btn-empty-cart').on('click', function() {
	var $form = $('<form action="/cart/" method="post" />').html('<input type="hidden" name="cmd" value="empty">');
	$('body').append($form);
	$form.submit();
});
```

##お会計ページ

最後にお会計処理。というか注文明細のメール送るだけで今回はおしまい。

```php:checkout.php
$str_item = "";
foreach ($chkout_products as $id=>$item) {
    $str_item .= "・".$item["product"]["name"];
    $str_item .= "\n";
    $str_item .= $item["usr_item"]["quantity"]." 個： ";
    $str_item .= number_format($item["product"]['price'])." 円";
    $str_item .= "\n";
    $str_item .= "小計： ";
    $str_item .= number_format($item["product"]['price'] * $item["usr_item"]["quantity"])." 円";
    $str_item .= "\n";
}

$tmp_total = 0;
foreach ($chkout_products as $id=>$item) {
    $tmp_total .= $item["product"]["price"] * $item["usr_item"]["quantity"];
}
$str_item .= "合計： ";
$str_item .= number_format(tmp_total)."円";
$str_item .= "\n";

// あとはメール送ってカート空にして終わり。
```

使用イメージがおわかりいただけただろうか・・