尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Laravel命名转换函数全解析:从camel_case到API字段优雅映射

Laravel命名转换函数全解析:从camel_case到API字段优雅映射 搞 Laravel 开发这些年我经常看到有人对着数据库字段和前端接口字段来回较劲。前端要用userProfile数据库里存的是user_profile模型里又是$user-profile一来一回全靠手动拼字符串代码里写满了str_replace(_, , $key)这种半吊子逻辑。实际上 Laravel 自带的命名转换函数camel_case、snake_case、kebab_case、studly_case早把这摊事做完了只是很多人不知道它们有多全也不知道怎么用才不踩坑。这篇东西就是一份带实战细节的命名转换函数总览适合正在做接口联调、写数据迁移、封装 API Resource 的 Laravel 开发者也适合刚入门但不想在这些琐碎命名上浪费时间的同学看完你至少能少写几十行重复代码。1. Laravel 命名转换函数全景一份能直接抄的速查表1.1 六个核心函数的用法与输出对照先上一张我日常开发里最常用的速查表。这里说的函数有两个来源一个是 Laravel 早期版本就有的全局辅助函数比如camel_case()、snake_case()另一个是Illuminate\Support\Str类提供的静态方法比如Str::camel()、Str::snake()、Str::kebab()、Str::studly()。两者在绝大多数情况下输出一致只是调用方式不同。函数Str::辅助函数写法输入示例输出结果Str::camel()camel_case()user_profileuserProfileStr::snake()snake_case()UserProfile/userProfileuser_profileStr::kebab()kebab_case()userProfileuser-profileStr::studly()studly_case()user_profileUserProfileStr::ucfirst()ucfirst()userProfileUserProfileStr::lower()strtolower()USER_PROFILEuser_profile用起来也很简单use Illuminate\Support\Str; Str::camel(user_profile); // userProfile Str::snake(UserProfile); // user_profile Str::kebab(userProfile); // user-profile Str::studly(user_profile); // UserProfileStr::camel()和Str::studly()看起来很像区别只在于首字母是否大写。camel适合模型属性、JavaScript 变量名studly适合类名、PHP 命名空间段。这个区分很多新手会搞混写类名时用了camel导致首字母小写后面use的时候怎么都找不到类排查半天。1.2 新老 API 的差异辅助函数与 Str 类的取舍如果你翻 Laravel 老代码会看到camel_case()、snake_case()这种全局函数这是 Laravel 5.x 时代的写法。从 Laravel 5.5 左右开始官方更推荐直接使用Str类。原因很简单全局函数散落在各个命名空间IDE 自动补全和代码跳转体验差而且容易污染全局环境。Str类作为组件化工具可以方便地被Str::macro()扩展也更容易写单元测试。还有一个隐藏区别辅助函数对传入参数的容错性更宽松某些版本里传null会被转成空字符串而Str::camel(null)在 PHP 8.1 以上会抛Deprecated警告。所以我的建议是别再用老的辅助函数统一用Str类特别是新项目里别为了少打几个字符留一笔技术债。另外要注意Str::snake()在 Laravel 里的底层实现和我们直觉里的“下划线转换”稍有出入它内部会先做一次ucwords()处理所以输入userProfile和UserProfile结果是一样的。这个细节在同时处理来自前端通常是驼峰和来自数据库通常是下划线的数据时特别重要两边怎么传都不怕。2. 为什么需要命名转换藏在场景里的设计逻辑2.1 数据库字段和模型属性约定大于配置Laravel 的 Eloquent 模型默认约定数据库字段名用snake_case比如user_profile而 PHP 属性通过访问器getUserProfileAttribute()来映射。这套约定让模型和数据库之间的关系非常透明不用写一堆映射配置代价是你得时刻在两种命名风格之间来回切换。举个例子你从表单提交里拿到profile相关数据前端传过来的是avatar_url数据库表里是avatar_url这个倒还同步。但一旦前端用了avatarUrl或者你在代码里写$request-avatarUrlEloquent 并不会自动帮你转成avatar_url去查数据库你需要自己调用Str::snake(avatarUrl)才能拿到正确的字段名。在实际项目中我更推荐的做法是在控制器的 FormRequest 阶段就把键名统一成snake_case这样后面无论是模型批量赋值还是写入数据库都不用再操心命名问题。步骤很简单public function rules() { return [ avatar_url required|url, user_profile nullable|string, ]; } protected function prepareForValidation() { $this-merge([ avatar_url $this-input(avatarUrl) ?? $this-input(avatar_url), user_profile $this-input(userProfile) ?? $this-input(user_profile), ]); }当然如果你项目里前端已经完全统一成驼峰也没有历史包袱那在模型上加$snakeAttributes false也可以绕过这个约定但我不建议轻易动它因为默认约定能让你少踩很多隐藏的坑比如某些扩展包内部仍然假设字段是下划线命名。2.2 API 边界上的命名风格统一另一个高频场景就是 API 的返回结构。数据库字段是user_profile文档要求返回userProfile于是你不得不在每个toArray()里手动改键名。最粗暴的方式是public function toArray($request) { $data parent::toArray($request); $data[userProfile] $data[user_profile]; unset($data[user_profile]); return $data; }一个两个字段还好字段一多就崩溃。更合理的方式是写一个递归转换器把整个数据数组的键名统一转成camelCase后面我会给出完整实现这里先说要解决什么问题让模型和数据访问层保持数据库原生风格只在 API 出口这一层做一次统一转换既不影响内部逻辑又满足外部接口规范。对应的请求进来时也应该在入口层把驼峰键转回snake_case这样才能和模型、数据库对齐。你可以在中间件里做也可以在 FormRequest 里做原则是同一个项目只保留一种命名风格做内部沟通另一种风格只在边界处理。2.3 动态方法调用中的隐式转换Laravel 很多魔法方法本身就是基于命名转换工作的。比如$model-getAttribute(userProfile)实际上 Eloquent 会在内部尝试snake_case转换然后去找user_profile这个真实字段。这也是为什么你能写$user-profile访问profile关联但写$user-UserProfile就未必有预期结果的原因。还有where条件里$query-where(userProfile, 1)并不会自动变成user_profile你得自己转换。这里有个经典坑如果你在查询构造器里混用了驼峰和下划线很容易出现“本地跑得好好的测试环境字段全报错”的情况因为不同数据库对字段名大小写的敏感性不一样。经验是凡是跟数据库交互的字符串统一走Str::snake()包一层例如$field Str::snake($request-input(sortField, created_at)); $query-orderBy($field);这样即使前端传sortFieldupdatedAt后端也不至于崩。3. 核心细节解读Str::camel / Str::snake 的边界行为3.1 连续大写、数字、特殊字符的处理规则很多人用Str::snake()处理类似PDFFile这种连续大写缩写时会得到意想不到的结果。按 Laravel 默认实现Str::snake(PDFFile)会输出p_d_f_file而不是你预期的pdf_file。原因在于实现策略是“每个大写字母前插下划线”它并不理解哪些大写字母属于同一个缩写词。这个坑在做文件上传、图片资源路径、第三方 UGC 内容转换时会特别明显。比如外部接口返回PDFFileURL你想存成pdf_file_url直接snake_case就翻车了。绕法不复杂先对连续大写做一次预处理function acronymFriendlySnake(string $value): string { $value preg_replace(/([A-Z])([A-Z][a-z])/, $1_$2, $value); $value preg_replace(/([a-z\d])([A-Z])/, $1_$2, $value); return strtolower($value); } acronymFriendlySnake(PDFFileURL); // pdf_file_url同理Str::camel()对数字的处理也有讲究。Str::camel(user_2_name)输出user2Name这基本符合预期但如果你希望保留数字后的可读边界比如user_2_name变成user_2_name而不是去掉下划线那camel就不合适了得自己处理。还有中文键名。Str::snake(用户昵称)会原样返回不影响中文但如果你在snake_case之后又做了strtolower中文字符不受影响可中间的英文字母会全变小写这个要结合业务判断是不是你想要的。3.2 分隔符定制不再只有下划线很多人只知道snake_case用下划线、kebab_case用连字符但Str::snake()实际上支持第二个参数自定义分隔符。比如你用Str::snake(userProfile, -)输出就是user-profile等于把kebab的一部分功能也覆盖了。反过来Str::camel()并不支持自定义分隔符它只认下划线分隔的输入。这个分隔符参数在生成数据库索引名、缓存键、路由别名时非常好用。我最近把一个老项目里所有缓存 key 统一改成Str::snake($key, :)风格比如user:profile可读性比user_profile更高也不容易和普通字符串混在一起同时兼容 Redis 的目录式管理习惯。$cacheKey Str::snake($model-getTable(), :) . : . $model-getKey(); // user:profile:12但要提醒一点kebab_case()的连字符在 URL 路径里比较友好在 Redis key 里却容易跟某些客户端命令参数冲突所以实际开发里要根据存储介质选择分隔符别只图好看。3.3 从字符串到数组键递归转换思路真正麻烦的不是单个字符串转换而是整个数组的键名批量转换。比如从数据库查出来的关联数组[ user_profile [ avatar_url /uploads/1.png, phone_number 13800000000, ], ]要转成[ userProfile [ avatarUrl /uploads/1.png, phoneNumber 13800000000, ], ]就需要递归遍历。这里有一个常见误区很多人直接用array_map然后只处理外层 key遇到嵌套数组就漏掉。正确做法是写一个递归函数加参数控制是否深入嵌套层级。还要注意键名重复的覆盖问题如果原始数组同时存在userProfile和user_profile转换后会发生覆盖这个要提前规避。通常的做法是转换前检查冲突或者约定输入数据里不允许同时出现两种风格键名。4. 实操封装一个数组键名命名转换器4.1 设计一个 KeyCaseConverter 服务基于上面的思路我通常会在app/Services/Support下放一个KeyCaseConverter类核心逻辑只有几十行namespace App\Services\Support; use Illuminate\Support\Str; class KeyCaseConverter { public const CASE_CAMEL camel; public const CASE_SNAKE snake; public const CASE_KEBAB kebab; public const CASE_STUDLY studly; public static function convert(array $data, string $case, bool $recursive true): array { $converted []; foreach ($data as $key $value) { $newKey is_string($key) ? static::convertKey($key, $case) : $key; if ($recursive is_array($value)) { $value static::convert($value, $case, $recursive); } if (array_key_exists($newKey, $converted)) { throw new \RuntimeException(Key case collision detected: {$newKey}); } $converted[$newKey] $value; } return $converted; } protected static function convertKey(string $key, string $case): string { return match ($case) { static::CASE_SNAKE Str::snake($key), static::CASE_KEBAB Str::kebab($key), static::CASE_STUDLY Str::studly($key), default Str::camel($key), }; } }这段代码有几个值得说的地方。数字键会被保留原样因为$key不是字符串就不做转换这符合大多数业务场景索引数组的下标不应该被改写成0、1之外的命名。其次array_key_exists冲突检测放在递归之前能保证一旦原始数据里同时存在userProfile和user_profile不是你我不明地覆盖掉而是立刻报错方便你在开发期就发现数据源的命名规范问题。4.2 接入 API Resource一个中间件搞定全局转换有了转换器接进 Laravel 的 API Resource 非常简单。比如你有一个UserResourcenamespace App\Http\Resources; use App\Services\Support\KeyCaseConverter; use Illuminate\Http\Resources\Json\JsonResource; class UserResource extends JsonResource { public function toArray($request) { $data parent::toArray($request); if ($request-has(case)) { $case $request-input(case); // 只允许白名单入参避免把任意字符串塞进 Str 方法 if (in_array($case, [camel, snake, kebab, studly], true)) { return KeyCaseConverter::convert($data, $case); } } return KeyCaseConverter::convert($data, KeyCaseConverter::CASE_CAMEL); } }如果你希望整个项目的所有 Resource 默认都输出驼峰可以选择在中间件里统一处理响应而不是每个 Resource 里都写一遍调用。思路是用response()-json(...)之后对json_encode的数组做转换。但这里有个性能问题如果响应体很大递归转换会有额外开销所以我在单体服务里倾向在 Resource 层转换只有网关层才做全局统一改键。还有一个细节分页数据的meta里last_page、per_page这类键通常也要一起转换转换器递归处理时注意别把meta里已经是camel的键再转一遍否则lastPage会被继续转成last_page再回到last_page。由于Str::camel对已经是camel的键基本幂等所以这个问题大多不致命但遇到perPage这种键再走一次camel也可能出边界问题建议做好测试。4.3 为转换器补上单元测试这个服务值得配几个单元测试能防止以后改正则或者升级 Laravel 版本时行为悄悄变化。最简单的测试就两个维度基础转换正确性、嵌套递归和冲突抛异常。namespace Tests\Unit\Services\Support; use App\Services\Support\KeyCaseConverter; use RuntimeException; use Tests\TestCase; class KeyCaseConverterTest extends TestCase { public function test_convert_array_keys_to_camel_case() { $source [ user_profile [ avatar_url /uploads/1.png, phone_number 13800000000, ], ]; $result KeyCaseConverter::convert($source, KeyCaseConverter::CASE_CAMEL); $this-assertArrayHasKey(userProfile, $result); $this-assertArrayHasKey(avatarUrl, $result[userProfile]); $this-assertArrayNotHasKey(avatar_url, $result[userProfile]); } public function test_convert_array_keys_to_snake_case() { $source [ userProfile [ avatarUrl /uploads/1.png, ], ]; $result KeyCaseConverter::convert($source, KeyCaseConverter::CASE_SNAKE); $this-assertArrayHasKey(user_profile, $result); $this-assertArrayHasKey(avatar_url, $result[user_profile]); } public function test_key_collision_throws_exception() { $this-expectException(RuntimeException::class); $source [ user_profile 1, userProfile 2, ]; KeyCaseConverter::convert($source, KeyCaseConverter::CASE_CAMEL); } }测试里最后这个test_key_collision_throws_exception特别重要它能保证你在某个接口突然出现 500 的时候能立刻意识到是上游数据源命名不规范而不是花半小时去排查到底是哪里覆盖了值。5. 常见问题与排查技巧实录5.1 转换后键冲突与覆盖前面提过冲突问题这里再具体说下实际现象。假设接口返回的数据里某个扩展包塞了一个userProfile你自己又有一个user_profile转换完成后userProfile会被覆盖前端拿到的是后者接口返回顺序还不固定很容易出现偶发性的字段错乱。排查这类问题最好的办法是先做一次 key 冲突扫描$keys array_filter(array_keys($source), is_string); $convertedKeys array_map(fn ($key) Str::camel($key), $keys); if (count($convertedKeys) ! count(array_unique($convertedKeys))) { // 存在冲突 }不过更治本的方式是约定输入源外部 API 传进来的键名先统一过一次snake_case再入库这样你自己项目内部永远不会出现两种风格同时存在的情况。5.2 转换后的字段如何反向映射有些同事习惯于把所有键名在边界转成驼峰然后内部全部用驼峰操作连数据库查询都传驼峰。这样做会带来一个麻烦Eloquent 的$fillable、where条件、查询结果索引全都要求snake_case你在业务层必须到处反向转换。等到某个地方忘了转报错又不好定位的时候你就会明白为什么不建议全链路驼峰。我的习惯是内部一律snake_case只在以下三个出口转驼峰API JSON 响应、日志输出、前端需要拿到直接用的缓存数据。这样反向映射的代码量最小。如果你实在要全链路驼峰也可以自定义一个Eloquenttrait在getAttribute和setAttribute里面先转snake再取字段但这是增加整个框架使用成本的方案不到万不得已不推荐。5.3 何时不应该做全局转换命名转换函数好用但很多人犯了“什么都转”的毛病。一个典型误区是把Str::camel应用到所有数组键连id、name这种本来就符合camel规则的键也不放过白白跑一轮正则。如果数组有几万行数据光键名转换就可能让接口响应时间翻倍这时候可以在转换器里加一行“键名不含下划线且不是snake风格就跳过”的判断能省不少计算。还有一种情况千万别转用来签名、校验的数据。比如支付回调里的签名原串如果键名顺序和命名规则被工具类改掉服务端验签必失败。这类数据在到达验签逻辑之前绝不经过任何键名转换器。最后给你一份排查速查表下次遇到命名转换相关的问题可以先对号入座异常现象可能原因解决思路Str::snake(PDFFile)输出p_d_f_file连续大写缩写被逐个拆分用acronymFriendlySnake()预处理接口返回null或字段丢失转换过程键名冲突被覆盖在转换器中开启冲突异常检测where(userProfile, 1)查不到数据查询构造器不会自动转字段名用Str::snake($field)包裹后再查大量数组转换后接口变慢每个键都执行了正则转换跳过本身符合目标的键或加内存缓存toArray()返回键全变成小写使用strtolower而不是Str::snake统一换用Str::snake/Str::camel分页数据meta里的键重复转换递归转换把lastPage再次转成last_page转换前判断键名是否已是目标风格我个人在实际操作中最深的体会是命名转换这件事大多数时候不是技术难度问题而是规范一致性的问题。与其到处封装各种“智能转换”不如在项目初始化阶段就定好一个规矩——数据库和模型内部统一snake_caseAPI 边界统一camelCase然后在少数几个出口用上面的KeyCaseConverter做一次统一转换。这样代码里剩下的命名转换调用会非常少每出现一个你都能感知到它存在的必要。最后再分享一个小技巧把KeyCaseConverter封装成一个门面类或Str宏比如Str::keyCaseArray($array, camel)在控制器里写起来会舒服很多别人接手代码时也会觉得你的工具类设计很顺手。
返回列表