iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🤖

Building a Chat App with Laravel (Up to Model Creation)

に公開
2025/06/07

This is a detailed article based on the post above.

Creating the Chat App

Now that the boilerplate is ready, let's start building the application step-by-step. The theme for this project is a chat application. The code is primarily based on a Laravel boilerplate implemented using Gemini 2.5 Flash (the version available for free anonymous use as of May 2025).

Requirements

  • Everyone enters a single room
  • Users can post messages anonymously or while logged in
  • Messages from others can be read in real-time (*)
  • Messages cannot be edited or deleted; posts older than one day are purged from the server once a day
  • The client side also removes messages older than one day from the screen every minute
  • Up to 50 messages can be read per request (up to 50 from a specific time or up to 50 of the latest messages. Messages older than one day are not returned)

*Uses SSE (Server-Sent Events)

Goals and Detailed Specifications for This Article

  • Initial display only
  • No message sending
  • No online user count display
  • Display up to 50 messages from the database
  • Use Laravel defaults for users, authentication, and settings
  • Display username if authenticated, otherwise display as anonymous
  • Support both dark and light modes based on default user settings
  • Up to the database model creation required for the above

Creating the Model

By "model," I am referring to the database model.
Since I'm at it, I'll include some design-style diagrams just to set the mood.

ER Diagram

At this stage, the ER Diagram (which illustrates the relationships between database tables) is quite simple.

Here, the users table is the one used by Laravel's default user authentication.
The chats table is the part we are creating this time.

Class Diagram

The entities in the ER diagram above are mapped to classes in a class diagram.

*Note: While the actual code of the Model-derived classes doesn't exactly follow this structure, this is how it behaves conceptually.

Code Generation

The mapping between entities (tables) in an ER diagram and classes in a class diagram is handled by an ORM, and in Laravel, Eloquent takes on this role.

Below, we generate the boilerplate code for the Eloquent model class and its associated classes.

~/php/example-app $ php artisan make:model Chat --all

   INFO  Model [app/Models/Chat.php] created successfully.

   INFO  Factory [database/factories/ChatFactory.php] created successfully.

   INFO  Migration [database/migrations/????_??_??_??????_create_chats_table.php] created successfully.

   INFO  Seeder [database/seeders/ChatSeeder.php] created successfully.

   INFO  Request [app/Http/Requests/StoreChatRequest.php] created successfully.

   INFO  Request [app/Http/Requests/UpdateChatRequest.php] created successfully.

   INFO  Controller [app/Http/Controllers/ChatController.php] created successfully.

   INFO  Policy [app/Policies/ChatPolicy.php] created successfully.

~/php/example-app $

Implementation

We will fill in the implementation based on the generated boilerplate classes.

Migration (Database Schema)

First, let's start with the database schema (migration). In Laravel, it seems the model is determined by the existence of the table first.

database/migrations/????_??_??_??????_create_chats_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('chats', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->nullable()->constrained()->onDelete('cascade');
            $table->string('message', 4096);
            $table->timestamps();
        });
    }

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

First, we establish a relation to the users table, allowing NULL values and configuring a cascade delete. Next, we define a message column as a string with a maximum of 4096 bytes.

(While we won't apply it just yet) When actually reflected in the database, a table like this will be created:

CREATE TABLE `chats` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) unsigned DEFAULT NULL,
  `message` varchar(4096) NOT NULL,
  `created_at` timestamp NULL DEFAULT NULL,
  `updated_at` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `chats_user_id_foreign` (`user_id`),
  CONSTRAINT `chats_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci

Note: This is the result of running show create table chats in MariaDB SQL.

Model

Next is the implementation of the Model itself.

app/Models/Chat.php
<?php

namespace App\Models;

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

class Chat extends Model
{
    /** @use HasFactory<\Database\Factories\ChatFactory> */
    use HasFactory;

    protected $fillable = [
        'user_id',
        'message',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

As mentioned earlier, since it seems models are generated based on the database schema, there is no need to describe the model itself in great detail. However, mass assignment (fillable) and the definitions for relationships appear to be necessary.

Factory

Next, we implement how to create dummy data for the model we created (factory). This is used for testing and other purposes.

database/factories/ChatFactory.php
<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;
use App\Models\User;
use Illuminate\Support\Facades\DB;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Chat>
 */
class ChatFactory extends Factory
{
     protected $model = \App\Models\Chat::class;

    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        $usersCount = DB::table('users')->count();
        $userId = null;
        if ($usersCount > 0 && $this->faker->boolean()) {
            $userId = User::inRandomOrder()->first()->id;
        }

        return [
            'user_id' => $userId,
            'message' => $this->faker->realtext,
        ];
    }
}

Faker is quite convenient.

Seed

This is the code used when creating dummy data (seed). It controls aspects such as how many items to create.

database/seeders/ChatSeeder.php
<?php

namespace Database\Seeders;

use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use App\Models\Chat;

class ChatSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        Chat::factory()->count(10)->create();
    }
}

Applying to the Database (Migration)

I'll apply the changes to the database to verify everything. However, since applying them as-is would result in English data, I'll change the settings before proceeding.

.env
...
#APP_LOCALE=en
APP_LOCALE=ja
#APP_FALLBACK_LOCALE=en
APP_FALLBACK_LOCALE=ja
#APP_FAKER_LOCALE=en_US
APP_FAKER_LOCALE=ja_JP
...

Now, let's apply the migrations.

~/php/example-app $ php artisan migrate:fresh

  Dropping all tables .................................................................... 25.79ms DONE

   INFO  Preparing database.

  Creating migration table ............................................................... 16.72ms DONE

   INFO  Running migrations.

  0001_01_01_000000_create_users_table ................................................... 37.85ms DONE
  0001_01_01_000001_create_cache_table ................................................... 12.06ms DONE
  0001_01_01_000002_create_jobs_table .................................................... 30.28ms DONE
  ????_??_??_??????_create_chats_table ................................................... 31.66ms DONE

~/php/example-app $ 

Generating Dummy Data

I'll create the default dummy users provided by default.

~/php/example-app $ php artisan db:seed

   INFO  Seeding database.

~/php/example-app $

Next, I'll create dummy data for the model we created.

~/php/example-app $ php artisan db:seed --class=ChatSeeder

   INFO  Seeding database.

~/php/example-app $ 

Checking the Database

Let's check the contents of the database. We'll use tinker, Laravel's REPL.

~/php/example-app $ php artisan tinker
Psy Shell v0.12.8 (PHP 8.4.2 — cli) by Justin Hileman
> App\Models\Chat::all()
= Illuminate\Database\Eloquent\Collection {#1365
    all: [
      App\Models\Chat {#6244
        id: 1,
        user_id: 1,
        message: "まれていしてつどうか、まわってるんだん大きなり、天の川の水ぎわに沿そっちに囲かことなり
、姉ねえ、おしまいました。「こどもほんとはなぜかさあ、ぼください」ジョバンニは勢いきなり、まるで運動場う
んどい高い高原で待まって見ました。そして、まもなくなり、姉ねえ」「どうも済すみのようで橋はしらがその黒い
門もんをたてていたのでした。そして、そこらにひざの黒い服ふくなりいろの方へ押おしてくびをたてていしょにし
。",
        created_at: "????-??-?? ??:??:??",
        updated_at: "????-??-?? ??:??:??",
      },
      App\Models\Chat {#6245
        id: 2,
        user_id: 1,
        message: "んとうに眼めをみて、あすこ石炭袋せきのどくびにぶっきりんとうのはじめはどうしてしまっ黒
な盤ばんでなしくわくような色をしているようなんだか鼻はなれて来て、あたした。「鳥がおもしおまた、高く星め
ぐったりは、頂いたいよく知りませんです。そしてきて、まだひざまずいぶんいたのでしたもんで行くん、風のよう
に雑作ぞうさえきちっと続つづいてはいっぱさをあけて行かないんだから飛とび乗のりました。そらのようなんだよ
。",
...
      },
    ],
  }

> exit

   INFO  Goodbye.

~/php/example-app $

It seems to be working, so next up is some light testing.

Testing

I haven't mentioned this much so far, but on Termux, xdebug does not work, and you cannot debug. Therefore, test code is essential—and that's an understatement; it's quite critical. Furthermore, since it's Android, unless you have a PC, you (typically) cannot use developer tools in the web browser, which makes debugging even more difficult. The issue with developer tools is a common problem for all web app/service development on Termux (such as node.js).

First, create a boilerplate for a unit test class using the artisan command.

~/php/example-app $ php artisan make:test ChatTest --unit

(Added on 2025/6/7)
Note: This is a Feature test, so --unit is a mistake (I won't fix it because it's a hassle).


Here is the test code with the content filled in.

tests/Unit/ChatTest.php
<?php

namespace Tests\Unit;

use Tests\TestCase;
use App\Models\Chat;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Illuminate\Foundation\Testing\WithFaker;

class ChatTest extends TestCase
{
    use RefreshDatabase;
    use WithFaker;

    /**
     * Verify that an anonymous chat can be created
     */
    #[Test]
    public function created(): void
    {
        $chat = Chat::factory()->state(['user_id' => null])->create();

        $this->assertDatabaseHas('chats', [
            'id' => $chat->id,
            'user_id' => null,
            'message' => $chat->message,
        ]);
        $this->assertInstanceOf(Chat::class, $chat);
        $this->assertNull($chat->user_id);
    }

    /**
     * Verify that a chat with a user can be created
     */
    #[Test]
    public function created_with_a_user(): void
    {
        $user = User::factory()->create();
        $chat = Chat::factory()->for($user)->create();

        $this->assertDatabaseHas('chats', [
            'id' => $chat->id,
            'user_id' => $user->id,
            'message' => $chat->message,
        ]);
        $this->assertEquals($user->id, $chat->user->id);
        $this->assertInstanceOf(User::class, $chat->user);
    }

    /**
     * Verify that a message can be updated
     */
    #[Test]
    public function message_updated(): void
    {
        $chat = Chat::factory()->create();
        $originalMessage = $chat->message;
        $updatedMessage = 'Updated message for chat: ' . $this->faker->sentence();

        $chat->update(['message' => $updatedMessage]);

        $this->assertDatabaseHas('chats', [
            'id' => $chat->id,
            'message' => $updatedMessage,
        ]);
        $this->assertEquals($updatedMessage, $chat->message);
        $this->assertNotEquals($originalMessage, $chat->message);
    }

    /**
     * Verify that it can be deleted
     */
    #[Test]
    public function deleted(): void
    {
        $chat = Chat::factory()->create();

        $chat->delete();

        $this->assertDatabaseMissing('chats', [
            'id' => $chat->id,
        ]);
    }

    /**
     * Verify cascade
     */
    #[Test]
    public function deleted_when_user_deleted(): void
    {
        $user = User::factory()->create();
        $chat = Chat::factory()->for($user)->create();

        $this->assertDatabaseHas('chats', ['id' => $chat->id]);

        $user->delete();

        // Verify cascade
        $this->assertDatabaseMissing('chats', ['id' => $chat->id]);
    }
}

The pre-existing test classes will also run simultaneously, but to execute all tests including the one we just created, run the following command:

~/php/example-app $ php artisan test

   PASS  Tests\Unit\ChatTest
  ✓ created                                                                         0.33s
  ✓ created with a user                                                             0.03s
  ✓ message updated                                                                 0.02s
  ✓ deleted                                                                         0.02s
  ✓ deleted when user deleted                                                       0.02s

   PASS  Tests\Unit\ExampleTest
  ✓ that true is true                                                               0.33s

   PASS  Tests\Feature\Auth\AuthenticationTest
...
   PASS  Tests\Feature\Settings\ProfileUpdateTest
...
  ✓ user can delete their account                                                   0.03s
  ✓ correct password must be provided to delete account                             0.03s

  Tests:    32 passed (76 assertions)
  Duration: 3.49s

~/php/example-app $

Since xdebug doesn't work on Termux, it's not possible to generate coverage reports, and this is about as far as we can go with testing.

Next Detailed Article

GitHubで編集を提案

Discussion