初学者がlaravel 8.x で画像投稿機能を実装してみた

本記事は初学者が初学者向けのCRUD機能つきブログを作成している時に
画像も載せたいと思いたったところから画像投稿機能を実装してみた時のメモです。
※画像投稿の部分だけを内容としています。

##環境
OS: Windows 10 home
CPU: AMD Ryzen 2700X
GPU: NVIDIA GTX 1060
RAM: 16GB 2666Mhz
PHP: ver 8.0.3
Laravel: ver 8.45.1
MySQL(MariaDB): ver 15.1

##下準備
web.phpにルーティングを記述する際、laravel 8.xでは
**Route::get('/user', [UserController::class, 'index']);**
といった記述になります。
**Route::get('/user', UserController＠index)>name('index');**
個人的に上記の書き方が現時点では分かりやすいのですが、
Laravel 8.xでこの記述を行った場合には、
**Target class [UserController] does not exist.**
というエラーが発生してしまいます。
そこで「app」>「providers」内にある「RouteServiceProvider.php」の
**//protected $namespace = 'App\\Http\\Controllers';**
という記述を探します。コメントアウトされているので//を消してあげることで
**Route::get('/user', UserController＠index)>name('index');**
上記のような記述でもエラーが発生することなくルーティングが可能となります。
######※大して変わらないので公式ドキュメントに従ったほうが良いと思います。

##1.テーブルの作成
はじめに、テーブル設計。
カラムはid,title,image,timestampといたってシンプル。
viewで表示されるのはあくまでもtitleとimageカラムに保存された文字列と同じ画像のみです。
timestampはカラムがあるものの、表示に使用はされていません。
無効にしておくと良いかもしれません。（timestampの記述を消すだけだとエラーが出るの注意）
※imageカラムにはxxxx.jpgのように保存されますがあくまで**「文字列」**
であることに留意してください。画像そのものは別のフォルダに保管されることになります。

テーブル設計は以下のようになります。
※マイグレーションファイルの作り方は省略します。
※ファイル名は任意なので好きなように

```xxx_xxxx_table.php
public function up()
    {
        Schema::create('テーブル名（複数形）', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string("image");
            $table->timestamps();
        });
    }
public function down()
    {
        Schema::dropIfExists('image_upload');
    }
```

後はマイグレーションすることでenvファイルに設定されたデータベースにテーブルが作成されます。

##2.モデルの作成
モデルを作成します。

```
php artisan make:model XXXXX（単数形）
```
```XXXXX.php(モデル)
class XXXXX extends Model
{
    protected $table = "YYYYY";
	protected $fillable =
    [
        'title',
        'image',
    ];
}
```
YYYYYにはマイグレーションファイルで指定したテーブル名を入れます。
protected table = テーブル名とすることでモデルがどのテーブルと関連するか明示します。
protected fillable = [属性名]とすることで複数代入可能な属性を指定しておきます。
**※筆者はfillableとguardedの必要性や意味がまだまだ理解出来ていないので間違っている可能性大です。**
ちなみに$fillableの部分は無くても動きます。

##3．コントローラーの作成

```
php artisan make:controller XXYYZZController
```

```XXYYZZController.php

namespace App\Http\Controllers;

use App\Models\image_upload;
use Illuminate\Http\Request;←使いません
use App\Http\Requests\XXYYRequest;←フォームリクエストバリデーションに使います

class XXYYZZController extends Controller
{
    public function index()
    {
　　　　$images = モデルクラス::all();
        return view('index',['images' => $images ]);
	}

	public function store(XXYYRequest $request)
    {
        $newImage = new モデルクラス();
        $newImage->title =$request->input('title');
        if($request->hasfile('image')){
            $file = $request->file('image');
            $originalName = $file->getClientOriginalName();
            $filename = time().'-'.$originalName;
            $file->storeAs('public/images',$filename); 
            $newImage->image = $filename;
        }
        $newImage->save();

        \Session::flash('err_msg','画像を登録しました');
        return redirect(route('index'));
    }

	public function delete($id)
    {
        $imageDelete = モデルクラス::find($id);
        $imageDelete -> delete();
    }
}

```
storeメソッドのif()の部分が画像の保存処理になります。

\$fileはimageの取得、\$originalNameは拡張子を含めたファイル名取得、
\$filenameで保存する画像のファイル名を定義しています。
storeAsメソッドでは、storage/appを指定しているので
第一引数にpublic/imagesとすることでstorage/app/public/imagesが
保存場所になります。
第二引数に\$filenameを入れることで$filenameで定義したファイル名で画像が保存されます。

次にフォームリクエストバリデーションのためにRequestファイルを作ります。
作ったRequestファイルが使用できるようコントローラー内にuse App\Http\Requests\XXYYRequest
というふうに記述しておきます。

```
php artisan make:request YYYYRequest
```
Requestファイルはapp/httpディレクトリ内に生成されるRequestsフォルダに配置されます。

```XXYYRequest.php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class XXYYRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'image' => 'required|mimes:jpg,jpeg,png|max:10240',
            'title' => 'required|min:2|max:100',
        ];
    }
}

```

```
    public function authorize()
    {
        return true;
    }
```
authorizeメソッドは初期状態でfalseになっていますのでtrueにしておきます。
ここをtrueにしておかないと403 THIS ACTION IS UNAUTHORIZED.というエラーが返ってきます。

```
    public function rules()
    {
        return [
            'image' => 'required|mimes:jpg,jpeg,png|max:10240',
            'title' => 'required|min:2|max:100',
        ];
    }
```
ruleメソッドにバリデーションを記述します。
requiredで値の入力が必須となります。
mimesは拡張子を指定しており、maxは**KB**でサイズを指定しています。
titleのminmaxは文字数を指定しています。
設定したバリデーションに反する値はエラーとして返されます。

##4.ビューの作成
以下は共通の表示になります。

```layout.blade.php
<!DOCTYPE HTML>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>title</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/normalize.css@8/normalize.css">
<body style="max-width: 1000px; margin:0 auto;">
    <header>
        <nav class="navbar navbar-expand " style="background-color: #41505a; , color:white;">
            <h2 style="color: rgba(201, 224, 247, 0.705)" class="mx-4">uploader</h2>

            <div class="navbar-nav ">
                <a class="btn btn-primary mx-3" href="{{ route('index') }}">list </a>
                <a class="btn btn-primary mx-3" href="{{ route('create') }}">upload</a>
            </div>

        </nav>
    </header>

    <br>

    <div class="container">
        @yield('content')
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-ygbV9kiqUc6oa4msXn9868pTtWMgiQaeYH7/t7LECLbyPA2x65Kgf80OJFdroafW" crossorigin="anonymous">
    </script>
</body>
</html>
```


![共通部分.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1507835/f280bb6c-ad0d-c7ff-edb6-916e35c769d1.png)

<hr>

上の画像で表示されているは投稿した画像一覧になり、index.blade.phpが対応します。
コードが汚らしいのはご勘弁を:sweat:

```index.blade.php
@extends('layout')
@section('title','uploader')
@section('content')

<div class="row" style="margin: 0 100px;">
    <h2 style="text-align:center;">
        ---List---
    </h2>
    <div style="text-align: center">
        @if (session('err_msg'))
        <p class="text-danger">
            {{ session('err_msg') }}
        </p>
        @endif
        <table class="table table-bordered table-hover my-3">
            <tr>
                <th>Title</th>
                <th>Image</th>
                <th></th>
            </tr>
            @foreach($images as $image)
            <tr>
                <td style="vertical-align: middle; font-size:2rem;">
                    {{ $image->title }}
                </td>
                <td class="p-4">
                    <img src="{{ asset('/storage/images/'.$image->image)}}" alt="image"
                        style="width:200px;" 　>
                </td>
                <td class="px-2" style="vertical-align: middle;;">
                    <form action="{{ route('delete',$image->id) }}" method="POST">
                        @csrf
                        <button type="submit" class="btn btn-primary my-1">
                            Delete
                        </button>
                    </form>
                </td>
            </tr>
            @endforeach
        </table>
    </div>
</div>
@endsection
```

画像の表示部分にはasset関数を用いています。
XXYYZZControllerのstoreメソッドでは画像の保存先がstoreAsでstorage/app/public/imagesと指定されていました。
しかし、asset関数が参照するのはpublicディレクトリになります。
この状態では、画像を表示することでが出来ないのでシンボリックリンクを張る必要があります。

```
php artisan storage:link
```
シンボリックリンクが張れました。
これでpublic/storageからstorage/app/public/imagesの画像にブラウザからアクセス出来るようになります。
第二引数は画像名となります。

<hr>
続いて画像投稿画面に対応するcreate_form.blade.phpです。

```
@extends('layout')
@section('title','投稿画面')
@section('content')
<div class="row">
    <div  style="margin: 0 auto;">
        <h2>Upload form</h2>
        <form action="{{ route('store') }}" method="POST" onsubmit="return checkSubmit()" enctype="multipart/form-data">
            @csrf

            <div class="form-group">
                <label for="title">
                    Title
                </label>
                <input 
                type="text" 
                name="title" 
                class="form-control" 
                value="{{ old('title')}}">

                @if($errors->first('title')) 
                <div class="text-danger">
                    {{ $errors->first('title') }}
                </div>
                @endif
            </div>

            <br>

            <div class="form-group">
                <input type="file" name="image" class="form-control">
            </div>
            <p>※画像は必須です。拡張子はjpg・jpeg・pngのいずれか限定です。</p>

            @if ($errors->first('image'))
                <div class="text-danger">
                    {{ $errors->first('image') }}
                </div>
                @endif
            

            <div class="mt-5">
                <a class="btn btn-secondary" href="{{ route('index') }}">
                    Cancel
                </a>
                <button type="submit" class="btn btn-primary">
                    Upload
                </button>
            </div>
        </form>
    </div>
</div>

<script>
    function checkSubmit()
    {
        return confirm("投稿しても良いですか？");
    }
</script>
@endsection
```

\<form>の中でenctype="multipart/form-data"を指定しています。
これがないと上手くいきませんので要注意
今回は入力フォームがタイトルと画像の2つの要素しか無いので無くてもいいのですが、
ブログのように長文を書いて投稿したいという場合にエラーが発生するとそれまでの
入力内容が消えてします。
そこでold関数を使うことによって値が保持されるようにしています。
onsubmit="return checkSubmit()で投稿に対して再度確認を行うようにしていますが
別に無くても良いやつなのでオマケだと思ってください。
![ezgif-3-06187e59b087.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1507835/b6dfe0ea-40e9-1c65-0ef6-c0711a7b61b0.gif)

##5.ルーティング
```web.php
Route::get('/', 'XXYYZZController@index')->name('index');
//一覧表示画面
Route::get('/create', 'XXYYZZController@create')->name('create');
//画像投稿画面
Route::post('/create', 'XXYYZZController@store')->name('store');
//投稿処理
Route::post('/delete/{id}','XXYYZZController@delete')->name('delete');
//削除処理
```
このルーティングの記述については「下準備」で触れています。
laravel8.xにおけるデフォルトの記述ではありませんのでご注意ください。

以上で初学者の画像投稿機能実装は終わりとなります。
CRUD機能を実装出来たぐらいの方にお役に立てば幸いです。
間違えている点、不足しているとうあればご教示いただけますと幸いです。