自動化無しに生活無し

WEBとかAIとかLinux関係をひたすら書く備忘録系ブログ

初心者でもlaravel 7.x を使い、45分でCRUD簡易掲示板を作る【Restful対応】

thumbnail

リハビリがてらlaravelでCRUDに対応した簡易掲示板を作る。この記事の手順に沿ってやれば、45分もあれば作れる。

本記事ではlaravel 7.X系を使用している。

流れ

  1. プロジェクトを作る
  2. DBはSQliteを読み込むように設定する
  3. Restful対応コントローラーを作る
  4. ルーティングの設定
  5. モデル定義とマイグレーション実行
  6. リクエストを作る
  7. ビューを作る
  8. コントローラーの修正
  9. タイムゾーンの修正

プロジェクトを作る

composer create-project --prefer-dist laravel/laravel laravel_crud_restful

コンポーザーコマンドを実行してプロジェクトを作る。

DBはSQliteを読み込むように設定する

デフォルトではMySQLが設定されているので、SQLiteを使うようにする。まずは下記コマンドでdatabase.sqliteファイルを作る。このファイル名(database.sqlite)の通りに作らないと動かないので注意。

touch ./database/database.sqlite

続いて、.envファイルの9行目付近にあるMySQL設定を全てコメントアウト、SQLiteを読み出すよう設定する。

# DB_CONNECTION=mysql
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
DB_CONNECTION=sqlite

これでDBの準備は完了。

Restful対応コントローラーを作る

make:controllerコマンドを使用してコントローラーを作る。今回はRestfulに対応したコントローラーを作るため、--resourceオプションを指定することを忘れなく。

php artisan make:controller TopicsController --resource

ルーティングの設定

--resourceオプションで作ったコントローラーをルーティング設定する際には、下記一行を追加すれば良いだけ。

Route::resource('/topics', 'TopicsController');

resource()メソッドを使用するだけでアクションとURIの対応付けをまとめてやってくれる。そのためresource()メソッドの第二引数に@index等のアクション名を指定する必要はない。

ちなみに、この–resourceを実行することで、まとめて書かれたルーティング情報、分解して記述したい場合は、下記記事に倣って、このように書く。

Laravelで–resourceで作ったコントローラのルーティングを解体する

#Route::resource('/topics', 'TopicsController');

# ↑と↓は等価

Route::get('/topics', 'TopicsController@index')->name('topics.index');
Route::get('/topics/create', 'TopicsController@create')->name('topics.create');
Route::post('/topics', 'TopicsController@store')->name('topics.store');
Route::get('/topics/{id}', 'TopicsController@show')->name('topics.show');
Route::get('/topics/{id}/edit', 'TopicsController@edit')->name('topics.edit');
Route::put('/topics/{id}', 'TopicsController@update')->name('topics.update');
Route::delete('/topics/{id}', 'TopicsController@destroy')->name('topics.destroy');

これは、VScodeのスニペットにも登録しておくと即変換ができて便利で良いだろう。VScodeのスニペット登録の方法は下記記事を参照。

VisualStudioCode(VScode)を使う前にやっておきたい設定と覚えておくと良い操作方法

モデル定義とマイグレーション実行

下記コマンドを実行して、Topicモデルを作る。--migrationコマンドでマイグレーションファイルも同時に作る。

php artisan make:model Topic --migration

まず、モデルの定義から。app/Topic.phpを下記のように編集する。

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Topic extends Model
{
    protected $fillable = [
        "name","content"
    ];
}

モデルの役割は、コントローラーがクライアントからデータを受け取った後、受け取ったデータをDBに入れてよいかどうかを決めること。上記のコードはnamecontentのみクライアントからの編集を許可する。それ以外のデータは受け取った時に全て破棄する。

続いて、マイグレーションファイルを編集する。database/migrations/[タイムスタンプ]_create_topics_table.phpにテーブルのカラムと挿入するデータを記述する。[タイムスタンプ]はマイグレーションファイルを作った日時なので、適宜解釈するように。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateTopicsTable extends Migration
{
    /** 
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {   
        Schema::create('topics', function (Blueprint $table) {
            $table->id();
            $table->string("name",10);
            $table->string("content",200);
            $table->timestamps();
        });
    }   

    /** 
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {   
        Schema::dropIfExists('topics');
    }   
}

上記コードにより、topicsテーブルにオートインクリメント数値型のid、10文字までのname、200文字までのcontenttimestamps()で投稿日を意味するcreated_at、編集日を意味するupdated_atの5つのカラムが作られる。

コード下の方に書かれてあるdown()メソッドはマイグレーションを元に戻すときのテーブル削除処理。そのままで良い。

定義したマイグレーションファイルに基づき、マイグレーションを実行する。

php artisan migrate

リクエストを作る

ユーザーから受け取ったリクエストをバリデーション(検証)して、DBに格納させるか否かを判定する。それがLaravelのリクエスト。下記コマンドを実行してリクエストを作る。

php artisan make:request CreateTopicRequest

続いて、app/Http/Requests/CreateTopicRequest.phpを下記のように編集

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class CreateTopicRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        #↓trueに書き換える
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'name'      => 'required|max:15',
            'content'   => 'required|max:2000',
        ];
    }
    public function messages() {
        return [
            'name.required'     => '名前を入力してください',
            'name.max'          => '名前は15文字でお願いします。',
            'content.required'  => 'コメントを入力してください',
            'content.max'       => 'コメントは2000文字でお願いします。',
        ];
    }
}

バリデーション対象とバリデーションルール、バリデーション失敗したときのメッセージが表示される。

ビューを作る

Restful化に対応させるため、作る必要のあるビューが5つある。ただ、今回はテンプレートの継承機能を使うので、1ファイル当たりのコード行数は30行程度。

まず、resources/views/base.blade.phpを作る。このbaseを継承して、残り4つのファイルを作る。

<!DOCTYPE html>
<html lang="ja">
<head>
	<meta charset="UTF-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
	<title>簡易掲示板</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
    @yield("extra_head")

</head>
<body>
    
    <a href="{{ route('topics.index') }}"><h1 style="background:orange;color:white;text-align:center;">簡易掲示板</h1></a>
    <main class="container">@yield("main")</main>

</body>
</html>

ディレクティブの@yield([任意の文字列])を記述することで、その部分は継承したビューが自由に内容を追加することができる。例えば、上記baseを継承したindex@yield("main")の部分にトップページである旨を表示させることが可能になる。

resources/views/index.blade.phpは下記のように記述する。

@extends("base")

@section("main")

<a class="btn btn-outline-success"  href="{{ route('topics.create') }}">+ トピックを作る</a>

@forelse( $topics as $topic )
<div class="border my-2 p-2">
    <div class="text-secondary">{{ $topic->name }} さん</div>
    <div class="p-2">{!! nl2br(e($topic->content)) !!}</div>
    <div class="text-secondary">投稿日:{{ $topic->created_at }}</div>
    <div class="text-secondary">編集日:{{ $topic->updated_at }}</div>
    <a class="btn btn-outline-primary" href="{{ route('topics.show',$topic->id) }}">詳細</a>
    <a class="btn btn-outline-success" href="{{ route('topics.edit',$topic->id) }}">編集</a>
    <form action="{{ route('topics.destroy',$topic->id) }}/" method="POST" style="display:inline-block;">
        {{ csrf_field() }}
        {{ method_field("delete") }}
        <button class="btn btn-outline-danger" type="submit">削除</button>
    </form>
</div>
@empty
<p>投稿はありません</p>
@endforelse

@endsection

冒頭に、@extends()で継承元になるbaseを指定する。続いて、@section()mainを指定する。base.blade.phpには@yield("main")と指定してある部分に当たる。つまりmainタグの中に@section("main")から@endsectionまでの内容が入るのだ。

ちなみに、@forelse()以下はコントローラーが受け取った内容を全て記述している。この処理はまだ書いていないので次項にて解説する。

削除ボタンのmethod_field("DELETE")はフォーム送信時HTTPリクエストのDELETEメソッドを使用してリクエストを送る指定をしている。

続いて、resources/views/create.blade.phpを作る。こちらもbaseを継承して作っている。

@extends("base")

@section("main")

@if( count($errors) )
<ul>
    @foreach($errors->all() as $error)
    <li>{{ $error }}</li>
    @endforeach
</ul>
@endif

<form class="" action="{{ route('topics.store') }}" method="POST">
    {{ csrf_field() }}
    <input class="form-control" type="text" name="name" placeholder="名前">
    <textarea id="" class="form-control" name="content" rows="4" placeholder="コメント"></textarea>
    <input class="form-control" type="submit" value="送信">
</form>
@endsection

createは新しいトピックを作るフォーム。inputタグ等のname属性はマイグレーションフィールドで定義したカラムに基づいている。

ここでバリデーションに失敗したときのメッセージを表示させている。

続いて、resources/views/show.blade.phpを作る。こちらもbaseを継承して作っている。

@extends("base")

@section("main")

@forelse($topics as $topic )
<div class="border my-2 p-2">
    <div class="text-secondary">{{ $topic->name }} さん</div>
    <div class="p-2">{!! nl2br(e($topic->content)) !!}</div>
    <div class="text-secondary">投稿日:{{ $topic->created_at }}</div>
</div>
@empty
<p>ありません。</p>
@endforelse

@endsection

showは個別ページを表示する。

続いて、resources/views/edit.blade.phpを作る。こちらもbaseを継承して作っている。

@extends("base")

@section("main")

@if( count($errors) )
<ul>
    @foreach($errors->all() as $error)
    <li>{{ $error }}</li>
    @endforeach
</ul>
@endif

@forelse($topics as $topic )
<form action="{{ route('topics.update',$topic->id) }}" method="POST">
    {{ csrf_field() }}
    {{ method_field("put") }}
    <input class="form-control" type="text" name="name" placeholder="名前" value="{{ $topic->name }}">
    <textarea id="" class="form-control" name="content" rows="4" placeholder="コメント">{{ $topic->content }}</textarea>
    <input class="form-control" type="submit" value="送信">
</form>
@empty
<p>ありません。</p>
@endforelse

@endsection

その名の通り、編集用のフォーム。method_field("PUT")はフォーム送信時HTTPリクエストのPUTメソッドを使用してリクエストを送る指定をしている。

作成時と同様に入力値にエラーがあった時、エラー文を表示させている。

これで一通りのビューは完成した。後はこのビューに値を入れるコントローラーを修正していく。

コントローラーの修正

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Topic;
use App\Http\Requests\CreateTopicRequest;

class TopicsController extends Controller
{
    public function index()
    {
        $topics     = Topic::latest()->get();
        $context    = [ "topics" => $topics ];

        return view("index",$context);
    }
    public function create()
    {
        return view("create");
    }
    public function store(CreateTopicRequest $request)
    {
        Topic::create($request->all());

        return redirect(route("topics.index"));
    }
    public function show($id)
    {
        $context            = []; 
        $context["topics"]  = Topic::where("id",$id)->get();

        #HACK:↑と↓は等価。
        #contextに仕込むモデルオブジェクトが増えるたび、context定義時の行数が増えるので、混乱を防ぐために↑のやり方のほうが良いかもしれない。

        /*            
        $topics     = Topic::where("id",$id)->get();
        $context    = [ "topics" => $topics ];
        */

        return view("show",$context);
    }
    public function edit($id)
    {
        $topics     = Topic::where("id",$id)->get();
        $context    = [ "topics" => $topics ];

        return view("edit",$context);
    }
    public function update(CreateTopicRequest $request, $id)
    {

        #編集処理のベストプラクティス
        Topic::find($id)->update($request->all());

        #HACK:このやり方ではモデルフィールドが増えると対処しきれない。
        /*
        $topic  = Topic::find($id);
        $topic->name    = $request->name;
        $topic->content = $request->content;
        $topic->save();
        */

        return redirect(route("topics.index"));
    }
    public function destroy($id)
    {

        #削除のベストプラクティス
        Topic::destroy($id);

        #HACK:もっと短く書ける
        #Topic::find($id)->delete();

        #HACK:deleteメソッドはつなげ書いても良い。
        /*
        $topic  = Topic::find($id);
        $topic->delete();
        */

        return redirect(route("topics.index"));
    }
}

長いのでそれぞれの処理内容は省略し、共通するものだけ解説する。

Topic::latest()->get();はDBからデータを全て抜き取り、最新が上になるように並び替える。間にwhere()メソッドが入ることで、絞り込みができる。find()メソッドに$idを指定することで一意に特定もできる。

create及びupdateはリクエストのバリデーションを経由しているので、不適切な値であればフォーム入力欄にそのままエラー文が表示される仕組みになっている。

モデルのメソッドには、save()メソッド、delete()メソッドが用意されている。それぞれ、指定した値でデータを保存、削除することができる。

route("topics.index")は名前解決によって、topicsを意味する。route/web.phpに定義した内容に基づいている。Route::resource("/topics", "TopicsController");のみだが、resource()メソッドにより、下記の名前とURIが対応付けられているのだ。

+--------+-----------+---------------------+----------------+-----------------------------------------------+------------+
| Domain | Method    | URI                 | Name           | Action                                        | Middleware |
+--------+-----------+---------------------+----------------+-----------------------------------------------+------------+
|        | GET|HEAD  | /                   |                | Closure                                       | web        |
|        | GET|HEAD  | api/user            |                | Closure                                       | api        |
|        |           |                     |                |                                               | auth:api   |
|        | GET|HEAD  | topics              | topics.index   | App\Http\Controllers\TopicsController@index   | web        |
|        | POST      | topics              | topics.store   | App\Http\Controllers\TopicsController@store   | web        |
|        | GET|HEAD  | topics/create       | topics.create  | App\Http\Controllers\TopicsController@create  | web        |
|        | GET|HEAD  | topics/{topic}      | topics.show    | App\Http\Controllers\TopicsController@show    | web        |
|        | PUT|PATCH | topics/{topic}      | topics.update  | App\Http\Controllers\TopicsController@update  | web        |
|        | DELETE    | topics/{topic}      | topics.destroy | App\Http\Controllers\TopicsController@destroy | web        |
|        | GET|HEAD  | topics/{topic}/edit | topics.edit    | App\Http\Controllers\TopicsController@edit    | web        |
+--------+-----------+---------------------+----------------+-----------------------------------------------+------------+

つまり、Route("topics.index")topicsを返し、Route("topics.create")topics/createを返すのだ。{topic}は引数になっており、ここには数値が入る。この引数指定により、ビューにて、{{ route('topics.show',$topic->id) }}と指定して、idに応じたパスを返すことができる。

タイムゾーンの修正

最後に、日付の問題を解決するため、config/app.phpのタイムゾーンを修正する。

'timezone' => 'Asia/Tokyo',

これで日本時間に設定できた。

実際に動かしてみる

開発用サーバーを起動する。

php artisan serve
簡易掲示板

編集したら編集日時が記録されるようになっている。

結論

laravelには『Web職人のためのフレームワーク』というキャッチコピーがあるが、調べれば調べるほど開発メソッドの選択肢が多く存在する。初学者からしてみれば選択肢の多さは、学習進捗に関わるので、ベストプラクティスのようなものがあると良いのだが。

とは言え、Djangoよりも自由度が高く、編集と作成の日時がまとめて指定できるtimestamps()は良いと思った。

だが、timestamp型はデプロイするときにMySQLを使うと2038年問題を引き起こしてしまう。Laravelのデプロイ時にはDBはMySQLではなく、PostgreSQLを使いましょう。

スポンサーリンク

シェアボタン

Twitter LINEで送る Facebook はてなブログ