【Laravel】HerokuでAWS S3画像アップロード

# 0. はじめに
大阪のLaravel初学者サウナーこと、kazumakishimoto([@kazuma_dev](https://twitter.com/kazuma_dev))です！
LaravelでAWS S3へ画像アップロードする方法について解説します！

## 0-1. 全体の流れ
> [1.設定](#1-設定)
> [2.画像アップロード機能](#2-画像アップロード機能)
> [3.補足](#3-補足)
> [Reference](#reference)

## 0-2. 本記事の対象者
- LaravelでAWS S3へ画像アップロード

## 0-3. 事前準備
- IAMユーザー作成
- S3バケット作成
- ACL有効

![grfl - S3 bucket.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/01b6b743-c7dd-f3e2-26a7-b43321a9d624.png)

![grfl - S3 bucket (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/1b6087e2-5985-aedd-62b6-4f99231b3bfd.png)

https://qiita.com/nobu0717/items/4425c02157bc5e88d7b6

https://qiita.com/nobu0717/items/51dfcecda90d3c5958b8

https://teratail.com/questions/375208

## 0-4. 要件
- インフラはEC2ではなくHeroku

## 0-5. 使用画像のイメージ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/5c27dada-31e4-fb17-6dcd-4f8d25fb6d38.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/efeabd07-c6f2-92bc-d69e-5db8920a643a.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/7b864b6e-305b-6efa-a51a-f5f54969a7f7.png)


# 1. 設定
## 1-1.環境変数
- HerokuのConfig Vars編集(`LOG_CHANNEL`は推奨)
```
AWS_ACCESS_KEY_ID= csvの認証情報に記載されているAccess key ID
AWS_SECRET_ACCESS_KEY= csvの認証情報に記載されているSecret access key
AWS_DEFAULT_REGION=ap-northeast-1 （リージョンをアジアパシフィック東京で作成したため）
AWS_BUCKET= 作成したバケット名
LOG_CHANNEL= errorlog
```

![grfl · Settings _ Heroku.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687994/93cb4b10-17b6-782e-0ab6-ab63001e90a9.png)

https://qiita.com/Yuhei_K/items/56d1b67601c1b5ec3e18#5aws%E3%81%AE%E7%92%B0%E5%A2%83%E5%A4%89%E6%95%B0%E3%82%92%E8%A8%AD%E5%AE%9A

## 1-2. S3パッケージのインストール
```
$ composer require league/flysystem-aws-s3-v3 ^1.0
```

https://qiita.com/ohmoto_2020/items/8b0f813fcca15553d34f

## 1-3. ファイルシステムの編集
```php:config/filesystems.php
return [

    'default' => env('FILESYSTEM_DRIVER', 'local'),

    'cloud' => env('FILESYSTEM_CLOUD', 's3'),

    'disks' => [

        'local' => [
            'driver' => 'local',
            'root' => storage_path('app'),
        ],

        'public' => [
            'driver' => 'local',
            'root' => storage_path('app/public'),
            'url' => env('APP_URL').'/storage',
            'visibility' => 'public',
        ],

        's3' => [
            'driver' => 's3',
            'key' => env('AWS_ACCESS_KEY_ID'),
            'secret' => env('AWS_SECRET_ACCESS_KEY'),
            'region' => env('AWS_DEFAULT_REGION'),
            'bucket' => env('AWS_BUCKET'),
            'url' => env('AWS_URL'),
            'endpoint' => env('AWS_ENDPOINT'),
        ],

    ],

];
```


# 2. 画像アップロード機能
## 2-1. Route
```php:routes/web.php
# ユーザー投稿関係(create, store, edit, update, destroy)
Route::get('/', 'ArticleController@index')->name('articles.index');
Route::resource('/articles', 'ArticleController')->except(['index', 'show'])->middleware('auth');
Route::resource('/articles', 'ArticleController')->only(['show']);

### ログイン状態で使用可能 ###
Route::middleware('auth')->group(function () {
    Route::prefix('users/{name}')->name('users.')->group(function () {
        // プロフィール編集
        Route::get('/edit', 'UserController@edit')->name('edit');
        // プロフィール更新
        Route::patch('/update', 'UserController@update')->name('update');
    });
});
```

## 2-2. Migration
```
$ php artisan make:model User --migration
$ php artisan make:model Article --migration
```

### create_user_table

```php:database/migrations/XXXXXXXXXXX_create_users_table.php
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('twitter_id')->nullable();
            $table->string('name')->unique();
            $table->string('age')->nullable();
            $table->string('gender')->nullable();
            $table->string('avatar')->nullable();
            $table->text('introduction')->nullable();
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password')->nullable();
            $table->rememberToken();
            $table->timestamps();
        });
    }
```

### create_articles_table
```php:database/migrations/XXXXXXXXXXX_create_articles_table.php
    public function up()
    {
        Schema::create('articles', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->bigInteger('user_id')->unsigned();
            $table->bigInteger('pref_id')->nullable();
            $table->text('title');
            $table->text('body');
            $table->string('image')->nullable();
            $table->timestamps();
            $table->softDeletes();

            $table->foreign('user_id')
            ->references('id')
            ->on('users')
            ->onDelete('cascade');
        });
    }
```
```
$ php artisan migrate
```

## 2-3. Model
### User.php
```php:app/Models/User.php
    protected $fillable = [
        'twitter_id', 'name', 'age', 'gender', 'avatar', 'introduction', 'email', 'password',
    ];
```

### Article.php
```php:app/Models/Article.php
    protected $fillable = [
        'pref_id', 'title', 'body', 'image',
    ];
```

## 2-4. Controller
```
$ php artisan make:controller UserController
$ php artisan make:controller ArticleController
```

### UserController
- `FormRequest`の`validated`メソッド
- `Storage`ファサードの`putFile`メソッド
- `Eloquent`の`fill`メソッド

```php:app/Http/Controllers/UserController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Auth;
use App\Http\Requests\UserRequest;
use App\Models\User;

    // プロフィール編集処理
    public function update(UserRequest $request, string $name)
    {
        $validated = $request->validated();

        $user = User::where('name', $name)->first();

        // 画像アップロード
        if (isset($validated['avatar'])) {
            $image = $request->file('avatar');
            $path = Storage::disk('s3')->putFile('avatar', $image, 'public');
            $validated['avatar'] = Storage::disk('s3')->url($path);
        }

        // UserPolicyのupdateメソッドでアクセス制限
        $this->authorize('update', $user);

        // バリデーションにかけた値だけをDBに保存
        $user->fill($validated)->save();

        $data = [
            "name" => $user->name
        ];

        return redirect()->route('users.show', $data);
    }
```

### ArticleController
```php:app/Http/Controllers/ArticleController.php
<?php

namespace App\Http\Controllers;

use App\Models\Article;
use App\Models\Tag;
use App\Http\Requests\ArticleRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;

    // 投稿処理
    public function store(ArticleRequest $request, Article $article)
    {
        $article->user_id = $request->user()->id;
        $all_request = $request->all();
        $article->pref_id = $request->pref;

        // 画像アップロード
        if (isset($all_request['image'])) {
            $image = $request->file('image');
            $path = Storage::disk('s3')->putFile('image', $image, 'public');
            $all_request['image'] = Storage::disk('s3')->url($path);
        }

        $article->fill($all_request)->save();

        $request->tags->each(function ($tagName) use ($article) {
            $tag = Tag::firstOrCreate(['name' => $tagName]);
            $article->tags()->attach($tag);
        });

        return redirect()->route('articles.index');
    }

    // 編集処理
    public function update(ArticleRequest $request, Article $article)
    {
        $article->user_id = $request->user()->id;
        $all_request = $request->all();
        $article->pref_id = $request->pref;

        // 画像アップロード
        if (isset($all_request['image'])) {
            $image = $request->file('image');
            $path = Storage::disk('s3')->putFile('image', $image, 'public');
            $all_request['image'] = Storage::disk('s3')->url($path);
        }

        $article->fill($all_request)->save();

        $article->tags()->detach();
        $request->tags->each(function ($tagName) use ($article) {
            $tag = Tag::firstOrCreate(['name' => $tagName]);
            $article->tags()->attach($tag);
        });

        return redirect()->route('articles.index');
    }
```

## 2-5. Request
```
$ php artisan make:request UserRequest
$ php artisan make:request ArticleRequest
```

### UserRequest
```php:app\Http\Requests\UserRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;

class UserRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        // ゲストユーザーログイン時に、ユーザー名とメールアドレスを変更できないよう対策
        if (Auth::id() == config('user.guest_user.id')) {
        return [
            'age' => ['numeric', 'min:1', 'max:100', 'nullable'],
            'avatar' => ['image', 'nullable'],
            'introduction' => ['max:200', 'nullable'],
            ];
        }

        return [
            'name' => ['required','regex:/^(?!.*\s).+$/u', 'regex:/^(?!.*\/).*$/u', 'max:15', Rule::unique('users')->ignore(Auth::id())],
            'age' => ['numeric', 'min:1', 'max:100', 'nullable'],
            'introduction' => ['max:200', 'nullable'],
            'avatar' => ['image', 'nullable'],
            'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users')->ignore(Auth::id())],
            // 'password' => ['required', 'string', 'min:8', 'confirmed'],
        ];
    }

    public function attributes()
    {
        return [
            'name' => 'ユーザー名',
            'age' => '年齢',
            'introduction' => '自己紹介',
            'avatar' => 'プロフィール画像',
            'email' => 'メールアドレス',
            // 'password' => 'パスワード',
        ];
    }

    public function messages()
    {
        return [
            'name.regex' => ':attributeに「/」と半角スペースは使用できません。'
        ];
    }
}
```

### ArticleRequest
```php:app\Http\Requests\ArticleRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ArticleRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'title' => 'required|max:50|not_regex:/<\/*script>/u',
            'body' => 'required|max:500|not_regex:/<\/*script>/u',
            'image' =>'image',
            'tags' => 'json|regex:/^(?!.*\s).+$/u|regex:/^(?!.*\/).*$/u',
        ];
    }

    public function attributes()
    {
        return [
            'title' => 'タイトル',
            'body' => '本文',
            'image' => '画像',
            'tags' => 'タグ',
        ];
    }

    public function messages()
    {
        return [
            'tags.regex' => ':attributeに「/」と半角スペースは使用できません。'
        ];
    }

    public function passedValidation()
    {
        $this->tags = collect(json_decode($this->tags))
            ->slice(0, 5)
            ->map(function ($requestTag) {
                return $requestTag->text;
            });
    }
}
```

## 2-6. View
### users
```php:resources/views/users/edit.blade.php
<form action="{{ route('users.update', ['name' => $user->name]) }}" method="POST" enctype="multipart/form-data">
    @method('PATCH')
    @csrf
    @if(Auth::id() != config('user.guest_user.id'))
    <label class="form-label d-block" for="avatar">
        <img src="{{ $user->avatar }}" id="img" class="img-fuild rounded-circle" width="80" height="80">
        <input type="file" name="avatar" id="avatar" class="d-none" onchange="previewImage(this);">
    </label>
    @endif
    <button type="submit" class="btn btn-block cyan darken-3 text-white col-lg-6 col-md-7 col-sm-8 col-xs-10 mx-auto mt-5 waves-effect">更新する</button>
</form>

// 略

@include('footer')
@endsection

<script>
    function previewImage(obj) {
        var fileReader = new FileReader();
        fileReader.onload = (function() {
            document.querySelector('#img').src = fileReader.result;
        });
        fileReader.readAsDataURL(obj.files[0]);
    }
</script>
```
```php:resources/views/users/user.blade.php
<a href="{{ route('users.show', ['name' => $user->name]) }}" class="text-dark">
    <img src="{{ $user->avatar }}" class="img-fuild rounded-circle" width="60" height="60">
</a>
```

### articles
```php:resources/views/articles/form.blade.php
<form method="POST" action="{{ route('articles.update', ['article' => $article]) }}" enctype="multipart/form-data">
    @method('PATCH')
    @csrf
    <input id="image" type="file" name="image" accept="image/*" onchange="previewImage(this);">
    <button type="submit" class="btn blue-gradient btn-block">更新する</button>
</form>
```
```php:resources/views/articles/card.blade.php
<img src="{{ $article->image }}" width="200px" class="mt-3 mb-1">
```

# 3. 補足
## 3-1. 開発環境（FW/ツールのバージョンなど）
|ツール|バージョン|
|:--|:--|
|Vue.js|2.6.14|
|jQuery|3.4.1|
|PHP|7.4.1|
|Laravel|6.20.43|
|MySQL|5.7.36|
|Nginx|1.18.0|
|Composer|2.0.14|
|npm|6.14.6|
|Git|2.33.1|
|Docker|20.10.11|
|docker-compose|v2.2.1|
|PHPUnit|8.0|
|CircleCI|2.1|
|heroku|7.59.4|
|MacBook Air|M1,2020|
|macOS|Monterey 12.3|
|Homebrew|3.3.8|

## 3-2. ディレクトリ構造
```
【ルートディレクトリ】
├─ .circleci
│   └─ config.yml
├─ aws / CloudFormation
│   └─ ec2.yml
├─ docker
│   └─ mysql
│   └─ nginx
│   └─ php
│   └─ phpmyadmin
├─ src
│   └─ 【Laravelのパッケージ】
│─ .env
│─ .gitignore
└─ docker-compose.yml
```

# Reference

- 【Laravel × AWS S3】プロフィール機能を実装する（画像アップロード） - Qiita

https://qiita.com/NULL_000000/items/01ae1ce3c473c1f85c45

- Rails, Laravel(画像アップロード)向けAWS(IAM:ユーザ, S3:バケット)の設定 - Qiita

https://qiita.com/nobu0717/items/4425c02157bc5e88d7b6

- LaravelでAWS S3へ画像をアップロードする - Qiita

https://qiita.com/nobu0717/items/51dfcecda90d3c5958b8

- 【Laravel】【AWS S3】LaravelでAWS S3に画像をアップロードできない（`400 Bad Request` ）

https://teratail.com/questions/375208

- AWSにデプロイしたポートフォリオをHerokuへ移行しようとしたらかなり苦労した - Qiita

https://qiita.com/Yuhei_K/items/56d1b67601c1b5ec3e18#5aws%E3%81%AE%E7%92%B0%E5%A2%83%E5%A4%89%E6%95%B0%E3%82%92%E8%A8%AD%E5%AE%9A

- AWS S3のパッケージインストール時に、composer require league/flysystem-aws-s3-v3を叩くと、Your requirements could not be resolved to an installable set of packages.とエラーが出るときの解決策。 - Qiita

https://qiita.com/ohmoto_2020/items/8b0f813fcca15553d34f

- Laravel(+Vue.js)でSNS風Webサービスを作ろう！ | Techpit

https://www.techpit.jp/courses/11

- Laravel × CircleCI × AWSで学ぶCI/CD | Techpit

https://www.techpit.jp/courses/78