The 60 CodeIgniter questions interviewers actually ask, with direct answers, runnable PHP code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
CodeIgniter is a PHP full-stack web framework, described in its own documentation as light, fast, flexible, and secure. It uses the Model-View-Controller pattern to separate data, presentation, and request handling, and it ships with libraries for the tasks web apps repeat: routing, database access through a query builder, form validation, sessions, and security. The point is a small footprint and little configuration, so a team ships features instead of wiring plumbing. CodeIgniter 4 is the current major line: a full rewrite that requires PHP 7.4 or newer, adds namespaces, PSR-4 autoloading, a spark command-line tool, and an improved model layer. In interviews, CodeIgniter questions probe the request lifecycle (route to controller to view), the query builder and models, routing and filters, and the built-in security features like CSRF protection and output escaping. This page collects the 60 questions that come up most, each with a direct answer and runnable code. If you're building fundamentals, the official CodeIgniter user guide is the canonical reference; increasingly the first backend round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: CodeIgniter 4 Tutorial - Simple Blog Part 1
Video: CodeIgniter 4 Tutorial - Simple Blog Part 1 (Alex Lancer, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your CodeIgniter certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
CodeIgniter is a light, fast PHP web framework built on the Model-View-Controller pattern. It ships with libraries for the tasks web apps repeat, routing, database access, form validation, sessions, and security, so you write far less boilerplate and spend your time on features instead of plumbing that every app needs.
Teams reach for it to build server-rendered web apps and APIs quickly, especially when they want a small footprint, little configuration, and a gentle learning curve rather than the deeper feature set of a heavier framework.
Key point: A one-line definition (light PHP MVC framework) plus one reason a team picks it (small footprint, fast to ship) beats reciting the feature list. This is the opener.
MVC splits an application into three parts with clear jobs. The Model handles data and database logic, the View handles presentation and produces the HTML sent back, and the Controller handles the request itself: it takes the input, calls the models it needs, and picks which view to render with the result.
The flow is: a route maps a URL to a controller method, the controller asks a model for data, then passes that data to a view. Keeping data, presentation, and request handling separate is what makes the code testable and easy to change.
// Controller: app/Controllers/Blog.php
namespace App\Controllers;
use App\Models\PostModel;
class Blog extends BaseController
{
public function index()
{
$model = new PostModel();
$data['posts'] = $model->findAll(); // ask the model
return view('blog/index', $data); // pick the view
}
}Key point: Draw the route to controller to model to view arrow out loud. the key point is the request flow, not just three definitions.
Watch a deeper explanation
Video: CodeIgniter 4 and PHP MVC basics: controllers, views and layouts (Dave Hollingworth, YouTube)
CodeIgniter 4 is a full rewrite, not an incremental update. It requires PHP 7.4 or newer, adds namespaces and PSR-4 autoloading, introduces the spark command-line tool, and reworks the whole model layer. CodeIgniter 3, by contrast, feels procedural, runs on much older PHP, and loads things through $this->load.
Practically, in 4 you write classes with namespaces, load things through dependency helpers instead of $this->load, and use spark for migrations and generators. Most concepts carry over, but the syntax and structure are cleaner.
| Area | CodeIgniter 3 | CodeIgniter 4 |
|---|---|---|
| PHP version | 5.6+ | 7.4+ |
| Namespaces | No | Yes (PSR-4) |
| Loading | $this->load->model() | new Model() / services |
| CLI tool | None built in | spark |
Key point: Naming even two concrete differences (namespaces, spark) shows you've used version 4 rather than only read about it.
The folders that matter are app (all your code: Controllers, Models, Views, Config, Filters), public (the web root, holding index.php and static assets), writable (logs, cache, sessions, and uploads), system (the framework core you never edit), and tests. Requests only ever enter through public.
Requests hit public/index.php, which is the front controller. Everything you build lives in app. Keeping the web root at public means the rest of the code sits outside the browser's reach.
Key point: Knowing public is the document root and app holds your code is the point. It's also the answer to 'why is only public web-accessible?' (security).
A controller is a class that handles incoming requests, and each of its public methods is an action a route can point to. Inside an action you read input from the request, call models or libraries to do the work, and then return either a rendered view or a response object back to the browser.
In CodeIgniter 4 a controller extends BaseController, lives in app/Controllers, and uses a namespace. You can generate one with spark to get the boilerplate right.
// php spark make:controller Products
namespace App\Controllers;
class Products extends BaseController
{
public function index()
{
return view('products/list');
}
public function show($id = null)
{
return view('products/detail', ['id' => $id]);
}
}A view is the presentation layer: a PHP file, usually kept in app/Views, that holds the HTML sent back to the browser. The controller passes data into it as an associative array, and the view echoes those values into the markup, keeping display logic out of the controller where it doesn't belong.
In CodeIgniter 4 you render one with the view() helper, which returns the rendered string. You can pass data as a second argument and read it by key inside the view.
// in a controller
return view('welcome', ['name' => 'Asha']);
// app/Views/welcome.php
<h1>Hello, <?= esc($name) ?></h1>Key point: Using esc() on output in your example shows you know to escape, which is exactly the habit the question needs to see even in a throwaway snippet.
A model is a class that represents one database table and holds the data-access logic for it. In CodeIgniter 4 it extends CodeIgniter\Model, and you configure it by setting properties: the table name, the primary key, the allowed fields, and whether it manages timestamps automatically for you.
That base class hands you ready-made CRUD methods: find(), findAll(), insert(), update(), delete(), plus validation and the query builder scoped to the table. You get a working data layer with almost no code.
namespace App\Models;
use CodeIgniter\Model;
class PostModel extends Model
{
protected $table = 'posts';
protected $primaryKey = 'id';
protected $allowedFields = ['title', 'body', 'author_id'];
protected $useTimestamps = true;
}
// usage
$model = new PostModel();
$post = $model->find(5); // by primary key
$all = $model->findAll();Watch a deeper explanation
Video: Creating the model in Codeigniter 4 | CRUD in Codeigniter 4 Tutorials (Shakzee, YouTube)
Routing maps an incoming URL and HTTP verb to a specific controller method. You define routes in app/Config/Routes.php using the verb as the method name: for example, $routes->get('products', 'Products::index') sends a GET request on /products to the index() method of the Products controller class.
You can capture URL segments with placeholders like (:num) or (:segment) and pass them as method arguments. Defined routes are explicit and preferred; there's also auto-routing that maps URLs to controllers by convention, off by default in newer versions.
// app/Config/Routes.php
$routes->get('products', 'Products::index');
$routes->get('products/(:num)', 'Products::show/$1');
$routes->post('products', 'Products::create');
$routes->get('blog/(:segment)', 'Blog::category/$1');Key point: Mention that auto-routing is off by default now and defined routes are the recommendation. It signals you follow current CodeIgniter practice, not a five-year-old tutorial.
Watch a deeper explanation
Video: CodeIgniter 4 from Scratch - #1 - Introduction (Alex Lancer, YouTube)
The query builder is a fluent interface that assembles SQL for you: you chain methods like table(), select(), where(), and get() instead of writing raw SQL strings by hand. It escapes and binds values automatically as you chain, which is what protects your queries against SQL injection without any extra effort.
You use it because it's safer (automatic escaping and binding), more readable, and database-agnostic, so the same code works across MySQL, PostgreSQL, and others. Raw queries still exist for the rare case the builder can't express.
$db = \Config\Database::connect();
$users = $db->table('users')
->select('id, name, email')
->where('active', 1)
->orderBy('name', 'ASC')
->get()
->getResultArray();Key point: The words 'automatic escaping' or 'parameter binding' are what earn the point. This question is really testing whether you know why the builder is safer than string concatenation.
Watch a deeper explanation
Video: CodeIgniter 4 from Scratch - #10 - Query Builder (Alex Lancer, YouTube)
Helpers are files of standalone functions grouped by purpose: the form helper, url helper, text helper, and more. They're plain procedural functions, not classes, meant for small repeated tasks you don't want to rewrite in every controller and view across the app.
You load a helper before using its functions, either in a controller with helper('form') or globally in the BaseController. Once loaded, functions like form_open() or base_url() are available anywhere.
helper('url');
echo base_url('images/logo.png');
echo anchor('products', 'View products');
helper('form');
echo form_open('products/create');A helper is a set of standalone functions that are procedural and stateless, loaded and called directly. A library is a class with methods and often internal state, like the session library, the email library, or the validation library, which you use as an object rather than a loose function.
The rule of thumb: reach for a helper for a small utility function, and a library when you need an object that holds configuration or state across calls. Libraries are usually accessed as services in CodeIgniter 4.
| Helper | Library | |
|---|---|---|
| Form | Functions | Class with methods |
| State | Stateless | Can hold state/config |
| Example | url, form, text | session, email, validation |
| Access | helper('name') | service('name') or new Class() |
Database settings live in app/Config/Database.php, and credentials usually come from the .env file so they stay out of version control. You get a live connection by calling \Config\Database::connect(), which reads that config and returns the default connection group defined there.
From that connection you use the query builder via table(), or run raw queries with query(). Models create their own connection automatically, so inside a model you rarely connect by hand.
$db = \Config\Database::connect();
// query builder
$rows = $db->table('orders')->where('paid', 1)->get()->getResult();
// raw query with bindings (still escaped)
$rows = $db->query('SELECT * FROM orders WHERE total > ?', [100])->getResult();spark is CodeIgniter 4's command-line tool, which you run with php spark from the project root. It starts a local development server, runs and rolls back database migrations, seeds data, clears caches, lists routes, and generates boilerplate classes like controllers and models so you don't write them by hand.
Common commands: php spark serve to run the app, php spark migrate to apply migrations, and php spark make:controller Name to scaffold a class. It saves you from writing boilerplate by hand.
php spark serve # start dev server on localhost:8080
php spark make:model PostModel # generate a model
php spark migrate # run pending migrations
php spark routes # list all defined routesYou define rules per field and run them against the incoming request. Inside a controller, $this->validate() takes an array of field rules, returns false if any of them fail, and lets you read the failure messages with $this->validator->getErrors() so you can show them back on the form.
Rules are strings like 'required|min_length[3]|valid_email'. You can also centralize rule sets in app/Config/Validation.php and reference them by name, which keeps controllers clean.
public function create()
{
$rules = [
'email' => 'required|valid_email',
'name' => 'required|min_length[3]',
];
if (! $this->validate($rules)) {
return view('signup', ['errors' => $this->validator->getErrors()]);
}
// input is valid, proceed
}Key point: validation belongs on the server even if the front end also validates. Client checks are convenience; server checks are the real gate.
The session library stores per-user data across requests. You get it with session() or service('session'), then set and read values like an array. By default data lives in files under writable, but you can switch the handler to the database, Redis, or Memcached.
Flashdata is a common feature: values that survive exactly one more request, ideal for a 'saved successfully' message after a redirect.
$session = session();
$session->set('user_id', 42);
$id = $session->get('user_id');
// flashdata: available on the next request only
$session->setFlashdata('msg', 'Profile updated');base_url() is a url-helper function that returns your application's root URL, optionally with a path appended to it. You use it to build links and asset URLs so they keep working no matter where the app is hosted, in a subfolder, on staging, or on the production domain.
It reads the baseURL setting from app/Config/App.php (commonly set through the .env file as app.baseURL). Setting it correctly matters because wrong base URLs break assets and links after deploy.
helper('url');
echo base_url(); // https://example.com/
echo base_url('css/site.css'); // https://example.com/css/site.css
echo current_url(); // the full URL of the current requestRead input through the request object rather than the raw PHP superglobals like $_GET and $_POST. Inside a controller, $this->request->getGet('key') reads query-string parameters, $this->request->getPost('key') reads submitted form fields, and $this->request->getVar('key') reads from either source when you don't care which one the value came from.
Going through the request object is the recommended way: it's consistent, testable, and keeps a single input path. Reaching straight into $_POST is discouraged.
$search = $this->request->getGet('q');
$email = $this->request->getPost('email');
$all = $this->request->getPost(); // whole array
$json = $this->request->getJSON(); // parsed JSON bodyDefine a route with a placeholder and the framework captures that URL segment and passes it straight into your controller method as an argument. For example, $routes->get('user/(:num)', 'User::show/$1') matches a numeric segment and hands the number to show($id) as its first parameter.
Placeholders include (:num) for digits, (:alpha) for letters, (:alphanum), (:segment) for one segment, and (:any) for anything. Matching the right placeholder is both cleaner and a light validation on the URL.
// route: $routes->get('user/(:num)/posts/(:num)', 'User::posts/$1/$2');
public function posts($userId, $postId)
{
// $userId and $postId come from the URL segments
}CodeIgniter gives you several out of the box: CSRF protection for forms, automatic escaping in the query builder to block SQL injection, the esc() function and view escaping to defend against XSS, secure password hashing through PHP's own functions, and content-security and header controls you can apply through filters on your routes.
The point is that safe defaults are built in. You still have to use them correctly: escape output, keep CSRF on, and never trust input, but the framework gives you the tools rather than making you build them.
Key point: Listing three real ones (CSRF, query builder escaping, esc() for XSS) with what each defends against beats saying 'it's secure'. the question needs the specific threat-to-defense mapping.
CodeIgniter 4 loads classes on demand through PSR-4 autoloading, using Composer's autoloader plus its own. A class is found by its namespace: the App namespace maps to the app folder, so App\Controllers\Blog resolves to the file app/Controllers/Blog.php without any manual include.
You register extra namespaces or classmaps in app/Config/Autoload.php. This replaced CodeIgniter 3's manual autoload lists; now files load on demand by namespace rather than being listed up front.
The .env file holds environment-specific settings like database credentials and the base URL, and it stays out of version control on purpose. Values there override the defaults baked into the Config classes at runtime, so the exact same code runs in development and production while pointing at different databases and URLs.
CIEnvironment (development, testing, production) controls behavior like error display: development shows detailed errors, production hides them. Setting it correctly is a common source of 'why are errors leaking' bugs.
# .env
CI_ENVIRONMENT = production
app.baseURL = 'https://example.com/'
database.default.hostname = localhost
database.default.database = shop
database.default.username = app
database.default.password = secretUse the response object rather than echoing JSON yourself. Calling return $this->response->setJSON($data) serializes the array or object to JSON and sets the correct content-type header for you, and you can chain setStatusCode() before it to control the HTTP status code the client receives.
This is the standard way to build API endpoints in CodeIgniter 4. The response object also handles headers, status codes, and other body formats when you need them.
public function apiList()
{
$model = new ProductModel();
return $this->response
->setStatusCode(200)
->setJSON($model->findAll());
}For candidates with working experience: the request lifecycle, the model layer, filters, and the judgment calls that separate users from understanders.
A request first hits public/index.php, the single front controller for the whole app. The framework boots, the Router matches the URL against app/Config/Routes.php, and any before filters run: CSRF, auth, throttling. Then the matched controller method executes, usually calling models for data and rendering a view with the result.
The controller returns a Response object. After filters run on the way out (things like adding headers or CORS), and the response is sent to the browser. Understanding this order explains where to hook auth (a before filter) versus response tweaks (an after filter).
CodeIgniter 4 request lifecycle
Auth and CSRF are before filters; header and CORS tweaks are after filters. Knowing the order is the whole point of this question.
Key point: Naming where filters sit (before vs after the controller) is what separates a lifecycle you've read about from one you've debugged.
Watch a deeper explanation
Video: CodeIgniter 4 Crash Course for Beginners 2025 (DevLap, YouTube)
Filters are classes with before() and after() methods that run around controllers. Before filters can short-circuit a request (return a redirect or a 401); after filters can modify the response. They handle cross-cutting concerns: authentication, CSRF, rate limiting, CORS, logging.
You register aliases in app/Config/Filters.php and apply them globally, by route, or by group. This is cleaner than repeating auth checks in every controller method.
class AuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
if (! session()->get('user_id')) {
return redirect()->to('/login');
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) {}
}
// app/Config/Filters.php: 'auth' => AuthFilter::class, then apply to routesKey point: The 'return a redirect from before() to stop the request' detail is the tell that you've actually written a filter, not just read the docs.
The base Model gives you find($id) and findAll() to read, insert($data) to create a row, update($id, $data) to modify one, and delete($id) to remove one. On top of those, save($data) is smart: it inserts when the data has no primary key and updates when it does, so one call handles both create and edit.
You also chain query builder methods before these: $model->where('active', 1)->findAll(). Only fields listed in $allowedFields are mass-assignable, which is a guard against unexpected columns being written.
$model = new PostModel();
$model->insert(['title' => 'Hello', 'body' => 'World']);
$model->update(5, ['title' => 'Updated']);
$model->where('author_id', 3)->findAll();
$model->save(['id' => 5, 'title' => 'Save updates existing']);$allowedFields is a property on the model that lists exactly which columns it may write during insert() and update() calls. Any field you pass that isn't on that list is silently dropped from the mass-assignment, so only the columns you explicitly named ever reach the database.
It matters for security: without it, a user could submit extra form fields (like is_admin or role) that get written straight to the database, a mass-assignment vulnerability. The allowlist is the fix, and forgetting to add a legitimate field is also the reason 'my update isn't saving' bugs happen.
Key point: Connecting $allowedFields to mass-assignment protection is the depth here. Anyone can say what it does; saying what it prevents is the point.
The join() method adds a SQL join, taking the table, the on condition, and an optional join type. To control boolean grouping, groupStart() and groupEnd() wrap a set of conditions in parentheses, and orWhere() adds an OR condition alongside the where() calls in the chain.
The builder still escapes values, so you get safe, readable SQL. For conditions the builder can't express, you can drop a raw fragment, but reach for that rarely.
$rows = $db->table('orders o')
->select('o.id, u.name, o.total')
->join('users u', 'u.id = o.user_id')
->where('o.status', 'paid')
->groupStart()
->where('o.total >', 100)
->orWhere('o.priority', 'high')
->groupEnd()
->get()->getResultArray();Migrations are versioned PHP files that describe database schema changes: creating tables, adding columns, adding indexes. Each one has an up() method to apply the change and a down() method to reverse it, so the full history of your schema lives in code alongside the app and travels with it through version control.
You generate one with spark, then run php spark migrate to apply pending migrations and php spark migrate:rollback to undo. This keeps every environment's schema in sync without hand-run SQL.
class AddPosts extends \CodeIgniter\Database\Migration
{
public function up()
{
$this->forge->addField([
'id' => ['type' => 'INT', 'auto_increment' => true],
'title' => ['type' => 'VARCHAR', 'constraint' => 255],
]);
$this->forge->addKey('id', true);
$this->forge->createTable('posts');
}
public function down() { $this->forge->dropTable('posts'); }
}Seeders are classes that populate the database with rows: either reference data the app needs to run (countries, roles, default settings) or fake data for development and testing. You run one from the command line with php spark db:seed ClassName, and a seeder can call other seeders to build a full data set.
You use them to make a fresh clone of the app usable immediately and to give tests a known starting state. Migrations shape the schema; seeders fill it.
A model can carry its own $validationRules and $validationMessages as properties. When you insert() or update() through that model, it runs those rules automatically and fails the write if any rule doesn't pass, and you read what went wrong through $model->errors() afterward instead of validating by hand.
This keeps data rules next to the data, so every write path enforces them, not just the one controller that remembered to validate. It's a layer beyond controller-level $this->validate(), useful when the same model is written from several places.
class UserModel extends Model
{
protected $validationRules = [
'email' => 'required|valid_email|is_unique[users.email]',
'name' => 'required|min_length[2]',
];
}
if (! $model->insert($data)) {
$errors = $model->errors(); // validation failed
}Write a method that returns true or false, place it in a class under app/Validation, and register that class in app/Config/Validation.php under the $ruleSets property. Once it's registered, you reference the rule by its method name in any rules string, right alongside the built-in rules like required.
Custom rules cover checks the built-ins don't, like 'must be a valid coupon code' or a cross-field comparison. The signature receives the value and can receive parameters and the whole data array.
// app/Validation/BusinessRules.php
class BusinessRules
{
public function valid_coupon(string $code, ?string &$error = null): bool
{
if (! CouponService::exists($code)) {
$error = 'That coupon is not valid.';
return false;
}
return true;
}
}
// register in Config\Validation::$ruleSets, then use 'code' => 'required|valid_coupon'When CSRF protection is on, CodeIgniter generates a token and expects it back on every non-GET request. In HTML forms, form_open() or the csrf_field() helper inserts a hidden field with the token; the framework validates it and rejects requests without a valid one.
You enable it through the csrf filter or the Security config, and pick between cookie-based and session-based token storage. For AJAX, you send the token in a header. It stops a third-party site from forging state-changing requests as your logged-in user.
// form helper inserts the hidden token automatically
echo form_open('account/update');
// or add it by hand in a raw form
echo csrf_field();
// renders: <input type="hidden" name="csrf_token" value="...">Key point: Explaining the threat (a forged request riding the user's session) matters more than the config flag. The 'why' is what shows you understand the attack, not just the checkbox.
Escape on output. The esc() function encodes a value for the context it lands in: esc($value) does HTML by default, with separate contexts for attributes, JavaScript, CSS, and URLs. You wrap any user-supplied value in esc() before echoing it into a page, so injected markup renders as text instead of running.
The rule is escape on output, not on input, because the safe encoding depends on where the value lands. Storing raw and escaping at render keeps the data clean and the page safe.
<h1><?= esc($post['title']) ?></h1>
<a href="<?= esc($url, 'attr') ?>">link</a>
<script>var name = <?= esc($name, 'js') ?>;</script>Key point: Saying 'escape on output, not input, because encoding is context-dependent' is the senior-sounding half of this answer.
Services are a central registry for framework objects: the router, request, response, session, validation, and many more. You fetch a shared instance with service('name'), or ask for a brand-new one by passing false as the shared argument. In effect, Services is CodeIgniter's lightweight dependency container.
The value is swappability: because everything comes through Services, you can override a service in app/Config/Services.php to return your own subclass, and the whole framework uses it. It's how CodeIgniter stays extensible without hard-coding classes.
$session = service('session'); // shared instance
$validation = service('validation');
$request = service('request');
// override in app/Config/Services.php
public static function renderer($viewPath = null, $config = null, bool $getShared = true)
{
// return your own renderer subclass
}The View layout system lets a child view extend a shared parent layout. The layout defines named holes with renderSection(), and a child view fills each hole using section() and endSection(). This replaces copy-pasting the same header and footer into every page, keeping the page shell defined in exactly one file.
You mark a view as extending a layout with extend(), then define only the parts that change. This keeps a consistent page shell in one file.
// app/Views/layouts/main.php
<html><body><?= $this->renderSection('content') ?></body></html>
// app/Views/page.php
<?= $this->extend('layouts/main') ?>
<?= $this->section('content') ?>
<h1>Page body here</h1>
<?= $this->endSection() ?>A view cell is a small, self-contained piece of a page rendered by a method that returns its own markup: a shopping-cart summary, a recent-posts widget, a login box. You invoke it from any view with view_cell(), and the cell method fetches whatever data it needs on its own rather than relying on the page's controller.
They're useful for reusable fragments that need their own logic without cluttering the controller. The cell method fetches its own data, so the page's controller doesn't have to know about every widget on the page.
CodeIgniter has a central exception handler that behaves differently by environment. In development it shows a detailed debug page with the full stack trace, while in production it shows a generic error page and quietly logs the details instead. The CI_ENVIRONMENT setting is what decides which of those you get.
You throw framework exceptions like PageNotFoundException to trigger a 404, and you can register custom handlers. Logs go to writable/logs at a threshold you set. Getting the environment right is what keeps stack traces out of production responses.
use CodeIgniter\Exceptions\PageNotFoundException;
public function show($id)
{
$post = $this->model->find($id);
if (! $post) {
throw PageNotFoundException::forPageNotFound(); // clean 404
}
return view('post', ['post' => $post]);
}The Model has a built-in paginate() method: calling $model->paginate(20) returns just one page of results and sets up a pager object behind the scenes. Then in the view, $pager->links() renders the numbered page links, reading which page to show from the query string automatically.
This wires the limit, offset, and link generation together so you don't compute page math by hand. You control per-page count, the page-number parameter, and the link template.
// controller
$data = [
'posts' => $model->where('published', 1)->paginate(20),
'pager' => $model->pager,
];
return view('blog/index', $data);
// view
<?= $pager->links() ?>Get the uploaded file from the request with $this->request->getFile('field'), confirm it's valid and hasn't already been moved, then call move() to store it in a writable folder. Validate the size and type first, either through validation rules or by inspecting the file object, before you trust anything a user uploaded.
Using a generated random name with getRandomName() avoids collisions and hides original filenames. Always validate the MIME type and extension, since trusting the client-supplied name is a common upload hole.
$file = $this->request->getFile('avatar');
if ($file->isValid() && ! $file->hasMoved()) {
$name = $file->getRandomName();
$file->move(WRITEPATH . 'uploads', $name);
}Wrap the writes between $db->transStart() and $db->transComplete(): if any query inside fails, the framework rolls the whole set back automatically, and it commits only when everything succeeded. For manual control, use transBegin() and then transCommit() or transRollback() yourself based on your own application logic.
Transactions matter when several writes must all succeed or all fail together, like deducting stock and recording an order. Without one, a mid-operation failure leaves the database half-updated.
$db->transStart();
$db->table('accounts')->where('id', 1)->update(['balance' => 'balance - 100']);
$db->table('accounts')->where('id', 2)->update(['balance' => 'balance + 100']);
$db->transComplete();
if ($db->transStatus() === false) {
// the transfer failed and was rolled back
}Configuration in CodeIgniter 4 is class-based. Each config file in app/Config is a PHP class with public properties, like App, Database, or one you write yourself. You read one with config('App') or new \Config\App(), and any property can be overridden by a matching value in the .env file at runtime.
This is different from CodeIgniter 3's config arrays: type hints, IDE autocomplete, and a clear place for defaults. Environment-specific values go in .env so secrets stay out of the repo.
$app = config('App');
$baseUrl = $app->baseURL;
// custom config: app/Config/Payments.php
class Payments extends \CodeIgniter\Config\BaseConfig
{
public string $currency = 'USD';
public int $retryLimit = 3;
}advanced rounds probe internals, design judgment, and production scars. Expect every answer here to draw a follow-up.
A model represents a table and handles the queries against it. An entity, by contrast, represents a single row as a typed object, where you define type casts, computed properties, and small business methods that belong to that record. A model can hand back entities instead of plain arrays by setting its $returnType property.
The split is data access (model) versus data behavior (entity). Entities give you things like automatic type casting, a full_name accessor from first and last name, and mutators that transform values on set, keeping row logic out of controllers.
class User extends \CodeIgniter\Entity\Entity
{
protected $casts = ['is_active' => 'boolean', 'created_at' => 'datetime'];
public function getFullName(): string
{
return trim($this->attributes['first'] . ' ' . $this->attributes['last']);
}
}
// model: protected $returnType = User::class;Key point: Framing it as 'model does queries, entity does row behavior' is the clean answer. Casts and accessors are the concrete examples that prove you've used them.
CodeIgniter uses the Services layer as a lightweight service locator rather than a full auto-wiring container like Symfony's. You register how to build an object once in Services, then pull it anywhere with service('name'), and overriding that one method replaces the object everywhere the framework and your code use it.
The honest senior take: it's a service locator, not constructor auto-wiring like Symfony's container. That keeps it simple and fast but means you wire dependencies explicitly through Services or pass them in yourself. For most CodeIgniter apps that's enough; teams needing full DI sometimes add a container.
Events let code publish a named signal that any number of listeners subscribe to, decoupling the thing that happens from the reactions to it. Events::on('user_registered', $callback) registers a listener, and Events::trigger('user_registered', $user) fires it. The framework also emits its own events like 'pre_system' and 'post_controller_constructor'.
Use them to hang side effects off a moment without wiring them into the main flow: send a welcome email on registration, invalidate a cache after a save. The trade-off is indirection, so keep listeners discoverable or the control flow gets hard to follow.
// app/Config/Events.php
Events::on('user_registered', function ($user) {
service('email')->setTo($user->email)->setSubject('Welcome')->send();
});
// somewhere after creating a user
Events::trigger('user_registered', $user);The Cache library puts several storage handlers (file, Redis, Memcached, and others) behind a single interface, so your code doesn't change when the backend does. You store a value with cache()->save($key, $value, $ttl) and read it back with cache()->get($key), and CodeIgniter also offers query caching and full-page caching on top.
Choose the handler by deployment: file cache for a single server, Redis or Memcached when several servers share cache. The real work is invalidation: cache computed or expensive reads, set a sensible TTL, and clear the key when the underlying data changes so you don't serve stale results.
$stats = cache()->get('dashboard_stats');
if ($stats === null) {
$stats = $this->buildExpensiveStats();
cache()->save('dashboard_stats', $stats, 300); // 5 min TTL
}
// after data changes:
cache()->delete('dashboard_stats');Extend ResourceController, which maps HTTP verbs to methods (index, show, create, update, delete) and gives helpers like respond() and failNotFound() for JSON responses with the right status codes. Register it with $routes->resource() to wire the verb-to-method mapping in one line.
Layer filters for auth and rate limiting, use models for data, and return consistent JSON with proper status codes. For versioning, group routes under a prefix. This gives you a clean REST surface without hand-writing every route.
class Products extends \CodeIgniter\RESTful\ResourceController
{
protected $modelName = ProductModel::class;
protected $format = 'json';
public function show($id = null)
{
$item = $this->model->find($id);
return $item ? $this->respond($item) : $this->failNotFound();
}
}
// routes: $routes->resource('products');Key point: Knowing respond() and failNotFound() give correct status codes shows API maturity. A common junior mistake is returning 200 with an error body; the failXxx helpers exist precisely to avoid that.
The N+1 happens when you fetch a list, then loop and run one more query per row (one query for posts, then one per post to get its author). It looks fine on ten rows and falls over on a thousand.
Spot it by watching the query count: enable the debug toolbar or log queries and count them per request. Fix it by fetching related data in one query with a join, or by loading the parent set and then all children in a single WHERE IN query, then stitching them in PHP.
// N+1: one query, then one per post
foreach ($posts as $p) {
$p['author'] = $userModel->find($p['author_id']); // bad
}
// fixed: one extra query for all authors
$ids = array_column($posts, 'author_id');
$authors = $userModel->whereIn('id', $ids)->findAll(); // single queryKey point: This is a database-thinking question wearing a CodeIgniter costume. The fix (join or WHERE IN, not a loop of finds) is what they're scoring.
CodeIgniter is built to be extended rather than edited. To swap a core piece, you subclass it and override the matching factory method in app/Config/Services.php, so the framework hands out your version everywhere it uses that object. For controllers, you put shared logic in BaseController and have your controllers extend it.
This is cleaner than editing the system folder, which you never touch because updates would overwrite it. The Services override point is the sanctioned seam for changing framework behavior.
Define extra named connection groups in app/Config/Database.php beyond the default one: a reporting connection, a read replica, or a legacy database, each with its own credentials. You then open a specific one by passing its name to \Config\Database::connect('groupName') instead of taking the default.
Models can point at a group through $DBGroup. Common uses are read/write splitting (writes to primary, reads to a replica) and talking to a second database. The connection is lazily opened, so naming a group doesn't cost you until you query it.
$reporting = \Config\Database::connect('reporting');
$rows = $reporting->table('metrics')->get()->getResultArray();
// or on a model
class MetricModel extends Model
{
protected $DBGroup = 'reporting';
}CodeIgniter 4 ships with PHPUnit integration and test helpers. You extend CIUnitTestCase, and traits add abilities: DatabaseTestTrait for migrations and a test database, FeatureTestTrait to call routes and assert on the response, and ControllerTestTrait to test a controller in isolation.
The pattern is feature tests that hit a route and check status and body, plus unit tests on models and services. A separate test database plus migrations gives each run a known state.
class ProductTest extends \CodeIgniter\Test\CIUnitTestCase
{
use \CodeIgniter\Test\FeatureTestTrait;
public function testListReturnsOk()
{
$result = $this->get('products');
$result->assertStatus(200);
$result->assertSee('Products');
}
}The Throttler service is a token-bucket rate limiter backed by the cache. You call $throttler->check($key, $capacity, $seconds), passing a key like the caller's IP; it returns false once that caller has used up its allowance in the window, and at that point you return a 429 Too Many Requests response.
You usually wire it into a before filter keyed by IP or user so it protects an endpoint without touching controller logic. Because it's cache-backed, it works across processes; with Redis it works across servers too.
$throttler = service('throttler');
// allow 10 requests per minute per IP
if ($throttler->check($request->getIPAddress(), 10, MINUTE) === false) {
return service('response')->setStatusCode(429)->setBody('Too many requests');
}For non-form requests, CodeIgniter still expects the CSRF token, so you send it in a header or request body. Read the current token name and value with csrf_token() and csrf_hash(), and attach it to each request. With cookie-based tokens you can read it from the cookie the framework sets.
The subtlety is token regeneration: if regenerate is on, the token rotates per request, so your front end must read the new token from each response rather than reusing a stale one. Getting that wrong causes intermittent 403s that only show up under real traffic.
Key point: The token-rotation gotcha is the senior detail. Anyone can add a header once; knowing the token can rotate per request is what separates it.
Create a class under app/Commands that extends BaseCommand, set its $group, $name, and $description properties, and implement the run() method with your logic. It then shows up automatically in the php spark list, and you invoke it by name, with any arguments and options parsed and handed to run() for you.
Custom commands are how you script one-off maintenance and scheduled jobs: nightly cleanup, importing a feed, backfilling a column. Pairing them with the system cron gives you background tasks without extra infrastructure.
class Cleanup extends \CodeIgniter\CLI\BaseCommand
{
protected $group = 'Maintenance';
protected $name = 'app:cleanup';
protected $description = 'Removes expired sessions and temp files.';
public function run(array $params)
{
// run: php spark app:cleanup
CLI::write('Cleanup done', 'green');
}
}The response object can format by content type, and the API response traits pick a format based on the request's Accept header or a configured default. respond() serializes to JSON or XML depending on the negotiated format, so one action can serve both.
You set the allowed formats in the config and let negotiation choose, or force one with setJSON()/setXML(). This keeps a single endpoint serving a JSON client and an XML client without branching logic in the controller body.
The default file session handler stores data on local disk, so behind a load balancer a user's requests can land on a server that doesn't have their session, and they appear logged out at random. That's the classic multi-server session bug.
The fix is a shared session handler: point the session config at the database, Redis, or Memcached so every server reads the same store. Sticky sessions at the balancer are a workaround, but a shared handler is the durable answer and also survives a single server dying.
Key point: Diagnosing 'random logouts behind a load balancer' as file-based sessions is a real production scar. Naming the shared-handler fix is what shows you've actually shipped a scaled app.
Drop to raw SQL when the builder can't express the query cleanly: window functions, complex CTEs, database-specific features, or a hand-tuned query the optimizer needs. The builder covers most work, so raw SQL should be the exception with a reason.
Keep it safe by binding every user value with query bindings rather than concatenating: $db->query('... WHERE id = ?', [$id]). Never interpolate input into the string. Binding gives you raw SQL's control with the builder's injection protection.
// safe: bindings, not concatenation
$rows = $db->query(
'SELECT * FROM orders WHERE user_id = ? AND total > ?',
[$userId, $minTotal]
)->getResultArray();Measure first, always. Turn on the debug toolbar to see the query count, query time, and memory used per request, and log the slow requests so you have real data. Intuition about where the bottleneck lives is usually wrong, so profile a realistic path with production-like data before you change a single line.
Then fix in order of impact: kill N+1 queries with joins or batched loads, add indexes for the columns you filter and join on, cache expensive computed reads with a sensible TTL, and only then reach for infrastructure. The escalation ladder ends at 'scale out', not begins there.
Key point: the check is for the discipline (profile, don't guess) and the order of fixes (queries and indexes before caching before hardware). The method is weighed more than any single tool.
There's no in-place upgrade; version 4 is a rewrite, so it's a migration, not a bump. Stand up a fresh CodeIgniter 4 app and port piece by piece: rewrite controllers into namespaced classes, move models onto the new Model base, translate $this->load calls to services and helpers, and convert config arrays to config classes.
The honest plan is incremental: run the two apps side by side, move routes across a feature at a time, and reuse the database as-is since schema doesn't change. A big-bang rewrite of a large app is where these migrations stall.
Observe before you touch anything: check writable/logs and your application logs, confirm the environment really is production so you aren't leaking stack traces, and correlate the failure with recent deploys or config changes. Then reproduce the failing request path and watch the query count and the timings on the debug toolbar.
Then form a hypothesis and verify it: the usual suspects are N+1 queries, a missing index, a wrong .env value after deploy, file-based sessions behind a balancer, or an uncaught exception hidden by a generic error page. Fix at the right layer and confirm with a measurement, not a hope.
Key point: The methodology is, observe, hypothesize, verify, fix, confirm, and tools support the evidence.
The official path is CodeIgniter Shield, the framework's own authentication and authorization library. It handles login, registration, password hashing, both session and token-based auth, and role or permission checks, so you don't hand-roll the security-sensitive parts that are easy to get subtly wrong on your own.
You install it via Composer, run its migrations, and wire its filters into your routes. Rolling your own auth is possible with the session library and password_hash(), but for anything real the guidance is to use Shield rather than reinvent login and forgot-password flows that are easy to get subtly wrong.
Key point: Naming Shield and saying 'don't hand-roll auth' is the mature answer. Interviewers worry about candidates who write their own password handling; pointing at the vetted library is the reassuring signal.
CodeIgniter wins when you want a small, fast PHP framework with a gentle learning curve and little configuration, which suits small teams, tight deadlines, and shared hosting. It trades the deep feature set of Laravel or Symfony for speed and simplicity: fewer built-in abstractions, a smaller ecosystem, but less to learn and less to load. Where it loses is large applications that lean on a rich package ecosystem, Eloquent-style relationships, or heavy dependency injection out of the box. Knowing these trade-offs out loud is itself an interview signal: it shows you pick a framework on merits, not habit.
| Framework | Learning curve | Best at | Watch out for |
|---|---|---|---|
| CodeIgniter | Gentle | Small, fast apps, quick delivery | Smaller ecosystem, fewer built-ins |
| Laravel | Moderate | Feature-rich apps, big ecosystem | Heavier footprint, more to learn |
| Symfony | Steep | Large enterprise, reusable components | Verbose config, slower to start |
| CakePHP | Moderate | Convention-heavy CRUD apps | Strong conventions, less flexible |
Prepare in layers, and practice out loud. Most CodeIgniter rounds move from concept questions to live coding to a design or debugging discussion, so rehearse each stage rather than only reading answers.
The typical CodeIgniter interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These CodeIgniter questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works