見出し画像

お知らせアプリを作ろう(#5) #Laravel基礎 #Laravelの教科書

こちらはLaravelを利用したお知らせアプリの記事の5回目です。表紙はこちらのマガジンからどうぞご確認ください

今回は画像のアップロード機能を作成していきます。


# アップロードした画像の保存先

ユーザーがアップロードした画像をどこに保存して管理するのか、Webアプリケーションではいつも悩む部分です。

無制限にアップロードを許可してしまうとサーバーの容量がすぐに枯渇してしまうため、例えばAWSのS3といった外部のストレージサービスを使うことも良くあります。

Laravelではこれらのアップロードに関わる内容をStorageという機能で解決しています。例えば設定を変更してS3にアップロードすることも簡単に出来ます。今回は標準の「storage」ディレクトリに画像を保存していきます。

storageディレクトリには3個のディレクトリが標準で作成されます。

・logs
・framework
・app

logsにはアクセスログやエラーログが、frameworkにはLaravelで利用したキャッシュファイルが保存されます。

appディレクトリは、ユーザーのアップロードしたファイルが保存される場所です。

勘の良い方やWebアプリケーションを作ったことのある方であれば、ここでちょっとまてLaravelのアプリケーションはpublic以下にアクセスさせて変な場所にアクセスされないような安全な仕組みになっていたのでは、と疑問が湧くと思います。

その疑問は正しく、storage/app以下にファイルを置いたとしても、そのままではアクセスが出来ません。

storage/app以下に置いたファイルにアクセスが出来るようにするには次のコマンドを入力する必要があります。

php artisan storage:link

こちらのコマンドを実行するとpublic/storageにシンボリックリンクが作成され、アクセス出来るようになります。この操作は一回だけ行えばその後、継続して利用出来ます。二回目以降行ってもエラーになるだけなので特に問題は発生しません。


# storage/appを使ってみる

では実際にコマンドを実行してみましょう。

php artisan storage:link

「storage:link」コマンドを実行後にstorage/appディレクトリを見るとpublicというディレクトリが出来ています。

スクリーンショット 2020-06-22 23.44.58

ここにファイルを置いてブラウザで表示してみます。

storage/app/public/profile.jpeg

を置いてみました。

このファイルをブラウザで確認する場合、次のURLになります。

http://localhost:8000/storage/profile.jpeg

ファイル設置場所は「storage/app/public」ですが、URLは「/storage」と異なっています。

ブラウザで直接アクセスすることはほとんどないのですが、テンプレートでstorage/app/publicに置いたファイルを表示することは良くあります。

テンプレートにはどのように書けば良いのか、書き方が二種類あります。

一つ目は storage/app/public/ディレクトリをstorage/に置き換えてasset()で書くというものです。

(ファイルのパス)
storage/app/public/profile.jpeg

(storage/app/public/の置き換え)
storage/profile.jpeg

{{ asset('storage/profile.jpeg') }}

この書き方はファイルを手動で設置したりするなどしてURLがわかる必要があります。わからない場合もstr_replace()と組み合わせて置換することで対応することができます。


もう一つはStorage::url()関数を使う方法です。

(ファイルのパス)
storage/app/public/profile.jpeg

(storage/appからの相対パスにする)
public/profile.jpeg

{{ Storage::url('public/profile.jpeg') }}

ファイルをアップロードした場合などは、「storage/appからの相対パス」が返却されるため、今回はこちらのStorage::url()を使っていきます。

# アップロードフォームを作成する

記事編集画面のテンプレートを修正し、画像のアップロードが出来るようにします。

記事には詳細用画像(image)とサムネイル用の画像(thumbnail)があり、アップロード先ではそれぞれをstorageディレクトリに保存して、「storage/appからの相対パス」をimage_url、thumbnail_urlに保存するという仕組みです。

resources/views/news/edit_form.blade.php

@extends('layouts.admin')
@section('content')
<div class="container">
	<div class="card">
		<div class="card-header">記事の編集</div>
		<div class="card-body">
			@if ($errors->any())
			<div style="color:red;">
			<ul>
				@foreach ($errors->all() as $error)
				<li>{{ $error }}</li>
				@endforeach
			</ul>
			</div>
			@endif
			<form method="post" action="{{ url('news/edit/' . $news->id ) }}" 
+                enctype="multipart/form-data">
			@csrf 
			<div class="form-group">
				<label>タイトル: </label><br />
				<input class="form-control" type="text" name="title" value="{{old('title', $news->title)}}" />
			</div>
			<div class="form-group">
				<label>概要: </label><br />
				<input class="form-control" type="text" name="description" value="{{old('description', $news->description)}}" />
			</div>
			<div class="form-group">
				<label>本文: </label><br />
				<textarea class="form-control" name="body">{{old('body', $news->body)}}</textarea>
			</div>
+			<div class="form-group">
+				<label>画像: </label><br />
+				@if($news->image_url)
+				<img src="{{ Storage::url($news->image_url)  }}" style="width: 150px;"/>
+				@endif
+				<input type="file" class="form-control" name="image">
+			</div>
+			<div class="form-group">
+				<label>サムネイル: </label><br />
+				@if($news->thumbnail_url)
+				<img src="{{ Storage::url($news->thumbnail_url) }}" style="width: 150px;"/>
+				@endif
+				<input type="file" class="form-control" name="thumbnail">
+				<p class="text-muted">サムネイルは画像と別に指定することも出来ます</p>
+			</div>
			
			<div class="mt-3">
				<input class="btn btn-primary" type="submit" value="保存" />
			</div>
			</form>
			<hr />
			<form method="post" action="{{ url('news/delete/' . $news->id ) }}">
			@csrf 
			<input class="btn btn-primary" type="submit" value="記事の削除" />
			</form>
		</div>
	</div>
</div>
@endsection

ポイントの一つは<form>を画像のアップロードが出来るようにenctype="multipart/form-data"をつけることです。

<form method="post" action="{{ url('news/edit/' . $news->id ) }}" enctype="multipart/form-data">

こちらの設定がないとファイルアップロードが出来ません。

@if($news->image_url)
<img src="{{ Storage::url($news->image_url) }}" style="width: 150px;"/>
@endif

画像のURLが保存されていれば「Storage::url()」で変換して表示しています。

<input type="file" class="form-control" name="image">

<input type="file">でファイル選択が出来るようになります。この時のnameはimageとthumbnailにしておきます。


ブラウザで記事の編集画面を確認し、ファイル選択が出来ることを確認しましょう。

スクリーンショット 2020-06-23 22.01.28


# アップロード機能を作成する

記事編集フォームの遷移先ManageEntryController@update()を修正し、画像がアップロードされるようにします。

app/Http/Controllers/user/ManageEntryController.php

use App\NewsEntry;
use Auth;
use Storage;
use Validator;
class ManageEntryController extends Controller
{

    function update(Request $request, $id){
		$user = Auth::user();
		$news = $user->newsEntry()->find($id);
		if(!$news){
			return redirect("home")->withStatus("記事がありません");
		}
		/* 入力値の受け取り */
		
		$input = $request->only('title', 'description', 'body');
		
		$validator = Validator::make($input, [
			'title' => 'required|string|max:200',
			'description' => 'string|max:200',
			'body' => 'required|string',
		]);
		//バリデーション失敗
		if($validator->fails()){
			return redirect('news/edit/' . $news->id)
				->withErrors($validator)
				->withInput();
		}
		//バリデーション成功
		$news->title = $input["title"];
		$news->description = $input["description"];
		$news->body = $input["body"];
		$news->user_id = Auth::id();
		$news->save();
		
		
		/* 画像のアップロード */
		$uploadInput = $request->only("image", "thumbnail");
		
		$uploadValidator = Validator::make($uploadInput, [
			'image' => 'file|image|mimes:jpeg,png',
			'thumbnail' => 'file|image|mimes:jpeg,png',
		]);
		//アップロード失敗
		if($uploadValidator->fails()){
			return redirect('news/edit/' . $news->id)
				->withErrors($uploadValidator)
				->withInput();
		}

		//画像が更新されたかどうか
		$is_change_image = false;

		//イメージのアップロード
		if(isset($uploadInput["image"])){
			$path = $uploadInput["image"]->store("public/news_uploads/" . $news->id);
			if($path){
				$news->image_url = $path;
				$is_change_image = true;
			}
		}

		//サムネイルのアップロード
		if(isset($uploadInput["thumbnail"])){
			$path = $uploadInput["thumbnail"]->store("public/news_uploads/" . $news->id);
			if($path){
				$news->thumbnail_url = $path;
				$is_change_image = true;
			}
		}
		
		//保存する
		if($is_change_image){
			$news->save();
		}
		return redirect("home")->withStatus("記事を更新しました");
	}

画像のアップロードを利用するためにStorageクラスが必要となります。use句を先頭に記述しましょう。

use Storage;

アップロードの処理はフォームの内容の保存後に行います。

/* 画像のアップロード */
$uploadInput = $request->only("image", "thumbnail");

$uploadValidator = Validator::make($uploadInput, [
    'image' => 'file|image|mimes:jpeg,png',
    'thumbnail' => 'file|image|mimes:jpeg,png',
]);
//アップロード失敗
if($uploadValidator->fails()){
    return redirect('news/edit/' . $news->id)
        ->withErrors($uploadValidator)
        ->withInput();
}

アップロードされたファイルは他のフォームの内容と同じく$request->only()で取り出すことができます。今回はimageとthumbnailの2個が対象です。

バリデーションも同じように書くことができます。

'image' => 'file|image|mimes:jpeg,png',

file = ファイルであること
image = 画像であること
mimes:jpeg,png = 画像の形式がjpegまたはpngのどちらかであること

ファイルのバリデーションは上記を確認しています。テキストファイルなどがアップロードされたらエラーになるようになっています。

if(isset($uploadInput["image"])){
    $path = $uploadInput["image"]->store("public/news_uploads/" . $news->id);
    if($path){
        $news->image_url = $path;
        $is_change_image = true;
    }
}

画像はオプションの値なので、アップロードされない場合もあります。アップロードされているかをissetで囲んで、その中でアップロード処理を行います。

$uploadInput["image"]->store("public/news_uploads/" . $news->id);

これがアップロード処理です。<input type="file" />を使ってアップロードしたファイルはstore()関数で保存されます。

こちらの関数は次の記法です。

$path = $upload->store("保存先ディレクトリ名")

この場合は「storage/app/public/news_upload/【記事ID】/ファイル名」という場所にファイルが作成されます。

ファイル名はランダムな値が設定されるようになっています。

store()関数を実行すると「storage/app/からの相対パス」が返却されます。

そのため返却値の$pathはStorage::url()でURLに直接変換することが出来ます。

画像のアップロード処理をimage、thumbnailでそれぞれ行い、image_urlとthumbnail_urlに保存することでアップロード処理は完了です。

ファイルをアップロードできるか実際に動作を確認してみてください。

スクリーンショット 2020-06-23 22.12.14


表示が上手く出来ない場合は、storage/appディレクトリにも正しくファイルが保存されているか、テンプレートの書き方が正しいか確認してください。

スクリーンショット 2020-06-23 22.13.11


# 記事一覧にサムネイル画像を表示

画像のアップロードが出来るようになったら、次は管理側の記事一覧(home.blade.php)、ユーザーページの記事一覧にサムネイルを表示していきます。

サムネイルが無い場合はimage_urlを使うという仕組みで行います。サムネイルを表示する@foreach〜@endforeachの中だけを抜粋して解説します。

resources/views/home.blade.php

@foreach ($news_list as $news)
<li class="list-group-item">
	<a href="{{ url('news/edit/' . $news->id) }}">
		<h5>{{ $news->title }}</h5>
	</a>
	<div class="row">
		@if($news->thumbnail_url || $news->image_url)
		<div class="col">
			@if($news->thumbnail_url)
			<img src="{{ Storage::url($news->thumbnail_url) }}" style="width: 150px;"/>
			@elseif($news->image_url)
			<img src="{{ Storage::url($news->image_url) }}" style="width: 150px;"/>
			@endif
		</div>
		@endif
		<div class="col">
			<p>{{ $news->description }}</p>
			<p>create: {{ $news->created_at->format("Y-m-d H:i:s") }}</p>
			<p class="text-right">
				<a class="btn btn-outline-secondary" href="{{ url('u/' . $user->display_name . '/' . $news->id) }}">確認</a>
			</p>
		</div>
	</div>
</li>
@endforeach

bootstrapの機能<div class="row">と<div class="col">を使うと簡単に左右のレイアウトが出来るのでそれを使っています。

参考リンク BootstrapのGrid System

@if($news->thumbnail_url || $news->image_url)
<div class="col">
@if($news->thumbnail_url)
<img src="{{ Storage::url($news->thumbnail_url) }}" style="width: 150px;"/>
@elseif($news->image_url)
<img src="{{ Storage::url($news->image_url) }}" style="width: 150px;"/>
@endif
</div>
@endif

thumbnail_url、image_urlのどちらかがあればサムネイルようの<div class="col">を表示し、thumbnailがあればそちらを優先で表示しています。

画像のURLはStorage::url()で変換をかけています。

保存できたらブラウザで記事一覧を表示してみましょう。

スクリーンショット 2020-06-23 22.19.46

サムネイルが上手く表示されていない場合は保存されている値を実際に出力して確認してみましょう。

<p>
{{ $news->thumbnail_url }}<br />
↓<br />
{{ Storage::url($news->thumbnail_url) }}
</p>

スクリーンショット 2020-06-23 22.22.22

管理側に書いた画像表示と同じものをユーザーページにも追加していきます。

resources/views/user_page.blade.php

@foreach ($news_list as $news)
<li class="list-group-item">
	<a href="{{ url('u/' . $user->display_name . '/' . $news->id) }}">
		<h5>{{ $news->title }}</h5>
	</a>
	<div class="row">
		@if($news->thumbnail_url || $news->image_url)
		<div class="col">
			@if($news->thumbnail_url)
			<img src="{{ Storage::url($news->thumbnail_url) }}" style="width: 150px;"/>
			@elseif($news->image_url)
			<img src="{{ Storage::url($news->image_url) }}" style="width: 150px;"/>
			@endif
		</div>
		@endif
		<div class="col">
			<p>{{ $news->description }}</p>
			<p>create: {{ $news->created_at->format("Y-m-d H:i:s") }}</p>
		</div>
	</div>
</li>
@endforeach

処理は管理側と全く同じです。

ブラウザで表示を確認してみましょう。

スクリーンショット 2020-06-23 22.25.17


# 記事詳細画面に画像を表示

記事詳細にも画像を表示するようにします。image_urlのみとするため一覧よりもシンプルになります。

resources/views/user_news_detail.blade.php

@extends('layouts.app')
@section('content')
<div class="container">
	<div class="card">
		<div class="card-header">
			{{ $user->name }}からのお知らせ
		</div>
		<div class="card-body">
			<h5>{{ $news->title }}</h5>
			@if($news->image_url)
			<div>
				<img src="{{ Storage::url($news->image_url) }}" style="width: 100%"/>
			</div>
			@endif
			<p>create: {{ $news->created_at->format("Y-m-d H:i:s") }}</p>
			
			<div>
				{!! nl2br(e($news->body)) !!}
			</div>
			<div class="mt-3 text-center">
				<a href="{{ url('u/' . $user->display_name )}}">一覧に戻る</a>
			</div>
			
		</div>
	</div>
</div>
@endsection

ブラウザで表示確認をしてみましょう。

スクリーンショット 2020-06-23 22.27.11


# まとめ

本稿では画像のアップロードを行い、お知らせアプリの仕上げを行いました。実際にこのアプリを実用出来るレベルまでブラッシュアップを行おうと思えば課題はまだまだありますが、学習課題としては十分なクオリティであり、これが作れるようになれば後はアイディアと実践あるのみ、という気もします。

一連の連載はここで終了します。

現段階はあくまでプロトタイプです。ここからこのアプリを仕上げていくためのアイディアについてまとめておきます。

・記事の削除は確認画面を出してからにする
・記事は削除すると一度ゴミ箱に入る(戻すことも出来る)
・記事に「下書き」「公開」のステータスがある。
・記事作成時にも画像とサムネイルを設定出来るようにする
・画像はアップロードすると(無い場合は)自動でサムネイルを作る
・サムネイル、画像の削除機能を作る
・記事は公開期間を設定出来る

などなど、いくつも考えられますね。

これらのうちのいくつかについては別の記事で書いてみようと思います。


今回作成したお知らせアプリのコードはGitHubで公開しています。


おつかれさまでした!

---







完成まで突っ走る意気込みです。サポートしていただけると非常に嬉しいです。応援よろしくお願いします。