ThinkPHP最新开发规范(官方超详细)

PHP版本选择

如果是新的项目,目前应该尽量选择PHP8.2+作为你的PHP版本,可以拥有更好的性能,ThinkPHP8.0版本的最低版本要求是PHP8.0

有些PHP扩展可能不支持PHP的高版本,这个时候你要做出选择,使用低版本还是寻求更好的扩展解决方案。

基本命名规范

ThinkPHP遵循PSR-2命名规范以及PSR-4自动加载规范,并注意如下规范:

如果你没有遵循某些规范,可能会导致部分功能的异常。

类和文件命名

  • 类(包括接口、Trait)文件名和类名保持一致,并且使用首字母大写的驼峰命名;
  • 函数文件、配置文件、路由定义文件等文件名使用小写规范;
  • 无论类还是普通文件都使用.php后缀;
  • 目录名统一使用小写规范,并且使用单数规范;
  • 模板文件使用小写规范;

配置和变量命名

  • 配置参数名统一使用小写规范;
  • 常量定义统一使用大写规范;
  • 环境变量定义统一使用大写规范;

函数和类、属性命名

  • 函数的命名使用小写字母和下划线(小写字母开头)的方式,例如get_client_ip
  • 方法的命名使用驼峰法(首字母小写),例如getUserName
  • 属性的命名使用驼峰法(首字母小写),例如tableNameinstance
  • 特例:以双下划线__打头的函数或方法作为魔术方法,例如__call__callStatic

使用统一的IDE以及代码规范配置或者插件

项目团队应当尽量使用统一的IDE作为开发工具,并规范一致的代码规范配置项,如果使用的第三方代码规范及自动完成插件。如果团队成员较多而无法完全统一,最低限度,项目代码风格必须遵循PSR-1PSR-2规范。

助手函数

助手函数的初衷是为了简化代码和更方便记忆,但如果不是很清楚助手函数的内部实现原理,很容易导致滥用,由于现代的IDE提示和自动完成功能之强大,助手函数的作用非常有限,而且只会用助手函数对于框架的原理认识较浅,因此建议是掌握助手函数的内部实现原理后再来决定在项目规范中是否需要使用助手函数,以及如何使用。

毕竟有些场景下,助手函数是非常简单实用的,例如:

public function getUser($id)
{
    $user = User::getOrEmpty($id);
    return json($user);
}

产品交付给客户的时候,有些时候助手函数能够让客户自定义模板的时候更方便。

如果你需要额外定义或者覆盖原有的助手函数,可以直接在应用的common.php公共文件中定义。

配置规范

线上环境和本地测试环境应该使用一致的配置文件,差异化的配置使用环境变量方式处理。本地环境可以通过定义.env文件(注意添加到忽略文件列表)来模拟环境变量。

在你需要差异化配置的参数中使用env函数定义,例如:

'db_host'    =>    env('db_host', '127.0.0.1'),

然后在环境变量中或者本地.env中定义

DB_HOST = 192.168.0.12

尽量不要在配置文件以外使用env函数获取配置参数。统一使用config函数获取配置参数。

除了定义配置文件之外,避免使用动态配置功能,保持仅读取配置参数的良好习惯。

如果需要提高配置文件的性能,可以考虑使用Yaconf扩展。安装

composer topthink/think-yaconf

日志规范

日志记录建议直接使用PSR-3规范提供的接口方法记录,例如:

Log::record('测试日志', 'error');
Log::record('测试日志', 'info');

应当改为

Log::error('测试日志');
Log::info('测试日志');

支持的方法包括debuginfonoticewarningerrorcriticalalertemergency以及用于SQL日志记录的sql方法。

默认情况下,日志是延时写入的,如果要实时写入日志信息,可以使用write方法

Log::wirte('测试日志', 'info');

确保设置日志的最大数量限制,避免日志空间过大导致存储空间占满。

'max_files'    =>    30

超过设置的数量后,最早的日志将会被自动清理。

如果需要把日志接入阿里云,可以设置为单一日志文件,具体可以参考:thinkphp日志接入阿里云日志系统

规范部署

请务必把你的WEB根目录指向public目录而不是应用根目录,并且不要随意更改入口文件的位置。public目录下面不要放除了入口文件和资源文件以外的其它应用文件。

保持测试环境和部署环境的一致性

在开发过程中,应该尽量保持你的测试环境和正式部署环境的一致性,包括运行环境和版本,无论在本地测试环境还是部署环境,都应当统一使用域名方式访问,本地可以使用测试域名,例如你的正式部署域名为thinkphp.cn,那么本地测试环境可以使用thinkphp或者thinkphp.test作为测试域名,避免使用localhost或者127.0.0.1这种测试地址。对于有多个域名的部署应用,本地也要尽量模拟多个域名。

关闭调试模式

在部署到生产环境的时候,确保你已经关闭了调试模式,可以通过修改环境变量的方式关闭调试模式。

APP_DEBUG=false

无论是本地开发还是生产环境部署,都建议保持统一的配置文件,然后通过修改环境变量的方式(本地开发可以通过定义.env文件)设置区别部分。

关闭调试模式后,系统的健康状态和运行监控主要依靠日志或者你使用的监控服务。所以,要养成定时检查日志和运行状态的习惯。

部署忽略清单

项目根目录下面有一个.gitignore文件,用于定义提交版本库的时候哪些文件或者目录需要忽略,设置忽略的文件不会被同步到远程服务器,只是用于本地开发。

该文件默认内容如下,你可以根据项目的目录和规范进行调整。

/.idea
/.vscode
/vendor
*.log
.env

项目使用的核心框架以及composer安装的扩展,不应当被同步到版本库中,只需要同步composer.json以及composer.lock文件。然后在服务器端进行composer更新。

做好应用优化工作

参考如何有效提高ThinkPHP的应用性能一文做好相关优化工作。

使用持续集成/持续部署构建你的项目

如果条件允许,请使用持续集成/持续部署,并添加自动化测试。Travis CI或者PHPCI都是不错的选择。

编写项目文档

每个项目都应该在根目录添加readme.md文件,并遵循Markdown规范写作,对项目做简要的说明(尤其是目录和代码规范),如果项目比较复杂,可以附上一个项目详细说明或者规范的文档地址,如果你的项目是前后端完全分离开发的话,应该事先规划好后台的API接口,然后创建一个API文档,便于指导前端开发人员进行接口调用,以及方便在线调试。

路由规范

所有的路由规则是不支持普通URL参数的,必须是PATHINFO地址。

路由定义文件不一定是route.php,事实上可以是任何文件名。

如果你开启了路由强制模式,那么未定义的路由访问将会抛出异常。

统一使用路由方法注册路由而不要再使用返回数组配置,路由规则不区分大小写,因此统一使用小写定义。

尽量避免使用闭包定义路由规则(注意不要和分组路由的闭包定义搞混淆),优先使用资源路由定义,在不适用资源路由的情况下也要多使用路由分组,不仅可以简化路由定义和提高性能,也更加规范。

大部分情况下,建议开启全局路由完整匹配,个别不需要完整匹配的路由规则可以在定义的时候使用completeMatch方法单独关闭。

Route::get('user/<name>', 'user/info')->completeMatch(false);

如果需要使用伪静态地址,可以全局配置URL访问后缀,对于个别特殊后缀的路由可以在路由定义的时候单独指定。

Route::get('hello/<name>', 'index/hello')->ext('htm');

明确你的路由变量规则,不要忽略路由变量的规则定义,避免可能的解析错误。例如,当你的路由变量中使用了小数点或者斜线的情况,必须严格定义你的变量规则。

优先在路由定义的时候指定中间件、进行数据验证和请求缓存等操作,原则就是在路由里面能做的事情尽量提前不要等到控制器里面才执行。

为了方便查看当前项目定义的路由规则,可以使用下面的指令生成路由规则查看文件。

php think route:list 

然后可以在runtime目录下的route_list.php可以查看所有的路由列表。

关闭路由

如果某个应用需要关闭路由功能,可以在应用的app.php配置文件中定义

'with_route'    =>    false,

但需要注意的是,即使关闭路由,也会走默认路由(即控制器/操作)解析,因此路由全局中间件仍然有效。

明确定义请求类型

尽量明确定义路由的请求类型,提高路由解析的效率。

推荐使用:

Route::get('hello/:name', 'index/hello');

替代:

Route::rule('hello/:name', 'index/hello');

不需要添加开头斜线

除了首页外,其它路由规则定义不需要添加开头的斜线。
推荐使用:

Route::get('hello/:name', 'index/hello');

不建议使用:

Route::get('/hello/:name', 'index/hello');

除非是首页路由

Route::get('/', 'index/index');

或者直接访问分组名的情况

Route::group('blog', function() {
    Route::get('/', 'index/blog');
    Route::get(':id$', 'blog/read');
    Route::get(':id/edit$', 'blog/edit');
});

多使用路由分组

可能的情况下,尽可能多使用路由分组。可以充分利用分组的匹配机制提高路由解析性能。

推荐使用

Route::group('blog', function() {
    Route::get(':id$', 'blog/read');
    Route::get(':id/edit$', 'blog/edit');
});

不建议使用

Route::get('blog/:id$', 'blog/read');
Route::get('blog/:id/edit$', 'blog/edit');

路由分组传入额外参数

可以统一给分组路由传入额外的参数

Route::group('blog',  function() {
    Route::get(':id$', 'blog/read');
    Route::get(':id/edit$', 'blog/edit');
})->ext('html')
->pattern(['id' => 'd+'])
->append(['group_id'=>1]);

上面的分组路由统一传入了group_id参数,该参数的值可以通过Request类的param方法获取。

路由变量定义规范

对于新版的路由变量定义来说,不再区分普通变量和组合变量,哪怕你使用:name方式定义,内部也会统一转换成<name>这种方式,因此为了提高路由解析性能,建议统一使用:

Route::group('blog', function() {
    Route::get('<id>$', 'blog/read');
    Route::get('<id>/edit$', 'blog/edit');
});

尽量明确定义路由变量的规则

Route::group('blog', function() {
    Route::get('<id>$', 'blog/read');
    Route::get('<id>/edit$', 'blog/edit');
})->pattern(['id' => 'd+']);

路由变量规则采用正则表达式方式定义,但无需在开头添加^或者在最后添加$,也无需使用模式修饰符,系统会自动添加。

不能使用$_GET方法或者Request类的get方法获取路由变量,而应该使用param方法或者参数绑定。

如果开启了路由合并解析的话,分组下面的多个路由规则是通过一次解析匹配完成的,如果路由规则较多性能会有大幅提升。

变量分隔符

你可以很随意的使用路由变量分隔符,只要注意不要和你的变量规则冲突。

Route::get('item/<date><name>-<id>', 'product/item')
    ->pattern(['date'=>'d{8}','name'=>'w+','id'=>'d+']);

默认路由变量规则

默认情况下,如果你没有定义变量规则,则使用w+作为变量规则

Route::get('hello/<name>', 'index/hello');

其实等同于

Route::get('hello/<name>', 'index/hello')->pattern([
    'name'    =>    'w+',
]);

如果你希望改变默认的路由变量规则,可以在应用配置文件中设置

// 默认的路由变量规则
'default_route_pattern'  => '[a-z0-9-_.]+',

路由完全匹配

默认的路由规则只是匹配URL地址的开头是否和定义的路由匹配

所以

Route::get('hello/:name', 'index/hello');

可以匹配下面的URL地址

hello/think
hello/think/thinkphp
hello/think/thinkphp/demo

但如果添加了路由完全匹配后

Route::get('hello/:name$', 'index/hello');

上面的三个URL地址中就只会匹配

hello/think

建议你开启全局路由完全匹配

// 路由完全匹配
'route_complete_match'   => true,

如果有个别路由仍然希望不要完全匹配,你可以使用

Route::get('hello/:name', 'index/hello')
    ->completeMatch(false);

关闭当前路由规则(或者路由分组)的完整匹配。

定义路由标识

路由标识的作用只是用于URL地址生成,而且默认会使用当前的路由地址作为路由标识。

Route::get('hello/:name', 'index/hello');
echo url('index/hello', ['name' => 'think']);

如果指定了路由标识的话,url方法的用法就需要调整为:

Route::get('hello/:name', 'index/hello')->name('hello');
echo url('hello', ['name' => 'think']);

如果你希望简化URL地址的生成调用,可以在项目规范中强制统一规范,而不要使用URL地址这种默认标识,使用路由标识的优势是即使路由地址发生了变化,也无需改变url方法的代码。

用方法定义路由参数

出于语义化考虑,路由参数建议使用方法而不是参数。

推荐使用(支持IDE)

Route::get('new/:id', 'News/read')
    ->ext('html')
    ->https();

不建议使用:

Route::get('new/:id','News/read')->option(['ext'=>'html','https'=>true]);

MISS路由

一旦你设置了全局的MISS路由,相当于开启了强制路由模式。

Route::miss('public/miss');

MISS路由可以针对不同路由分组(或者域名)设置,也可以针对不同的请求类型设置,

Route::group('blog', function() {
    Route::get(':name$', 'blog/read');
    ...
    Route::miss('blog/error','get');
    Route::miss('blog/noAuth','post');
});

Route::group('user', function() {
    Route::get(':id', 'user/info');
    ...
    Route::miss('user/error');
});

控制器规范

为了避免命名冲突,可以在路由配置文件中统一开启控制器类库后缀。

'controller_suffix'     => true,

优先使用资源控制器,可以通过命令行快速生成一个资源控制器类

php think make:controller Blog

控制器建议继承一个公共的控制器类,便于统一调整和增加通用逻辑。默认安装后,系统提供了一个appBaseController实例基础控制器类,你可以根据自己项目的需求进行调整,包括改变命名空间。

对于控制器操作方法的拦截以及统一处理应当使用中间件独立操作,原来的初始化方法已经废弃。控制器中间件不需要继承任何的基础控制器类即可使用,仅仅需要你定义middleware属性即可。

控制器的代码应当尽量少,以确保逻辑清晰和可读性。并始终保持controller层作为访问控制器层的名称。

请求数据的验证操作统一使用验证器进行验证。

操作方法中的对象使用依赖注入,其它的必要参数使用参数自动绑定。

不要在操作方法中输出除了调试信息之外的任何内容,而是通过return返回需要输出的内容。

操作方法中始终明确响应输出的类型,默认的return方式使用的是HTML输出类型。

命名规范

数据表命名

数据表和字段采用小写加下划线方式命名,例如think_user表和user_name字段,禁止使用驼峰、中文或者拼音作为数据表及字段命名。

字段规范

  • 主键统一使用id
  • 外键统一使用resource_id形式(例如user_id);
  • 模型数据字段统一使用小写+下划线命名,和数据表字段规范一致;
  • 数据表统一添加系统时间字段(create_timeupdate_time),并使用datetime类型;
  • 使用软删除并添加时间字段delete_time,类型和系统时间字段保持一致;
  • 模型类应该继承一个统一的公共类,便于调整和统一设置;
  • 模型类应当通过定义autoWriteTimestamp属性明确时间字段类型;

查询规范

不要在数据库配置文件以外的地方配置或者动态设置数据库连接信息,包括模型内部。

尽量不使用原生SQL查询,而应当使用查询构造器。

不要使用任何数据库工具创建、修改数据表和填充数据,应当使用数据迁移并同步版本库给所有成员。

每次数据查询都用Db类或者模型类的静态方法。

避免在模型方法中直接写复杂的查询条件,而应当使用查询范围或者搜索器统一定义后调用。用查询表达式方式替代传统的数组查询。

查询数据的处理统一使用获取器定义,而不要直接处理数据。

对写入数据需要额外处理的话统一使用修改器。

对于使用了SQL函数的用法,使用fieldRaworderRawwhereRaw/whereExp替代fieldorderwhere用法。

仅在使用字符串查询条件,以及调用whereExpwhereRaw方法的时候需要使用手动参数绑定,其余情况下都会自动进行参数绑定,禁止手动调用bind方法。

不要在WEB访问的时候进行大量数据操作,容易超时的数据处理应当在命令行下通过创建指令完成。

查询值为Null的数据

查询值为Null的数据应当使用whereNull或者whereNotNull方法

// 查询email为空,并且name不为空的用户数据
User::whereNull('email')
    ->whereNotNull('name')
    ->select();

使用快捷方法

对于一些常用的查询,尽量使用系统封装的快捷查询方法,例如:

User::whereIn('id', [1,2,3])
    ->whereLike('name', 'think%')
    ->select();

相当于下面的查询

User::where('id', 'in', [1,2,3])
    ->where('name', 'like', 'think%')
    ->select();

更多的方法可以参考官方手册或者使用IDE的自动提示。

获取字段值和列数据

对于一些简单的数据获取,你完全不需要查询整个表的数据,例如查询某个字段(满足条件的)值或者列数据。

// 获取id为10的用户名称
User::where('id', 10)
    ->value('name');

// 获取状态为1的用户名称列表
User::where('status', 1)
    ->column('name');

// 获取分数大于80的用户分数列表,以用户ID为索引
User::where('score', '>', 80)
    ->column('score', 'id');

聚合查询

如果你的min/max查询的是一个字符串类型字段,记得加上第二个参数并传入false

// 获取name字段的最大值
User::max('name', false);

时间区间查询

时间查询主要用于时间字段的区间查询,whereTime方法的优势是支持自动识别时间字段类型并进行转换处理。

// 大于某个时间
User::whereTime('birthday', '>=', '2008-10-1')
    ->select();
// 小于某个时间
User::whereTime('birthday', '<', '2000-10-1')
    ->select();
// 时间区间查询
User::whereBetweenTime('birthday', '1990-10-1', '2000-10-1')
    ->select();
// 不在某个时间区间
User::whereNotBetweenTime('birthday', '1970-10-1', '2000-10-1')
    ->select();

年/月/日/周查询

对于年/月/日/周的时间查询,推荐使用whereYear/whereMonth/whereDay/whereWeek方法查询,例如:

// 查询本月注册的用户
Db::name('user')
    ->whereMonth('create_time')
    ->select();   

// 查询上月注册用户
Db::name('user')
    ->whereMonth('create_time','last month')
    ->select();   

// 查询2018年6月注册的用户
Db::name('user')
    ->whereMonth('create_time', '2018-06')
    ->select(); 

// 查询当天注册的用户
Db::name('user')
    ->whereDay('create_time')
    ->select();   

// 查询昨天注册的用户
Db::name('user')
    ->whereDay('create_time', 'yesterday')
    ->select();  

// 查询2018年6月1日注册的用户
Db::name('user')
    ->whereDay('create_time', '2018-06-01')
    ->select();    

时间表达式查询

高级的时间表达式查询可以使用PHP的相对时间格式,例如:

// 查询两天以内的博客
Blog::whereTime('create_time','-2 days')
    ->select();

// 查询昨天中午后发的博客
Blog::whereTime('create_time','yesterday noon')
    ->select();

更多的时间表达式查询你可以自由发挥。

时间字段范围查询

你可以查询当前时间是否在两个时间字段区间范围内,通常用于一些活动以及优惠券的有效期查询等等。

// 查询有效期内的活动
Event::whereBetweenTimeField('start_time','end_time')
    ->select();

// 查询没有开始或者已经过期的活动
Event::whereNotBetweenTimeField('start_time','end_time')
    ->select();

字段比较

可以直接比较两个字段的大小进行查询

User::whereColumn('update_time', '>', 'create_time')
    ->select();
User::whereColumn('score1', '>', 'score2')
    ->select();

如果需要比较两个字段相同,可以使用

User::whereColumn('score1', 'score2')
    ->select();

条件查询

应当使用条件查询替代在组装查询条件的时候写大量的ifelse

User::when($condition, function ($query) {
    // 满足条件后执行
    $query->where('score', '>', 80)->limit(10);
})->select();

并且支持不满足条件的分支查询,并且支持多次调用when方法。

User::when($condition, function ($query) {
    // 满足条件后执行
    $query->where('score', '>', 80)->limit(10);
}, function ($query) {
    // 不满足条件执行
    $query->where('score', '>', 60);
})->select();

JSON查询

如果你的字段类型使用的是JSON类型,那么可以直接使用框架提供的JSON查询支持。

User::where('info->nickname', 'ThinkPHP')
    ->find();

注意,需要在模型里面定义JSON字段属性。

<?php
namespace appindexmodel;

use thinkModel;

class User extends Model
{
	// 设置json类型字段
	protected $json = ['info'];
}

如果使用Db查询的话,可以改为

$user = Db::name('user')
	->json(['info'])
    ->where('info->nickname','ThinkPHP')
    ->find();

SQL函数查询

如果需要对某个字段使用SQL函数表达式查询,可以使用

User::whereExp('nickname', "= CONCAT(name, '-', id)")
    ->whereRaw('LEFT(nickname, 5) = ?', ['think'])
    ->select();

注意whereExpwhereRaw方法的区别,前者是对某个字段使用SQL函数表达式,后者是整个查询就是一个SQL函数表达式。

字段递增/递减

可以使用:

// 博客的阅读数递增1 评论数递减2
Blog::where('id', 10)
    ->inc('read_count')
    ->dec('comment_count', 2)
    ->save();

新版已经取消了延时更新功能。

指定字段值排序

如果你需要按照指定字段的值的顺序来排序,可以使用

User::where('status', 1)
    ->orderField('id', [1,2,3])
    ->select();

从主库读取

如果你使用了数据库的主从分离,当刚写入数据后,数据库的主从同步可能还没来得及同步,这个时候立刻查询数据可能会出错,你可以使用下面的方法从主库读取。

$user = User::create($data);
$user->readMaster()->select();

你可以全局配置数据写入后自动读取主库

// 模型写入后自动读取主服务器
'read_master'     => true,

获取自增ID

使用Db类的insert方法或者模型的save方法返回的不是自增主键,不过你可以使用。

$userId = Db::name('user')->insertGetId($data);

如果使用模型的话,自增主键的值会自动赋值给模型对象,可以直接获取。

$user = User::create($data);
echo $user->id;

模型查询为空的处理

模型查询数据不存在的话返回值为Null,所以必须要添加返回值判断然后进行数据处理,建议使用下面的方法查询,如果数据不存在则返回空的模型对象。

// 始终返回模型对象
$user = User::where('id', 10)->findOrEmpty();

自动分批写入

如果你需要一次写入大量数据,建议使用limit方法自动分批多次写入。

// 自动分批多次写入数据库 每次最多写入1000条
Db::name('user')
    ->limit(1000)
    ->insertAll($dataList);

如果是使用模型的话,建议直接使用saveAll方法而不需要limit方法。

$user = new User;
$user->saveAll($dataList);

数据分批处理

对于大量数据的处理操作,建议使用chunk分批处理方法。

// 每次处理100个数据
User::chunk(100, function($users) {
    foreach ($users as $user) {
        // 处理数据
    }
});

游标查询

对于内存开销比较大的应用,在做大量数据查询和处理的时候,建议使用cursor方法进行游标查询,可以利用PHP的生成器特性,减少内存占用。

$cursor = User::cursor();
foreach($cursor as $user){
    // 处理数据
}

关联查询

关联方法定义应该始终使用小驼峰规范,但关联查询的时候支持驼峰或者小写加下划线方法,但区别在于关联属性的名称不同。

是否需要模型分层

一般情况下,仅仅使用Model层已经够用,但如果项目比较大,建议对模型进行分层,例如使用数据层、逻辑层和服务层等等,视项目需求而定,原则就是避免某一层过大导致结构杂乱,尽量让各个层分工明确,各司其职。

请求变量过滤

永远不要相信用户的输入,这是一句至理名言。尽可能的过滤请求变量能有效防范大部分的漏洞和隐患。

框架建议的获取请求变量的方法是Request类的param方法(如非必要不要再使用get或者post方法获取,更不要使用原生的$_GET/$_POST等方法获取)。

public function index(Request $request)
{
    $name = $request->param('name');

    // 在这里可以根据你的业务需求进行更严谨的过滤
    // 例如 $name = $request->param('name','','htmlentities,strtolower');
    // 或者使用验证器进行专门的验证
}

对于有明确类型的请求变量,可以在使用param方法的时候使用类型强制转换,例如:

public function index(Request $request)
{
    // 强制转换字符串数据
    $name = $request->param('name/s');

    // 强制转换整型数据
    $name = $request->param('id/d');

    // 强制转换浮点型数据
    $name = $request->param('score/f');
}

或者直接使用方法参数获取请求变量

public function index(string $name)
{
    // 在这里可以根据你的业务需求进行更严谨的过滤
    // 或者使用验证器进行专门的验证
}

如果你需要对所有数据进行处理,可以设置全局的过滤方法。对不同的应用需求设置default_filter过滤规则(默认没有任何过滤规则),常见的安全过滤函数包括stripslasheshtmlentitieshtmlspecialcharsstrip_tags等,请根据业务场景选择最合适的过滤方法。

如果需要获取多个数据,建议使用only方法指定需要获取的变量名称,避免有些不怀好意的数据提交导致权限问题。

public function index(Request $request)
{
    // 指定表单数据名称
    $data = $request->only(['name','title']);
}

当你使用数据库或者模型操作写入数据的时候,也可以指定字段,避免非法和不希望的字段写入数据库。

// 模型
User::allowField(['name','title'])
    ->save($data);

// 数据库
Db::name('user')
    ->field(['name','title'])
    ->insert($data);

模型还有一个只读字段功能能避免你的数据受到外部的修改。

上传检测

网站的上传功能也是一个非常容易被攻击的入口,所以对上传功能的安全检查是尤其必要的。

系统的验证类提供了文件上传的安全支持,包括对文件后缀、文件类型、文件大小以及上传图片文件的合法性检查,确保你已经在上传操作中启用了这些合法性检查。

SQL注入

ThinkPHP的查询统一使用了PDOprepare预查询和参数绑定机制,能有效的避免SQL注入的发生。但不代表绝对安全,如果你缺乏良好的代码规范,仍然有可能被利用。

一个最简单的原则就是不要让用户决定你的查询条件(或者字段排序)和控制你的查询数据。

对于一些字符串的查询条件(包括原生查询)或者特殊的查询(包括ORDER部分),需要手动进行参数绑定。

// 错误的
Db::query("select * from think_user where id=$id AND status=$statis");
// 正确的
Db::query("select * from think_user where id=? AND status=?", [ $id, $status]);
// 正确的
Db::execute("update think_user set name=:name where status=:status", [
    'name'     => 'thinkphp', 
    'status'   => 1
]);

对于使用了whereExpwhereRaw方式的查询,你也需要使用参数绑定。

Db::name('user')
    ->whereRaw('id > ? AND status = ?',[10, 1])
    ->select();

使用验证器

对于大量的表单需要验证的情况,建议使用验证器功能统一进行数据的合规验证。验证器的验证操作应该在控制器或者路由阶段使用validate方法进行处理,模型的数据验证功能新版已经取消不再建议使用,模型和数据库操作的时候应该传入经过安全处理过的数据。

XSS攻击

跨站脚本攻击(cross-site scripting,简称XSS),XSS是一种在web应用中的计算机安全漏洞,它允许恶意web用户将代码植入到提供给其它用户使用的页面中。

在渲染输出的页面中,要对一些数据进行安全处理,防止被恶意利用造成XSS攻击,如果是5.1版本的话,所有的输出都已经经过了htmlentities转义输出,确保安全。如果是5.0版本的话,你可以自定义一个xss过滤函数,在模板文件中对一些关键内容变量进行函数处理。

CSRF

CSRF 跨站请求伪造是 Web 应用中最常见的安全威胁之一,攻击者伪造目标用户的HTTP请求,然后此请求发送到有CSRF漏洞的网站,网站执行此请求后,引发跨站请求伪造攻击。攻击者利用隐蔽的HTTP连接,让目标用户在不注意的情况下单击这个链接,由于是用户自己点击的,而他又是合法用户拥有合法权限,所以目标用户能够在网站内执行特定的HTTP链接,从而达到攻击者的目的。

开启表单令牌验证,尽量开启强制路由并严格规范每个URL请求,定义单独的MISS路由规则。

遵循请求类型的使用规范并做好权限验证,删除操作必须使用DELETE请求,数据更改操作必须使用POSTPUT或者PATCH请求方法,GET请求不应该更改任何数据。

写更健壮的代码

安全问题不容忽视,参考官方发布的安全规范指引,提前做好安全防范,让你的项目更健壮。同时也要关注官方的开发者周刊和微信公众号,及时获取官方的安全更新通告。