Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

laracoft's avatar

Database schema for chat

Hi,

I'm stuck trying to create a suitable database schema for a group chat platform using Eloquent and pivot tables.

Specifically:

  • Muting per Contact per Conversation
  • Read timestamp per Contact per Message (Think WhatsApp double blue ticks)

This is what I have at the moment

// An organization can have many conversations
Organization -< Conversation

// contact and Conversation has a many-to-many relation through member
Contact >- Member -< Organization

// contact and Conversation has a many-to-many relation through participant to store `is_mute`
Contact >- Participant -< Conversation

// a Conversation has many messages
Conversation -< Message

// contact and message has a many-to-many relation through status to store `read_at`
Contact >- Status -< Message

// but every message was also written by one contact (author)
message hasOne contact

To show a list of statuses for each message, I'm thinking of looping through $message->statuses

  1. Does Status have to be defined as a relation in the Contact and Message?
  2. What's the "Laravel way" of muting a Conversation as a Contact? (Statuses was easier, but this caught me in a bind)
  3. Anything I ought to change about the schema above?

Thank you.

0 likes
5 replies
Tippin's avatar
Tippin
Best Answer
Level 13

@laracoft I usually base a chat off of 3 main tables (threads / participants / messages). I do set it up similar to you, however I use the participant to hold both "read at" and "muted". In general, a user will have many threads through participants, and I also do as you do and link the "owner" id on all sent messages.

        Schema::create('threads', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->string('subject')->nullable();
            $table->string('image')->nullable();
            $table->timestamps();
            $table->softDeletes();
        });

        Schema::create('participants', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->uuid('thread_id');
            $table->unsignedBigInteger('user_id');
            $table->boolean('muted')->default(0);
            $table->timestamp('last_read')->nullable()->default(null);
            $table->timestamps();
            $table->softDeletes();
            $table->foreign('thread_id')
                ->references('id')
                ->on('threads')
                ->onDelete('cascade')
                ->onUpdate('cascade');
        });

        Schema::create('messages', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->uuid('thread_id');
            $table->unsignedBigInteger('user_id');
            $table->integer('type')->index();
            $table->text('body');
            $table->timestamps();
            $table->softDeletes();
            $table->index('created_at');
            $table->foreign('thread_id')
                ->references('id')
                ->on('threads')
                ->onDelete('cascade')
                ->onUpdate('cascade');
        });

Muting wise I also check all participants I will broadcast to upon a new message / etc, so I use a scope on my $thread->participants()->notMuted() when gathering users to broadcast to (I prefer to broadcast messages over individual private channels).

You can also checkout a side project I am working on, and how I setup my full tables for chat: https://github.com/RTippin/messenger

laracoft's avatar

@tippin

  1. Are you able to have a read timestamp per contact per message?
  2. Supposed in my case, I have a contact and message ID and want to mark it as read, do you know what is the "Laravel way" to code it? $status->markAsRead($contact_id, $message_id)? Is there a better way?

Thank you.

Tippin's avatar

@laracoft In my current system no. I use the last read time to locate unread count and to give the participant over the API a last read message_id so I can show my bobble heads under corresponding messages. I just update the threads updated_at when a new message is sent, and a thread is unread when it's updated at is greater than the last_read on a participant (per participant).

My act of marking read either happens with a single API hit when the client "sees" a message, or when a thread is "show" / loaded in. But the update() itself on the timestamp will only ever update if the thread is indeed unread for the user. (so pinging the mark-read endpoint for no reason will not actually do anything).

As for going about doing it on an individual message basis, I suppose you could create a pivot or has many "reads" per message. But that just means that you may be doing extra queries for every message sent (which is why I never really went that path). In various projects I have just used the single timestamp and it has never been brought up as an issue from clients.

Now I will be looking to add in reactions, which in some way is going to be a similar relationship, I will just separate out where / when I call for that data specifically instead of tacking it on with every message request. Probably a good place to utilize caching the queries. Maybe try something along that route?

laracoft's avatar

@tippin

Mine is a side project I want to finish off but at the same time, gain some learning value. My thought was, if WhatsApp was able to do it (read timestamp per contact per message), there should be an efficient way to do it.

Anyway, thank you. :)

Please or to participate in this conversation.