1. 为什么还在谈CodeIgniter?——框架定位与上手前的准备工作
聊到PHP框架,很多人第一反应是Laravel、Symfony这些主流选手,但CodeIgniter在我心里一直有个特殊位置。它体积小、起步快、文档清晰,不需要命令行工具也能跑起来,对于刚接触框架概念的开发者、或者维护老项目的朋友来说,CodeIgniter依然是快速落地业务的高效工具。
CodeIgniter(下文简称CI)的核心特点可以用一句话概括:它是一个极端轻量的PHP MVC框架,不依赖Composer也能完整运行。对比Laravel动辄几百MB的依赖体积,CI 4.x完整解压也就1MB左右,在没有外网环境、或者服务器配置较低的生产场景下,这种轻量本身就是巨大优势。而且CI的学习曲线非常平缓,如果你熟悉原生PHP的写法,切换到CI几乎不需要重新学习一套“世界观”,它只是帮你把代码梳理进MVC结构中。
在开始写代码之前,我必须先强调版本问题。CodeIgniter目前有两个大版本在活跃使用:老牌的3.x和现代化的4.x。3.x发布于2015年前后,语法更简单,网上教程也最多;4.x是2020年发布的重写版本,引入了命名空间、PSR-4自动加载、内置测试支持等现代PHP特性。新项目建议直接用4.x,3.x只推荐用于维护存量系统。本文所有内容基于CodeIgniter 4.x展开,但核心的MVC思想在两个版本中是通用的。
1.1 环境需求与安装方式
CI 4.x对PHP版本的要求是7.4以上(官方推荐8.1+),需要开启intl、mbstring这两个扩展,其中intl扩展在某些精简版PHP环境中是缺失的,安装前先确认一下。
安装方式有两种,我建议你根据实际场景选:
方式一:Composer安装(推荐)
composer create-project codeigniter4/appstarter myproject cd myproject php spark serve执行完以上命令后,在浏览器访问localhost:8080,看到CI的默认欢迎页就代表安装成功。php spark serve是CI内置的开发服务器命令,相当于Laravel的php artisan serve,本地调试非常方便。
方式二:手动下载
直接从官网下载ZIP包,解压到Web根目录(比如htdocs/myproject),访问http://localhost/myproject/public即可。这种方式适合没有Composer环境、或者需要离线部署的场景。
这里有一个最容易被新手忽略的点:生产环境部署时,Web根目录必须指向public文件夹,而不是项目根目录。CI 4.x把入口文件放在了public/index.php,所有公开资源(CSS、JS、图片)也都在public目录下,这样设计是为了保护系统文件不被直接访问。如果你把根目录指到项目根目录,虽然也能跑,但会产生严重的安全隐患——app/Config/Database.php里存放着数据库密码,外部可以直接通过URL访问到。
1.2 base_url配置:第一个必须改的设置
安装完成后,第一件事不是写Hello World,而是设置base_url。打开app/Config/App.php,找到baseURL属性:
public $baseURL = 'http://localhost:8080/';如果你是手动部署到子目录,比如http://localhost/myproject,这里必须写成:
public $baseURL = 'http://localhost/myproject/';注意末尾的斜杠/不能丢。base_url的作用是告诉CI所有URL生成的基础路径,如果这个配置不对,base_url()等辅助函数生成的链接全部会指向错误位置,表单提交、页面跳转会一片混乱。
提示:开发阶段如果不想频繁改配置,可以在
app/Config/App.php里开启自动检测。CI 4.2+版本支持将baseURL留空,系统会自动根据当前请求的域名和路径推断出正确值。但在生产环境我仍然建议显式配置,避免多域名或HTTPS跳转场景下出现意外。
2. 目录结构与MVC运转机制——看懂CI怎么处理一次请求
很多初学者用框架时的痛苦在于“不知道文件该放哪里”,这其实是没有理解框架的约定。CI 4.x的目录结构比3.x清晰得多,核心目录如下表所示:
| 目录 | 作用 |
|---|---|
app/Controllers | 控制器文件存放处,接收请求并协调业务逻辑 |
app/Models | 模型文件存放处,负责与数据库交互 |
app/Views | 视图文件存放处,输出HTML页面 |
app/Config | 所有配置文件,包括数据库、路由、自动加载等 |
app/Routes | 路由定义目录(4.x中路由文件在app/Config/Routes.php) |
public | Web入口目录,存放index.php和静态资源 |
writable | 用于存放日志、缓存、上传文件等可写内容 |
2.1 一次请求的完整流转路径
理解CI的工作流程,只需要记住一条链子:URL → 入口文件 → 路由解析 → 控制器调用 → 模型读取数据 → 视图渲染 → 响应返回浏览器。
举个例子,当用户访问http://localhost:8080/blog/view/42时,CI内部发生的事如下:
public/index.php接收所有请求(通过Web服务器的URL重写规则)。- 路由组件解析URL,默认规则是
控制器名/方法名/参数,所以控制器是Blog,方法是view,参数是42。 - CI实例化
Blog控制器,调用view(42)方法。 - 控制器的
view()方法内部调用Blog_model获取ID为42的文章数据。 - 控制器把数据传给视图文件,视图渲染出完整的HTML并返回给浏览器。
这个流程就是经典的MVC模式。控制器是协调者,它本身不写SQL也不输出HTML,只负责“调度”;模型只管数据的存取;视图只管展示。代码有了边界之后,多人协作时不会互相干扰,后期维护时定位问题也快很多。
2.2 路由规则:从默认路由到自定义路由
默认路由规则虽然方便,但真实项目里URL往往需要语义化。CI 4.x的路由定义在app/Config/Routes.php中,支持非常灵活的自定义规则。
常见用法有三种:
普通路由映射:
$routes->get('about', 'Page::about'); $routes->get('blog/(:num)', 'Blog::view/$1');第一个将/about映射到Page控制器的about方法;第二个用(:num)占位符匹配数字,$1将匹配到的数字传给Blog::view方法的第一个参数。
闭包路由,适合简单页面:
$routes->get('ping', function() { return 'pong'; });HTTP动词路由:
$routes->post('api/user', 'Api\User::create'); $routes->put('api/user/(:num)', 'Api\User::update/$1'); $routes->delete('api/user/(:num)', 'Api\User::delete/$1');一个非常实用的技巧是路由分文件管理。项目后期路由规则多了之后,全部堆在Routes.php里会非常臃肿。CI支持通过$routes->group()方法将路由按模块分组,或者直接把不同模块的路由文件单独建,在Routes.php里require进来。
关于路由,我踩过一个很典型的坑:CI 4.x的路由定义有先后顺序,前面的规则会优先匹配,一旦匹配成功就不会继续往后查找。所以当你同时定义了product/(:num)和product/new时,product/new必须写在product/(:num)之前,否则new会被当作数字参数解析,导致匹配失败。这类问题特别隐蔽,页面打开明明URL正确,结果却报404,排查半天才发现是路由顺序的问题。
3. 控制器、视图与传参——写第一个能跑的页面
路由配好之后,接下来就是实际写代码。控制器是请求处理的起点,这一步我建议从零开始写一个完整的小例子,跑通了再研究复杂的用法。
3.1 控制器的标准写法与命名规范
在app/Controllers/下新建Blog.php文件:
<?php namespace App\Controllers; class Blog extends BaseController { public function index() { echo '这里是博客列表页'; } public function view($id) { echo '文章ID:' . $id; } }访问http://localhost:8080/blog会看到“这里是博客列表页”,访问http://localhost:8080/blog/view/5会输出“文章ID:5”。
有几个必须遵守的规范,违背任何一个都会报错:
- 文件名必须与类名完全一致,大小写敏感。
Blog.php对应class Blog。 - 文件必须放在
app/Controllers目录下。 - 命名空间
App\Controllers不能丢(这是4.x与3.x最大的语法区别之一)。 - 控制器类应该继承
BaseController(位于app/Controllers/BaseController.php),这个基类里预加载了常用服务,比如数据库连接、session、request对象等,不继承的话很多功能需要自己手动调用。 - 方法名中不建议使用下划线,某些配置下会导致路由无法访问。
3.2 视图加载与数据传递
控制器里输出内容不应该直接用echo,而是通过加载视图文件。在app/Views/下新建blog_view.php:
<!DOCTYPE html> <html> <head> <title><?= $title ?></title> </head> <body> <h1><?= $title ?></h1> <ul> <?php foreach ($articles as $article): ?> <li><?= esc($article['title']) ?></li> <?php endforeach; ?> </ul> </body> </html>控制器中这样加载:
public function index() { $data['title'] = '博客列表'; $data['articles'] = [ ['title' => '第一篇'], ['title' => '第二篇'], ]; return view('blog_view', $data); }注意三个细节:
view()方法属于全局辅助函数,不需要额外引入。- 视图中不需要写
<html>和<body>之类的骨架结构都行,但实际项目中建议拆分:一个header.php、一个footer.php,页面中间部分用<?= $this->include('header') ?>拼装,避免每个页面重复整套HTML。 - 视图中输出变量使用
<?= $variable ?>,等同于<?php echo $variable; ?>,简洁且常用。涉及用户输入或数据库中的内容时,务必使用esc()函数包裹,它能自动转义HTML字符,防止XSS攻击:
<?= esc($article['title']) ?>很多初学者省掉esc(),在本地自己玩没事,一旦上线处理真实用户数据,二分之一的概率会成为被攻击的对象。这是安全红线,不是可选项。
3.3 布局模板与视图碎片化
CI 4.x原生支持简单的模板布局功能。在app/Views/layouts/下建一个主布局文件:
<!DOCTYPE html> <html> <head> <title><?= $this->renderSection('title') ?></title> </head> <body> <header>站点导航区</header> <?= $this->renderSection('content') ?> <footer>版权信息</footer> </body> </html>视图文件变为:
<?= $this->extend('layouts/default') ?> <?= $this->section('title') ?>博客列表<?= $this->endSection() ?> <?= $this->section('content') ?> <h1>文章列表</h1> <ul>...</ul> <?= $this->endSection() ?>extend声明继承哪个布局文件,section用来填充布局中对应的区块。这种做法在涉及大量页面的项目中能省下巨量的重复HTML代码。我第一次从“每个页面写全套HTML”切到布局模式时,整体页面代码量大约少了40%,动导航栏只需要改一个文件。
4. 模型层与数据库操作——查询构造器的正确打开方式
Web应用的核心说到底是对数据的操作。CI 4.x的模型层设计得很实用,既支持简单的数据表映射,也提供了功能完备的查询构造器(Query Builder)。这一节覆盖实战中最常用的数据操作场景。
4.1 数据库配置与自动加载
打开app/Config/Database.php,配置默认组:
public $default = [ 'DSN' => '', 'hostname' => '127.0.0.1', 'username' => 'root', 'password' => '', 'database' => 'blog_db', 'DBDriver' => 'MySQLi', 'DBPrefix' => '', 'charset' => 'utf8mb4', 'compress' => false, ];理解一个关键配置:DBPrefix是表前缀,如果设置为blog_,那么模型里写$this->table = 'articles'时,实际查询的表是blog_articles。这个机制在多应用共享同一个数据库时很好用,能避免表名冲突。
4.2 模型的标准写法
在app/Models/下新建ArticleModel.php:
<?php namespace App\Models; use CodeIgniter\Model; class ArticleModel extends Model { protected $table = 'articles'; protected $primaryKey = 'id'; protected $allowedFields = ['title', 'content', 'status', 'created_at']; protected $returnType = 'array'; protected $useTimestamps = true; }需要重点说明的是$allowedFields——它在CI中扮演“白名单”角色。不在这个数组里的字段,无法通过模型的insert()或save()方法写入数据库。我第一次用CI时为了方便,把所有字段都填进了$allowedFields,后来发现这个机制存在意义在于防止批量赋值漏洞。比如用户通过表单提交了is_admin = 1,如果is_admin在白名单里,这就会成为安全漏洞。所以$allowedFields应该只包含真正允许用户写入的字段。
$useTimestamps = true是CI比较贴心的一点。它会自动在插入时写入created_at,更新时写入updated_at,前提是表里要有这两个字段。省去手动组装时间戳的重复劳动。
4.3 查询构造器:链式调用的正确用法
控制器里使用模型:
public function index() { $model = new ArticleModel(); // 查询状态为已发布的文章,按时间倒序,每页10条 $articles = $model->where('status', 'published') ->orderBy('created_at', 'DESC') ->paginate(10); return view('blog_view', ['articles' => $articles]); }查询构造器支持完整的链式调用,底层实际是CI自己封装的SQL构建器。常用的方法汇总如下表:
| 方法 | 作用 | 示例 |
|---|---|---|
where(字段, 值) | 条件过滤 | ->where('status', 'published') |
like(字段, 关键词) | 模糊搜索 | ->like('title', 'PHP') |
orderBy(字段, 方向) | 排序 | ->orderBy('created_at', 'DESC') |
limit(数量, 偏移) | 分页 | ->limit(10, 20) |
join(表, 条件) | 表连接 | ->join('users', 'users.id = articles.user_id') |
select(字段列表) | 指定查询字段 | ->select('id, title, created_at') |
countAllResults() | 统计总条数 | ->countAllResults() |
paginate()方法特别提一下。CI内置了完整的分页支持,在控制器里调用paginate(10),视图中用$pager->links()输出分页链接:
<?= $pager->links() ?>底层自动生成上一页、下一页和页码导航,不用手动写任何分页逻辑,对于一个“快速上手”的框架来说,这种设计非常友好。
4.4 事务处理与原生SQL的边界
涉及金额、库存这类多表联动操作时,必须使用事务保证数据一致性:
$db = \Config\Database::connect(); $db->transStart(); // 开始事务 $model->insert($data1); $model->insert($data2); $db->transComplete(); // 提交事务 if ($db->transStatus() === false) { // 事务失败,系统已自动回滚 return redirect()->back()->with('error', '操作失败,请重试'); }transStart()和transComplete()之间的所有数据库操作会被包进同一个事务中,任意一步失败时自动回滚全部操作。
使用模型方法时,框架会在后台调用查询构造器生成SQL。但有些复杂场景(如多表嵌套子查询、特殊数据库函数)构造器表达起来会很别扭,此时可以直接用原生SQL:
$db = \Config\Database::connect(); $sql = "SELECT * FROM articles WHERE MATCH(title) AGAINST('PHP' IN BOOLEAN MODE)"; $query = $db->query($sql); $results = $query->getResultArray();有个原则很重要:能用查询构造器解决的,就不要写原生SQL。一是因为构造器会自动处理参数绑定,有效防止SQL注入;二是因为CI的构造器自动适配不同数据库驱动(MySQL、PostgreSQL、SQLite),换数据库时无需改代码。原生SQL一旦用了特定数据库的函数,就绑定死了。但全文搜索这类构造器支持不好的场景,也不必硬凑,直接上原生SQL反而清晰。
5. 表单验证、CSRF与安全过滤——上线前必须补的功课
本地开发时跑通页面很简单,真正让框架发挥价值的时刻是处理用户输入。这一节的内容直接关系到一个PHP应用能不能安全地上生产环境。
5.1 表单验证规则
CI 4.x内置了完善的验证组件,在控制器中应用:
public function create() { $validation = \Config\Services::validation(); $rules = [ 'title' => 'required|min_length[3]|max_length[100]', 'content' => 'required', 'email' => 'required|valid_email', 'status' => 'permit_empty|in_list[draft,published]', ]; if (! $this->validate($rules)) { return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } $model = new ArticleModel(); $model->save([ 'title' => $this->request->getPost('title'), 'content' => $this->request->getPost('content'), 'status' => $this->request->getPost('status'), ]); return redirect()->to('/blog')->with('message', '发布成功'); }规则的含义比较直白:required必填,min_length[3]最少3个字符,valid_email校验邮箱格式,in_list限定可选值。以上写法中,$this->validate($rules)会在验证失败时记录错误,并通过with('errors', ...)把错误信息闪存到session中,视图中这样显示:
<?php if (session()->getFlashdata('errors')): ?> <ul> <?php foreach (session()->getFlashdata('errors') as $error): ?> <li><?= esc($error) ?></li> <?php endforeach; ?> </ul> <?php endif; ?>withInput()会把用户上次提交的旧数据带回去,配合old('title')辅助函数回显到表单里。这个交互细节很关键,用户填错时表单不会清空,体验差距很大。
5.2 CSRF防护:默认开启,但需要正确配合
CI 4.x默认开启了CSRF(跨站请求伪造)防护。这意味着所有POST请求的表单必须包含一个令牌字段。在表单中这样添加:
<form method="post" action="/blog/create"> <?= csrf_field() ?> <input type="text" name="title"> ... </form>csrf_field()会自动生成一个隐藏的csrf_test_name字段。如果每张POST表单漏掉这个字段,提交时CI会直接拒绝请求。
这里有个容易踩的坑:AJAX请求也必须携带CSRF令牌。用fetch提交时,从meta标签中读取令牌:
<meta name="csrf-token" content="<?= csrf_hash() ?>">fetch('/api/article', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content') }, body: JSON.stringify(data) });另外注意,CI 4.x默认CSRF令牌在每次请求后会重新生成。多标签页场景下,A标签页中的令牌可能已经失效,导致POST请求被拒。应对方案有两种:一种是在app/Config/Filters.php的CSRF过滤器配置中将'regenerate' => true改为false;更安全的做法是保持默认,但前端在收到CSRF失败响应时重新加载页面获取新令牌。
5.3 全局过滤器:在请求进入控制器之前做好安全检查
CI 4.x的过滤器(Filters)相当于中间件,在请求到达控制器之前或响应返回之后执行一些通用逻辑。最常用的过滤器是内置的CSRF过滤器和路由过滤器。
打开app/Config/Filters.php可以看到默认配置:
public $aliases = [ 'csrf' => \CodeIgniter\Filters\CSRF::class, 'toolbar' => \CodeIgniter\Filters\DebugToolbar::class, 'honeypot' => \CodeIgniter\Filters\Honeypot::class, ]; public $globals = [ 'before' => [ 'honeypot', 'csrf', ], 'after' => [ 'toolbar', ], ];$globals['before']中的过滤器会在所有请求前执行。如果想给某组路由单独加登录验证过滤器,可以在$routes->group()中指定:
$routes->group('admin', ['filter' => 'login_check'], function($routes) { $routes->get('dashboard', 'Admin\Dashboard::index'); $routes->get('settings', 'Admin\Settings::index'); });自定义一个login_check过滤器,在app/Filters/LoginCheck.php中实现逻辑,然后在$aliases中注册:
public $aliases = [ 'csrf' => \CodeIgniter\Filters\CSRF::class, 'login_check' => \App\Filters\LoginCheck::class, ];过滤器中的before()方法在控制器之前执行:
public function before(RequestInterface $request, $arguments = null) { if (! session()->get('isLoggedIn')) { return redirect()->to('/login'); } }返回一个redirect()响应时,CI会中断后续请求,直接跳转到登录页。这套机制让权限控制非常清爽,不需要在每个控制器里重复写“是否登录”的判断逻辑。
6. 环境配置、日志与开发调试——遇到问题时的生存技能
任何一个框架,写代码的时间远少于查问题的时间。掌握CI的调试手段,能让你在遇到问题时保持清醒。
6.1 环境配置:三种环境与敏感信息管理
CI 4.x默认配置文件位于env文件(Composer安装时自动生成),运行前先执行:
cp env .env在.env文件中可以覆盖app/Config/下的任何配置项,比如:
# 环境类型:development / testing / production CI_ENVIRONMENT = development # 覆盖数据库配置 database.default.hostname = 127.0.0.1 database.default.database = blog_db database.default.username = root database.default.password = secret # 关闭调试工具栏 CI_DEBUG = false不要把数据库密码写在app/Config/Database.php中,而应该放在.env里,并且.env必须加入.gitignore。.env文件不会被Web服务器直接访问到,这样即使代码仓库被分享,敏感信息也不会泄露。这个习惯从项目第一天就要养成,后期再迁移环境只需要修改.env,不需要改动PHP代码——开发环境和生产环境共用同一份代码,配置各管各的。
6.2 debug工具栏与日志使用
在development环境下,页面底部会显示一个调试工具栏(Debug Toolbar),上面实时展示每次请求执行的SQL语句、耗时、内存占用、包含的文件列表。这个工具栏对排查“页面慢到底慢在哪”很有帮助,点开“Queries”标签能直接看到每一条SQL语句:
SELECT * FROM articles WHERE status = 'published' ORDER BY created_at DESC LIMIT 10调试工具栏在生产环境(CI_ENVIRONMENT = production)下不会显示,避免泄露数据库结构。
自己排查问题时,可以在代码中手动记录日志:
log_message('error', '文章ID {id} 查询失败', ['id' => $id]);日志写入writable/logs/目录,按照日期生成文件。log_message()支持emergency、alert、critical、error、warning、notice、info、debug八个级别,不同级别的日志会被过滤(由app/Config/Logger.php中的$threshold配置控制)。
6.3 高频异常与解决方案对照
我在使用CI 4.x的过程中,遇到过几个极其高频的问题,整理成表格供读者对照排查:
| 症状 | 根本原因 | 解决方案 |
|---|---|---|
| 页面404,但URL看起来正确 | 控制器名首字母未大写,或路由顺序错误 | 检查app/Controllers下的类名首个字母是否大写;调整路由定义顺序 |
| 表单提交返回403 | CSRF令牌缺失或过期 | 表单添加csrf_field();AJAX携带令牌;检查regenerate配置 |
| SQL执行报错 “Unknown column” | $allowedFields中缺少该字段 | 将字段加入模型的$allowedFields数组 |
base_url()生成的URL缺少子目录 | baseURL未配置 | 在app/Config/App.php或.env中显式配置完整URL |
| 修改PHP代码不生效 | 使用了php spark serve,某些OPcache环境未刷新 | 重启开发服务器,或清除OPcache扩展缓存 |
| 时区错误,时间差8小时 | PHP时区未设置 | 在.env中配置app.defaultLocale = zh-CN,并设置date_default_timezone_set('Asia/Shanghai') |
第2个问题值得反复提醒:CSRF的403错误非常容易让人一头雾水,因为页面上没有任何提示,只返回一个空白页面或浏览器自带的403页面。先把app/Config/Filters.php中的CSRF过滤器暂时注释掉,如果能跑通了,就说明问题在CSRF机制上,然后依次检查表单令牌、session启动状态和令牌刷新策略。
7. 几个让代码更规范的小技巧
框架用熟练之后,一些习惯会影响项目的长期体验。分享几个我实打实用过几年、回头觉得特别值得坚持的做法。
尽量用路由到控制器,再通过控制器调模型,不要在视图中直接写SQL或调用模型。视图只负责数据展示,一旦视图里出现$db->query()这类代码,数据逻辑和展示逻辑就混在一起了,后期改业务逻辑时极容易改出隐蔽Bug。
模型的职责要收敛。一个模型尽量对应一张主表,不要在模型里堆砌过多的跨表关联逻辑。实在需要多表关联查询时,我会新建一个独立的服务类(放在app/Services目录)来处理复杂查询,把控制器-模型-视图这条链路保持简单清晰。CI官方不强制这个做法,但项目规模上来后,你会发现“单表模型+独立服务类”的组合比“模型里汇聚所有查询”好维护得多。
表单验证规则不要散落在各个控制器方法里,统一放到模型或专门的验证配置中管理。CI的模型类支持$validationRules属性,验证规则可以定义在模型内部,控制器中一行$model->save($data),框架自动验证,不通过就返回错误。这个方式能够在多个控制器调用同一张表时不重复写验证规则。
从安装环境到表单安全,写这篇内容时我一直在想一件事:CI之所以能在PHP陆续出现大批新框架的背景下被很多人持续使用,恰恰是因为“务实”。它不追求花哨,不会强制你学习一整套DSL或命令行体系,而是回归框架的本质——把常用的问题解决好,把不受用的复杂度挡在外面。这种特质放到今天,确实能让人静下心来完成手头的事。