The Messages module provides a secure, role-aware messaging system for communication between couples and vendors within the Wedissimo platform.
Overview
The Messages module facilitates private communication between platform users, primarily couples seeking vendors and vendors responding to inquiries. Built on a thread-based architecture, it ensures conversations remain organized, secure, and performant.
Key Features
- Thread-based conversations: Messages are organized into threads between two participants
- Role-aware permissions: Couples can initiate threads with vendors; vendors can only respond
- Read tracking: Automatic tracking of read/unread status per participant
- Content moderation: Flagging system for messages containing potential contact information
- Automated reminders: Bidirectional escalating reminders when messages need responses
- AI-powered detection: Vertex AI determines if messages require a response (with heuristic pre-filtering)
- Performance optimized: Cached unread counts, indexed queries, PostgreSQL partial indexes, and N+1 prevention
- Soft deletes: Complete audit trail with recoverable message history
Architecture
Module Structure
modules/Messages/
├── Config/
│ └── config.php # Module configuration (incl. reminders)
├── Console/
│ └── Commands/
│ └── SendMessageRemindersCommand.php # Scheduled reminder dispatch
├── Database/
│ └── Migrations/ # Database schema
├── Events/
│ └── ParticipantRespondedToThread.php # Fired when participant responds
├── Http/
│ ├── Controllers/
│ │ └── MessageThreadController.php # API endpoints
│ ├── Requests/ # Form validation
│ └── Resources/ # API response formatting
├── Jobs/
│ ├── CheckMessageNeedsResponseJob.php # AI check + schedule reminders
│ ├── ScanMessageJob.php # Content moderation scanning
│ └── SendSingleReminderJob.php # Dispatch individual reminder
├── Listeners/
│ └── CancelRemindersOnResponse.php # Cancel when recipient responds
├── Models/
│ ├── Message.php # Individual messages
│ ├── MessageThread.php # Conversation threads
│ └── MessageThreadReminder.php # Pre-scheduled reminders
├── Notifications/
│ ├── NewMessageNotification.php # Email notifications
│ └── UnrespondedMessageReminderNotification.php # Escalating reminders
├── OpenAPI/
│ └── messages.yaml # API documentation
├── Services/
│ ├── MessageReminderService.php # Reminder business logic
│ ├── MessageThreadService.php # Thread queries & caching
│ └── VertexAiScanningService.php # AI scanning (disintermediation + needs-response)
└── Tests/
├── Feature/
│ └── MessageReminderTest.php # Reminder integration tests
└── Unit/
└── VertexAiScanningServiceTest.php # AI service unit testsDesign Principles
Thread-Centric: All messages belong to a thread with exactly two participants. This simplifies permissions, notifications, and UI rendering.
Role-Based Initiation: Couples can start new threads with vendors to ask questions. Vendors cannot initiate threads but can respond to any thread they're part of. This prevents unsolicited vendor outreach.
Automatic Read Tracking: When a user views a thread's messages, the system automatically marks that thread as read for them. No manual "mark as read" action required.
Performance First: Unread counts are cached for 5 minutes and invalidated on new messages. Database queries use composite indexes and eager loading to prevent N+1 problems.
Core Entities
MessageThread
Primary Entity: Represents a conversation between a couple and a vendor
Key Attributes:
id(UUID)subject(nullable string, max 255)vendor_id(FK to Vendor)user_id(FK to User - the couple who initiated)last_message_at(timestamp)
Relationships:
vendor- BelongsTo Vendoruser- BelongsTo User (couple)messages- HasMany Messagereads- HasMany MessageReadflags- HasMany MessageFlag
Model Location: modules/Messages/Models/MessageThread.php
Message
Entity: Individual message within a thread
Key Attributes:
id(UUID)thread_id(FK to MessageThread)sender_id(FK to User)body(text, max 5000 chars)body_redacted(nullable text - sanitized version)status(enum: pending, approved, flagged, blocked, redacted)risk_score(float 0.0-1.0)detected_patterns(JSON array)deleted_at(nullable timestamp - soft delete)
Soft Deletes: Messages use soft deletes to maintain audit trails for compliance and dispute resolution. Soft-deleted messages remain in the database but are hidden from API responses and excluded from unread counts.
Statuses:
pending- Awaiting scan resultsapproved- Clean, no patterns detectedflagged- Low-risk patterns, flagged for admin reviewredacted- Medium-risk, contact info replaced with [REDACTED]blocked- High-risk, hidden from recipient
Relationships:
thread- BelongsTo MessageThreadsender- BelongsTo Userflags- HasMany MessageFlag
Model Location: modules/Messages/Models/Message.php
MessageRead
Entity: Tracks read status per user per thread
Key Attributes:
id(UUID)thread_id(FK to MessageThread)user_id(FK to User)last_read_message_id(nullable FK to Message)last_read_at(timestamp)
Purpose: Enables unread count calculation for thread lists
Model Location: modules/Messages/Models/MessageRead.php
MessageFlag
Entity: Admin review flags for suspicious messages
Key Attributes:
id(UUID)message_id(FK to Message)flagged_by_user_id(nullable FK to User - null for system flags)reason(text)status(enum: pending, reviewed, dismissed)risk_score(float)detected_patterns(JSON array)
Model Location: modules/Messages/Models/MessageFlag.php
MessageThreadReminder
Entity: Pre-scheduled reminder for unanswered messages
Key Attributes:
id(UUID)thread_id(FK to MessageThread)trigger_message_id(FK to Message - the message needing response)recipient_id(FK to User - who should be reminded)recipient_type(string: 'vendor' or 'couple')level(tinyint: 1, 2, or 3 - escalation level)scheduled_for(timestamp - when to send)sent_at(nullable timestamp)cancelled_at(nullable timestamp)cancellation_reason(nullable string: 'recipient_responded', 'thread_closed')
Relationships:
thread- BelongsTo MessageThreadtriggerMessage- BelongsTo Messagerecipient- BelongsTo User
Model Location: modules/Messages/Models/MessageThreadReminder.php
Bidirectional Logic:
| Message Direction | AI Says Needs Response | Who Gets Reminded |
|---|---|---|
| Couple → Vendor | Yes | Vendor (recipient) |
| Vendor → Couple | Yes | Couple (recipient) |
Domain Services
MessageThreadService
Location: modules/Messages/Services/MessageThreadService.php
Responsibilities:
- Query optimization for thread lists
- Handles complex eager loading and unread counts
- Prevents N+1 query problems
Key Methods:
class MessageThreadService
{
public function getUserThreads(mixed $user, int $perPage): LengthAwarePaginator;
}MessageScanningService
Location: modules/Messages/Services/MessageScanningService.php
Responsibilities:
- Pattern detection using regex (UK phone numbers, emails, URLs, social handles)
- Risk score calculation based on weighted pattern matches
- Content redaction with [REDACTED] placeholders
- Automatic admin flag creation
Pattern Detection:
- UK Mobile:
07XXX XXXXXX,+44 7XXX XXXXXX(weight: 0.9) - UK Landline:
0XXXX XXXXXX,+44 XXXX XXXXXX(weight: 0.8) - Email: Standard email format (weight: 0.85)
- URL:
https://...,www....(weight: 0.7) - Social Handle:
@username(weight: 0.6)
Risk Thresholds:
>= 0.5- Redact content>= 0.3- Flag for review< 0.3- Auto-approve
Key Methods:
class MessageScanningService
{
public function scanMessage(Message $message): void;
public function shouldScanMessage(Message $message): bool;
}VertexAiScanningService
Location: modules/Messages/Services/VertexAiScanningService.php
Responsibilities:
- AI-powered disintermediation detection
- AI-powered needs-response detection (for reminders)
- Heuristic pre-filtering to reduce AI costs
Key Methods:
class VertexAiScanningService
{
public function scanForDisintermediation(string $message): array;
public function scanForAvailability(string $message): array;
public function scanForNeedsResponse(string $message): array;
public function isEnabled(): bool;
}scanForNeedsResponse Logic:
- Heuristic pre-filter (reduces AI costs):
- Messages with
?→needs_response: true(confidence:heuristic) - Short messages with "thank", "ok", "great" →
needs_response: false
- Messages with
- AI fallback for ambiguous messages → Calls Vertex AI (Gemini 2.0 Flash)
- Default when AI disabled →
needs_response: true(safe default)
MessageReminderService
Location: modules/Messages/Services/MessageReminderService.php
Responsibilities:
- Query due reminders efficiently (uses PostgreSQL partial index)
- Send reminders with pessimistic locking (prevents duplicates)
- Cancel reminders when recipient responds
Key Methods:
class MessageReminderService
{
public function getDueReminders(): Collection;
public function sendReminder(MessageThreadReminder $reminder): bool;
public function cancelPendingRemindersForRecipient(
MessageThread $thread,
User $recipient,
string $reason = 'recipient_responded'
): int;
public function cancelAllPendingReminders(
MessageThread $thread,
string $reason = 'thread_closed'
): int;
}Pessimistic Locking: sendReminder() uses lockForUpdate() within a transaction to prevent race conditions when multiple scheduler processes run simultaneously.
Background Jobs
ScanMessageJob
Location: modules/Messages/Jobs/ScanMessageJob.php
Purpose: Asynchronously scan messages after creation
Configuration:
- Queue: default
- Retries: 3 attempts
- Timeout: 60 seconds
- Fail-open: Approves message if all retries fail (UX over security)
Dispatched From:
MessageThreadController::store()- New thread creationMessageController::store()- Reply to thread
CheckMessageNeedsResponseJob
Location: modules/Messages/Jobs/CheckMessageNeedsResponseJob.php
Purpose: Check if a message needs a response and schedule reminders
Configuration:
- Queue: default
- Retries: 3 attempts
- Timeout: 30 seconds
- Backoff: 60s, 120s, 300s (escalating)
- Fail-safe: Schedules reminders if AI check fails after all retries
Process Flow:
- Check if reminders are enabled globally
- Check if reminders are enabled for recipient type (vendor/couple)
- Guard against race condition (recipient may have already responded)
- Call
VertexAiScanningService::scanForNeedsResponse() - If
needs_response: true, schedule 3 reminder levels (24h, 72h, 168h)
Dispatched From:
MessageService::sendMessage()- After any message creation
SendSingleReminderJob
Location: modules/Messages/Jobs/SendSingleReminderJob.php
Purpose: Send an individual reminder notification with duplicate prevention
Configuration:
- Queue: default
- Retries: 3 attempts
- Timeout: 30 seconds
Process: Uses pessimistic locking via MessageReminderService::sendReminder() to ensure only one worker sends the reminder even if multiple schedulers run.
Scheduled Commands
SendMessageRemindersCommand
Location: modules/Messages/Console/Commands/SendMessageRemindersCommand.php
Signature: php artisan messages:send-reminders
Schedule: Every 5 minutes (configured in app/Console/Kernel.php)
Process:
- Query due reminders using efficient partial index
- Dispatch individual
SendSingleReminderJobfor each reminder - Jobs handle locking and actual notification send
Scheduler Configuration:
$schedule->command('messages:send-reminders')
->everyFiveMinutes()
->withoutOverlapping()
->onOneServer();Events & Listeners
ParticipantRespondedToThread
Location: modules/Messages/Events/ParticipantRespondedToThread.php
Fired When: Any participant (vendor or couple) sends a message in a thread
Payload:
thread- The MessageThreadsender- The User who sent the messagesenderType- 'vendor' or 'couple'
CancelRemindersOnResponse
Location: modules/Messages/Listeners/CancelRemindersOnResponse.php
Listens To: ParticipantRespondedToThread
Action: Cancels all pending reminders where the sender was the intended recipient (they responded, so no reminder needed)
Reminder Configuration
Configuration in modules/Messages/Config/config.php:
'reminders' => [
'enabled' => env('MESSAGE_REMINDERS_ENABLED', true),
'recipients' => [
'vendor' => env('MESSAGE_REMINDERS_VENDOR_ENABLED', true),
'couple' => env('MESSAGE_REMINDERS_COUPLE_ENABLED', true),
],
'levels' => [
1 => ['min_hours' => 24], // First reminder at 24h
2 => ['min_hours' => 72], // Second at 3 days
3 => ['min_hours' => 168], // Third at 7 days
],
'ai_prompt' => "The client wrote a message: ':message'. Please analyze whether the client is waiting for an answer to this question. Answer in one word: if you need a reply to the message, write 'Yes'. If the answer is optional, write 'No'.",
],Environment Variables:
| Variable | Default | Description |
|---|---|---|
MESSAGE_REMINDERS_ENABLED | true | Master switch for all reminders |
MESSAGE_REMINDERS_VENDOR_ENABLED | true | Enable reminders to vendors |
MESSAGE_REMINDERS_COUPLE_ENABLED | true | Enable reminders to couples |
OpenAPI Documentation
Location: modules/Messages/OpenAPI/messages.yaml
Schemas: Separate files in modules/Messages/OpenAPI/schemas/
MessageThread.yamlMessage.yamlCreateThreadRequest.yamlCreateMessageRequest.yamlVendorSummary.yamlUserSummary.yaml
Bundling: Run npm run openapi:bundle to generate openapi/openapi.yaml
Authorization
Policies
MessageThreadPolicy (modules/Messages/Policies/MessageThreadPolicy.php):
viewAny- Any authenticated userview- User is thread participant (couple OR vendor owner)create- Any authenticated userreply- User is thread participant
MessagePolicy (modules/Messages/Policies/MessagePolicy.php):
reply- Handled by MessageThreadPolicy
Policy Registration
Policies are registered in MessagesServiceProvider (not main AuthServiceProvider) for module self-containment:
protected array $policies = [
MessageThread::class => MessageThreadPolicy::class,
Message::class => MessagePolicy::class,
];Security Features
Anti-Disintermediation
Prevents users from exchanging contact information to bypass platform:
- All messages created with
status = 'pending' ScanMessageJobdispatched to queue- Regex patterns detect UK phone numbers, emails, URLs, social handles
- Risk score calculated based on pattern weights
- Content redacted or flagged based on thresholds
- Admin receives flag for manual review
Rate Limiting
Thread Creation: 10 per minute per user Replies: 60 per minute per user
Exception Handling
Server-side errors are logged but generic messages returned to client:
{
"message": "Failed to send message. Please try again."
}No stack traces or sensitive data leaked.
Foreign Key Constraints
message_reads.last_read_message_id has foreign key with onDelete('set null') to prevent orphaned references.
API Endpoints
The Messages API provides endpoints for thread-based messaging between couples and vendors.
Base Path: /api/v1/messages
Key Endpoints:
GET /unread-count- Global unread thread count (cached 5 minutes)GET /threads- List user's message threads with paginationPOST /threads- Create new thread (rate limited: 10/min)GET /threads/{id}- View thread and messages (auto-marks as read)POST /threads/{id}/messages- Reply to thread (rate limited: 60/min)
Postman Integration
The bundled OpenAPI spec is automatically synced to Postman via CI/CD. Import the collection from the Postman workspace for interactive testing.
Key Behaviors
- Viewing a thread automatically marks it as read and clears cache
- Messages created with
status: "pending", scanned asynchronously - Unread count returns number of threads with unread messages, not total message count
- All contact information (email, phone) hidden from responses for anti-disintermediation
For detailed request/response schemas, authentication, and interactive testing, see the full API documentation
Content Moderation
Automatic Flagging
Messages are automatically scanned for potential contact information including:
- Phone numbers (various formats)
- Email addresses
- URLs and domains
- Social media handles
Flag States
Flagged: is_flagged: true with flagged_reason populated (e.g., "contact_info", "inappropriate")
Not Flagged: is_flagged: false with flagged_reason: null
Admin Workflow
- Query flagged messages using
is_flaggedindex - Review message in context of full thread
- Take action: warn user, delete message, or clear flag
- Soft-deleted messages remain in database for compliance
Performance & Caching
Caching Strategy
Cache TTL: 5 minutes for all unread counts
Cache Keys:
- Global unread count:
user:{userId}:messages:unread-count - Per-thread unread: Calculated on-demand, not cached separately
Why 5 Minutes?
The system balances real-time accuracy with performance. For a messaging system, a 5-minute delay in badge counts is acceptable because:
- Most users don't switch between devices within 5 minutes
- Viewing a thread immediately clears the cache, showing accurate counts
- Reduced database load allows the system to handle more concurrent users
Automatic Cache Invalidation:
Cache automatically clears when:
- User marks thread as read via
MessageThread::markAsReadBy() - New message created in any thread user participates in
- Message status changes to
approved(scanning complete)
Manual Cache Clearing (for debugging):
docker compose exec -T wedissimo-api php artisan tinker
>>> Cache::forget('user:' . $userId . ':messages:unread-count');N+1 Prevention
MessageThreadService::getUserThreads() uses eager loading to prevent N+1 query problems:
Eager Loading:
MessageThread::with([
'vendor',
'user',
'messages' => fn($q) => $q->latest()->limit(1),
])->withCount('unreadMessages')->get();Impact:
- Before optimization: 60-80 queries for 20 threads
- After optimization: ~5 queries total
Query Reduction:
with()- Eager load relationships in bulkwithCount()- Efficient unread counts (single aggregation query vs 3-4 per thread)latest()->limit(1)- Load only most recent message per thread (Laravel 12 feature)
Database Indexes
Optimized for unread count queries (reduces query time from ~200ms to <50ms):
Messages Table:
idx_messages_unread_lookup- Composite index on(thread_id, sender_id, created_at)idx_messages_status_date- Index on(status, created_at)for approved messagesmessages_sender_id_index- Foreign key index onsender_id
Message Reads Table:
idx_reads_lookup- Composite index on(thread_id, user_id, last_read_message_id)message_reads_last_read_message_id_foreign- Foreign key index withonDelete('set null')
Message Threads Table:
threads_last_response_idx- Index onlast_response_atfor filtering
Message Thread Reminders Table:
reminders_due_idx- PostgreSQL partial index:scheduled_for WHERE sent_at IS NULL AND cancelled_at IS NULLreminders_cancel_idx- Compound index on(thread_id, recipient_id, sent_at, cancelled_at)unique_reminder- Unique constraint on(thread_id, trigger_message_id, level)
Migrations:
modules/Messages/Database/Migrations/2025_11_11_210244_add_message_performance_indexes.phpmodules/Messages/Database/Migrations/2025_12_03_xxxxxx_create_message_thread_reminders_table.phpmodules/Messages/Database/Migrations/2025_12_03_185736_add_missing_indexes_to_message_reminder_tables.php
Testing
Test Coverage
Location: modules/Messages/Tests/
Test Suites:
Feature/MessagingFlowTest.php- End-to-end messaging workflowsFeature/AntiDisintermediationTest.php- Privacy and security validationFeature/GlobalUnreadCountTest.php- Unread count accuracy and caching behaviorFeature/MessageReminderTest.php- Reminder scheduling, sending, and cancellationUnit/VertexAiScanningServiceTest.php- AI service includingscanForNeedsResponse()
Key Test Scenarios
Messaging Flow:
- Thread creation and replies
- Read/unread tracking
- Authorization checks
- Validation errors
- Pagination and ordering
Anti-Disintermediation:
- No email/phone exposure in any API response
- Vendor contact info hidden from couples
- Couple contact info hidden from vendors
- Booking URL safely exposed (platform-hosted)
Unread Count Testing:
- Global unread count calculation
- Cache behavior (TTL, invalidation triggers)
- Multi-thread unread scenarios
- Soft-deleted messages excluded from counts
Message Reminders:
- AI needs-response detection (heuristic and AI fallback)
- Reminder scheduling at correct intervals (24h, 72h, 168h)
- Reminders cancelled when recipient responds
- Race condition handling (recipient responds before AI check)
- Escalating notification tone (friendly → urgent → final)
- Pessimistic locking prevents duplicate sends
Running Tests
# All Messages tests
docker compose exec -T wedissimo-api vendor/bin/pest modules/Messages/Tests/
# Specific test suite
docker compose exec -T wedissimo-api vendor/bin/pest --filter="GlobalUnreadCount"
# With coverage
docker compose exec -T wedissimo-api vendor/bin/pest modules/Messages/Tests/ --coverageFuture Enhancements
Important: The features listed below are aspirational ideas for potential future development. They are NOT currently implemented and should not be treated as committed roadmap items. These represent possibilities that may be considered based on user feedback and business priorities.
Potential Features Under Consideration
- AI-Assisted Scanning - Use AI to validate regex matches and reduce false positives in contact info detection
- Typesense Search - Full-text search within messages for faster conversation discovery
- Archive Functionality - Allow users to hide threads without deletion
- MessageThread Soft Deletes - Recoverable deletion for threads (Messages already use soft deletes)
- Read Receipts - Show timestamp when recipient viewed message
- Typing Indicators - Real-time "User is typing..." notifications via WebSockets
Not Planned (Out of Scope)
- Message Editing - Not planned due to audit trail concerns and policy compliance
- User Message Deletion - Not planned due to dispute resolution and compliance requirements
- Admin API Endpoints - Laravel admin panel provides sufficient admin functionality
Related Documentation
Guides
- Common Scenarios - Real-world messaging workflows
- Troubleshooting - Common issues and solutions
- Full API Reference - Interactive OpenAPI documentation
Project Guides
- Module System Guide - How modules work
- API Documentation Guide - OpenAPI workflow
- Testing Guide - Running tests
Last Updated: 2025-12-03 Module Version: 1.1 (FOU-59: Message Reminders) Maintainer: Wedissimo Platform Team