Laravelでお知らせ機能(未読機能つき)をつくる

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、これはどのサイトにも言えることですが、リリースしてからも機能の追加やコンテンツを変更することが多いと思います。

そして、そうなってくると(特にログイン機能があるサイトに)必要になってくるものが出てきます。

それが・・・・・・

お知らせ機能

です。

例えばSNSにある、ページ上部に表示されているアイコンを思い浮かべるとわかりやすいと思います。


(スクリーンショットは AdminLte 3 より引用)

この部分に、例えば「新機能が追加されました 🎉」などのニュースを表示することで、よりユーザーに情報を知ってもらうことができるわけですね。

そこで❗

今回はこの「お知らせ機能」をLaravelで実装してみたいと思います。

ちなみに、各お知らせには「未読 or 既読」のデータを保存するようにし、「どれが未読のお知らせなのか?」がわかるようにしてみます。

ぜひ、開発の参考になりましたら嬉しいです😊✨
(最後に今回実際に開発したソースコード一式をダウンロードできますよ👍)

「クライアント様からのお歳暮で、
優雅なひと時を過ごしてます😊✨」

開発環境: Laravel 8.x、Boostrap 4、axios 0.19

前提として

冒頭でも書きましたが、未読がわかるようにしますので、ユーザー情報が必要になってきます。そのため、すでにLaravel 8.xにログイン機能がインストールされていることが前提になります。

もしまだログイン機能をインストールしていない方は、以下いずれかを参考にして実装しておいてください。

では、楽しく実際の作業をしていきましょう❗

マイグレーション&モデルをつくる

まずは、お知らせデータをつくりますのでマイグレーションとモデルをつくります。以下のコマンドを実行してください。

php artisan make:model Announcement -m

すると、ファイルが作成されますので、マイグレーションの中身を以下のように変更します。

database/migrations/****_**_**_******_create_announcements_table.php

// 省略

public function up()
{
    Schema::create('announcements', function (Blueprint $table) {
        $table->id();
        $table->string('title')->comment('お知らせ:タイトル');
        $table->text('description')->comment('お知らせ:内容');
        $table->timestamps();
    });
}

そして、Announcementモデルに「リレーションシップ」と「Accessor」を追加しておいてください。

app/Models/Announcement.php

<?php

namespace App\Models;

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

class Announcement extends Model
{
    use HasFactory;

    protected $appends = ['url', 'date'];

    // Relationship
    public function reads() {

        return $this->hasMany(AnnouncementRead::class, 'announcement_id', 'id');

    }

    // Accessor
    public function getUrlAttribute() {

        return route('announcement.show', $this->id);

    }

    public function getDateAttribute() {

        return $this->created_at->format('m月d日');

    }
}

Accessorの詳しい説明は、全15件!Laravel・使い回しできるAccessor実例をご覧ください。

続いてもう一つ、お知らせの「未読 or 既読」を管理するマイグレーション&モデルも以下のコマンドでつくりましょう。

php artisan make:model AnnouncementRead -m

こちらもマイグレーションを以下のように変更します。

database/migrations/****_**_**_******_create_announcement_reads_table.php

// 省略

public function up()
{
    Schema::create('announcement_reads', function (Blueprint $table) {
        $table->id();
        $table->unsignedBigInteger('user_id')->comment('ユーザーID');
        $table->unsignedBigInteger('announcement_id')->comment('お知らせID');
        $table->boolean('read')->default(false)->comment('未読 or 既読');
        $table->timestamps();

        $table->foreign('user_id')->references('id')->on('users');
        $table->foreign('announcement_id')->references('id')->on('announcements');
    });
}

テストデータをつくる

続いて、テストデータをつくります。
以下のコマンドを実行してSeederファイルを作成してください。

php artisan make:seed AnnouncementsTableSeeder

すると、ファイルが作成されるので中身を以下のように変更します。

database/seeders/AnnouncementsTableSeeder.php

// 省略

public function run()
{
    $user_ids = User::orderBy('id')->pluck('id');

    for($i = 0 ; $i < 25 ; $i++) {

        $announcement = new Announcement();
        $announcement->title = 'テストタイトル - '. $i;
        $announcement->description = "テストお知らせ\nテストお知らせ\nテストお知らせ - ". $i;
        $announcement->save();

        foreach ($user_ids as $user_id) {

            $read = new AnnouncementRead();
            $read->user_id = $user_id;
            $read->announcement_id = $announcement->id;
            $read->save();

        }

    }
}

Seederファイルが作成できたら、Laravelへ登録します。

database/seeders/DatabaseSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Item;
use App\Models\Team;
use App\Models\User;
use App\Models\Address;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run()
    {
//         User::factory(10)->create();
        $this->call(AnnouncementsTableSeeder::class); // 👈 ここを追加しました
    }
}

では、この状態でマイグレーションを実行します。
以下のコマンドを実行してください。

php artisan migrate:fresh --seed

するとDBテーブルは以下のようになります。

ルートをつくる

次に、ルートをつくります。
以下のコードを追加してください。

routes/web.php

Route::prefix('announcement')->middleware('auth')->group(function(){

    Route::get('/', [AnnouncementController::class, 'index'])->name('announcement.index');
    Route::get('/list', [AnnouncementController::class, 'list'])->name('announcement.list');
    Route::get('/{announcement}', [AnnouncementController::class, 'show'])->name('announcement.show');

});

この中では3つのルートを定義しています。

まず1つ目が、ページ上部にお知らせを表示するページです。
なお、お知らせ情報は全ページで表示されるべきなので、本来は「/resources/views/layouts」の中にいれるべきですが、今回はテストなのでこのルートだけで表示することにします。

そして、2つ目が、未読のお知らせデータを取得するためのものです。
これはAjaxでの取得を想定しています。

最後に、お知らせの詳細ページです。

※ なお、authミドルウェアをつけて、ログインを必須にしています。

コントローラーをつくる

次にコントローラーをつくります。
以下のコマンドを実行してください。

php artisan make:controller AnnouncementController

すると、ファイルが作成されますので、中身を以下のように変更します。

app/Http/Controllers/AnnouncementController.php

<?php

namespace App\Http\Controllers;

use App\Models\Announcement;
use App\Models\AnnouncementRead;
use Illuminate\Http\Request;

class AnnouncementController extends Controller
{
    public function index() {

        return view('announcement.index');

    }

    public function show(Request $request, Announcement $announcement) {

        $user = $request->user();
        $announcement_read = AnnouncementRead::where('user_id', $user->id)
            ->where('announcement_id', $announcement->id)
            ->first();

        if(!is_null($announcement_read)) {

            $announcement_read->read = true;
            $announcement_read->save();

        }

        return $announcement;

    }

    public function list(Request $request) {

        $user = $request->user();
        return Announcement::whereHas('reads', function($query) use($user){

            $query->where('user_id', $user->id)
                ->where('read', false);

        })
        ->orderBy('created_at', 'desc')
        ->orderBy('id', 'desc')
        ->paginate(7);

    }
}

なお、list()の中で重要なのがwhereHas()の部分です。

意味としては、「リレーションシップ先で絞り込みをしたデータだけを取得する」となります。つまり、「各ユーザーの announcement_reads が未読になっているものだけ」をデータ取得しているわけですね。

また、show()メソッドにも注目してください。
ここでは、表示したお知らせの「未読」を「既読」に変更しています。

このようにすることで、一度表示したお知らせデータが次回から未読には含まれなくなるということですね。

ビューをつくる

先ほどコントローラーでセットしたビュー・ファイルをつくります。

なお、せっかくなので「使い回し」ができるよう、Vueコンポーネントで実装します。

resources/views/announcement/index.blade.php

<html>
<head>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.1/css/all.min.css">
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css">
</head>
<body>
<div id="app">
    <nav class="navbar navbar-expand-lg navbar-light bg-light">
        お知らせ機能サンプル
        <div class="collapse navbar-collapse">
            <ul class="navbar-nav ml-auto">

                <!-- ③ 独自コンポーネントを表示 -->
                <v-dropdown-nav-item v-model:items="announcements">

                    <!-- 表示するアイコン -->
                    <i class="far fa-bell"></i>

                    <!-- フッターは任意です -->
                    <template v-slot:footer>
                        <div class="dropdown-divider"></div>
                        <a class="dropdown-item text-center text-secondary" href="#">
                            <small>全てのお知らせを見る</small>
                        </a>
                    </template>

                </v-dropdown-nav-item>

            </ul>
        </div>
    </nav>
</div>
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js"></script>
<script src="https://unpkg.com/vue@3.0.2/dist/vue.global.prod.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.2/axios.min.js"></script>
<script>

    // ① ドロップダウンを表示するVueコンポーネントを定義
    const dropdownNavItemComponent = {
        props: ['items'],
        computed: {
            hasItem() {

                return (
                    Object.keys(this.items).length > 0 &&
                    this.items.data.length > 0
                );

            }
        },
        template: `
            <li class="nav-item dropdown" v-if="hasItem">
                <a style="position:relative;min-width:40px;" class="nav-link" data-toggle="dropdown" href="#">
                    <slot>
                        <i class="far fa-bell"></i>
                    </slot>
                    <span style="position:absolute;top:0;left:16px;" class="badge badge-danger" v-text="items.total"></span>
                </a>
                <div style="width:300px;" class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">
                    <a style="overflow: hidden;text-overflow:ellipsis;" class="dropdown-item" :href="item.url" v-for="item in items.data">
                        <small class="float-right text-muted pl-3" v-text="item.date"></small>
                        <small v-text="item.title"></small>
                    </a>
                    <footer>
                        <slot name="footer"></slot>
                    </footer>
                </div>
            </li>
        `
    };

    Vue.createApp({
        data() {
            return {
                announcements: {}
            }
        },
        mounted() {

            const url = '{{ route('announcement.list') }}';
            axios.get(url)
                .then(response => {

                    this.announcements = response.data;

                });

        }
    })
    .component('v-dropdown-nav-item', dropdownNavItemComponent) // ② コンポーネントをセット
    .mount('#app');

</script>
</body>
</html>

少し複雑なのでひとつずつみていきましょう。

① ドロップダウンを表示するVueコンポーネントを定義

「お知らせアイコン」とその内容を表示する以下のような機能をもったコンポーネントを定義しています。

ここで重要なのがこのコンポーネントは、「Laravel の paginate() で取得できるデータを想定している」という部分です。

なぜなら、このコンポーネントに必要になるのは、以下2つだからです。

  • 最新のお知らせ情報(今回は最大7件)
  • 「全ての」未読件数(7件を超えることがある)

これを取得するにはpaginate()が都合がいいため、この形を採用しました。

また、表示内容をお好みで変更できるように「スロット」をつくっています。

まず、メインのスロットはアイコンを入れかえるためのものです。

つまり、以下のようにタグで囲まれた部分を変更すると、アイコンが変更できます。

<v-dropdown-nav-item v-model:items="announcements">
    <i class="far fa-envelope"></i>
</v-dropdown-nav-item>

※ なお、省略するとベルのマークが表示されます。

そして、「名前つきスロット」もフッターとして用意しています。
フッター部分は、以下の部分をお好みで入れかえることができますが、こちらは、省略すると何も表示されないということになります。

<slot name="footer"></slot>

使い方は、次のとおりです。

<v-dropdown-nav-item v-model:items="announcements">

    <!-- 省略 -->

    <!-- 👇 ここの部分がフッターとして表示されます -->
    <template v-slot:footer>
        <div class="dropdown-divider"></div>
        <a class="dropdown-item text-center text-secondary" href="#">
            <small>全てのお知らせを見る</small>
        </a>
    </template>

</v-dropdown-nav-item>

② コンポーネントをセット

次に で作ったコンポーネントをVueにセットして有効にします。
この書き方はVue 3からのものです。

③ 独自コンポーネントを表示

最後にコンポーネントを表示していますが、v-model:itemsにセットしているannouncementsは、mounted()の中でAjax通信で取得したものになります。

おまけ:未読データをつくるために

今回は全ユーザーの未読データ(announcement_readsテーブル)は一気につくりました。

しかし、一度に大量にデータを作成する場合、もし途中で何か不具合が発生してしまったら、データの整合性がおかしくなってしまいます。(特に登録ユーザーが増えてくるとその可能性は高くなってきます)

そのため、この解決策として以下のようにしておくといいかもしれません。

  1. ユーザーごとに「最後にお知らせ情報を更新した日時」を保存しておく
  2. Ajax通信で「お知らせデータ」を取得するとき、もし一定期間(例えば、24時間)を超えている場合は未読データをチェックし追加する
  3. すると、最新のお知らせデータが取得できる

テストしてみる

では、実際にテストしてみましょう。

まずはログインをして、「http://******/announcement」にアクセスします。

すると、アイコンが表示されているので、これをクリックし「お知らせ」の中身を表示します。

そして、「未読」が「既読」になるかをチェックするために、「テストタイトル – 21」をクリックしてみます。

どうなるでしょうか・・・・・・

はい❗

データが表示されました。(テストですので、このJSON形式ですが、実際はreturn view('****');などで表示してください)

では、先ほどのページに戻ってリロードし、「テストタイトル – 21」が消えているか確認してみます。

すると・・・・・・

はい❗「テストタイトル – 21」が消えました。
そして、未読件数が25から24に減っています。

成功です😊✨

ダウンロードする

今回実際に開発したソースコード一式を以下からダウンロードできます。

Laravelでお知らせ機能(未読機能つき)をつくる

※ マイグレーション等はご自身で行ってください。

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回はLaravel + Vueで「お知らせ機能」を作ってみました。

この機能をつかえば、メッセージの新着にもつかえるでしょうし、なんならリアルタイムでの通知も可能になると思います。

さらに、SNSで経験がある方もいるかもしれませんが、私の場合、クリックして未読が消えるとなぜか嬉しいという感覚があったりします。

もしかすると、より未読をなくすためにそういった作戦があるのかもしれませんね。

ぜひ皆さんもいろいろと応用してみてください。

ではでは〜❗

「年賀状はタイムラインやSNSで
投稿するスタイルです👍」

このエントリーをはてなブックマークに追加       follow us in feedly