Help us understand the problem. What is going on with this article?

Laravel5.8 画像アップロード機能を仕組みから理解する

1.はじめに

ECサイトを開発中に画像アップロード機能について最高に詰まったのでこの記事にまとめます。
誰かのお役に立てたら幸いです。
なお、今回実装したのは
画像をストレージに保存してDBにその「パス」を保存する方法です。

対象読者
・画像アップロードに躓いている方
・Laravel初学者

注)
本記事に記載してあるコードは要点部分のみです。
本記事は画像アップロードの仕組みを理解するためのものとお考えください。

環境
・Laravel Framework 5.8.38
・PHP 7.2.34
・mysql Ver 14.14 Distrib 5.7.32
・phpmyadmin

2.実現したい内容

今回実装したかったのはECサイトにおける商品登録の段階で、
①フォームに画像データを添付して送信したい
②送ったデータをDBに保存したい
③保存された画像データをView(商品一覧)に表示させたい
という内容を実装したい。

つまりは
スクリーンショット 2021-02-20 15.24.13.png
このようなFormを作って、画像添付して登録ボタンを押すと

スクリーンショット 2021-02-20 15.29.15.png
データがデータベースに保存されるようにして

スクリーンショット 2021-02-20 15.31.22.png
保存したものをviewに表示させる。

この一連の流れを習得できるようにします。

3.体系的に理解する

まず前提知識を列挙していきます。

3.1.今回の画像アップロード機能概要

第一に、画像アップロード機能の構造的解釈で躓いたので記載しておきます。
「2.実現したい内容」で述べた手順(構成)がそもそも間違っていました。

実現したい内容を要約すると

①商品登録をする際に、商品情報を記載するフォーム画面に画像を添付して送信する
②その画像をDBに保存する
③商品一覧画面にその画像を表示させる

となりますが、正しくは下記の通りです

①商品登録をする際に、商品情報を記載するフォーム画面に画像を添付して送信する
②画像データ自体はサーバ(Laravel)に保存される
③DBには②で保存した位置情報(ファイルパス)のみを保存する
④商品一覧画面にその画像を表示させる

僕はこの赤文字部分を理解するのに時間がかかりました...
順に解説しますと
「①」はそのままです。

「②、③」に関して、「①」で送信したリクエストに画像データファイルパスの二つの情報を持たせます。そのうち画像データはサーバー(Laravel)内に保存します。ファイルパスのみをDBに保存します。

「④」は表示のさせ方が少しややこしいですが、すぐ理解できると思います。(後ほど記載します)

次に画像データはどこに保存されるか?について記載していきます

3.2.publicフォルダについて

前項で、画像データはサーバー(Laravel)内に保存すると書きましたが
その保存先が、publicディレクトリです。

実際のpublicディレクトリを見てみると...
スクリーンショット 2021-02-20 16.53.44.png

...!?
publicが二つある..!?

ここも理解に苦しみました。。解説します

Laravelにはpublicディレクトリが二つあります。
・public(appと同じ階層)(便宜上、「上のpublic」と呼ばせていただきます)
・storage/app/public   (便宜上、「下のpublic」と呼ばせていただきます)

3.2.1.上のpublicディレクトリ

アプリケーションに送られる、全てのリクエストのエントリーポイント(最初に実行される)となるindex.php ファイルがあるところ。
ここには他に画像、JS、CSSといったものを置いたりする。
https://qiita.com/shosho/items/93cbff79376c41c3a30b (参考サイト)
このpublicはブラウザからサーバーにアクセスした時には公開ディレクトリとなっていることが特徴(セキュリティ的に弱いディレクトリである)
つまりは公開ディレクトリなので、あまり情報はおきたくないところです。

3.2.2.下のpublicディレクトリ

まずstorageディレクトリについて
Bladeテンプレートをコンパイルしたものやセッションのファイル、キャッシュファイル、その他フレームワークが作り出したファイルなんかが置かれる。

storage以下の階層
ディレクトリは app framework logs の3つに分かれている。
このうちの「app」のなかに「public」は存在する。

storage/app/publicについて
storage/app/public についてはユーザーのプロフィールアバターといったいろんなとこで使いたくなるようなファイルを置く場所で、非公開ディレクトリ。
https://laracasts.com/series/whats-new-in-laravel-5-3/episodes/12 (参考サイト)

Laravel 5.3からアップロードファイルの格納が簡単にできるようになり、その置き場所となっているそう。
つまりは、あまり外部に漏らしたくないアップロードされた画像を格納しておくところぐらいの解釈で良いかなと思ってます。

3.2.3.publicまとめ

上のpublic = 公開ディレクトリ
下のpublic = 非公開ディレクトリ
いったんこれだけ抑えておけば事足りるかと思います

結論をいうと、
フォームから送信された画像データは「下のpublic」(非公開ディレクトリ)に格納します。
この二つの「public」のせいでかなり混乱しましたが、それぞれ存在意義があります。
というのも、僕らがブラウザでショッピングサイトを開いても、公開されているのは上のpublicであって、画像データがあるのは下のpublicです。
ではどうやって下のpublicから画像を引っ張ってきているのか..?
答えは次項のシンボリックリンクで

3.3.シンボリックリンクについて

最初この言葉を見た時、意味不明だったので簡単に記載します。

3.3.1シンボリックリンクとは??

ショートカットのようなものです。今回の例で言うと、laravelアプリの、上のpublicが、ウェブ上に公開されるディレクトリとなります。ブラウザからサーバー上のファイルにアクセスするときは、画像は /storage/app/public(下のpublic)の中にあるため、表示することができません。そこで、上のpublic/storage(後に、上のpublicのなかにstorageを作成します) と、下のstorage/app/public にリンクを持たせることにより、public/storageにアクセスする=storage/app/publicにアクセスする、ということを実現できます。(間違っていたらすみません)
https://qiita.com/si-ma/items/16565d925b0558cbba58 (参考サイト)

つまりは、シンボリックリンクとやらを行うと、公開されてる上のpublicから非公開である下のpublicにアクセスできるようになるということ(多分)
感覚的には上のpublicと下のpublicは全く別物ではなく「表と裏の存在」のようなイメージ。

3.3.2シンボリックリンクを張る

というわけでシンボリックリンクを張っていきます。

ターミナル上にて(artisanファイルがあるディレクトリで)

php artisan storage:link

を実行すると...
スクリーンショット 2021-02-20 17.37.12.png

上のpublicのなかにstorageファイルができました。
そしてその右側に「↩️」こんなマークができました。(リンクが張られているマークだと思います)
これで、下のpublicにアクセスが可能となったはずです。

3.4.画像データの保存先を理解する

ここまでで何度か記載しましたが、重要なので大項目で記載しておきます。
画像データの保存先は下のpublicとなります

3.5.DBへの保存について

3.5.1.保存する内容

前項でも書きましたがDBに保存するのは
下のpublicに保存した画像データのファイルパス
となります。

「下のpublic」 と 「DB」 に保存するデータ内容抑えておくと考えがまとまりやすいです。

※DBに画像データを保存するやり方もあります。ですが推奨はファイルパスのみをDBに保存する方法だそうです。
ーー番外編ーー
アップロード画像をDBに保存しないほうがいい理由
・レコードのデータ量が多くなり、クエリに時間がかかる
・WebとDBを分割しようと思った時に弊害がある
・DBのストレージ容量を圧迫する
・ネットワークを圧迫する
・メンテナンス性が低下する
・キャッシュしにくくなる
どこかのサイトで見ましたが、URLがわかりませんでした。。
ーーーーーーー

3.5.2.DBに保存するために

僕の場合画像アップロード機能をつけることは当初の設計に入っていなかったので、migrationファイルからいじる必要がありました。
つまりは、新しくファイルパスをいれるカラムが必要となったので作成しておかないといけません。
既にmigrationファイルを作成してしまっている人でも、すぐに追加することができます。

画像のファイルパスを入れたいmigrationファイルに以下のコードを追加してください

$table->string('product_image');  //カラム名は好きなものでOK

完成した僕のmigrationファイルです↓↓

//略
    public function up()
    {
        Schema::create('m_products', function (Blueprint $table) {
            $table->increments('id');
            $table->string('product_name', 64);
            $table->integer('category_id')->unsigned();
            $table->integer('price')->unsigned();
            $table->integer('sale_status_id')->unsigned();
            $table->integer('product_status_id')->unsigned();
            $table->string('description', 256);
            $table->string('product_image');          //←ここに入れました
            $table->timestamp('resist_date');
            $table->integer('user_id')->unsigned();
            $table->char('delete_flag', 1);
            $table->foreign('sale_status_id')->references('id')->on('m_sales_statuses')->onDelete('cascade');
            $table->foreign('product_status_id')->references('id')->on('m_products_statuses')->onDelete('cascade');
            $table->timestamps();
        });
    }
//略

これで、DBでのファイルパスの受け皿も完成しました。
では実装していきます。

4.実装

4.1.Form部分

{!! Form::open(['route' => 'back_product_store', 'enctype'=>'multipart/form-data']) !!}

//略

<div class="form-group-sm">
    {!! Form::label('image', '商品画像', ['class' => 'd-block mt-2 mb-0']) !!}
    <input type="file" name="product_image" value="" class="ml-3 mr-2 d-inline">
</div>

//略

{!! Form::close() !!}

※bootstrapとLaravelCollectiveを用いているので、少し書き方が独特な部分があります。

ポイント
①'enctype'=>'multipart/form-data'
ファイルのアップロードを行う場合は、enctype=”multipart/form-data”は忘れずにform要素に設定をしてください。

②type="file"
取り扱うデータはfileなのでtype属性はfileに設定します

③name="product_image"
送信するデータの名前です。なんでもいいです。
僕の場合は商品画像なのでproduct_imageとしました。

④'route' => 'back_product_store'
このフォームを送信するとback_product_storeという名前のルーティングにいきます。

4.2.Routing

今回の場合は、、

Route::post('product/store', 'BackProductController@store')->name('back_product_store');

ルーティング部分に関しては特に注意事項はないです。

このルーティングで
BackProductController の storeアクションにいきます。

4.3.Controller

    public function store(CreateProductRequest $request)
    {
        //バリデーションの記載
        $this->validate($request, CreateProductRequest::rules());
        $productImage = $request->product_image;
        if ($productImage) {

            //一意のファイル名を自動生成しつつ保存し、かつファイルパス($productImagePath)を生成
            //ここでstore()メソッドを使っているが、これは画像データをstorageに保存している
            $productImagePath = $productImage->store('public/uploads');
        } else {
            $productImagePath = "";
        }

        $user = Auth::user();
        if ($user->id) {
            $userId = $user->id;
        }
        //userIdとproductImageが存在すれば以下の項目をMProductテーブルに保存
        if ($userId && $productImage) {
            $data = [
                'product_name'      => $request->productName,
                'category_id'       => $request->categoryId,
                'price'             => $request->price,
                'sale_status_id'    => $request->saleStatusId,
                'product_status_id' => $request->productStatusId,
                'description'       => $request->description,
                'user_id'           => $userId,
                'resist_date'       => date('Y-m-d H:i:s'),
                //DBにはファイルパスを保存!!!!!!
                'product_image'     => $productImagePath,
                'delete_flag'       => '',
            ];
           //$dataをクリエイトする
           MProduct::create($data);
        }
    }

ポイント
①バリデーションについて

$this->validate($request, CreateProductRequest::rules());

今回はクラスを使ってるので少しややこしい書き方です、、
調べれば、これとは別に基本的な書き方がすぐ出てくると思うので割愛させていただきます。

ファイルパスの作成及び画像データの保存を1行で記載 ※超重要※

$productImagePath = $productImage->store('public/uploads');

この一文、超重要です。
全てがここに集約されていると言っても過言ではないくらい重要です。

解説)
・前提として、$productImageは、送信されてきた画像データです。
・まず$productImagePathという変数を作ります。
・右側にて、$productImageをstore()関数で保存します。
 保存場所は引数にある'public/uploads'です。
 つまりpublicディレクトリのなかのuploadsに保存します。
・「uploadsディレクトリ」は準備していませんでしたが、保存の際に自動的に作成されます。
・ここでのpublicはもちろん「下のpublicディレクトリ」です。
・この時保存されるファイル名はstore()関数によって乱数的に作成されます。
 (これによりプロフィール画像など、ファイル名が被って欲しくない時には有効です。ファイル名を固定したければstoreAs()関数を使います。)

そして
・作った変数$productImagePathの値として何が入るかというと、
 この変数名からお察しかと思いますが
 "保存された場所へのパス"がこの変数の値となります。(ここで感動しました)

コードのコメントアウトにあるように、このコード1行のみで
//一意のファイル名を自動生成しつつ保存し、かつファイルパス($productImagePath)を生成
//ここでstore()メソッドを使っているが、これは画像データをstorageに保存している
これだけのことを行っています。

③DBにファイルパスを保存

//DBにはファイルパスを保存!!!!!!
'product_image'     => $productImagePath,

少しカラム数が多くてわかりにくいですが、やってることで重要なのはこの部分です。
product_imageというカラムに $productImagePath (ファイルパス)を保存します
dd()で取り出してみるとわかりやすいので見てみます。

以下のコメントアウト部分でデバッグをかけてみます

BackProductController
    public function store(CreateProductRequest $request)
    {
        //バリデーションの記載
        $this->validate($request, CreateProductRequest::rules());
        $productImage = $request->product_image;
        if ($productImage) {

            //一意のファイル名を自動生成しつつ保存し、かつファイルパス($productImagePath)を生成
            //ここでstore()メソッドを使っているが、これは画像データをstorageに保存している
            $productImagePath = $productImage->store('public/uploads');

            dd($productImagePath);    //ここでデバッグをかけてみる

        } else {
            $productImagePath = "";
        }

結果がこちら

スクリーンショット 2021-02-20 22.30.10.png

このようなデータが取れました。
これはファイルパスなので画像データの位置情報です。

public/uploads/にある7qaJNxnb4cKml1aOwUWoOVUoHdaAUos1U55SkYU2.jpgという画像

という意味です(そのままですみません)

というわけでしっかりとファイルパスも設定できているのでこのままDBに保存してしまえば、この後viewで表示したい時にそのパスを頼りに、下のpublic(の中のuploads)から画像データを引っ張り出せるということになります。

4.4.View(画像を表示)

最後にサーバー(Laravel)に保存した画像データを、データベースに保存したファイルパスを使ってviewに表示させます。

表示に関しては冒頭で「少しややこしい」と言いましたが、すぐ理解できるはずです。

結論から書くとこうです。

<img class="product_image" src="{{ Storage::url($product->product_image) }}" alt="" width="150px" height="100px">

class=""とか、alt=""に関してはつけたい方は適当につけてください。
width=""やheight=""は割愛します。

大事な部分はここです。

src="{{ Storage::url($product->product_image) }}"

分けて考えます。
まずは
Storage::url() の部分ですが、
指定したファイルのURLを取得するには、urlメソッドを使います。
Storage::をつけてurl()メソッドを使用することで、その引数のファイルパスを取得することができます。(言い回しがあってるかわかりませんが)

詳しくはリファレンスに記載があります。
https://readouble.com/laravel/5.5/ja/filesystem.html

次に
$product->product_image の部分ですが

$productは僕が設定した商品データの変数です。
なので
$product->product_image と記載することで
その商品のファイルパスを表しています。

つまり、最初のコードの src は「ある商品のファイルパス」を取得していたことになるので、その商品にあった場所に適した画像を表示できるわけです。

というわけで、実際のView画像である

スクリーンショット 2021-02-20 15.31.22.png

こちらにたどり着きます。

画像アップロードの解説は以上となります。

5.まとめ

何がどこに保存されているかわからなくて、大いに躓いたので記事にしました。
データの動きをみることがすごく大切だと痛感しました。。

僕なりの解釈でこの記事を書きましたが、不備などありましたらご指摘頂けると幸いです。
以下参考にさせていただいたサイトです。

参考サイト一覧
https://qiita.com/ryo-program/items/35bbe8fc3c5da1993366
https://note.com/akina7/n/ne9af79fea62e
https://reffect.co.jp/laravel/how_to_upload_file_in_laravel#i-13
https://qiita.com/koru1893/items/1d2f522e20744b03e3ad#%E5%BE%8C%E3%81%AF%E7%A7%BB%E5%8B%95%E3%81%95%E3%81%9B%E3%81%A6db%E3%81%B8%E4%BF%9D%E5%AD%98
https://laraweb.net/tutorial/2707/
https://note.com/laravelstudy/n/n038bd68f53a7#nRJwi
https://qiita.com/u-dai/items/8a904cc7fd2795c0e70d
https://biz.addisteria.com/image-upload/
https://qiita.com/shosho/items/93cbff79376c41c3a30b
https://qiita.com/si-ma/items/16565d925b0558cbba58
https://reffect.co.jp/laravel/laravel-storage-manipulation-master#visibility

最後まで読んでいただき、ありがとうございました。

kei_Q
医療からエンジニアへ転生します。 PHP/Laravel初学者です。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away