Looking to hire Laravel developers? Try LaraJobs

laravel-support-tickets maintained by rasmuscnielsen

Description
Email-based support tickets for Laravel. IMAP inbox sync, threading, drafts, lifecycle events and AI-powered similarity search.
Last update
2026/07/23 23:23 (dev-main)
License
Links
Downloads
0

Comments
comments powered by Disqus

Laravel Support Tickets

Email-based support tickets for Laravel: IMAP inbox sync, threading, drafts, lifecycle events, and AI-powered similarity search.

This package turns one or more shared mailboxes into a full support ticket backend. It polls your inboxes over IMAP, parses raw MIME into clean reply/signature/quote segments, threads messages into tickets, and sends your replies back out through Laravel's mailer with correct In-Reply-To/References headers so conversations stay threaded in your customers' mail clients.

It is a headless package: models, actions, workflows, jobs, commands, and events, but no UI. Build your agent-facing interface on top with Filament, Inertia, Livewire, or anything else.

  • Inbox sync: poll IMAP folders incrementally, store raw MIME, dedupe by provider UID and Message-ID. Or skip IMAP and feed the ingest workflow from inbound-mail webhooks.
  • Parsing: extract reply text, signature, and quoted trail from both plain text and HTML emails (via zbateson/mail-mime-parser and willdurand/email-reply-parser), with UTF-8 scrubbing for hostile input.
  • Threading: resolve inbound mail to existing tickets through In-Reply-To, References, and subject/participant heuristics with a confidence score.
  • Drafts & replies: compose rich HTML drafts with quoted parent content and per-agent signatures, then send immediately or on a schedule, idempotently.
  • Lifecycle: assign, resolve, close, reopen, spam, prioritize. Every mutation records an audit TicketEvent.
  • AI similarity: embed ticket conversations via the Laravel AI SDK and find similar historical tickets with pgvector distance search.

Requirements

  • PHP 8.3+, Laravel 12+
  • Any database Laravel supports (MySQL, PostgreSQL, MariaDB, SQLite), with one exception: the AI similarity search feature needs a PostgreSQL connection with the pgvector extension. Everything else works normally; only similarity search is off the table. The ticket tables can also live on a dedicated Postgres connection (see database_connection below) while your app's primary database stays MySQL.

Installation

composer require rasmuscnielsen/laravel-support-tickets

Publish the config and run the migrations:

php artisan vendor:publish --tag=support-tickets-config
php artisan migrate

Schedule the commands that keep mail flowing:

use Illuminate\Support\Facades\Schedule;

Schedule::command('support-tickets:inbox:sync')->everyFiveMinutes();
Schedule::command('support-tickets:drafts:send-scheduled')->everyMinute();
Schedule::command('support-tickets:tickets:close-resolved')->hourly();

How it works

flowchart LR
    customer([Customer]) -- "email" --> mailbox[IMAP mailbox]
    mailbox -- "inbox:sync" --> sync

    subgraph pkg [laravel-support-tickets]
        sync["Sync, parse<br>& thread"] --> db[("Tickets &<br>messages")]
        send["Send threaded<br>reply"]
    end

    subgraph app [Your app]
        ui["Agent UI"]
    end

    db <-- "browse &<br>compose drafts" --> ui
    ui -- "SendDraft" --> send
    send -- "reply" --> customer
  1. Sync: support-tickets:inbox:sync dispatches a SyncInboxMessages job per enabled inbox. The job pulls new messages from the configured IMAP folder (incrementally, using a per-inbox sync cursor) and hands each one to the ReceiveInboundMessage workflow.
  2. Receive: the workflow stores the message (raw MIME included), parses the body into reply/signature/quote segments, resolves it to an existing ticket via threading or opens a new one, and fires InboundMessageReceived.
  3. Reply: your UI reads tickets and messages straight from the models, and composes drafts with UpsertTicketDraft. Dispatching SendDraft runs the SendDraftReply workflow: the draft is rendered with signature and quoted trail, sent through Laravel's mailer with proper threading headers, and marked as sent. OutboundMessageSent fires.
  4. React: configured listeners respond to InboundMessageReceived and OutboundMessageSent. By default the ticket conversation is re-vectorized when vectorization is enabled, and AI-drafted replies hook in the same way.

Data model

All tables are prefixed support_ and all models live in Rasmuscnielsen\LaravelSupportTickets\Models:

Model Table Purpose
Inbox support_inboxes A synced mailbox, linked to a config profile by name.
Ticket support_tickets One conversation: status, priority, requester, assignee, tags, SLA timestamps.
Message support_messages One email or note: direction, type, status, addressing, threading metadata, raw MIME.
MessageContent support_message_contents Parsed body segments: text, html, signature_text, quoted_text, full_text, full_html.
Attachment support_attachments Stored files with disk/path, MIME type, checksum, inline flag.
TicketEvent support_ticket_events Audit trail: every lifecycle change with actor and properties.
Embedding support_embeddings Embeddings of ticket conversations (pgvector on Postgres).

Ticket.requester, Ticket.assignee, Ticket.context, and Message.createdBy are morph relations. Point them at whatever User/Customer/Order models your app has.

Email is the first-class channel, but the schema is deliberately not email-shaped and can host other channels too. More on that under More channels.

Useful entry points:

use Rasmuscnielsen\LaravelSupportTickets\Enums\TicketStatus;
use Rasmuscnielsen\LaravelSupportTickets\Models\Ticket;

Ticket::open()->get();                         // New, Open, or Resolved
$ticket->messages()->delivered()->get();       // what the customer saw
$ticket->drafts;                               // unsent drafts
$ticket->latestMessage->content->text;         // parsed reply text
$ticket->events;                               // audit trail

Configuration

The published config/support-tickets.php at a glance:

return [
    // Connection for all package tables (null = your default connection).
    'database_connection' => env('SUPPORT_TICKETS_DB_CONNECTION'),

    // Enum for Message::$type; swap in your own to add channels.
    'enums' => ['message_type' => MessageType::class],

    // Named bundles of sender identity, inbound IMAP settings and outbound mailer.
    'profiles' => [
        'default' => [
            'sender' => [
                'address' => env('SUPPORT_TICKETS_SENDER_ADDRESS'),
                'name' => env('SUPPORT_TICKETS_SENDER_NAME'),
            ],
            'inbound' => [
                'driver' => 'imap',
                'imap_mailbox' => env('SUPPORT_TICKETS_INBOUND_IMAP_MAILBOX', 'default'),
                'folders' => [
                    'all' => env('SUPPORT_TICKETS_INBOUND_FOLDER_ALL', 'INBOX'),
                    'drafts' => env('SUPPORT_TICKETS_INBOUND_FOLDER_DRAFTS', 'Drafts'),
                ],
            ],
            'outbound' => [
                'driver' => 'mail',
                'mailer' => env('SUPPORT_TICKETS_OUTBOUND_MAILER'),
            ],
        ],
    ],

    // Storage and limits for message attachments.
    'attachments' => [
        'disk' => env('SUPPORT_TICKETS_ATTACHMENTS_DISK', 'local'),
        'max_bytes' => env('SUPPORT_TICKETS_ATTACHMENTS_MAX_BYTES', 5 * 1024 * 1024),
        'allowed_mime_types' => [/* images */],
        'inbound_allowed_mime_types' => [/* images, pdf, office documents, ... */],
        'outbound_allowed_mime_types' => [/* images, pdf, office documents, ... */],
        'outbound_max_files' => env('SUPPORT_TICKETS_OUTBOUND_ATTACHMENTS_MAX_FILES', 5),
    ],

    // Composition of outgoing replies.
    'replies' => [
        'signature_resolver' => NullTicketReplySignatureResolver::class,
        'quote_attribution_date_format' => env('SUPPORT_TICKETS_QUOTE_ATTRIBUTION_DATE_FORMAT', 'd/m/Y H.i'),
    ],

    // Close tickets that stay resolved, via the scheduled command.
    'auto_close_resolved_tickets' => [
        'enabled' => env('SUPPORT_TICKETS_AUTO_CLOSE_RESOLVED_TICKETS_ENABLED', true),
        'after_days' => env('SUPPORT_TICKETS_AUTO_CLOSE_RESOLVED_TICKETS_AFTER_DAYS', 7),
    ],

    // Listeners wired to the package's lifecycle events at boot.
    'listeners' => [
        InboundMessageReceived::class => [VectorizeLifecycleMessage::class],
        OutboundMessageSent::class => [VectorizeLifecycleMessage::class],
    ],

    // Ticket conversation embeddings for similarity search (Postgres + pgvector).
    'vectorization' => [
        'enabled' => env('SUPPORT_TICKETS_VECTORIZATION_ENABLED', false),
        'provider' => env('SUPPORT_TICKETS_VECTORIZATION_PROVIDER'),
        'model' => env('SUPPORT_TICKETS_VECTORIZATION_MODEL'),
        'redactor' => BasicContentRedactor::class,
    ],
];

Most keys speak for themselves. The ones with more going on each have a section below: Signatures, Events for the listener map, and AI similarity search for the redactor and vectorization.

Profiles and inboxes

The mail-facing configuration is organized around profiles: named bundles of sender identity, inbound IMAP settings, and outbound mailer. Each Inbox row points at a profile via its profile column, so several inboxes can share one profile or each have their own.

imap_mailbox refers to a mailbox defined in the imap.php config from directorytree/imapengine-laravel, which this package uses for IMAP access. Configure your host and credentials there.

Create an inbox and syncing starts on the next scheduler tick:

use Rasmuscnielsen\LaravelSupportTickets\Models\Inbox;

Inbox::create([
    'name' => 'Support',
    'profile' => 'default',
    'enabled' => true,
]);

Replying to tickets

Compose or update a draft with UpsertTicketDraft. It builds the outgoing content (your text, the resolved agent signature, and a quoted trail of the parent message) in both HTML and plain text:

use Rasmuscnielsen\LaravelSupportTickets\Actions\UpsertTicketDraft;
use Rasmuscnielsen\LaravelSupportTickets\Jobs\SendDraft;

$draft = app(UpsertTicketDraft::class)->handle(
    ticket: $ticket,
    parentMessage: $ticket->latestMessage,
    text: 'Hi Jane, your order shipped this morning.',
    html: '<p>Hi Jane, your order shipped this morning.</p>',
);

SendDraft::dispatch($draft);

Sending is idempotent: the draft gets a stable Message-ID before the mailer is invoked, so a retried job can't double-send. Failures mark the draft FailedSending and rethrow.

To schedule a reply instead of sending immediately, mark the draft Scheduled with a send time (pass status and scheduled_send_at via the attributes argument, or set them on the draft). The support-tickets:drafts:send-scheduled command queues due drafts, and holds any draft whose ticket has received a newer customer message in the meantime, so you never fire a stale scheduled reply.

Signatures

Bind your own resolver to give each agent a personal signature on outgoing replies:

// config/support-tickets.php
'replies' => [
    'signature_resolver' => App\Support\AgentSignatureResolver::class,
    // ...
],
use Rasmuscnielsen\LaravelSupportTickets\Contracts\ResolvesTicketReplySignature;
use Rasmuscnielsen\LaravelSupportTickets\Models\Message;
use Rasmuscnielsen\LaravelSupportTickets\ValueObjects\TicketReplySignature;

class AgentSignatureResolver implements ResolvesTicketReplySignature
{
    public function resolve(Message $message): ?TicketReplySignature
    {
        return new TicketReplySignature(
            text: "Regards\n{$message->createdBy?->name}",
            html: "<p>Regards<br>{$message->createdBy?->name}</p>",
        );
    }
}

The default NullTicketReplySignatureResolver adds no signature.

Internal notes

Notes live on the ticket as messages of type Note. They are never emailed, but they are part of the conversation and the audit trail:

use Rasmuscnielsen\LaravelSupportTickets\Actions\CreateInternalNote;

app(CreateInternalNote::class)->handle($ticket, 'Customer called. Refund approved.', actor: $user);

UpdateInternalNote and DeleteInternalNote complete the set.

Ticket lifecycle

Each lifecycle mutation is a single-purpose action that updates the ticket, stamps the relevant timestamp, and records a TicketEvent with the acting model:

use Rasmuscnielsen\LaravelSupportTickets\Actions\AssignTicket;
use Rasmuscnielsen\LaravelSupportTickets\Actions\ChangeTicketPriority;
use Rasmuscnielsen\LaravelSupportTickets\Actions\CloseTicket;
use Rasmuscnielsen\LaravelSupportTickets\Actions\MarkTicketAsSpam;
use Rasmuscnielsen\LaravelSupportTickets\Actions\ReopenTicket;
use Rasmuscnielsen\LaravelSupportTickets\Actions\ResolveTicket;
use Rasmuscnielsen\LaravelSupportTickets\Enums\TicketPriority;

app(AssignTicket::class)->handle($ticket, $agent, actor: $user);
app(ChangeTicketPriority::class)->handle($ticket, TicketPriority::Urgent, actor: $user);
app(ResolveTicket::class)->handle($ticket, actor: $user);
app(CloseTicket::class)->handle($ticket, actor: $user);
app(ReopenTicket::class)->handle($ticket, actor: $user);
app(MarkTicketAsSpam::class)->handle($ticket, actor: $user);

Statuses flow New → Open → Resolved → Closed (plus Spam). Resolved tickets are auto-closed by the scheduled command after auto_close_resolved_tickets.after_days. A new inbound customer message flips an open or resolved ticket back to New, while closed and spam tickets stay put.

AI similarity search

Enable vectorization to embed each ticket's conversation and search for similar historical tickets. Useful for suggesting past answers to agents, or to an AI drafting assistant:

'vectorization' => [
    'enabled' => env('SUPPORT_TICKETS_VECTORIZATION_ENABLED', false),
    'provider' => env('SUPPORT_TICKETS_VECTORIZATION_PROVIDER'),   // e.g. 'openai'
    'model' => env('SUPPORT_TICKETS_VECTORIZATION_MODEL'),         // e.g. 'text-embedding-3-small'
],

This feature requires the package tables to live on Postgres with pgvector, and it is the only part of the package that does. With vectorization disabled (the default), the vector code paths are never touched and any database driver works.

Vectorization is built on the Laravel AI SDK (laravel/ai), so provider must be one of the providers configured in your app's config/ai.php, which is also where the API credentials live. The package itself holds no AI credentials; if embeddings work in your app via the SDK, they work here.

With vectorization on, the default VectorizeLifecycleMessage listener re-embeds the conversation whenever a message arrives or a reply is sent. Backfill older tickets with support-tickets:tickets:vectorize-missing.

Before content is embedded it passes through the bound RedactsSupportTicketContent implementation. The default BasicContentRedactor strips email addresses and phone numbers. Bind your own via vectorization.redactor for stricter PII handling.

Then:

use Rasmuscnielsen\LaravelSupportTickets\Actions\FindSimilarTickets;

$similar = app(FindSimilarTickets::class)->handle($ticket, limit: 5);

foreach ($similar as $embedding) {
    $embedding->ticket;      // the similar ticket
    $embedding->distance;    // vector distance (lower = more similar)
}

An optional closure lets you constrain the search (e.g. to resolved tickets only):

app(FindSimilarTickets::class)->handle($ticket, filter: fn ($query) => $query
    ->whereHas('ticket', fn ($q) => $q->where('status', TicketStatus::Closed)));

Events

Two package events are your integration points for notifications, auto-triage, AI draft generation, and the like:

Rasmuscnielsen\LaravelSupportTickets\Events\InboundMessageReceived  // ->message
Rasmuscnielsen\LaravelSupportTickets\Events\OutboundMessageSent     // ->message, ->draft

Register listeners the normal Laravel way, or through the package's listeners config map, which the service provider wires at boot:

'listeners' => [
    InboundMessageReceived::class => [
        VectorizeLifecycleMessage::class,
        App\Listeners\NotifySupportChannel::class,
    ],
],

Make it your own

The IMAP-to-mailer pipeline is one good setup, not the only one. Underneath, the package is a set of plain actions, workflows, and events, and each piece can be driven directly or replaced. A few patterns that fall out of that:

Bring your own ingest

IMAP polling is the batteries-included default, not a requirement. The sync job is just one caller of the ReceiveInboundMessage workflow, and everything downstream (threading, parsing, ticket state, events, vectorization) starts there. If your mail arrives some other way (a Mailgun/Postmark/SES inbound webhook, a Microsoft Graph subscription, another system's export), normalize it to an array and hand it to the workflow yourself:

use Rasmuscnielsen\LaravelSupportTickets\Workflows\ReceiveInboundMessage;

Route::post('/webhooks/inbound-mail', function (Request $request) {
    $message = app(ReceiveInboundMessage::class)->handle($inbox, [
        'subject' => $request->input('subject'),
        'mail_from' => [['address' => $request->input('sender'), 'name' => $request->input('from_name')]],
        'mail_to' => [['address' => 'support@example.com']],
        'mail_message_id' => $request->input('message_id'),
        'mail_in_reply_to_message_id' => $request->input('in_reply_to'),
        'mail_references' => $request->input('references', []),
        'text' => $request->input('body-plain'),
        'html' => $request->input('body-html'),
        'mail_raw_mime' => $request->input('body-mime'),   // optional but enables the richest parsing
        'mail_provider' => 'mailgun',
        'mail_provider_message_id' => $request->input('token'),
        'received_at' => now(),
    ]);

    return response()->noContent();
});

The workflow dedupes on Message-ID and provider message id, so webhook retries are safe. If you skip IMAP entirely, simply don't schedule support-tickets:inbox:sync.

AI-drafted replies

Because drafts are first-class records and every inbound message fires an event, an AI copilot is a listener away: generate a suggested reply for each new customer message, store it as a draft, and let agents review, edit, and send from your UI. Nothing reaches the customer until a human (or your own automation) says so.

use Rasmuscnielsen\LaravelSupportTickets\Actions\ConvertPlainTextToEditorHtml;
use Rasmuscnielsen\LaravelSupportTickets\Actions\FindSimilarTickets;
use Rasmuscnielsen\LaravelSupportTickets\Actions\UpsertTicketDraft;
use Rasmuscnielsen\LaravelSupportTickets\Events\InboundMessageReceived;

class DraftReplyToInboundMessage
{
    public function handle(InboundMessageReceived $event): void
    {
        $ticket = $event->message->ticket;

        if (! $ticket || $ticket->drafts()->exists()) {
            return;
        }

        // Build context: the parsed conversation, plus e.g. similar resolved tickets.
        $conversation = $ticket->messages()->delivered()->with('content')->get();
        $similar = app(FindSimilarTickets::class)->handle($ticket, limit: 3);

        // ReplyAgent is your Laravel AI SDK agent, instructed on how to
        // write replies for your product.
        $reply = ReplyAgent::make()->prompt(json_encode([
            'subject' => $ticket->subject,
            'conversation' => $conversation->map(fn ($message) => [
                'direction' => $message->direction,
                'text' => $message->content?->text,
            ]),
            'similar_resolved_tickets' => $similar->map(fn ($embedding) => $embedding->content),
        ]))->text;

        app(UpsertTicketDraft::class)->handle(
            ticket: $ticket,
            parentMessage: $event->message,
            text: $reply,
            html: app(ConvertPlainTextToEditorHtml::class)->handle($reply),
            attributes: ['created_by_type' => 'system'],
        );
    }
}

Register it in the listeners config next to the vectorization listener, and pair it with AI similarity search so the model can lean on how similar tickets were resolved before.

More channels

The schema is deliberately not email-shaped. The mail_* addressing and threading columns are all nullable metadata rather than required fields, and content, attachments, and events hang off Message generically. Message.type is extensible by design: point enums.message_type at your own backed enum to add cases, and $message->type comes back as your enum. The only contract is that it includes the Email and Note values the package's own behavior keys off (assigning the package's MessageType cases keeps working too, since values are matched by backing value rather than enum class):

enum AppMessageType: string
{
    case Email = 'Email';
    case Note = 'Note';
    case Chat = 'Chat';
}

So storing chat messages on a ticket works today. What the package does not yet ship is the machinery for other channels: ingest, threading, and the reply path are email-specific. A new channel means bringing your own ingest and send path, with the data model ready to receive it.

Translations

Outgoing quote attributions ("On {date}, {sender} wrote:") ship in English and Danish. Publish and adjust, or add your locale:

php artisan vendor:publish --tag=support-tickets-lang

The mail text view is publishable too, via --tag=support-tickets-views.

Testing

The test suite runs against a real PostgreSQL database by default, so all similarity-search tests can execute against pgvector. Create the database, then:

composer test

Connection defaults live in phpunit.xml.dist (127.0.0.1:5432, user postgres, database support_tickets_testing). The suite also passes on MySQL (DB_CONNECTION=mysql DB_PORT=3306 ...), where the Postgres-only similarity tests are skipped.

License

MIT. See LICENSE.md.