Open7

【挑戦】Laravel 11 がリリースされたのでメモアプリを作ってみる

DaiNakaDaiNaka

はじめに

今回の作ってみるシリーズは、Laravel 11がリリースされたので、基本機能などを触れつつメモアプリの作成に再挑戦していきたいと思います。

アーカイブ

No 記事 デモサイト
1 【挑戦】Laravel 8 でLINEの様なチャットサービスを作ってみた https://chat-app.dainaka.live/
2 【挑戦】Laravel 8 で簡易的な掲示板を作ってみた https://bbs-app.dainaka.live/
3 【挑戦】Laravel 8 でEvernoteの様なメモアプリを作ってみる 😢完成に至らず
4 【挑戦】Laravel 9 でポートフォリオを作ってみた https://portfolio.dainaka.live/
5 【挑戦】Laravel 9 でとてもシンプルなブログシステムを作ってみた https://blog-app.dainaka.live/
6 【挑戦】Laravel 10 でチームで使えるタスク管理システムを作ってみた https://task-app.dainaka.live/
7 【挑戦】Zennで書いた記事をLaravel 10で作ったページに連携してみた https://article-app.dainaka.live/

開発環境

  • Docker 4.27.1
  • VS Code

利用言語

  • PHP PHP 8.3.3
  • Laravel 11.0.0

公開コード[GitHub]

https://github.com/DaiNaka1207/memo-app

注意事項

ここで説明しているコマンドプロンプトで扱うコマンドですが、エイリアスを設定しておりXAMPPの時と同じようなコマンドで実行できるようにしています。
エラーが吐かれる場合には、phpvendor/laravel/sail/bin/sailに置き換えて実行してみてください。
例:php artisan route:list → vendor/laravel/sail/bin/sail artisan route:list

DaiNakaDaiNaka

Laravel 11のインストール

~\Project\memo-appにLaravel 11をインストールしたいと思います。
Projectがカレントディレクトリの状態でcurl -s https://laravel.build/memo-app | bashを実行します。

~ここで数十分は経過~

初起動

いったん、localhostへアクセスしてみるもエラーが発生してダメなようす。

環境設定

ひとまず.envファイルの中身を確認してみるとDB_DATABASEがコメントアウトしている

調査開始

調査するにも何から調べたら良いのか・・・🤔

  • コメントアウトを解除して起動 🤔変わらず
  • mysqlでデータベースにアクセスができるか確認 😀アクセスできた
  • show databases;を実行してデータベースmemo_appがあるか確認 🤔出来ていない

アプローチを変更して、.envのとおりにデータベースが作成されない方法を調査したところ、下記のサイトを発見してコメントアウトを解除した後にビルドを開始。
https://qiita.com/kiyoshi999/items/a7d9a12bb638e9c5c1c3

sail down -vでいったんコンテナを削除
sail up -d --buildでビルドし直し

起動してみた結果、ここまで表示された😀

php artisan migrateを実行してデータベースを構築

再度、localhostへアクセスして起動したところ、やっとできた!!

データベースを使わない人はどうするのだろう・・・🤔

DaiNakaDaiNaka

機能説明

  • ユーザー毎にメモが残せる
  • メモを一覧で表示することができる(ダッシュボード)
  • メモを登録することができる
  • メモを更新することができる
  • メモを削除することができる
  • メモを検索することができる
  • メモをPDFにして保存することができる

作っていく手順

  • ログイン認証機能の追加(Jetstream:ダッシュボードも付属)
  • ダッシュボードにメニューを追加
  • メモの登録画面の作成
  • メモの一覧表示機能の追加
  • メモの編集機能の追加
  • メモの削除機能の追加
  • メモのPDF保存機能の追加
DaiNakaDaiNaka

ログイン認証機能の追加

ログイン認証にはJetstreamを使っていきたいと思います。
https://jetstream.laravel.com/introduction.html

上記ページのInstallationページを参考にして進めます。
コマンドプロンプトにてプロジェクトフォルダ~\Project\memo-appをカレントディレクトリにして、下記のコマンドを順に実行します。

Jetstreamインストール手順

  1. composer require laravel/jetstream
  2. php artisan jetstream:install livewire
  3. npm install
  4. npm run build

2を実行した時に選択肢が表示されたらYesを選択することで、認証関係のデータベースがマイグレーションされます。

ユーザー登録を試行

トップページの右上にLog inRegisterが表示される

Registerに遷移すると下記の画面が表示されるので、必要事項を入力

ダッシュボードはこんな感じ

DaiNakaDaiNaka

ダッシュボードにメニューを追加

JetstreamTailwind CSSが使われているため、Prelineというプラグインを使っていきます。
https://preline.co/index.html

Laravelへのインストール手順

https://preline.co/docs/frameworks-laravel.html

tailwind.config.js
import defaultTheme from 'tailwindcss/defaultTheme';
import forms from '@tailwindcss/forms';
import typography from '@tailwindcss/typography';

/** @type {import('tailwindcss').Config} */
export default {
    content: [
        './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
        './vendor/laravel/jetstream/**/*.blade.php',
        './storage/framework/views/*.php',
        './resources/views/**/*.blade.php',
+       'node_modules/preline/dist/*.js',
    ],

    theme: {
        extend: {
            fontFamily: {
                sans: ['Figtree', ...defaultTheme.fontFamily.sans],
            },
        },
    },

    plugins: [
        forms,
        typography,
+       require('preline/plugin'),
    ],
};
app.js
  import './bootstrap';
+ import 'preline';

ダッシュボードに新規作成ボタンを追加

dashboard.blade.php
<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Dashboard') }}
        </h2>
    </x-slot>

-   <div class="py-12">
-       <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
+       <div class="mt-2 max-w-7xl mx-auto sm:px-6 lg:px-8">
-           <div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
-               <x-welcome />
-           </div>
+           <button type="button" class="py-3 px-4 inline-flex items-center gap-x-2 text-sm font-semibold rounded-lg border border-transparent bg-blue-100 text-blue-800 hover:bg-blue-200 disabled:opacity-50 disabled:pointer-events-none dark:hover:bg-blue-900 dark:text-blue-400 dark:focus:outline-none dark:focus:ring-1 dark:focus:ring-gray-600">
+               新規作成
+           </button>
-       </div>
    </div>
</x-app-layout>

画面キャプチャ

こんな感じになります。

DaiNakaDaiNaka

メモの登録画面の作成

画面を作っていくにあたり、メモのデータベースを考える必要があるので、ここで書き出しておきます。
大したものは用意していません😄

+------------+-----------------+------+-----+---------+----------------+
| Field      | Type            | Null | Key | Default | Extra          |
+------------+-----------------+------+-----+---------+----------------+
| id         | bigint unsigned | NO   | PRI | NULL    | auto_increment |
| user_id    | int             | NO   |     | NULL    |                |
| subject    | varchar(100)    | NO   |     | NULL    |                |
| content    | text            | YES  |     | NULL    |                |
| created_at | timestamp       | YES  |     | NULL    |                |
| updated_at | timestamp       | YES  |     | NULL    |                |
| deleted_at | timestamp       | YES  |     | NULL    |                |
+------------+-----------------+------+-----+---------+----------------+

モデルファイルを生成

モデルファイルと合わせて、コントローラー、マイグレーションのファイルも作成しています。
また、モデルにはソフトデリートの機能を設定していきます。

コマンドプロンプトにてphp artisan make:model Memo -crmを実行します。
後ろの方に記載されている-crmにコントローラーやマイグレーションのファイルも作成することを指定しています。ご参考はLaravel公式をご覧ください。

モデルファイルにソフトデリートを記述

Memo.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;

class Memo extends Model
{
    use HasFactory;
+   use SoftDeletes;
}

マイグレーションを記述

yyyy_mm_dd_HHMMSS_create_memos_table.php
<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('memos', function (Blueprint $table) {
            $table->id();
+           $table->integer('user_id');
+           $table->string('subject', 100);
+           $table->text('content')->nullable();
            $table->timestamps();
+           $table->softDeletes();
        });
    }

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

登録画面への遷移

まずは、ダッシュボードにある新規作成ボタンから画面遷移ができるようにします。
MemoControllerはリソースで出力しているため、基本的なCRUDが含まれているので、ここでは、memo.createに遷移するようにします。

dashboard.blade.php
    <div class="mt-2 max-w-7xl mx-auto sm:px-6 lg:px-8">
-       <button type="button" class="py-3 px-4 inline-flex items-center gap-x-2 text-sm font-semibold rounded-lg border border-transparent bg-blue-100 text-blue-800 hover:bg-blue-200 disabled:opacity-50 disabled:pointer-events-none dark:hover:bg-blue-900 dark:text-blue-400 dark:focus:outline-none dark:focus:ring-1 dark:focus:ring-gray-600">新規作成</button>
+       <button type="button" onclick="location.href='{{route('memo.create')}}'" class="py-3 px-4 inline-flex items-center gap-x-2 text-sm font-semibold rounded-lg border border-transparent bg-blue-100 text-blue-800 hover:bg-blue-200 disabled:opacity-50 disabled:pointer-events-none dark:hover:bg-blue-900 dark:text-blue-400 dark:focus:outline-none dark:focus:ring-1 dark:focus:ring-gray-600">新規作成</button>
    </div>

次にルーティングの設定を行います。
ここでもリソースでルーティングの設定を行います。

web.php
<?php

use Illuminate\Support\Facades\Route;
+use App\Http\Controllers\MemoController;

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

+Route::resource('memo', MemoController::class);

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

エラーが起きないようにコマンドプロンプトにてphp artisan route:cacheを行っておきましょう。

コントローラーの修正

新規作成ボタンが押されたらcreateが実行されるようになっているので、ユーザーデータを取得して画面を表示するようにします。

MemoController.php
-    //
+    public function create()
+    {
+        $user = auth()->user();
+        return view('memo.index', compact('user'));
+    }

登録画面の作成

views\memo\index.blade.phpファイルを作成します。

そして、下記のとおり記述します。

index.blade.php
<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Dashboard') }}
        </h2>
    </x-slot>

    <form action="{{route('memo.store')}}" method="post" class="mt-5 max-w-7xl mx-auto sm:px-6 lg:px-8">
        @csrf
        <input type="hidden" name="user_id" value="{{$user->id}}">
        <input type="text" name="subject" class="py-3 px-4 mb-3 block w-full border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600" placeholder="タイトル" autofocus required>
        <div class="relative">
            <textarea name="content" class="py-3 px-4 block w-full h-48 border-gray-200 rounded-lg text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:focus:ring-gray-600" rows="3" placeholder="本文"></textarea>
        </div>
        <div class="text-right">
            <button type="button" onclick="location.href='{{route('dashboard')}}'" class="py-3 px-4 mr-3 inline-flex items-center gap-x-2 text-sm font-semibold rounded-lg border border-transparent bg-yellow-100 text-yellow-800 hover:bg-yellow-200 disabled:opacity-50 disabled:pointer-events-none dark:hover:bg-yellow-900 dark:text-yellow-500 dark:hover:text-yellow-400">戻る</button>
            <button type="submit" class="py-3 px-4 mt-3 inline-flex items-center gap-x-2 text-sm font-semibold rounded-lg border border-transparent bg-teal-100 text-teal-800 hover:bg-teal-200 disabled:opacity-50 disabled:pointer-events-none dark:hover:bg-teal-900 dark:text-teal-500 dark:hover:text-teal-400">送信</button>
        </div>
    </form>
</x-app-layout>

戻るが押されるとダッシュボードに戻るようにしています。
送信が押されるとデータベースへ登録するようにします。

データベースへの登録

送信が押されるとstoreが実行されるようになっているので、フォームに入力されたものをデータベースへ登録していきます。

MemoController.php
    public function store(Request $request)
    {
-        //
+        $memo = new Memo;
+        $memo->fill($request->all())->save();
+        return redirect(route('dashboard'))->with('message', 'メモが登録されました。');
    }

最後に登録したことをメッセージとしてダッシュボードに表示するようにします。

dashboard.blade.php
    <div class="mt-2 max-w-7xl mx-auto sm:px-6 lg:px-8">
+       @if (session('message'))
+           <div class="mt-2 mb-2 bg-teal-100 border border-teal-200 text-sm text-teal-800 rounded-lg p-4 dark:bg-teal-800/10 dark:border-teal-900 dark:text-teal-500" role="alert">
+               <span class="font-bold">Info</span> {{ session('message') }}
+           </div>
+       @endif
        <button type="button" onclick="location.href='{{route('memo.create')}}'" class="py-3 px-4 inline-flex items-center gap-x-2 text-sm font-semibold rounded-lg border border-transparent bg-blue-100 text-blue-800 hover:bg-blue-200 disabled:opacity-50 disabled:pointer-events-none dark:hover:bg-blue-900 dark:text-blue-400 dark:focus:outline-none dark:focus:ring-1 dark:focus:ring-gray-600">新規作成</button>
    </div>

登録後の画面

登録後はこんな感じでメッセージが表示されます。

DaiNakaDaiNaka

メモの一覧表示機能の追加

ここでは、ダッシュボード画面が表示された時に、メモの一覧が表示されるようにしていきたいと思います。

ルーティングの編集

今のままだとダッシュボードを表示するだけの状態のため、/dashboardにリンクするとmemo.indexにリダイレクトするようにします。

web.php
Route::middleware([
    'auth:sanctum',
    config('jetstream.auth_session'),
    'verified',
])->group(function () {
    Route::get('/dashboard', function () {
-       return view('dashboard');
+       return redirect(route('memo.index'));
    })->name('dashboard');
});

コントローラーの編集

コントローラーを通してメモ一覧をデータベースから取得した後、ビューを表示させるようにします。

MemoController.php
    public function index()
    {
-       //
+       $memos = Memo::all();
+       return view('dashboard', compact('memos'));
    }

モデルの編集

モデルを通してメモ一覧を取得する時に認証しているユーザーのデータのみを取得してくるようにします。この処理をしないと、他のユーザーの情報が取得できるようになってしまいます。
ここでは、memoモデルにグローバルスコープを設定して制御しています。

Memo.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
+use Illuminate\Database\Eloquent\Builder;

class Memo extends Model
{
    use HasFactory;
    use SoftDeletes;

    protected $fillable = ['user_id', 'subject', 'content'];

+   protected static function booted(): void
+   {
+       static::addGlobalScope('auth', function (Builder $builder) {
+           $builder->where('user_id', auth()->id());
+       });
+   }
}

リストデザイン

リストのデザインはこのサンプル(Preline)を利用します。
右上のCopyからHTMLをコピーすることができます。

ダッシュボードにリストを表示

新規作成ボタンの下あたりに貼り付けます。
このままだとデータベースから取得してきたデータを表示できないので、下記の通り変更します。
コード真ん中付近にある@foreach@endforeachで囲まれているところが編集しているところで、データの数だけループしている部分です。

dashboard.blade.php
<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Dashboard') }}
        </h2>
    </x-slot>

    <div class="mt-2 max-w-7xl mx-auto sm:px-6 lg:px-8">
        @if (session('message'))
            <div class="mt-2 mb-2 bg-teal-100 border border-teal-200 text-sm text-teal-800 rounded-lg p-4 dark:bg-teal-800/10 dark:border-teal-900 dark:text-teal-500" role="alert">
                <span class="font-bold">Info</span> {{ session('message') }}
            </div>
        @endif
        <button type="button" onclick="location.href='{{route('memo.create')}}'" class="py-3 px-4 inline-flex items-center gap-x-2 text-sm font-semibold rounded-lg border border-transparent bg-blue-100 text-blue-800 hover:bg-blue-200 disabled:opacity-50 disabled:pointer-events-none dark:hover:bg-blue-900 dark:text-blue-400 dark:focus:outline-none dark:focus:ring-1 dark:focus:ring-gray-600">新規作成</button>
        
+       <div class="flex flex-col mt-2">
+           <div class="-m-1.5 overflow-x-auto">
+               <div class="p-1.5 min-w-full inline-block align-middle">
+                   <div class="border rounded-lg divide-y divide-gray-200 dark:border-neutral-700 dark:divide-neutral-700">
+                       <div class="py-3 px-4">
+                           <div class="relative max-w-xs">
+                               <label class="sr-only">Search</label>
+                               <input type="text" name="hs-table-with-pagination-search" id="hs-table-with-pagination-search" class="py-2 px-3 ps-9 block w-full border-gray-200 shadow-sm rounded-lg text-sm focus:z-10 focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600" placeholder="アイテムを検索する">
+                               <div class="absolute inset-y-0 start-0 flex items-center pointer-events-none ps-3">
+                                   <svg class="size-4 text-gray-400 dark:text-neutral-500" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                                       <circle cx="11" cy="11" r="8"></circle>
+                                       <path d="m21 21-4.3-4.3"></path>
+                                   </svg>
+                               </div>
+                           </div>
+                       </div>
+                       <div class="overflow-hidden">
+                           <table class="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
+                               <thead class="bg-gray-50 dark:bg-neutral-700">
+                                   <tr>
+                                       <th scope="col" class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-neutral-500">件名</th>
+                                       <th scope="col" class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-neutral-500">更新日時</th>
+                                       <th scope="col" class="px-6 py-3 text-end text-xs font-medium text-gray-500 uppercase dark:text-neutral-500">アクション</th>
+                                   </tr>
+                               </thead>
+                               <tbody class="divide-y divide-gray-200 dark:divide-neutral-700">
+                                   @foreach($memos as $memo)
+                                       <tr>
+                                           <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-neutral-200">{{$memo->subject}}</td>
+                                           <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-neutral-200">{{$memo->updated_at}}</td>
+                                           <td class="px-6 py-4 whitespace-nowrap text-end text-sm font-medium"><button type="button" class="inline-flex items-center gap-x-2 text-sm font-semibold rounded-lg border border-transparent text-blue-600 hover:text-blue-800 disabled:opacity-50 disabled:pointer-events-none dark:text-blue-500 dark:hover:text-blue-400">消去</button></td>
+                                       </tr>
+                                   @endforeach
+                               </tbody>
+                           </table>
+                       </div>
+                       <div class="py-1 px-4">
+                           <nav class="flex items-center space-x-1">
+                               <button type="button" class="p-2.5 min-w-[40px] inline-flex justify-center items-center gap-x-2 text-sm rounded-full text-gray-800 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none dark:text-white dark:hover:bg-white/10">
+                                   <span aria-hidden="true">«</span>
+                                   <span class="sr-only">Previous</span>
+                               </button>
+                               <button type="button" class="min-w-[40px] flex justify-center items-center text-gray-800 hover:bg-gray-100 py-2.5 text-sm rounded-full disabled:opacity-50 disabled:pointer-events-none dark:text-white dark:hover:bg-white/10" aria-current="page">1</button>
+                               <button type="button" class="min-w-[40px] flex justify-center items-center text-gray-800 hover:bg-gray-100 py-2.5 text-sm rounded-full disabled:opacity-50 disabled:pointer-events-none dark:text-white dark:hover:bg-white/10">2</button>
+                               <button type="button" class="min-w-[40px] flex justify-center items-center text-gray-800 hover:bg-gray-100 py-2.5 text-sm rounded-full disabled:opacity-50 disabled:pointer-events-none dark:text-white dark:hover:bg-white/10">3</button>
+                               <button type="button" class="p-2.5 min-w-[40px] inline-flex justify-center items-center gap-x-2 text-sm rounded-full text-gray-800 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none dark:text-white dark:hover:bg-white/10">
+                                   <span class="sr-only">Next</span>
+                                   <span aria-hidden="true">»</span>
+                               </button>
+                           </nav>
+                       </div>
+                   </div>
+               </div>
+           </div>
+       </div>

    </div>
</x-app-layout>

画面キャプチャ

こんな感じになります。
検索窓や消去ボタンはまだ機能していませんので、クリックしても何も起きません。