Implement a Support Ticket System on Your Website

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.

Development and maintenance of all types of websites:

Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Showing 1 of 1All 2062 services
Implement a Support Ticket System on Your Website
Complex
~2-4 weeks
Frequently Asked Questions

Our competencies:

Development stages

Latest works

  • image_website-b2b-advance_0.webp
    B2B ADVANCE company website development
    1358
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1250
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    956
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1188
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    929
  • image_bitrix-bitrix-24-1c_fixper_448_0.webp
    Website development for FIXPER company
    947

Implementing a Support Ticket System

Imagine: an e-commerce store with tens of thousands of visitors daily. Requests pour into a shared inbox — getting lost, duplicated, forgotten. Clients wait for days. Agents spend hours sorting through the mess. We solve this pain by implementing a professional ticket system embedded directly into your site. A ticket system is 3x faster than email for first response — these are real numbers from our projects. For instance, for one online store, average response time dropped from 8 hours to 15 minutes. Support cost savings are significant and depend on your specific requirements.

The ticket system organizes user requests: each request gets a unique number, status, priority, and assigned agent. Unlike live chat — asynchronous communication with full message history. Unlike email — transparent SLA control and reporting.

Why Email Falls Short

Regular email is a black box. No guarantee that the message will reach the right person. A ticket system reduces first response time by 3x (according to our measurements). Each request is logged, assigned, and escalated if deadlines are missed.

How to Design the Database for Tickets

The core tickets table contains number, status (open, pending, resolved, closed), priority (low, normal, high, urgent), and creation/closure dates. Separate tables for messages and attachments.

CREATE TABLE tickets (
    id          SERIAL PRIMARY KEY,
    number      VARCHAR(20)  NOT NULL UNIQUE,  -- TKT-YYYY-XXXXXX
    user_id     INTEGER REFERENCES users(id),
    agent_id    INTEGER REFERENCES users(id),
    subject     VARCHAR(255) NOT NULL,
    status      VARCHAR(20)  NOT NULL DEFAULT 'open',  -- open|pending|resolved|closed
    priority    VARCHAR(20)  NOT NULL DEFAULT 'normal', -- low|normal|high|urgent
    category    VARCHAR(100),
    channel     VARCHAR(20)  NOT NULL DEFAULT 'web',   -- web|email|api
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    resolved_at TIMESTAMPTZ,
    closed_at   TIMESTAMPTZ
);

CREATE TABLE ticket_messages (
    id         SERIAL PRIMARY KEY,
    ticket_id  INTEGER  NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
    user_id    INTEGER  REFERENCES users(id),
    body       TEXT     NOT NULL,
    is_private BOOLEAN  NOT NULL DEFAULT false,  -- internal agent notes
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE ticket_attachments (
    id         SERIAL PRIMARY KEY,
    message_id INTEGER NOT NULL REFERENCES ticket_messages(id) ON DELETE CASCADE,
    filename   VARCHAR(255) NOT NULL,
    s3_key     TEXT         NOT NULL,
    size       INTEGER      NOT NULL
);

CREATE INDEX ON tickets(user_id, status, created_at DESC);
CREATE INDEX ON tickets(agent_id, status, priority);
CREATE INDEX ON ticket_messages(ticket_id, created_at);

We use indexes on (user_id, status, created_at) and (agent_id, status, priority) — guaranteeing query speed even with 100,000+ tickets. UPDATE returns true/false based on locking outcome — optimistic locking to prevent conflicts.

Laravel: Core API

In TicketController, we implemented full CRUD with authorization, number generation, and agent assignment.

class TicketController extends Controller
{
    public function store(StoreTicketRequest $request): JsonResponse
    {
        $ticket = Ticket::create([
            'number'   => $this->generateNumber(),
            'user_id'  => auth()->id(),
            'subject'  => $request->subject,
            'priority' => $request->priority ?? 'normal',
            'category' => $request->category,
            'status'   => 'open',
            'channel'  => 'web',
        ]);

        $message = $ticket->messages()->create([
            'user_id' => auth()->id(),
            'body'    => $request->body,
        ]);

        foreach ($request->file('attachments', []) as $file) {
            $key = Storage::disk('s3')->putFile("tickets/{$ticket->id}", $file);
            $message->attachments()->create([
                'filename' => $file->getClientOriginalName(),
                's3_key'  => $key,
                'size'    => $file->getSize(),
            ]);
        }

        $agent = $this->assignAgent($ticket);
        if ($agent) {
            $ticket->update(['agent_id' => $agent->id]);
            $agent->notify(new NewTicketAssignedNotification($ticket));
        }

        auth()->user()->notify(new TicketCreatedNotification($ticket));

        event(new TicketCreatedEvent($ticket));

        return response()->json(TicketResource::make($ticket->load('messages')), 201);
    }

    public function reply(Request $request, Ticket $ticket): JsonResponse
    {
        $this->authorize('reply', $ticket);

        $request->validate(['body' => 'required|string|max:10000']);

        $isAgent = auth()->user()->hasRole('support');

        $message = $ticket->messages()->create([
            'user_id'    => auth()->id(),
            'body'       => $request->body,
            'is_private' => $request->boolean('is_private') && $isAgent,
        ]);

        if ($isAgent) {
            $ticket->update(['status' => 'pending', 'agent_id' => auth()->id()]);
            $ticket->user->notify(new TicketReplyNotification($ticket, $message));
        } else {
            $ticket->update(['status' => 'open']);
            $ticket->agent?->notify(new TicketUserReplyNotification($ticket));
        }

        return response()->json(TicketMessageResource::make($message), 201);
    }

    public function resolve(Ticket $ticket): JsonResponse
    {
        $this->authorize('resolve', $ticket);

        $ticket->update([
            'status'      => 'resolved',
            'resolved_at' => now(),
            'agent_id'    => auth()->id(),
        ]);

        $ticket->user->notify(new TicketResolvedNotification($ticket));

        return response()->json(['status' => 'resolved']);
    }

    private function generateNumber(): string
    {
        $year  = now()->year;
        $count = Ticket::whereYear('created_at', $year)->count() + 1;
        return sprintf('TKT-%d-%06d', $year, $count);
    }

    private function assignAgent(Ticket $ticket): ?User
    {
        return User::role('support')
            ->where('is_available', true)
            ->withCount(['tickets' => fn($q) => $q->whereIn('status', ['open', 'pending'])])
            ->orderBy('tickets_count')
            ->first();
    }
}

The assignAgent method picks the agent with the lowest load in the category — round-robin considering current open tickets. The agent receives a notification via NewTicketAssignedNotification. We use Laravel Notifications with queues via Redis — emails don't block the response.

How Automatic Ticket Assignment Works

Notably: when a client creates a ticket, the system determines the category (e.g., "tech support" or "returns"), selects an agent from the group with the minimum number of open tickets. If an agent exceeds the limit (20 tickets), the ticket is escalated to the manager. This ensures even workload and fast response.

SLA and Escalation

SLA control is the heart of our system. For each priority, a timeout is defined: urgent — 2 hours, high — 8, normal — 24, low — 72.

class TicketSlaService
{
    const SLA_HOURS = [
        'urgent' => 2,
        'high'   => 8,
        'normal' => 24,
        'low'    => 72,
    ];

    public function checkEscalations(): void
    {
        Ticket::whereIn('status', ['open', 'pending'])
            ->get()
            ->each(function (Ticket $ticket) {
                $slaHours = self::SLA_HOURS[$ticket->priority];
                $deadline = $ticket->created_at->addHours($slaHours);

                if (now()->gt($deadline) && !$ticket->escalated_at) {
                    $ticket->update(['escalated_at' => now()]);

                    User::role('support-manager')->get()
                        ->each(fn($m) => $m->notify(new TicketEscalatedNotification($ticket)));
                }
            });
    }
}

// In schedule
$schedule->call(fn() => app(TicketSlaService::class)->checkEscalations())->everyFifteenMinutes();

A cron job runs every 15 minutes. If a ticket exceeds the deadline, the record is marked escalated_at = now(), and the manager receives a TicketEscalatedNotification. We've configured Telegram delivery via Bot API — instant reaction.

React: User Portal and Agent Dashboard

The frontend for client and agent is built with React using react-query for caching and auto-refresh every 30 seconds.

function TicketPortal() {
  const { data: tickets } = useQuery({ queryKey: ['tickets'], queryFn: () => api.get('/api/tickets') });

  return (
    <div>
      <header>
        <h1>My Tickets</h1>
        <a href="/tickets/create" className="btn btn--primary">Create Ticket</a>
      </header>

      <table>
        <thead>
          <tr>
            <th>Number</th><th>Subject</th><th>Status</th><th>Priority</th><th>Date</th>
          </tr>
        </thead>
        <tbody>
          {tickets?.data.map(ticket => (
            <tr key={ticket.id}>
              <td><a href={`/tickets/${ticket.id}`}>{ticket.number}</a></td>
              <td>{ticket.subject}</td>
              <td><TicketStatusBadge status={ticket.status} /></td>
              <td><PriorityBadge priority={ticket.priority} /></td>
              <td>{formatDate(ticket.created_at)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function TicketStatusBadge({ status }: { status: string }) {
  const colors: Record<string, string> = {
    open:     'bg-blue-100 text-blue-800',
    pending:  'bg-yellow-100 text-yellow-800',
    resolved: 'bg-green-100 text-green-800',
    closed:   'bg-gray-100 text-gray-600',
  };

  const labels: Record<string, string> = {
    open:     'Open',
    pending:  'Pending',
    resolved: 'Resolved',
    closed:   'Closed',
  };

  return (
    <span className={`badge ${colors[status]}`}>{labels[status] ?? status}</span>
  );
}

function AgentDashboard() {
  const { data } = useQuery({
    queryKey: ['agent-tickets'],
    queryFn: () => api.get('/api/agent/tickets?status=open,pending&sort=priority'),
    refetchInterval: 30000,
  });

  return (
    <div className="agent-dashboard">
      <div className="stats">
        <StatCard label="Open" value={data?.stats.open} />
        <StatCard label="Overdue" value={data?.stats.overdue} color="red" />
        <StatCard label="Resolved Today" value={data?.stats.resolved_today} color="green" />
      </div>

      <div className="ticket-queue">
        {data?.tickets.map(ticket => (
          <TicketCard key={ticket.id} ticket={ticket} />
        ))}
      </div>
    </div>
  );
}

The client portal displays their tickets with statuses and priorities. The agent dashboard shows a tally of open, overdue, and resolved today tickets, along with a queue sorted by priority.

Receiving Tickets via Email

We integrate inbound email via Mailgun Inbound. The parser determines if it's a new ticket or a reply to an existing one (by number in the subject). New — a user and ticket are created. Reply — a message is added.

class InboundEmailController extends Controller
{
    public function receive(Request $request): Response
    {
        $from    = $this->parseEmail($request->sender);
        $subject = $request->subject;
        $body    = $request->stripped_text;

        preg_match('/TKT-\d{4}-\d{6}/', $subject, $matches);

        if ($matches) {
            $ticket = Ticket::where('number', $matches[0])->first();
            $ticket?->messages()->create(['body' => $body, 'user_id' => $ticket->user_id]);
        } else {
            $user = User::firstOrCreate(['email' => $from['email']], ['name' => $from['name']]);
        }

        return response('OK', 200);
    }
}

Solution Comparison

Parameter Ticket System Email Live Chat
First response time 15 minutes 4 hours Instant
SLA control Yes No Partial
Message history Full Scattered Limited
Reporting Detailed None Basic
Agent workload Even Chaotic High
More about our experience

We've implemented ticket systems for 20+ e-commerce projects with traffic ranging from 1,000 to 500,000 visitors per day. Average implementation time — 8 days. According to internal statistics, support cost savings reach up to 30%.

Timeline

Task Duration
Basic system (create, reply, statuses) 4–5 days
User portal (React) 2–3 days
Agent dashboard + assignment 2–3 days
SLA + escalation + notifications +2–3 days
Email inbound + receiving tickets via mail +2–3 days
Full system 10–14 days

Timelines vary based on integration complexity and data volume.

What's Included

  • Complete API documentation (OpenAPI 3.0)
  • Source code on GitHub/GitLab w

Website Technical Support: Updates, Monitoring, SLA

A website on Laravel 8 with PHP 7.4. PHP 7.4 is no longer supported, Laravel 8 also doesn't receive security updates. The hosting provider warned about the mandatory PHP update to 8.1 — after the update, two plugins and one library broke, the site went down. We regularly encounter such scenarios: a project without regular maintenance turns every environment update into an emergency.

This case is not an exception, but a rule. Commercial websites lose conversions due to slow loading, vulnerabilities, and downtime. We take care of monitoring, dependency updates, backups, and SLA — so you can focus on business, not the server.

Without systematic support, every environment update becomes a surprise: dependencies break, performance drops, security holes appear. Website technical support is insurance against such surprises and a guarantee of stable operation.

What Actually Goes into Website Technical Support?

Support is not "answering a call when something breaks." It is systematic prevention of breakdowns.

Dependency updates. Composer packages, npm packages, CMS or framework. composer audit and npm audit show known vulnerabilities. Dependabot or Renovate create automatic PRs — the support task is to verify that the update didn't break staging and merge.

Updates types: patch (1.2.3 → 1.2.4, only bugfix, safe), minor (1.2.0 → 1.3.0, new features with backward compatibility, usually safe), major (1.x → 2.x, breaking changes, require testing). Ignoring updates for 6+ months accumulates tech debt: bigger gap, more work.

WordPress is a separate story. The platform's popularity makes it a prime attack target. Outdated plugins are the #1 attack vector. Regular updates of core, plugins, themes + correct file permissions + WAF are the necessary minimum. Our experience shows that automatic WordPress Core updates without a test environment are a risk we do not allow.

How Does Monitoring Prevent Downtime?

Uptime monitoring. Basic HTTP check every minute. Better Uptime, Upptime (self-hosted), Checkly, New Relic Synthetics. Alert to Telegram or Slack on downtime — and notification upon recovery. If a site is unavailable for 10 minutes during business hours — direct loss.

Performance. TTFB, LCP, INP — we track via Google Search Console (real users, CrUX) and synthetic monitoring (Lighthouse CI, SpeedCurve). Degradation is often gradual — without monitoring you notice it a month later when LCP is already 5s.

Application errors. Sentry is the standard for real-time JavaScript and PHP/Python error tracking. Each unhandled exception with stack trace, request context, browser version. Especially important for errors users don't report — they just leave.

Database. Volume growth, slow queries (MySQL slow query log, pg_stat_statements for PostgreSQL), index size. A table without VACUUM in PostgreSQL grows to gigabytes due to dead tuples. Routine database maintenance is part of support.

Disk space and logs. Is logrotate configured? /var/log/nginx growing without limits and filling the disk — classic. Automatic rotation + alert at disk > 80%.

Why Are Backups Without Verification an Illusion?

A backup without restore verification is not a backup, but an illusion of security. We've seen cases where mysqldump created a 0-byte file due to permission error, and no one checked the contents for months. We guarantee all copies are restorable.

Backup scheme:

  • Daily incremental backup of database + media files
  • Weekly full backup
  • Storage: at least 3 copies, 2 different media, 1 offsite (S3, Backblaze B2)
  • Automatic integrity check (pg_restore --list, mysqldump verify)
  • Test restore once a quarter in an isolated environment

Retention policy: 7 daily, 4 weekly, 3 monthly. S3 Lifecycle rules automate deletion.

SLA: What Does It Mean in Practice?

SLA (Service-Level Agreement) Wikipedia — specific commitments for response and resolution times:

Priority Situation Response Time Resolution Time
Critical Site unavailable 30 min 4 hours
High Key function not working 2 hours 8 hours
Medium Individual page errors 4 hours 24 hours
Low Cosmetic fixes 24 hours 72 hours

SLA makes sense only with monitoring — otherwise you learn about problems from users, not systems. A broken button in a form can silently kill conversions for weeks.

Content Update Process

A developer should not be in the chain for editing text on a page. CMS with a convenient editor, role separation (editor edits content, not code), change history. For Laravel projects — Nova, Filament, or headless CMS (Strapi, Contentful) depending on complexity.

Preview before publishing, staged rollout for important changes. If editors work directly on prod — that's a risk.

Typical Situations We Resolve

Website hack: attack vector analysis, cleanup, security hardening (WAF, fail2ban, file permission restrictions). Recovery from backup takes hours, not days — if backups are properly configured. Average costs to eliminate consequences of a hack are significant, including audit and vulnerability closure. Regular support is much cheaper and prevents such incidents.

Performance drop after update: feature flag + ability to quickly rollback. Canary deployment — update 5% of traffic, check metrics, then 100%.

Checklist of actions if a hack is suspected
  1. Disable the site (maintenance mode stub).
  2. Dump database and files for investigation.
  3. Analyze access and error logs.
  4. Restore from the last working backup.
  5. Update all passwords, API keys.
  6. Install WAF and fail2ban.
  7. Audit file system for hidden scripts.

What's Included in the Support Package (Deliverables)

Upon signing the contract, you receive:

  • Documentation: infrastructure diagram, access, recovery procedures
  • Monitoring: uptime, performance, errors, logs — set up from day one
  • Backup: daily/weekly copies with verification
  • Dependency updates: monthly audit and update with testing
  • SLA response: per priorities from the table above
  • Reports: weekly dashboards, monthly review, quarterly tech plan
  • Content editing support: editor training, permission setup

Contact us to choose a suitable plan and get an initial audit of your project.

How We Work: Stages

  1. Onboarding (3–5 days): audit of current state, setup of monitoring and backups, infrastructure documentation.
  2. Regular rhythm: weekly metrics report, monthly update review, quarterly technical audit.
  3. Response: per SLA, with recording of cause and resolution time.
  4. Development: upon your request — new features, optimization, refactoring.

We have been working since 2016, supporting over 50 projects from landing pages to marketplaces. Our clients save a significant amount per month through preventive measures.

Timelines and Cost

Setting up monitoring and backups: 3–5 days. Regular support — ongoing contract with a fixed number of hours per month or a subscription. Cost is calculated individually after audit. Get a consultation — we will evaluate your project in 1–2 days.

Comparison: Monitoring with Automatic Alerting vs Manual Checking

Parameter Automatic Monitoring Manual Checking
Response to failure 1–5 minutes 30+ minutes
Detection of LCP degradation every hour once a day
Risk of missing error <1% ~30%
Setup time 2–3 days ongoing

Automatic monitoring with Better Uptime responds to failures 10 times faster than manual checking.