【Laravel】24時間で削除される「ストーリー」機能をつくる

こんにちは❗フリーランス・エンジニアの 九保すこひ です。

さてさて、私が2012年にカナダ留学したときに初めて使い、今でもなお利用しているものといえば、「LINE」です。

当時は「便利だけど、みんなが使わなきゃ意味ないけどね!」なんて言ってましたが、すぐシェアを拡大し今ではインフラのような存在になってしまいました。

さて、そんなLINEですが「ストーリー」という機能があるのをご存知でしょうか。簡単に言うと、

24時間で削除される投稿

のことです。

つまり、今日キレイな景色を写真で投稿したとしても次の日には消えてしまうので、「ゆるーく投稿したい」という人には向いている機能といっていいんじゃないでしょうか。

そして、今回はこの「ストーリー」機能をLaravelで作ってみたいと思います。
ぜひ何かの参考になりましたら嬉しいです。😊✨

※ なお、今回は新しい試みとして、簡単なユニットテストも合わせてご紹介します。

「私はいつでも見られる
タイムライン派です」

やりたいこと

今回ストーリー機能をつくるわけですが、多機能なLINEと同じにするとコードがあまりにも長くなりすぎてしまうので、今回は以下の機能に絞って開発を進めていきます。

  • 投稿してから 24時間で自動でデータ削除(物理削除はしない)
  • 誰が閲覧したかがわかる「足跡機能」はつける

なお、「友達だけ公開」などは今回実装しませんが、以下の記事を応用すれば実装できると思います。

📝 参考ページ: Laravel で「好きな芸人が似てる」マッチングシステムをつくってみる

前提として

今回は足跡機能をつくるので、ログイン機能がインストールされている必要があります。

もしまだの方は以下を参考にしてインストールを済ませておいてください。

📝 参考ページ: Laravel Breezeで「シンプルな」ログイン機能をインストール

ストーリーが管理できるようにする

ストーリー用のモデル&マイグレーションをつくる

では、まずは24時間で削除される「ストーリー」を管理するDB&モデルをつくっていきましょう。

以下のコマンドを実行してください。

php artisan make:model Story -m

すると、マイグレーションとモデルが一気に2つ作成されるので中身を次のように変更してください。

database/migrations/****_**_**_******_create_stories_table.php

// 省略

public function up()
{
    Schema::create('stories', function (Blueprint $table) {
        $table->id();
        $table->unsignedBigInteger('user_id')->comment('ユーザーID');
        $table->text('content')->comment('内容');
        $table->dateTime('expired_at')->comment('有効期限');
        $table->timestamps();
    });
}

// 省略

そしてモデルです。

app/Models/Story.php

<?php

namespace App\Models;

use App\Events\StoryCreating;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Story extends Model
{
    use HasFactory;

    protected $dispatchesEvents = [
        'creating' => StoryCreating::class
    ];
    protected $casts = [
        'expired_at' => 'datetime'
    ];
    protected $appends = [
        'is_expired'
    ];

    // Global scope
    protected static function booted()
    {
        static::addGlobalScope('expiration', function (Builder $builder) {

            $builder->where('expired_at', '>', now());

        });
    }
}

登録時のイベントをつくる

登録するごとに有効期限(expired_at)をセットしてもいいのですが、めんどうなので自動で実行できるよう「creating イベント」を作っておきましょう。

以下のコマンドを実行してください。

php artisan make:event StoryCreating

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

app/Events/StoryCreating.php

<?php

namespace App\Events;

use App\Models\Story;

// 省略

class StoryCreating
{
    // 省略    

    public function __construct(Story $story)
    {
        $story->expired_at = now()->addDay();
    }

// 省略

なお、このイベントはすでにStoryモデルに登録しています。($dispatchesEventsの部分です)

これで、以下のようにexpired_atのセットを省略してもきちんと1日後の日時が格納されるようになりました。

$story = new Storage();
$story->user_id = 1;
$story->content = 'テストのストーリーです!';

// $story->expired_at は省略してOK! 

$story->save();

ユニットテストをつくる

では、ストーリーのユニットテストを作ってみましょう。
以下のコマンドを実行してください。

php artisan make:test StoryTest --unit

すると、テスト用のファイルが作成されるので中身を次のようにします。

tests/Unit/StoryTest.php

<?php

namespace Tests\Unit;

use App\Models\Story;
use Carbon\Carbon;
use Tests\TestCase; // 👈 ここ注意です

class StoryTest extends TestCase
{
    // データ登録できるかチェック
    public function test_create()
    {
        $story = new Story();
        $story->user_id = 1;
        $story->content = 'テストのストーリーです!';
        $result = $story->save();

        $this->assertTrue($result);
    }

    // expired_at が型変換されているかチェック
    public function test_expired_at_is_carbon_instance()
    {
        $story = Story::latest()->first();
        $this->assertTrue($story->expired_at instanceof Carbon);
    }

    // 有効期限が切れたら削除されるか(取得できないようになっているか)
    public function test_is_null_after_expired()
    {
        Carbon::setTestNow(now()->addDay()); // 今が「1日後」であるように実行させる

        $story = Story::latest()->first();
        $this->assertTrue(is_null($story));
    }
}

これでphp artisan test --testsuit=Unitを実行してユニットテストを実行してみましょう。

うまくいくと以下のようになります。

成功ですね😊✨

足跡機能をつくる

では、続いて「誰がストーリーを見たか」が分かる「足跡」機能をつくっていきましょう。

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

まずはDB周りです。
以下のコマンドを実行してください。

php artisan make:model StoryAccess -m

モデルとマイグレーションが作成されるので、中身を次のようにします。

database/migrations/****_**_**_******_create_story_accesses_table.php

// 省略

public function up()
{
    Schema::create('story_accesses', function (Blueprint $table) {
        $table->id();
        $table->unsignedBigInteger('user_id')->comment('ユーザーID');
        $table->unsignedBigInteger('story_id')->comment('ストーリーID');
        $table->timestamps();

        $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
        $table->foreign('story_id')->references('id')->on('stories')->onDelete('cascade');
        $table->unique(['user_id', 'story_id']);
    });
}

// 省略

そして、モデルです。

app/Models/StoryAccess.php

<?php

namespace App\Models;

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

class StoryAccess extends Model
{
    use HasFactory;

    protected $guarded = ['id']; // 👈 mass assignment 対策で追加しました
}

イベントをつくる

Storyモデルに「データが取得されたら」実行されるイベントをつくります。
以下のコマンドを実行してください。

php artisan make:event StoryRetrieved

イベントファイルが作成されるので中身を次のようにします。

app/Events/StoryRetrieved.php

<?php

namespace App\Events;

use App\Models\Story;
use App\Models\StoryAccess;

// 省略

class StoryRetrieved
{
    // 省略

    public function __construct(Story $story)
    {
        if(auth()->check()) {

            $user_id = auth()->id();

            if($user_id !== $story->user_id) { // 👈 自分以外なら足跡保存

                $story_id = $story->id;
                $story_access = StoryAccess::firstOrNew([
                    'user_id' => $user_id,
                    'story_id' => $story_id
                ]);
                $story_access->save();

            }

        }
    }

// 省略

そして、Storyモデルに以下のようにセットしてください。

app/Models/Story.php

// 省略

class Story extends Model
{
    use HasFactory;

    protected $dispatchesEvents = [
        'retrieved' => StoryRetrieved::class, // 👈 ここを追加しました
        'creating' => StoryCreating::class
    ];

リレーションをつくる

また、StoryからStoryAccessを連結する「1:多(hasMany)」のリレーションをつくっておきましょう。

app/Models/Story.php

<?php

// 省略

class Story extends Model
{
    // 省略
    
    // Relationship
    public function accesses()
    {
        return $this->hasMany(StoryAccess::class, 'story_id', 'id');
    }
}

ユニットテストをつくる

では、「足跡」機能にもテストコードを書いてみましょう。
先ほどのStoryTestに追加します。

tests/Unit/StoryTest.php

<?php

namespace Tests\Unit;

use App\Models\Story;
use Carbon\Carbon;
use Tests\TestCase;

class StoryTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        auth()->loginUsingId(2); // 👈 ID:2 のユーザーでログイン
    }

    // 省略

    // Story にアクセスした情報が保存されるかどうか
    public function test_can_save_history()
    {
        $story = Story::with('accesses')->latest()->first();
        $this->assertNotEmpty($story->accesses);
    }
}

なお、ここで重要なのはsetup()の部分です。

この中では、ユーザーIDが2でログインしていますが、これはsetup()の中に書かないとテスト中にログインが有効になりません。

また、なぜID2を使っているかというと、「自分以外のアクセスは履歴(足跡)を残す」という仕様にしているため、1だとエラーになるからです。

では、php artisan test --testsuit=Unit を実行してテストしてみましょう。

これもOKですね😊✨

テストしてみる

今回はユニットテストで実行したのでテストはありませんが、使い方としてはStoryモデルにデータ追加する、そして、ログインしてデータにアクセスするというコードがあればストーリー機能は有効になるかと思います。

企業様へのご提案

今回のストーリー機能を使えば、「期間限定コンテンツ」を登録ユーザーにつくってもうことができるため、より多くのユーザーが定期的に訪問するインセンティブにつながります。

また、投稿する側からしても「24時間だから投稿してもいいかな」と意欲が多くなるのも事実かと思います。

ぜひ今回のようなストーリー機能をご希望の場合はお問い合わせからご連絡ください。どうぞよろしくお願いいたします。m(_ _)m

おわりに

ということで今回は「ストーリー」機能をLaravelで作ってみました。期間限定のデータをつくるのはそれほど難しいものではなかったんじゃないでしょうか。(Laravelサマサマです👍)

また、今回はいつもと違ってユニットテストを記事に入れてみましたが、いかがだったでしょうか。(テストを通過したときの見た目、かっこいいですね👍)

こういったテストが標準搭載されているのもLaravelの人気のひとつなんだろうなと実感する今日この頃です。

ぜひみなさんも試してみてくださいね。

ではでは〜❗


「Laravel 9 は 2022年1月
リリースに変更になりました」

開発のご依頼お待ちしております 😊✨ お問い合わせ
また、こちらもお待ちしております。
  • 実案件の開発サポート: 詳細
  • ツイッターのフォロー: 詳細
どうぞよろしくお願いいたします!
このエントリーをはてなブックマークに追加       follow us in feedly