Difficulty: Intermediate | Read time: 12 min | Framework: CodeIgniter 4
Why Build This?
Most tutorials show you CRUD forms. But real client projects need more — things like:
- Tickets visible only to the right people (admin vs staff vs customer)
- Auto-assigning tickets to staff and notifying them by email
- Status history so you can audit what happened and when
I built exactly this for an internal CRM at my company. This tutorial walks you through the key parts so you can adapt it to your own projects.
What we're building: A ticket module where non-admin users only see tickets assigned to them, admins see everything, and staff get an email when assigned.
Database Structure
Start with two tables — tickets and a status history log.
CREATE TABLE `tblsupport_tickets` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`subject` VARCHAR(255) NOT NULL,
`description` TEXT,
`status` ENUM('open','in_progress','resolved','closed') DEFAULT 'open',
`priority` ENUM('low','medium','high','critical') DEFAULT 'medium',
`assigned_to` INT NULL,
`created_by` INT NOT NULL,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE `tblticket_status_history` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`ticket_id` INT NOT NULL,
`old_status` VARCHAR(50),
`new_status` VARCHAR(50),
`changed_by` INT NOT NULL,
`note` TEXT,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`ticket_id`) REFERENCES `tblsupport_tickets`(`id`) ON DELETE CASCADE
);
The Model — Role-Based Ticket Fetching
This is where the real logic lives. The key insight: one method, different queries depending on who's asking.
<?php
// app/Models/Support_model.php
namespace App\Models;
use CodeIgniter\Model;
class Support_model extends Model
{
protected $table = 'tblsupport_tickets';
/**
* Fetch tickets based on user role.
* Admins see all. Staff see only their assigned tickets.
*/
public function getTicketsForUser(int $userId, bool $isAdmin): array
{
$builder = $this->db->table('tblsupport_tickets t')
->select('t.*, s.name as assigned_name, c.name as created_by_name')
->join('tblstaff s', 's.staffid = t.assigned_to', 'left')
->join('tblstaff c', 'c.staffid = t.created_by', 'left')
->orderBy('t.created_at', 'DESC');
if (!$isAdmin) {
// Non-admins only see their own assigned tickets
$builder->where('t.assigned_to', $userId);
}
return $builder->get()->getResultArray();
}
/**
* Assign a ticket to a staff member and log the change.
*/
public function assignTicket(int $ticketId, int $staffId, int $assignedBy): bool
{
$this->db->table('tblsupport_tickets')
->where('id', $ticketId)
->update(['assigned_to' => $staffId, 'status' => 'in_progress']);
// Log the assignment in history
$this->db->table('tblticket_status_history')->insert([
'ticket_id' => $ticketId,
'old_status' => 'open',
'new_status' => 'in_progress',
'changed_by' => $assignedBy,
'note' => 'Ticket assigned to staff #' . $staffId,
]);
return $this->db->affectedRows() > 0;
}
}
💡 Note:
Why this approach works: Keeping the visibility logic in the model means your controller stays clean. If you ever need to change the rules (e.g. team leads see their team's tickets), you change it in one place.
The Controller — Listing and Assigning
<?php
// app/Controllers/Support_tickets.php
namespace App\Controllers;
use App\Models\Support_model;
class Support_tickets extends BaseController
{
protected Support_model $supportModel;
public function __construct()
{
$this->supportModel = new Support_model();
}
public function index(): string
{
$session = session();
$userId = $session->get('staff_id');
$isAdmin = $session->get('is_admin') == 1;
$data['tickets'] = $this->supportModel->getTicketsForUser($userId, $isAdmin);
$data['isAdmin'] = $isAdmin;
return view('support_tickets/index', $data);
}
/**
* AJAX endpoint — assign ticket to a staff member.
*/
public function assign(): \CodeIgniter\HTTP\ResponseInterface
{
if (!$this->request->isAJAX()) {
return $this->response->setStatusCode(403);
}
$ticketId = (int) $this->request->getPost('ticket_id');
$staffId = (int) $this->request->getPost('staff_id');
$assignedBy = session()->get('staff_id');
$assigned = $this->supportModel->assignTicket($ticketId, $staffId, $assignedBy);
if ($assigned) {
$this->_sendAssignmentEmail($ticketId, $staffId);
return $this->response->setJSON(['success' => true]);
}
return $this->response->setJSON(['success' => false, 'message' => 'Assignment failed']);
}
/**
* Send email to the assigned staff member.
*/
private function _sendAssignmentEmail(int $ticketId, int $staffId): void
{
$staffRow = $this->db->table('tblstaff')
->where('staffid', $staffId)
->get()->getRowArray();
if (!$staffRow) return;
$ticket = $this->db->table('tblsupport_tickets')
->where('id', $ticketId)
->get()->getRowArray();
$email = \Config\Services::email();
$email->setTo($staffRow['email']);
$email->setSubject('New Ticket Assigned: ' . $ticket['subject']);
$email->setMessage(
"Hi {$staffRow['name']},\n\n" .
"A ticket has been assigned to you:\n\n" .
"Subject: {$ticket['subject']}\n" .
"Priority: {$ticket['priority']}\n\n" .
"Please log in to review and action it.\n\n" .
"Regards,\nSupport System"
);
$email->send();
}
}
The View — DataTable with Inline Assignment
<!-- app/Views/support_tickets/index.php -->
<table id="ticketsTable" class="table table-hover">
<thead>
<tr>
<th>#</th>
<th>Subject</th>
<th>Priority</th>
<th>Status</th>
<?php if ($isAdmin): ?>
<th>Assign To</th>
<?php endif; ?>
<th>Created</th>
</tr>
</thead>
<tbody>
<?php foreach ($tickets as $ticket): ?>
<tr>
<td><?= $ticket['id'] ?></td>
<td><?= esc($ticket['subject']) ?></td>
<td>
<span class="badge bg-<?= $ticket['priority'] === 'critical' ? 'danger' : 'warning' ?>">
<?= ucfirst($ticket['priority']) ?>
</span>
</td>
<td><?= ucfirst(str_replace('_', ' ', $ticket['status'])) ?></td>
<?php if ($isAdmin): ?>
<td>
<select class="selectpicker assign-staff"
data-ticket="<?= $ticket['id'] ?>"
data-live-search="true"
title="Assign...">
<!-- Populated via AJAX or PHP loop over tblstaff -->
</select>
</td>
<?php endif; ?>
<td><?= date('d M Y', strtotime($ticket['created_at'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<script>
// Inline assignment via AJAX
$(document).on('changed.bs.select', '.assign-staff', function () {
const ticketId = $(this).data('ticket');
const staffId = $(this).val();
$.post('<?= base_url('support_tickets/assign') ?>', {
ticket_id: ticketId,
staff_id: staffId,
'<?= csrf_token() ?>': '<?= csrf_hash() ?>'
}, function (res) {
if (res.success) {
toastr.success('Ticket assigned and staff notified!');
} else {
toastr.error('Assignment failed. Try again.');
}
});
});
</script>
Common Pitfalls (and How I Fixed Them)
⚠️ 1. CSRF "Page Expired" on AJAX POST
Make sure you're sending csrf_token() and csrf_hash() with every POST. If you're using CodeIgniter's auto-regenerate CSRF, update the hash in the response and swap it client-side so the next request doesn't fail.
⚠️ 2. Bootstrap selectpicker not initializing on dynamic rows
Call $('.selectpicker').selectpicker('refresh') after any DOM update. If you're loading rows via DataTables, hook into drawCallback.
⚠️ 3. Email not sending on local dev
Set protocol = smtp in app/Config/Email.php and test with a real SMTP like Gmail or Mailtrap. Never debug with sendmail locally — it silently fails every time.
What to Build Next
This is a solid foundation. Here's what you can layer on top:
- File attachments — store in S3 or local uploads, link to ticket ID
- Customer-facing portal — external users submit and track their own tickets
- SLA tracking — flag tickets that breach response time thresholds
- Canned replies — let staff insert pre-written responses
Conclusion
A real support ticket system isn't complicated — but the details matter. Role-based visibility, status history, and email notifications are what separate a toy demo from something you can actually ship to a client.
If you found this useful, follow me for more CodeIgniter production tutorials — I publish new ones every week. 🙌
Senior PHP Developer with 12+ years building production systems on CodeIgniter, Laravel, and WordPress.
Top comments (0)