search
LoginSignup
1

posted at

updated at

【AWS】LaravelアプリをEC2デプロイ⑥【S3編】

0. はじめに

大阪のLaravel初学者サウナーこと、kazumakishimoto(@kazuma_dev)です!
LaravelでS3へ画像アップロードする方法です。

0-1. 前回記事

  • 【AWS】LaravelアプリをEC2デプロイ【まとめ編】

  • 【AWS】LaravelアプリをEC2デプロイ①【CloudFormation / EC2 / RDS編】

  • 【AWS】LaravelアプリをEC2デプロイ②【Route53編】

  • 【AWS】LaravelアプリをEC2デプロイ③【ACM / ELB編】

  • 【AWS】LaravelアプリをEC2デプロイ④【CircleCI / CodeDeploy編】

  • 【AWS】LaravelアプリをEC2デプロイ⑤【SNS / Chatbot編】

0-2. 全体の流れ

1.S3
2.Laravel
補足
Reference
次回記事

0-3. 本記事の対象者

  • LaravelでS3へ画像アップロード機能を実装したい方

0-4. 事前準備

  • スマホでGoogle AuthenticatorをDL

  • AWSアカウント作成済み
  • リージョンはアジアパシフィック(東京)ap-northeast-1
  • grflhogeはサンプル名なので適宜変更して下さい

0-5. 要件

  • IAMでS3用ユーザー追加(パスワード / MFA)
  • S3でバケットとポリシー作成
  • Laravelの画像アップロード機能

0-6. 本番環境

ツール バージョン
OS Amazon Linux 2
nginx 1.12
PHP 7.4.28
Laravel 6.20.44
MySQL 5.7.37
Composer 1.10.26
Node.js 13.14.0

0-7. AWS構成図

aws

1. S3

1-1. S3用ユーザー追加(IAM)

image.png
image.png
image.png
image.png
image.png
image.png

1-2. パスワード設定

image.png
image.png
image.png
image.png

1-3. 二段階認証(MFA)

  • スマホでGoogle AuthenticatorをDL

image.png
image.png
image.png
image.png
image.png

1-4. バケット作成

image.png
image.png

1-5. ポリシー追加

  • ユーザー名grfl-s3grflに変わってますが、スクショ用のため気にしないでください…
    image.png
    image.png
    image.png
    image.png
    image.png
    image.png
    image.png
    image.png
    image.png

1-6. S3パッケージインストール

local
$ composer require league/flysystem-aws-s3-v3

2. Laravel

2-1. .env

.env
AWS_ACCESS_KEY_ID= csvの認証情報に記載されているAccess key ID
AWS_SECRET_ACCESS_KEY= csvの認証情報に記載されているSecret access key
AWS_DEFAULT_REGION=ap-northeast-1 (リージョンをアジアパシフィック東京で作成したため)
AWS_BUCKET= 作成したバケット名

2-2. config

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-3. Route

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-4. Migration

$ php artisan make:model User --migration
$ php artisan make:model Article --migration

2-4-1. create_user_table

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();
        });
    }

2-4-2. create_articles_table

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-5. Model

2-5-1. User.php

app/Models/User.php
    protected $fillable = [
        'twitter_id', 'name', 'age', 'gender', 'avatar', 'introduction', 'email', 'password',
    ];

2-5-2. Article.php

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

2-6. Controller

$ php artisan make:controller UserController
$ php artisan make:controller ArticleController

2-6-1. UserController

  • FormRequestvalidatedメソッド
  • StorageファサードのputFileメソッド
  • Eloquentfillメソッド
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);
    }

2-6-2. ArticleController

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-7. Request

$ php artisan make:request UserRequest
$ php artisan make:request ArticleRequest

2-7-1. UserRequest

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に「/」と半角スペースは使用できません。'
        ];
    }
}

2-7-2. ArticleRequest

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-8. View

2-8-1. app.blade.php

resources/views/app.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <meta http-equiv="x-ua-compatible" content="ie=edge">
  <title>
    @yield('title')
  </title>

  <link rel="shortcut icon" href="{{ asset('images/favicon.png') }}">

  <!-- Font Awesome -->
  <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.2/css/all.css">
  <!-- Bootstrap core CSS -->
  <link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet">
  <!-- Material Design Bootstrap -->
  <link href="https://cdnjs.cloudflare.com/ajax/libs/mdbootstrap/4.8.11/css/mdb.min.css" rel="stylesheet">
</head>

<body>
    <div id="app">
        @yield('content')
    </div>

  <script src="{{ mix('js/app.js') }}"></script>
  <!-- JQuery -->
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
  <!-- Bootstrap tooltips -->
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.4/umd/popper.min.js"></script>
  <!-- Bootstrap core JavaScript -->
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/js/bootstrap.min.js"></script>
  <!-- MDB core JavaScript -->
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/mdbootstrap/4.8.11/js/mdb.min.js"></script>
</body>

</html>

2-8-2. users

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>
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>

2-8-3. articles

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>
resources/views/articles/card.blade.php
<img src="{{ $article->image }}" width="200px" class="mt-3 mb-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

ディレクトリ構造

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

Reference

次回記事

  • 【AWS】LaravelアプリをEC2デプロイ⑦【API編】

  • 【AWS】お役立ちリンク集【随時更新】

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
1