Role-Based Access Control in CodeIgniter 4 — A Practical Guide

php dev.to

Difficulty: Intermediate | Read time: 14 min | Framework: CodeIgniter 4


Why RBAC Matters in Real Projects

Every project I've built beyond a simple blog has needed access control. Not just "is the user logged in?" — but "can this user do this specific thing?"

Without a proper RBAC system you end up with scattered if ($user->is_admin) checks everywhere, and one missed check means a security hole.

In this tutorial I'll show you how I built a clean, reusable RBAC system for an internal CRM — covering roles, permissions, middleware, and view-level gating.

What we're building: A role and permission system where each user has a role (Admin, Manager, Staff), each role has specific permissions, and access is enforced at the route, controller, and view level.


Database Structure

Three tables — users, roles, and permissions.

CREATE TABLE `tblroles` (
  `id`          INT AUTO_INCREMENT PRIMARY KEY,
  `name`        VARCHAR(100) NOT NULL UNIQUE,
  `description` VARCHAR(255),
  `created_at`  DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE `tblpermissions` (
  `id`          INT AUTO_INCREMENT PRIMARY KEY,
  `name`        VARCHAR(100) NOT NULL UNIQUE,
  `module`      VARCHAR(100),
  `description` VARCHAR(255)
);

CREATE TABLE `tblrole_permissions` (
  `role_id`       INT NOT NULL,
  `permission_id` INT NOT NULL,
  PRIMARY KEY (`role_id`, `permission_id`),
  FOREIGN KEY (`role_id`)       REFERENCES `tblroles`(`id`) ON DELETE CASCADE,
  FOREIGN KEY (`permission_id`) REFERENCES `tblpermissions`(`id`) ON DELETE CASCADE
);

-- Add role_id to your staff/users table
ALTER TABLE `tblstaff` ADD COLUMN `role_id` INT NULL REFERENCES `tblroles`(`id`);
Enter fullscreen mode Exit fullscreen mode

Seed Some Default Data

-- Roles
INSERT INTO `tblroles` (`name`, `description`) VALUES
('Admin',   'Full access to everything'),
('Manager', 'Can manage staff and view reports'),
('Staff',   'Limited access to assigned tasks only');

-- Permissions
INSERT INTO `tblpermissions` (`name`, `module`) VALUES
('view_dashboard',   'dashboard'),
('manage_users',     'users'),
('view_reports',     'reports'),
('export_reports',   'reports'),
('manage_tickets',   'tickets'),
('view_tickets',     'tickets'),
('manage_leads',     'leads'),
('view_leads',       'leads');

-- Assign all permissions to Admin (role_id = 1)
INSERT INTO `tblrole_permissions` (`role_id`, `permission_id`)
SELECT 1, id FROM `tblpermissions`;

-- Manager gets view + manage reports + view leads
INSERT INTO `tblrole_permissions` (`role_id`, `permission_id`)
SELECT 2, id FROM `tblpermissions`
WHERE `name` IN ('view_dashboard','view_reports','export_reports','view_leads','view_tickets');

-- Staff gets minimal access
INSERT INTO `tblrole_permissions` (`role_id`, `permission_id`)
SELECT 3, id FROM `tblpermissions`
WHERE `name` IN ('view_dashboard','view_tickets','view_leads');
Enter fullscreen mode Exit fullscreen mode

The Permission Helper

This is the core of the system — a single function you'll call everywhere.

<?php
// app/Helpers/permission_helper.php

/**
 * Check if the currently logged-in user has a specific permission.
 */
function has_permission(string $permission): bool
{
    $session     = session();
    $permissions = $session->get('permissions') ?? [];

    return in_array($permission, $permissions);
}

/**
 * Check if the user has ANY of the given permissions.
 */
function has_any_permission(array $permissions): bool
{
    foreach ($permissions as $permission) {
        if (has_permission($permission)) {
            return true;
        }
    }
    return false;
}

/**
 * Abort with 403 if user doesn't have permission.
 */
function require_permission(string $permission): void
{
    if (!has_permission($permission)) {
        throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
    }
}
Enter fullscreen mode Exit fullscreen mode

Load it in app/Config/Autoload.php:

public $helpers = ['url', 'form', 'permission'];
Enter fullscreen mode Exit fullscreen mode

Load Permissions Into Session at Login

After the user logs in, load their permissions into the session so every check is fast (no DB query per request).

<?php
// app/Controllers/Auth.php (your login controller)

private function loadUserPermissions(int $staffId): void
{
    $db = \Config\Database::connect();

    $permissions = $db->table('tblrole_permissions rp')
        ->select('p.name')
        ->join('tblpermissions p', 'p.id = rp.permission_id')
        ->join('tblstaff s',       's.role_id = rp.role_id')
        ->where('s.staffid', $staffId)
        ->get()
        ->getResultArray();

    $permissionNames = array_column($permissions, 'name');

    session()->set([
        'staff_id'    => $staffId,
        'permissions' => $permissionNames,
        'role'        => $this->getUserRole($staffId),
    ]);
}

private function getUserRole(int $staffId): string
{
    $db = \Config\Database::connect();

    $row = $db->table('tblstaff s')
        ->select('r.name')
        ->join('tblroles r', 'r.id = s.role_id')
        ->where('s.staffid', $staffId)
        ->get()->getRowArray();

    return $row['name'] ?? 'Staff';
}
Enter fullscreen mode Exit fullscreen mode

Call $this->loadUserPermissions($staffId) right after successful login verification.


The RBAC Middleware (Filter)

Create a filter that protects entire route groups.

<?php
// app/Filters/RBACFilter.php
namespace App\Filters;

use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Filters\FilterInterface;

class RBACFilter implements FilterInterface
{
    public function before(RequestInterface $request, $arguments = null)
    {
        // Not logged in at all
        if (!session()->get('staff_id')) {
            return redirect()->to(base_url('login'));
        }

        // No permission argument passed — just check login
        if (empty($arguments)) {
            return;
        }

        // Check each required permission
        $userPermissions = session()->get('permissions') ?? [];

        foreach ($arguments as $required) {
            if (!in_array($required, $userPermissions)) {
                // Redirect to 403 page
                return redirect()->to(base_url('errors/permission_denied'));
            }
        }
    }

    public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
    {
        // Nothing needed after
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it in app/Config/Filters.php:

public array $aliases = [
    'csrf'     => CSRF::class,
    'toolbar'  => DebugToolbar::class,
    'honeypot' => Honeypot::class,
    'rbac'     => \App\Filters\RBACFilter::class,  // <-- add this
];
Enter fullscreen mode Exit fullscreen mode

Protect Routes With the Filter

// app/Config/Routes.php

// Anyone logged in
$routes->group('dashboard', ['filter' => 'rbac'], function ($routes) {
    $routes->get('/', 'Dashboard::index');
});

// Only users with manage_users permission
$routes->group('users', ['filter' => 'rbac:manage_users'], function ($routes) {
    $routes->get('/',        'Users::index');
    $routes->get('create',   'Users::create');
    $routes->post('store',   'Users::store');
    $routes->get('edit/(:num)',   'Users::edit/$1');
    $routes->post('update/(:num)', 'Users::update/$1');
    $routes->post('delete/(:num)', 'Users::delete/$1');
});

// Reports — needs view_reports
$routes->group('reports', ['filter' => 'rbac:view_reports'], function ($routes) {
    $routes->get('/',       'Reports::index');
    $routes->get('export',  'Reports::export', ['filter' => 'rbac:export_reports']);
});

// Tickets
$routes->group('tickets', ['filter' => 'rbac:view_tickets'], function ($routes) {
    $routes->get('/', 'Tickets::index');
    $routes->get('manage', 'Tickets::manage', ['filter' => 'rbac:manage_tickets']);
});
Enter fullscreen mode Exit fullscreen mode

Controller-Level Check

For finer control inside a controller method:

<?php
// app/Controllers/Reports.php
namespace App\Controllers;

class Reports extends BaseController
{
    public function export(): \CodeIgniter\HTTP\ResponseInterface
    {
        // Extra check at controller level
        if (!has_permission('export_reports')) {
            return redirect()->to(base_url('errors/permission_denied'));
        }

        // ... export logic
    }

    public function delete(int $id): \CodeIgniter\HTTP\ResponseInterface
    {
        // Use the helper shortcut — throws 404 if no permission
        require_permission('manage_reports');

        // ... delete logic
    }
}
Enter fullscreen mode Exit fullscreen mode

View-Level Gating

Hide buttons and menu items the user can't access:

<!-- Sidebar navigation — show only what the user can access -->
<ul class="sidebar-menu">

    <li>
        <a href="<?= base_url('dashboard') ?>">
            <i class="fas fa-home"></i> Dashboard
        </a>
    </li>

    <?php if (has_permission('manage_users')): ?>
    <li>
        <a href="<?= base_url('users') ?>">
            <i class="fas fa-users"></i> Users
        </a>
    </li>
    <?php endif; ?>

    <?php if (has_permission('view_reports')): ?>
    <li>
        <a href="<?= base_url('reports') ?>">
            <i class="fas fa-chart-bar"></i> Reports
        </a>
    </li>
    <?php endif; ?>

    <?php if (has_permission('view_tickets')): ?>
    <li>
        <a href="<?= base_url('tickets') ?>">
            <i class="fas fa-ticket-alt"></i> Tickets
        </a>
    </li>
    <?php endif; ?>

</ul>

<!-- Hide export button from users without permission -->
<?php if (has_permission('export_reports')): ?>
<a href="<?= base_url('reports/export') ?>" class="btn btn-success">
    <i class="fas fa-download"></i> Export CSV
</a>
<?php endif; ?>
Enter fullscreen mode Exit fullscreen mode

The 403 Permission Denied Page

Create a clean error page instead of crashing:

<?php
// app/Controllers/Errors.php
namespace App\Controllers;

class Errors extends BaseController
{
    public function permissionDenied(): string
    {
        return view('errors/permission_denied');
    }
}
Enter fullscreen mode Exit fullscreen mode
<!-- app/Views/errors/permission_denied.php -->
<div class="container text-center py-5">
    <div class="mb-4">
        <i class="fas fa-lock fa-4x text-danger"></i>
    </div>
    <h2 class="fw-bold">Access Denied</h2>
    <p class="text-muted">
        You don't have permission to view this page.<br>
        Contact your administrator if you think this is a mistake.
    </p>
    <a href="<?= base_url('dashboard') ?>" class="btn btn-primary mt-3">
        Go to Dashboard
    </a>
</div>
Enter fullscreen mode Exit fullscreen mode

Add the route:

$routes->get('errors/permission_denied', 'Errors::permissionDenied');
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls

⚠️ 1. Checking permissions on every request with a DB query

Don't query the DB on every request to check permissions — it kills performance. Load permissions into the session at login and check from there. That's what we did above with session()->get('permissions').

⚠️ 2. Forgetting to refresh permissions after role change

If an admin changes a user's role, that user's session still has old permissions until they log out. Fix this by forcing a session refresh when role changes:

// In your Users controller — after updating role
if ($userId == session()->get('staff_id')) {
    $this->loadUserPermissions($userId); // Refresh own session
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 3. Hiding buttons is NOT enough security

Never rely on just hiding buttons in the view. Always check permissions in the controller or filter too. A user can manually type a URL and bypass view-level checks.

⚠️ 4. Route filter not applying

If your filter isn't working, make sure the filter is registered in Filters.php AND CodeIgniter's filters are enabled. Check app/Config/Filters.php for the $globals or $methods array too.


What to Build Next

  • ABAC (Attribute-Based Access Control) — go beyond roles, add conditions like "manager can only see their own team's data"
  • Role management UI — let admins create/edit roles and assign permissions from a dashboard
  • Permission audit log — track who accessed what and when
  • API route protection — extend the filter to check Bearer tokens with permissions

Conclusion

A solid RBAC system isn't hard to build in CodeIgniter 4 — it just needs to be done right from the start. Load permissions into the session, use a filter for route groups, and gate at the view level for UI polish.

Your controllers stay clean, your routes are self-documenting, and you never have another scattered is_admin check again.

Follow me for more CodeIgniter production tutorials — 3 new articles every week. 🙌


Senior PHP Developer · 12+ years building production systems on CodeIgniter, Laravel & WordPress

Source: dev.to

arrow_back Back to Tutorials