Ikeda's Blog

Laravelでブログサイトを作る 10. テンプレートの作成

はじめに

今回は、サイトのデザインを作成します。
プログラム作ってからだと、「あれ足りない」「これ足りない」という事態が起きやすいので、先に、各URLへアクセスした際に、ただページを表示させるだけのものを作ります。

LaravelはBladeというテンプレートを採用しており、これを使用して構築していきます。
Bladeに限りませんが、継承機能がありますので、これを利用して構築していきます。

おおまかな構成は、以下のようにします。

+ resources/views
  + app.blade.php -> 全体の共通部分
  + front
  | + app.blade.php -> 公開ページの共通部分
  | + article.blade.php -> 記事1件のページ
  | + list.blade.php -> 記事一覧のページ(親子カテゴリ絞り込み等でも同じテンプレートを使う)
  + admin
    + app.blade.php -> 管理ページの共通部分
    + articleEdit.blade.php -> 記事の編集ページ
    + articleList.blade.php -> 記事の一覧ページ
    + parent.blade.php -> 親カテゴリの一覧・編集ページ
    + child.blade.php -> 子カテゴリの一覧・編集ページ

Controller

TOPページのルーティングは、以下のようになっている。

Route::group(['prefix' => '/', 'namespace' => 'App\\Http\\Controllers\\'], function() {
    Route::get('/', 'ArticleController@list')->name('top');
});

なので、TOPページにアクセスした時には、app/Http/Controllers/ArticleController.phpのlistメソッドが実行されます。
ArticleController.phpの内容を、以下のようにします。テンプレートを読み込んで表示するだけです。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class ArticleController extends Controller
{
    /**
     * 一覧画面表示
     */
    public function list()
    {
        // view()で引数としている文字列は、resources/views以下のテンプレートファイルを表しています。
        // 「.」(ドット)でディレクトリが表現されるので、上記の場合は、
        // `resources/views/front/articleList.blade.php`
        // を表示します。
        return view('front.articleList');
    }
}

テンプレート・全体の共通部分

サイト全体の共通部分を作成します。
対象は、resources/views/app.blade.phpです。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    {{--
    ***@yield*** は、子孫テンプレートで設定した値が入ります。
    例えば、タイトルタグ内に`@yield('title')`と記述することで、
    子孫テンプレートでそれぞれに設定して出し分けることが可能となります。
    --}}
    <title>サイトタイトル | @yield('title')</title>

    {{--
    ***asset()*** は、https(またはhttp)からはじまる、public以下のURLを生成してくれます。
    この方法で記述を統一することで、「画像やJS、CSSファイルをAWS S3に置く」などの
    変更が起きた際、`env`を書き換えるだけで済むので、大変便利です。
    --}}
    <link rel="stylesheet" href="{{ asset('/css/style.css') }}">

    <script type="text/javascript" src="{{ asset('/js/jquery.min.js') }}" charset="UTF-8"></script>
    <script type="text/javascript" src="{{ asset('/js/main.js') }}" charset="UTF-8"></script>
    <script src="https://kit.fontawesome.com/207c617789.js" crossorigin="anonymous"></script>
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
</head>

<body>

<header>
    <h1><a href="/"><img src="{{ asset('/img/header.png') }}" alt="サイトタイトル" /></a></h1>
</header>

<main>
    @yield('paging')

    @yield('main')

    <aside>
        @yield('aside')
    </aside>

    @yield('paging')
</main>

<footer>
    フッターテキスト
</footer>

</body>
</html>

テンプレート・公開ページの共通部分

先程のサイト全体(親)に対し、公開ページの共通部分(子)を作成します。
ファイルは、resources/views/front/app.blade.phpです。

{{--
***@extends*** は、継承元となるテンプレートを指定します。
ここでは、全体の共通部分`resources/views/app.blade.php`が対象です。
--}}
@extends('app')

{{--
***@section*** は、上位テンプレートで`@yield`を使用した箇所に挿入する内容を記述します。
@sectionから@endsectionまでの内容が対象となります。
以下の例では、`@yield('aside')`に出力されるサイドメニューの内容を記述しています。
--}}
@section('aside')
    <section class="side">
        <h3>検索</h3>
        <div id="searchWrapper">
            <form action="{{ route('list_search') }}" method="post" name="searchForm">
                {{ csrf_field() }}
                <input type="text" id="searchTxt" name="search" placeholder="サイト内検索" />
                <a href="#" id="searchBtn"><i class="fas fa-search"></i></a>
            </form>
        </div>
    </section>
@endsection

テンプレート・個別ページ

最後に、個別ページ(孫)を作成します。
ファイルは、resources/views/front/articleList.blade.phpです。

{{--
継承元のテンプレートファイルを指定します。
ルールは、Controller同様に「.」(ドット)でディレクトリの区切りです。
以下の場合は、`resources/views/front/app.blade.php`を表しています。
--}}
@extends('front.app')

{{--
@sectionは、一行で記述することも可能です。
以下のように、
--}}
@section('title', '記事一覧')

@section('paging')
    <nav>
        <ul class="pagination">
            <li><a href="/" rel="prev" aria-label="« Previous">&lt;&lt;</a></li>
            <li><a href="/">1</a></li>
            <li><a href="/">2</a></li>
            <li><a href="/">3</a></li>
            <li class="active" aria-current="page"><span>4</span></li>
            <li class="disabled" aria-disabled="true" aria-label="Next »">
                <span aria-hidden="true">&gt;&gt;</span>
            </li>
        </ul>
    </nav>
@endsection

@section('main')
    <article>
        <section class="article">
            <h2><a href="#">記事タイトル</a></h2>

            <p>これはテストです。</p>
            <p>これはテストです。</p>
            <p>これはテストです。</p>
            <p>これはテストです。</p>
            <p>これはテストです。</p>

            <a href="#" class="more">続きを読む</a>
        </section>
        <section class="article">
            <h2><a href="#">記事タイトル</a></h2>

            <p>これはテストです。</p>
            <p>これはテストです。</p>
            <p>これはテストです。</p>
            <p>これはテストです。</p>
            <p>これはテストです。</p>

            <a href="#" class="more">続きを読む</a>
        </section>
        <section class="article">
            <h2><a href="#">記事タイトル</a></h2>

            <p>これはテストです。</p>
            <p>これはテストです。</p>
            <p>これはテストです。</p>
            <p>これはテストです。</p>
            <p>これはテストです。</p>

            <a href="#" class="more">続きを読む</a>
        </section>
        <section class="article">
            <h2><a href="#">記事タイトル</a></h2>

            <p>これはテストです。</p>
            <p>これはテストです。</p>
            <p>これはテストです。</p>
            <p>これはテストです。</p>
            <p>これはテストです。</p>

            <a href="#" class="more">続きを読む</a>
        </section>
        <section class="article">
            <h2><a href="#">記事タイトル</a></h2>

            <p>これはテストです。</p>
            <p>これはテストです。</p>
            <p>これはテストです。</p>
            <p>これはテストです。</p>
            <p>これはテストです。</p>

            <a href="#" class="more">続きを読む</a>
        </section>
    </article>
@endsection

以上で、テンプレートの表示は完了です。
あとは、public/css/style.cssを編集してデザインを整えておきましょう。
http://192.168.56.10/で確認できます。

テンプレート・管理画面の共通部分

なお、管理画面の共通テンプレートは以下のようにしました。
違いとしては、管理画面用のCSSとJavaScriptを読み込むようにしています。
あとは、サイドメニューに各管理機能のリンクを設置しております。

@extends('app')

@section('title')
    @yield('adminTitle')
@endsection

@section('main')
    {{-- 管理画面専用のCSSとJavaScript --}}
    <link rel="stylesheet" href="{{ asset('/css/admin.css?v=').'?'.time() }}">
    <script type="text/javascript" src="{{ asset('/js/admin.js?v=').'?'.time() }}" charset="UTF-8"></script>
    @yield('adminMain')
@endsection

@section('aside')
    <section class="aside">
        <h3>メニュー</h3>
        <ul>
            <li><a href="{{ route('admin_parent') }}">親カテゴリ管理</a></li>
            <li><a href="{{ route('admin_child') }}">子カテゴリ管理</a></li>
            <li><a href="{{ route('admin_article_list') }}">記事一覧</a></li>
            <li><a href="{{ route('admin_article_edit') }}">記事新規作成</a></li>
        </ul>
    </section>
@endsection

Laravelでブログサイトを作る 09. ルーティング

はじめに

ルーティングは、どのURLでアクセスされた時に、どのコントローラの、どのメソッドを呼ぶかを指定します。
URL設計で挙げたURLを、routes/web.phpに記述していくことで設定します。

不要なものを削除(コメントアウト)

routes/web.phpには、動作確認の際に見たwelcomページと、認証機能を導入した際に追加されたdashboardページについての記述が存在しているはずです。
これらは、今後使用する予定が無いので、コメントアウトしてしまいます。

/*
Route::get('/', function () {
    return view('welcome');
});

Route::middleware(['auth:sanctum', 'verified'])->get('/dashboard', function () {
    return view('dashboard');
})->name('dashboard');
*/

管理ページ

管理側はログインしていることがアクセス条件となります。
※認証機能についてはコチラで解説しています。
'middleware' => 'auth'の記述を加えることで、簡単に実現できます。

また、Route::groupで、admin以下のアクセスについての設定をまとめることができます。
管理側のページ1件ずつに上記の記述を書くのは面倒なので、こうしてまとめれば簡単で、かつ「このグループは管理用」というのが分かりやすいです。

// 管理画面(認証後に触れるページ)
Route::group(
    [
        'prefix' => 'admin',
        'middleware' => 'auth',
        'namespace' => 'App\\Http\\Controllers\\'
    ],
    function() {
        // 親カテゴリ管理:一覧(入力欄込)表示
        Route::get('parent', 'ParentCategoryController@list')->name('admin_parent');
        // 親カテゴリ管理:更新処理
        Route::post('parentUpdate', 'ParentCategoryController@update')->name('admin_parent_update');

        // 子カテゴリ管理:一覧(入力欄込)表示
        Route::get('child', 'ChildCategoryController@list')->name('admin_child');
        // 子カテゴリ管理:更新処理
        Route::post('childUpdate', 'ChildCategoryController@update')->name('admin_child_update');

        // 記事:入力フォーム
        Route::get('articleEdit/{id?}', 'ArticleController@edit')->name('admin_article_edit');
        // 記事:更新処理
        Route::post('articleUpdate', 'ArticleController@update')->name('admin_article_update');
        // 画像アップロード
        Route::post('articleImageUpload', 'ArticleController@image')->name('admin_article_image_upload');
        // 記事一覧
        Route::get('articleList', 'ArticleController@adminList')->name('admin_article_list');
        // 記事一覧(親カテゴリ絞り)
        Route::get('articleList/parent/{parent}', 'ArticleController@adminParentList')->name('admin_article_list_parent');
        // 記事一覧(子カテゴリ絞り)
        Route::get('articleList/child/{child}', 'ArticleController@adminChildList')->name('admin_article_list_child');
        // 記事一覧(検索)
        Route::post('articleList/search/', 'ArticleController@adminSearchList')->name('admin_article_list_search');
        // 記事プレビュー
        Route::post('articlePreview', 'ArticleController@preview')->name('admin_article_preview');

        // 「/admin/」にアクセスされたら、記事一覧へ
        Route::get('/', 'ArticleController@adminList')->name('admin_top');
});

小ネタ

prefixの値を変更することで、管理画面のURLを外部から推測されづらいようにします。

たとえは、'prefix' => 'qawsedrftgyhujikolp',としておくと、管理画面のURLは「https://ドメイン/qawsedrftgyhujikolp/」となるので、URLを直接指定されるようなアクセスを防ぐことができます。

フロントページ

それぞれに->name('XXXXX')を付けることで、後から利用しやすくします。
詳しくはbladeテンプレートを作成する際に記載しますが、href="{{ route('XXXXX') }"のように記述することで、URLを生成してくれるのです。
例えば、「親カテゴリ一覧のURLを/p/{id}にしよう」となった時、routes/web.phpを修正し、nameの方はそのままにしておけば、出現箇所すべてを修正する必要はなくなるのです。

// フロントページ
Route::group(['prefix' => '/', 'namespace' => 'App\\Http\\Controllers\\'], function() {
    // 一覧(http://ドメイン/)
    Route::get('/', 'ArticleController@list')->name('top');

    // 記事1件表示(http://ドメイン/content/XX)
    Route::get('content/{id}', 'ArticleController@content')->name('content');

    // 親カテゴリ別記事一覧(http://ドメイン/parent/XX/)
    Route::get('parent/{id}', 'ArticleController@parentL')->name('list_parent');

    // 子カテゴリ別記事一覧(http://ドメイン/child/XX/)
    Route::get('child/{id}', 'ArticleController@childList')->name('list_child');

    // 検索結果一覧(http://ドメイン/search/)
    Route::post('search/', 'ArticleController@searchList')->name('list_search');
});

Laravelでブログサイトを作る 08. テーブル生成

設計について

設計はデータベース定義にて行っております。
これを、migrationに落とし込み、テーブルを作成します。
Laravelのmigrationについてはコチラを参考に。

親カテゴリmigrationファイル

createするテーブルのカラムについては、upメソッド内で設定します。
主キーを、デフォルトの$table->id();ではなく、increments('id')としています。
これは、$table->id()を使ってしまうと、BIGINT型になってしまうからです。
BIGINTの方が大量の上限値が上ですが、符号ありで考えても
INT型:約21億
BIGINT型:約922京(1000兆の922倍)
なので、個人運営サイトでわざわざBIGINTにするほどではないかなぁ、という考えです。
(とはいえ、ギリギリを詰めなければならない状況でもないので、MEDIUMINT型にもしていません)

対象ファイル:database/migrations/XXXX_XX_XX_XXXXXX_create_parent_categories_table.php

// ...省略...
    public function up()
    {
        Schema::create('parents', function (Blueprint $table) {
            $table->increments('id'); // 主キー(自動採番のINT型)
            $table->text('name'); // カテゴリ名
            $table->integer('number')->index(); // 表示順
            $table->softDeletes(); // ソフトデリートのためにNULL値可能なdeleted_at TIMESTAMPカラム追加
            $table->timestamps(); // NULL値可能なcreated_atとupdated_atカラム追加
        });
    }
// ...省略...

子カテゴリmigrationファイル

対象ファイル:database/migrations/XXXX_XX_XX_XXXXXX_create_child_categories_table.php

// ...省略...
    public function up()
    {
        Schema::create('parents', function (Blueprint $table) {
            $table->increments('id'); // 主キー(自動採番のINT型)
            $table->text('name'); // 子カテゴリ名
            $table->integer('number')->index(); // 表示順
            $table->integer('parent'); // 親カテゴリID
            $table->softDeletes(); // ソフトデリートのためにNULL値可能なdeleted_at TIMESTAMPカラム追加
            $table->timestamps(); // NULL値可能なcreated_atとupdated_atカラム追加
        });
    }
// ...省略...

記事migrationファイル

対象ファイル:database/migrations/XXXX_XX_XX_XXXXXX_create_articles_table.php

// ...省略...

public function up()
    {
        Schema::create('articles', function (Blueprint $table) {
            $table->increments('id'); // 主キー(自動採番のINT型)
            $table->text('title'); // 記事タイトル
            $table->text('body'); // 本文
            $table->integer('parent')->index(); // 親カテゴリID
            $table->softDeletes(); // ソフトデリートのためにNULL値可能なdeleted_at TIMESTAMPカラム追加
            $table->timestamps(); // NULL値可能なcreated_atとupdated_atカラム追加

        });
    }

// ...省略...

記事-子カテゴリmigrationファイル

対象ファイル:database/migrations/XXXX_XX_XX_XXXXXX_create_article_children_table.php

// ...省略...
    public function up()
    {
        Schema::create('parents', function (Blueprint $table) {
            $table->increments('id'); // 主キー(自動採番のINT型)
            $table->integer('article')->index(); // 符記事ID
            $table->integer('children')->index(); // 子カテゴリID
            $table->softDeletes(); // ソフトデリートのためにNULL値可能なdeleted_at TIMESTAMPカラム追加
            $table->timestamps(); // NULL値可能なcreated_atとupdated_atカラム追加
        });
    }
// ...省略...

テーブル作成

サーバに接続し、以下のコマンドを実行します。

$ php artisan migrate

先述の4ファイルそれぞれで、MigratingMigratedが表示され、テーブルが作成されます。
MySQLでテーブル一覧を表示すると、作成されていることが確認できます。

$ mysql -u root -p
Enter password: [コメント]設定したパスワードを入力(過去記事では`NEW_pass_123`を設定)

mysql> use blog
mysql> show tables;
+------------------------+
| Tables_in_blog         |
+------------------------+
| article_children       |
| articles               |
| children               |
| failed_jobs            |
| migrations             |
| parents                |
| password_resets        |
| personal_access_tokens |
| sessions               |
| team_invitations       |
| team_user              |
| teams                  |
| users                  |
+------------------------+
13 rows in set (0.00 sec)

Modelの編集

app/Models以下にあるファイルを、テーブルの構成に合わせて編集します。
記事(Article.php)をサンプルにします。

  1. 論理削除のため、SoftDeletesを追加
  2. 主キーのカラム名を追加
  3. INSERTやUPDATE時に、入力値を保存するカラムを指定
  4. 日時関係のカラムを指定
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; // 【追加】1.論理削除用

class Article extends Model
{
    use HasFactory;
    use SoftDeletes; // 【追加】1.論理削除用

    // 【追加】2.主キー
    protected $primaryKey = 'id';

    // 【追加】3.入力値用カラム指定
    protected $fillable = ['title', 'body', 'parent'];

    // 【追加】2.日時系カラム
    protected $dates = ['created_at', 'updated_at', 'deleted_at'];
}

Laravelでブログサイトを作る 06. Laravelに認証機能を導入

はじめに

前回の作業を完了し、http://192.168.56.10/にアクセスすると、Laravelのwelcome画面が表示されることを前提に進めます。

導入

認証には、Laravel Jetstreamを使用します。
インストールは、composerとnpmを使用して行います。

# [コメント]サーバ内の作業ディレクトリへ移動します
$ cd /home/vagrant/blog

# [コメント]インストール実行
$ composer require laravel/jetstream
$ php artisan jetstream:install livewire --teams
$ npm install
$ npm run dev

# [コメント]必要なテーブルを作成します
$ php artisan migrate

ユーザの登録

http://192.168.56.10/registerにアクセスすると、登録画面が表示されます。

名前、メールアドレス、パスワードを入力し、「REGISTER」ボタンをクリックすれば登録は完了です。

http://192.168.56.10/loginからログインできるか試してみましょう。

2要素認証

2要素認証(メールアドレスとパスワードの知識要素と、スマートフォンを使った所有要素)でのログイン制御が可能です。それも、かなり簡単に。

1. アプリインストール【スマートフォン側で作業】

公式が案内している、「Google Authenticator application」をスマートフォンにインストールします。
Android:Google認証システム
iPhone:Google Authenticator
名前は違いますが、どちらも同様のアプリ、のはずです。(私がiPhoneを所有していないので、そちらは未確認です)
インストールしただけではまだ何もできないので、そのままスマートフォンは一旦置いておきます。

2. サイトのユーザ設定を変更【PC側で作業】

ログイン後、ダッシュボードの右上から「Profile」を選択します。

「Two Factor Authentication」のところで、ENABLEをクリックします。

3. アプリを起動し、QRコード読み込み【スマートフォン側で作業】

アプリを起動し、「QRコードをスキャン」を選択します。
後は、上記2の後に表示されたQRコードを、カメラで読み取ります。

「アカウント追加」ボタンを押しておけば、アプリにこのサイトが記憶されます。
これで、準備は完了です。

4. ログインしてみる【PC側で作業】

試しに、一度ログアウトして、もう一度ログインします。
メールアドレスとパスワードを入力してログインボタンを押すと

コードの入力画面が表示されます。

ここに、アプリで表示される6桁の数字を入力することで、初めてログインが完了するようになりました。

ユーザの登録をできないようにする

今回制作するのは、自分だけが管理画面にアクセスできれば良いので、登録画面へのアクセスをできなくしてしまいます。
config/fortify.phpの最後の方にある、Features::registration(),の行をコメントアウトしてしまいます。
すると、登録画面にアクセスしても、404 NOT FOUNDになります。
他のメニューも、必要に応じてコメントアウトしても構わないでしょう。

    'features' => [
        //Features::registration(), // 登録。今回コメントアウトするもの。
        Features::resetPasswords(), // パスワードリセット。
        // Features::emailVerification(), // メール認証。デフォルトでコメントアウト済み。
        Features::updateProfileInformation(), // プロフィール更新。
        Features::updatePasswords(), // パスワードの更新。
        Features::twoFactorAuthentication([ // 2段階認証
            'confirmPassword' => true,
        ]),
    ],