Models

Wirechat ships with six core models: Conversation, Participant, Message, Group, Attachment, and Action.

Together they handle threads, membership, message delivery, uploads, group metadata, and action history.

As of 0.5.1, Wirechat resolves these model classes from config, which means you can extend them in your app without forking the package.


#How Model Resolution Works

Wirechat resolves the following classes from config/wirechat.php:

'models' => [
    'action' => \Wirechat\Wirechat\Models\Action::class,
    'attachment' => \Wirechat\Wirechat\Models\Attachment::class,
    'conversation' => \Wirechat\Wirechat\Models\Conversation::class,
    'group' => \Wirechat\Wirechat\Models\Group::class,
    'message' => \Wirechat\Wirechat\Models\Message::class,
    'participant' => \Wirechat\Wirechat\Models\Participant::class,
],

Each configured class must extend the corresponding base Wirechat model. This matters because the package depends on inherited relationships, casts, scopes, cleanup hooks, and helper methods.

Recommended approach:

  • Extend the base model instead of rewriting it from scratch.
  • Keep the package tables and core columns unless you intentionally own the whole Wirechat data layer.
  • Preserve relationship names and enum casts because package jobs, UI flows, and scopes call them directly.
  • If you override boot(), booted(), or __construct(), call parent.
  • Add your app-specific logic as extra scopes, relations, traits, accessors, or convenience helpers.

Good customizations:

  • Add accessors and convenience methods.
  • Add app-specific relationships.
  • Add observers or traits.
  • Add non-breaking casts or guarded or fillable adjustments that still respect the package schema.

Risky customizations:

  • Removing required columns.
  • Renaming tables without also updating the full schema contract.
  • Replacing relationship methods with incompatible behavior.
  • Overriding deletion hooks without preserving package cleanup.

Custom model example:

namespace App\Models;

use Wirechat\Wirechat\Enums\ConversationType;
use Wirechat\Wirechat\Models\Conversation as BaseConversation;

class WirechatConversation extends BaseConversation
{
    protected $casts = [
        'type' => ConversationType::class,
        'updated_at' => 'datetime',
        'disappearing_started_at' => 'datetime',
        'archived_at' => 'datetime',
    ];

    public function supportTicket()
    {
        return $this->belongsTo(\App\Models\SupportTicket::class, 'ticket_id');
    }
}

Then register it:

'models' => [
    'conversation' => \App\Models\WirechatConversation::class,
],

You do not need a dedicated App\Models\Wirechat namespace. A regular class in App\Models works fine as long as it extends the matching Wirechat base model.


#Conversation

Conversation is the top-level thread model. Every private chat, self chat, and group chat starts here.

This record coordinates participants, messages, disappearing-message state, and conversation-level visibility rules.

Core fields:

Field Purpose
id Primary key for the thread.
type Conversation type enum: private, self, or group.
disappearing_started_at When disappearing mode was enabled.
disappearing_duration Disappearing duration in seconds.
created_at Thread creation timestamp.
updated_at Latest activity timestamp used by unread and delete or clear logic.

#Important Relationships

participants(): HasMany

Returns the membership rows for everyone in the conversation.

$conversation->participants()->with('participantable')->get()

messages(): HasMany

Returns the messages that belong to the conversation.

$conversation->messages()->latest()->get()

lastMessage(): HasOne

Returns the latest message for previews and list ordering.

$conversation->lastMessage

group(): HasOne

Returns the group metadata row when the conversation is a group thread.

$conversation->group

attachments(): Builder

Builds an attachment query for all message attachments in the conversation.

$conversation->attachments()->latest()->get()

actions(): MorphMany

Returns actions recorded directly against the conversation model.

$conversation->actions()->latest()->get()

#Important Helpers

participant(Model|Authenticatable $user, bool $withoutGlobalScopes = false): ?Participant

Resolves the participant row for a given app model.

$conversation->participant(auth()->user())

addParticipant(Model $user, ParticipantRole $role = ParticipantRole::PARTICIPANT, bool $undoAdminRemovalAction = false): Participant

Adds a participant while enforcing private, self, and admin-removal rules.

$conversation->addParticipant($user)

peerParticipant(Model|Authenticatable $reference): ?Participant

Resolves the other participant in a private conversation.

$conversation->peerParticipant(auth()->user())

peerParticipants(Model $reference): Collection

Returns every participant except the given reference model.

$conversation->peerParticipants(auth()->user())

getReceiver()

Resolves the other side of a private conversation for UI usage. This helper depends on an authenticated user being available internally, so it is best used inside auth-aware UI flows. When you already have the current user model, prefer peerParticipant() because it accepts the current user explicitly.

$peerParticipant = $conversation->peerParticipant(auth()->user())

markAsRead(?Model $user = null): void

Updates conversation_read_at for the given user or the authenticated user.

$conversation->markAsRead()

readBy(Model|Participant $user): bool

Checks whether the conversation is fully read for a participant.

$conversation->readBy(auth()->user())

getUnreadCountFor(Model $model): int

Counts unread messages for a given participantable model.

$conversation->getUnreadCountFor(auth()->user())

hasDisappearingTurnedOn(): bool

Returns true when disappearing mode is active.

$conversation->hasDisappearingTurnedOn()

turnOnDisappearing(int $durationInSeconds): void

Enables disappearing messages and currently requires at least one hour.

$conversation->turnOnDisappearing(86400)

turnOffDisappearing(): void

Disables disappearing messages.

$conversation->turnOffDisappearing()

deleteFor(Model|Authenticatable $user): ?bool

Deletes the conversation for one participant and may fully delete the record when package rules allow.

$conversation->deleteFor(auth()->user())

hasBeenDeletedBy(Model|Authenticatable $user): bool

Checks whether delete-for-user is still active for a participant.

$conversation->hasBeenDeletedBy(auth()->user())

clearFor(Model|Authenticatable $user): void

Clears conversation history for one participant without fully deleting the thread.

$conversation->clearFor(auth()->user())

isPrivate(): bool

Returns true when the conversation is a private conversation between two different users.

$conversation->isPrivate()

isSelf(): bool

Returns true when the conversation is a self conversation for a single user.

$conversation->isSelf()

isGroup(): bool

Returns true when the conversation is a group conversation.

$conversation->isGroup()

isOwner(Model|Authenticatable $model): bool

Checks whether the given user is the owner of the conversation. In group conversations this is the group owner. In private conversations both participants are effectively owners, so this can return true for either side.

$conversation->isOwner(auth()->user())

isAdmin(Model|Authenticatable $model): bool

Checks whether the given user has admin-level access in the conversation. In groups this returns true for admins and for the owner, because the owner is also treated as an admin.

$conversation->isAdmin(auth()->user())

#Important Query Helpers

whereParticipantable(Model $participantable)

Filters conversations that contain a specific model as participant.

Conversation::query()->whereParticipantable(auth()->user())->get()

whereHasParticipant($userId, $userType)

Filters conversations by raw participantable id and morph type.

Conversation::query()->whereHasParticipant($user->getKey(), $user->getMorphClass())->get()

withoutBlanks()

Hides conversations with no visible messages for the authenticated user.

Conversation::query()->withoutBlanks()->get()

withoutCleared()

Hides conversations cleared by the authenticated participant until new activity appears.

Conversation::query()->withoutCleared()->get()

withoutDeleted()

Hides conversations deleted by the authenticated participant until new activity appears.

Conversation::query()->withoutDeleted()->get()

withDeleted()

Includes conversations even if the authenticated participant has deleted them.

Conversation::query()->withDeleted()->get()

Legacy helpers receiverParticipant() and authParticipant() still exist, but new code should prefer peerParticipant() and participant().

Typical safe customizations:

  • Add app-specific relations such as tickets, orders, or inbox metadata.
  • Add accessors for labels or badges.
  • Add custom scopes for conversation lists.
  • Keep participants() and messages() intact because most of Wirechat depends on them.

#Participant

Participant is the membership pivot model between a conversation and one of your application's models. This is the model that makes Wirechat polymorphic.

It is one of the most important models to keep compatible because message visibility, role checks, and delete or clear behavior all depend on it.

Core fields:

Field Purpose
conversation_id The thread this membership belongs to.
participantable_id The underlying app model id.
participantable_type The morph class for the underlying app model.
role Participant role enum, including owner and admin.
exited_at Timestamp used to mark members who left a conversation.
last_active_at Participant activity timestamp.
conversation_read_at Last read marker for unread logic.
conversation_cleared_at Last clear-history marker.
conversation_deleted_at Last delete-for-user marker.

#Important Relationships

participantable(): MorphTo

Returns the real app model behind the participant row.

$participant->participantable

conversation(): BelongsTo

Returns the parent conversation.

$participant->conversation

messages(): HasMany

Returns messages sent by this participant.

$participant->messages()->latest()->get()

latestMessage(): HasOne

Resolves the most recent message sent by this participant.

$participant->latestMessage

actions(): MorphMany

Returns actions recorded against this participant row.

$participant->actions()->latest()->get()

performedActions(): MorphMany

Returns actions this participant performed as an actor.

$participant->performedActions()->latest()->get()

#Important Helpers

isAdmin(): bool

Returns true when the participant is an admin or the owner. The owner is also treated as an admin by Wirechat.

$participant->isAdmin()

isOwner(): bool

Returns true only when this participant is the conversation owner.

$participant->isOwner()

exitConversation(): bool

Marks the participant as exited while enforcing package rules such as blocking owners from leaving their own group.

$participant->exitConversation()

hasExited(): bool

Checks whether the participant already left the conversation.

$participant->hasExited()

isRemovedByAdmin(): bool

Checks whether an admin-removal action exists for this participant.

$participant->isRemovedByAdmin()

removeByAdmin(Model|Authenticatable $admin): void

Records the admin-removal action using the admin's participant row as the actor, then downgrades the removed member's role.

$participant->removeByAdmin(auth()->user())

hasDeletedConversation(bool $checkDeletionExpired = false): bool

Checks whether delete-for-user is active, optionally comparing it against the conversation's latest update timestamp.

$participant->hasDeletedConversation(true)

#Important Query Helpers

whereParticipantable(Model|Authenticatable $model)

Filters participant rows for a specific app model.

Participant::query()->whereParticipantable(auth()->user())->first()

withExited()

Includes participants hidden by the default exited scope.

Participant::query()->withExited()->get()

withoutParticipantable(Model|Authenticatable $user)

Excludes a specific participantable model from the query.

$conversation->participants()->withoutParticipantable(auth()->user())->get()

Typical safe customizations:

  • Add participant-level profile helpers.
  • Add app-specific membership metadata.
  • Add convenience methods that build on role and visibility state.
  • Preserve the default global scopes unless you are intentionally replacing the membership rules.

#Message

Message represents an individual chat item inside a conversation.

Wirechat now treats participant_id as the source of truth for ownership and sender identity, so this model should stay aligned with the participant layer.

Core fields:

Field Purpose
conversation_id Parent conversation id.
participant_id Sender participant id.
reply_id Parent message reference for threaded replies.
body Message text or link content.
type Message type enum such as text, link, or attachment.
kept_at Timestamp used to preserve a disappearing message.
deleted_at Soft-delete timestamp for delete-for-everyone flows.

Wirechat will automatically keep the type aligned with the message body. If the body contains a recognized URL, the message is marked as link. If the body no longer contains a URL, the type falls back to text. Attachment messages remain attachment regardless of body changes.

#Important Relationships

conversation(): BelongsTo

Returns the parent conversation.

$message->conversation

participant(): BelongsTo

Returns the sender participant row.

$message->participant

attachment(): MorphOne

Returns the attachment model when the message is an attachment message.

$message->attachment

parent(): BelongsTo

Returns the message this message replies to.

$message->parent

reply(): HasOne

Returns a reply to this message when one exists.

$message->reply

actions(): MorphMany

Returns actions recorded against the message, including delete-for-user actions.

$message->actions()->latest()->get()

#Important Accessors And Helpers

$message->user

Returns the underlying app model behind the sender participant.

$sender = $message->user

$message->sendable

Legacy accessor alias for $message->user. New code should prefer $message->user.

$message->sendable

$message->sendable_id

Legacy accessor for the underlying participantable id. New code should usually work through $message->participant or $message->user instead.

$message->sendable_id

$message->sendable_type

Legacy accessor for the underlying participantable morph type. New code should usually work through $message->participant or $message->user instead.

$message->sendable_type

$message->resolved_link

Normalizes the message body into a full URL when it is a standalone link. Returns null for mixed-content messages.

$url = $message->resolved_link

hasAttachment(): bool

Checks whether the message currently has an attachment record.

$message->hasAttachment()

isAttachment(): bool

Checks whether the message type is attachment.

$message->isAttachment()

isLink(): bool

Checks whether the message type is link. This is set automatically when the message body contains a URL.

$message->isLink()

readBy(Model|Participant $user): bool

Checks whether the message is effectively read for a participant or app model.

$message->readBy(auth()->user())

ownedBy(Model $user): bool

Checks whether the given user owns the message by comparing against the sender participant.

$message->ownedBy(auth()->user())

belongsToAuth(): bool

Checks whether the currently authenticated user owns the message.

$message->belongsToAuth()

hasReply(): bool

Checks whether this message already has a reply.

$message->hasReply()

hasParent(): bool

Checks whether this message is itself a reply to another message.

$message->hasParent()

deleteFor(Model|Authenticatable $user): ?bool

Deletes the message only for one participant and may fully delete it once package rules are satisfied.

$message->deleteFor(auth()->user())

deleteForEveryone(Model $user): void

Deletes the message globally when the actor owns it or when a group admin is allowed to do it.

$message->deleteForEveryone(auth()->user())

isEmoji(): bool

Checks whether the message body contains only emoji characters.

$message->isEmoji()

#Important Query Helpers

whereIsNotOwnedBy(Model|Authenticatable $user)

Filters out messages owned by the given participantable model.

$conversation->messages()->whereIsNotOwnedBy(auth()->user())->get()

#Group

Group stores the metadata for group conversations. The actual thread still lives in Conversation; Group adds group-specific information on top of it.

This model is a good place to add app-specific room metadata, as long as you keep the package permission flags intact.

Core fields:

Field Purpose
conversation_id Parent conversation id for the group thread.
name Group name.
description Group description or about text.
type Group type enum.
allow_members_to_send_messages Permission flag for regular members.
allow_members_to_add_others Permission flag for inviting others.
allow_members_to_edit_group_info Permission flag for editing name and description.
admins_must_approve_new_members Approval flag used during join flows.

#Important Relationships

conversation(): BelongsTo

Returns the parent conversation.

$group->conversation

cover(): MorphOne

Returns the cover attachment model when the group has one.

$group->cover

#Important Accessors And Helpers

$group->cover_url

Returns the resolved URL for the cover attachment when it exists.

$group->cover_url

isOwnedBy(Model|Authenticatable $user): bool

Checks whether the given app model is the owner of the group conversation.

$group->isOwnedBy(auth()->user())

allowsMembersToSendMessages(): bool

Checks whether regular members are allowed to send messages in the group.

$group->allowsMembersToSendMessages()

allowsMembersToAddOthers(): bool

Checks whether regular members are allowed to invite other users.

$group->allowsMembersToAddOthers()

allowsMembersToEditGroupInfo(): bool

Checks whether regular members can update the group name or description.

$group->allowsMembersToEditGroupInfo()

$group->admins_must_approve_new_members

This is a stored flag rather than a helper method and is useful when building join-request or approval flows.

$group->admins_must_approve_new_members

Typical safe customizations:

  • Add organization or workspace relations.
  • Add visual metadata such as themes, tags, or external ids.
  • Add read-only accessors for display data.
  • Preserve the permission flags and cover-cleanup behavior.

#Attachment

Attachment stores uploaded file metadata for attachable records. In practice, this is commonly used for message attachments and group covers.

This model is tightly coupled to storage config, URL generation, and file cleanup.

Core fields:

Field Purpose
attachable_id Parent model id.
attachable_type Parent model morph class.
file_path Storage path on the configured disk.
file_name Stored file name.
original_name Original uploaded file name.
mime_type Stored MIME type.
url Computed URL accessor based on storage config.

#Important Relationships

attachable(): MorphTo

Returns the model this attachment belongs to, usually a Message or Group.

$attachment->attachable

#Important Accessors

$attachment->url

Returns a public URL or a temporary private URL depending on the global Wirechat storage configuration.

$attachment->url

$attachment->size

Returns the file size in bytes when the file exists on disk.

$attachment->size

$attachment->formatted_size

Returns the file size in a human-readable format.

$attachment->formatted_size

$attachment->extension

Returns an extension-like value derived from the MIME type.

$attachment->extension

$attachment->clean_mime_type

Returns the MIME subtype, such as png, pdf, or zip.

$attachment->clean_mime_type

If you customize this model, preserve the filesystem cleanup behavior unless you are intentionally replacing it with your own storage lifecycle.


#Action

Action is Wirechat's audit-style polymorphic action model. It is used for package-level state changes such as deletions and admin removals.

Core fields:

Field Purpose
actionable_id Target model id.
actionable_type Target model morph class.
actor_id Actor model id.
actor_type Actor model morph class.
type Action enum such as delete or removed-by-admin.
data Optional extra payload for the action.

#Important Relationships

actionable(): MorphTo

Returns the model the action was performed on.

$action->actionable

actor(): MorphTo

Returns the model that performed the action. In package flows this is often a Participant.

$action->actor

#Important Query Helpers

whereActor(Model $actor)

Filters actions created by a specific actor.

Action::query()->whereActor($participant)->get()

withoutActor(Model $model)

Excludes actions created by a specific actor.

Action::query()->withoutActor($participant)->get()

Common package uses:

  • Message delete-for-user actions.
  • Participant removed-by-admin actions.
  • Other audit-style state changes attached to conversations, messages, or participants.

If you add custom action types in your app, extending this model is a clean place to centralize action formatting or app-specific helpers.


#Security And Consistency

The goal of configurable models is to give your team flexibility without forcing a fork of the package. Wirechat still protects the integration by requiring each configured model to extend the matching base model.

That gives you a strong default contract:

  • Package queries, jobs, scopes, and relationships continue to work.
  • Core authorization and visibility logic stays in place unless you intentionally override it.
  • Your team can focus on domain-specific behavior instead of rebuilding the package internals.

If your developers only need custom labels, extra relations, metadata, or app-specific helper methods, subclassing the default models is the safest path.