Laravel-启动指南-全- - 绝不原创的飞龙 - 博客园 (2024)

Table of Contents
“我只是自己构建它” 一致性与灵活性 Ruby on Rails PHP 框架的激增 CodeIgniter 的优劣 Laravel 1、2 和 3 Laravel 4 Laravel 5 Laravel 6 Laravel 在新 SemVer 世界(6+)的版本 Laravel 的哲学 Laravel 如何实现开发者的幸福感 Laravel 社区 Artisan Serve Laravel Sail Laravel Valet Laravel Herd Laravel Homestead 使用 Laravel 安装工具安装 Laravel 使用 Composer 的 create-project 功能安装 Laravel 使用 Sail 安装 Laravel 文件夹 松散的文件 .env 文件 什么是 MVC? HTTP 动词 什么是 REST? 路由动词 路由处理 路由参数 路由名称 中间件 路径前缀 子域路由 名称前缀 路由组控制器 回退路由 签名路由 修改路由以允许签名链接 使用 Route::view() 直接返回简单路由 使用视图组合器与每个视图共享变量 获取用户输入 将依赖项注入到控制器中 资源控制器 Laravel 资源控制器的方法 绑定资源控制器 API 资源控制器 单一操作控制器 隐式路由模型绑定 自定义路由模型绑定 Laravel 中的 HTTP 动词 HTML 表单中的 HTTP 方法伪造 redirect()->to() redirect()->route() redirect()->back() 其他重定向方法 redirect()->with() response()->make() response()->json()和->jsonp() response()->download()、->streamDownload()和->file() 条件语句 @if @unless 和 @endunless 循环 @for、@foreach 和 @while @forelse 和 @endforelse 使用 @section/@show 和 @yield 定义区段 @extends @section 和 @endsection @parent 包含视图部分 @include @each 使用组件 创建组件 将数据传递给组件 组件方法 属性收集袋 使用堆栈 使用视图组合器将数据绑定到视图 全局共享变量 使用闭包的视图作用域视图组合器 使用类的视图作用域视图组合器 Blade 服务注入 自定义 Blade 指令中的参数 示例:在多租户应用程序中使用自定义 Blade 指令 更容易定制的“if”语句指令 数据库连接 URL 配置 其他数据库配置选项 定义迁移 创建迁移 创建表 创建列 流畅地构建额外的属性 删除表 修改列 压缩迁移 索引和外键 运行迁移 创建一个填充器 模型工厂 创建一个模型工厂 使用模型工厂 专业级模型工厂 DB门面的基本用法 原始 SQL 原始选择 参数绑定和命名绑定 原始插入 原始更新 原始删除 使用查询构建器进行链式调用 约束方法 修改方法 条件方法 结束/返回方法 使用 DB::raw 在查询构建器方法中编写原始查询 连接 联合 插入 更新 删除操作 JSON 操作 事务 创建和定义 Eloquent 模型 表名 主键 时间戳 使用 Eloquent 检索数据 获取一个 获取多个 使用 chunk() 分块响应 聚合 使用 Eloquent 进行插入和更新 插入 更新 批量赋值 firstOrCreate() 和 firstOrNew() 使用 Eloquent 进行删除 普通删除 软删除 作用域 局部作用域 全局作用域 使用访问器、修改器和属性类型转换自定义字段交互 访问器 变动器 属性转换 自定义属性转换 Eloquent 集合 引入基础集合 延迟集合 Eloquent 集合增加了什么 Eloquent 序列化 从路由方法直接返回模型 从 JSON 中隐藏属性 Eloquent 关系 一对一 一对多 一对多关系的 has one of many 多对多关系 一对一通过 多对多 多态 多对多多态 子记录更新父记录时间戳 急加载 Laravel Breeze 安装 Breeze Breeze 所带来的内容 Laravel Jetstream 安装 Jetstream Jetstream 包含了什么 自定义你的 Jetstream 安装 更多 Jetstream 特性 使用 Vite 打包文件 Vite 开发服务器 使用静态资产和 Vite 与 JavaScript 框架和 Vite 一起工作 Vite 和 Vue Vite 和 React Vite 和 Inertia Vite 和 SPAs 在 Vite 中使用环境变量 分页数据库结果 手动创建分页器 字符串助手和复数形式 本地化 基本本地化 本地化中的参数 本地化中的复数形式 使用 JSON 存储默认字符串作为键 测试消息和错误包 翻译和本地化 在测试中禁用 Vite $request->all() $request->except() 和 ->only() $request->has() 和 ->missing() $request->whenHas() $request->filled() $request->whenFilled() $request->mergeIfMissing() $request->input() $request->method() 和 ->isMethod() $request->integer()、->float()、->string()和->enum() $request->dump() 和 ->dd() 数组输入 JSON 输入(和 $request->json()) 来自请求 来自路由参数 在请求对象上进行验证() 更多关于 Laravel 验证规则的信息 手动验证 使用验证数据 自定义规则对象 显示验证错误消息 创建表单请求 使用表单请求 选项 分组命令 一个示例命令 参数和选项 参数 — 必填、可选、或者带有默认值 选项 — 必填值、默认值和快捷方式 数组参数和数组选项 输入描述 使用输入 argument() 和 arguments() option() 和 options() 提示 输出 表格输出 进度条 编写基于闭包的命令 使其他设备上的会话失效 更改默认守卫 在不更改默认值的情况下使用其他守卫 添加新的守卫 闭包请求守卫 创建自定义用户提供程序 非关系数据库的自定义用户提供程序 定义授权规则 Gate外观(和注入Gate) 资源门 授权中间件 控制器授权 检查用户实例 Blade 检查 拦截检查 策略 生成策略 检查政策 覆盖政策 引导应用程序 服务提供商 在 Laravel 中获取请求对象 获取请求的基本信息 基本用户输入 用户和请求状态 文件 持久性 在控制器中使用和创建响应对象 设置标头 添加 cookie 专用响应类型 查看响应 下载响应 文件响应 JSON 响应 重定向响应 自定义响应宏 负责任接口 中间件简介 创建自定义中间件 理解中间件的 handle() 方法 绑定中间件 绑定全局中间件 绑定路由中间件 使用中间件组 将参数传递给中间件 维护模式 速率限制 受信任的代理 CORS 绑定到一个闭包 绑定到单例、别名和实例 将一个具体实例绑定到接口 上下文绑定 门面是如何工作的 实时门面 RefreshDatabase DatabaseMigrations DatabaseTransactions WithoutMiddleware 使用 $this->get() 和其他 HTTP 调用测试基本页面 使用 $this->getJson() 和其他 JSON HTTP 调用来测试 JSON API 对 $response 的断言 验证响应 对 HTTP 测试的一些其他自定义 在应用程序测试中处理异常 调试响应 针对数据库的断言 针对 Eloquent 模型的断言 在测试中使用模型工厂 在测试中进行种子填充 事件伪造 Bus 和 Queue 的伪造 邮件虚拟 通知虚拟 存储虚拟 关于模拟的简短介绍 关于 Mockery 的简短介绍 伪造其他外观 选择工具 使用 Dusk 进行测试 安装 Dusk 编写 Dusk 测试 身份验证和数据库 与页面的交互 等待中 其他断言 其他组织结构 在 Laravel 中发送响应标头 在 Laravel 中读取请求标头 您的 API 结果排序 过滤您的 API 结果 创建一个资源类 资源集合 嵌套关系 使用分页与 API 资源 有条件地应用属性 API 资源的更多自定义 使用 Sanctum 进行 API 身份验证 安装 Sanctum 手动发放 Sanctum 令牌 Sanctum 令牌能力 SPA 认证 移动应用认证 进一步的配置和调试 使用 Laravel Passport 进行 API 身份验证 OAuth 2.0 简介 安装 Passport Passport 的 API Passport 可用的授权类型 护照作用域 部署 Passport 触发回退路由 测试 Passport 配置文件访问 使用 Storage 门面 添加额外的 Flysystem 提供者 访问会话 会话实例上可用的方法 闪存会话存储 访问缓存 缓存实例上可用的方法 Laravel 中的 Cookies 访问 cookie 工具 cookie 门面 cookie() 全局辅助函数 请求和响应对象中的 Cookies 何时以及为何使用日志 写入日志 日志通道 单一频道 日志频道 Slack 频道 stack 频道 写入特定日志频道 安装 Scout 标记您的模型以进行索引 搜索您的索引 队列和 Scout 执行无索引操作 有条件地对模型进行索引 通过代码手动触发索引 通过 CLI 手动触发索引 使用 HTTP Facade 处理错误和超时以及检查状态 文件存储 上传虚假文件 返回虚假文件 会话 缓存 Cookies 日志 Scout HTTP 客户端 基本的“Mailable”邮件使用 电子邮件模板 envelope() 方法中可用的方法 附加文件和内联图片 可附加的邮件对象 内联图像 Markdown 可发送邮件 将可邮件渲染到浏览器 队列 本地开发 log 驱动程序 虚拟收件箱 为您的通知定义via()方法 发送通知 使用通知门面发送通知 使用通知特性发送通知 将通知排队 开箱即用的通知类型 电子邮件通知 数据库通知 广播通知 短信通知 Slack 通知 其他通知 邮件 针对邮件进行断言 断言邮件是否已发送 通知 为什么要使用队列? 基本队列配置 排队作业 创建一个作业 将作业推送到队列 作业链 作业批处理 运行队列工作者 处理错误 处理异常情况 限制重试次数 基于作业的重试延迟 作业中间件 处理失败的作业 控制队列 支持其他功能的队列 触发事件 监听事件 自动事件发现 事件订阅者 配置与设置 广播事件 接收消息 高级广播工具 排除当前用户的广播事件 广播服务提供程序 Laravel Echo(JavaScript 部分) 将 Echo 引入到您的项目中 使用 Echo 进行基本事件广播 私有频道和基本认证 存在频道 排除当前用户 使用 Echo 订阅通知 客户端事件 可用的任务类型 可用的时间框架 为计划命令定义时区 阻塞和重叠 处理任务输出 任务钩子 在本地开发中运行调度程序 数组 字符串 应用程序路径 URLs 杂项 基础知识 几个集合操作 Valet Homestead Herd Laravel 安装程序 Dusk Passport Sail Sanctum Fortify Breeze Jetstream Horizon Echo Forge Vapor Envoyer Cashier Socialite Nova Spark Envoy 望远镜 Octane Pennant Folio Volt Pint References

原文:zh.annas-archive.org/md5/d0c72cd35a2ef551cf4f36bed0d4e4e2

译者:飞龙

协议:CC BY-NC-SA 4.0

我如何开始使用 Laravel 的故事很普遍:我写了多年的 PHP,但我已经准备放弃,追求 Rails 和其他现代 Web 框架的力量。Rails 特别是有一个充满活力的社区,一个完美的结合了倾向性默认和灵活性,以及 Ruby-Gems 的力量来利用预打包的常用代码。

有些东西阻止我跳船,当我发现 Laravel 时我为此感到高兴。它提供了我在 Rails 中被吸引的一切,但它不仅仅是 Rails 的克隆;这是一个创新的框架,具有令人难以置信的文档、一个友好的社区,并且明显受到许多语言和框架的影响。

从那天起,我能够通过博客、播客和在会议上演讲来分享我学习 Laravel 的旅程;我为工作和副业项目编写了数十个 Laravel 应用程序;我在网上和面对面见过成千上万的 Laravel 开发人员。我有很多开发工具,但当我坐在命令行前,输入laravel new *项目名称*时,我是最开心的。

这不是关于 Laravel 的第一本书,也不会是最后一本。我不打算让这本书覆盖每一行代码或每一个实现模式。我不希望这本书在新版本的 Laravel 发布时就过时。相反,它的主要目的是为开发人员提供一个高层次的概述和具体示例,以便他们在任何 Laravel 代码库中工作,并使用每一个 Laravel 的功能和子系统。与其复制文档,我想帮助你理解 Laravel 背后的基本概念。

Laravel 是一个功能强大且灵活的 PHP 框架。它有一个充满活力的社区和广泛的工具生态系统,因此它在吸引力和影响力上都在增长。这本书是为那些已经知道如何制作网站和应用程序的开发人员准备的,他们希望学习如何在 Laravel 中做到更好。

Laravel 的文档详尽而出色。如果你发现我没有深入讨论你喜欢的任何特定主题,我鼓励你访问在线文档,深入了解该特定主题。

我认为你会发现这本书在高层次介绍和具体应用之间有一个舒适的平衡,在最后,你应该能够从头开始在 Laravel 中编写一个完整的应用程序。而且,如果我做得好的话,你会对尝试感到兴奋。

本书假定读者具备基本的面向对象编程实践知识,了解 PHP(或至少 C 语系语言的一般语法),以及模型-视图-控制器(MVC)模式和模板化的基本概念。如果你以前没有制作过网站,可能会感到有些吃力。但只要你有一些编程经验,在阅读本书之前不需要对 Laravel 有任何了解——我们将覆盖你需要了解的一切,从最简单的“Hello, world!”开始。

Laravel 可以在任何操作系统上运行,但本书中有些 bash(shell)命令在 Linux/macOS 上运行起来最简单。Windows 用户可能会在使用这些命令和现代 PHP 开发时遇到一些困难,但如果按照获取 Homestead(Linux 虚拟机)运行的说明进行操作,你将能够在那里运行所有命令。

本书的结构按照我设想的时间顺序进行排列:如果你正在使用 Laravel 构建你的第一个 Web 应用程序,早期章节涵盖了你开始所需的基本组件,而后续章节涵盖了较少基础或更深奥的特性。

本书的每个部分都可以单独阅读,但对于框架新手来说,我尝试将章节结构化,从头开始阅读到结尾是非常合理的。

如适用,每个章节都会以两个部分结束:“测试”和“TL;DR”。如果你不熟悉,“TL;DR”意思是“太长了,不想读”。这些最后的部分将展示如何为每个章节涵盖的特性编写测试,并提供所覆盖内容的高级概述。

本书是为 Laravel 10 编写的。

Laravel: Up & Running的第一版于 2016 年 12 月发布,涵盖了 Laravel 5.1 至 5.3 版本。第二版于 2019 年 4 月发布,增加了对 5.4 至 5.8 以及 Laravel Dusk 和 Horizon 的覆盖,并添加了第十八章关于社区资源和其他非核心 Laravel 包,这些在前 17 章中没有涵盖。第三版将本书更新至 Laravel 10,并添加了 Breeze、Jetstream、Fortify、Vite 等新内容。

本书使用以下排版约定:

斜体

指示新术语、网址、电子邮件地址、文件名和文件扩展名。

固定宽度

用于程序列表,以及段落内引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

固定宽度粗体

显示用户应按字面意思键入的命令或其他文本。

固定宽度斜体

显示应由用户提供值或由上下文确定的代码文本。

{大括号中的斜体}

显示应由用户提供值或由上下文确定的文件名或文件路径。

小贴士

这个元素表示提示或建议。

注意

这个元素表示一般注意事项。

警告

这个元素表示警告或注意事项。

注意

超过 40 年来,O’Reilly Media一直为公司提供技术和商业培训、知识和见解,帮助它们取得成功。

我们独特的专家和创新者网络通过图书、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。有关更多信息,请访问https://oreilly.com

请将关于本书的评论和问题发送至出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • CA 95472,Sebastopol

  • 800-889-8969(美国或加拿大境内)

  • 707-829-7019(国际或本地)

  • 707-829-0104(传真)

  • support@oreilly.com

  • https://www.oreilly.com/about/contact.html

我们为本书设有网页,列出勘误、示例和任何额外信息。您可以访问此页面:https://oreil.ly/laravel-up-and-running-3e

有关我们的图书和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

观看我们的 YouTube 频道:https://www.youtube.com/oreillymedia

在这个项目中,我收到了许多人的支持,但我甚至不知道从哪里开始表达我的感激之情。

我的伴侣,Imani,在每一个胜利中都与我共庆,鼓励我,坐在我旁边,她打开笔记本电脑,狂热地打字,我们一起推动,以满足我们的截止日期。我的儿子 Malachi 和女儿 Mia 在整个过程中非常宽容和理解。自 Tighten 团队成立以来,我的整个团队一直给予我支持和鼓励。我的朋友 Trent 和 Tevin 努力创造了艺术和艺术家的空间,我很荣幸能成为他们小家庭的一部分。

我有一系列的研究助理:Wilbur Powery、Brittany Jones Dumas、Reeka Maharaj 和 Ana Lisboa。在我现在繁忙的生活中,没有他们的帮助,我不可能写出第二版和第三版。

Laravel 社区中有如此多的人值得感谢,以至于我甚至无法列举所有。因此,对于所有为此付出了大量爱、奉献、关心和精湛技艺的人,谢谢你们。感谢你们帮助这个社区成为一个令人难以置信的地方;感谢你们在育儿、离婚、疫情、抑郁等方面鼓励我的许多人。你们都是了不起的。

Taylor Otwell 为创建 Laravel 而受到感谢和荣誉——因此创造了如此多的工作岗位,帮助了众多开发者更加热爱我们的生活。他因专注于开发者的幸福感而受到赞赏,以及他为理解开发者并建立积极鼓舞的社区而付出的努力。但我也想感谢他成为一位友善、鼓舞和富有挑战性的朋友。Taylor,你真是一位了不起的领导者。

感谢所有我的技术审阅者!对于第一版:Keith Damiani, Michael Dyrynda, Adam Fairholm 和 Myles Hyson。对于第二版:Tate Peñaranda, Andy Swick, Mohamed Said 和 Samantha Geitz。对于第三版:Anthony Clark, Ben Holmen, Jake Bathman 和 Tony Messias。

当然,也感谢我的家人和朋友们,无论是直接还是间接地支持我度过这一过程——我的父母和兄弟姐妹,我在芝加哥、盖恩斯维尔、迪凯特和亚特兰大的社区,其他企业主和作家,其他会议演讲者,其他父母,以及我有幸遇见和交往的大量了不起的人类。

在动态网络的早期阶段,编写 Web 应用程序看起来与今天大不相同。当时的开发人员不仅负责编写我们应用程序独特的业务逻辑代码,还负责编写那些在各个站点中如此常见的组件——用户认证、输入验证、数据库访问、模板化等等。

如今,程序员们可以轻松访问数十种应用程序开发框架和数千个组件和库。程序员们常说,当你学会一个框架时,可能已经有三个更新(据称更好)的框架出现,试图取代它。

“因为它存在”可能是攀登山峰的有效理由,但选择使用特定框架——或者根本使用框架——有更好的理由。值得问的问题是,为什么要使用框架?更具体地说,为什么选择 Laravel?

显然,使用 PHP 开发者可用的各个组件或包是有益的。通过包,其他人负责开发和维护一个有明确定义作用的隔离代码片段,理论上,这个人对这个单一组件的理解应该比你有时间去深入了解得更多。

像 Laravel、Symfony、Lumen 和 Slim 这样的框架,会将一系列第三方组件与自定义框架“粘合剂”(如配置文件、服务提供者、预定义的目录结构和应用程序引导)打包在一起。因此,使用框架的好处不仅在于有人已经为您做出了关于单独组件的决定,还包括这些组件如何组合在一起的决策。

“我只是自己构建它”

假设您开始一个新的 Web 应用程序,没有框架的帮助。您应该从哪里开始呢?嗯,它可能需要路由 HTTP 请求,所以现在您需要评估所有可用的 HTTP 请求和响应库并选择一个。然后,您将不得不选择一个路由器。哦,您可能还需要设置某种形式的路由配置文件。它应该使用什么语法?应该放在哪里?控制器呢?它们应该放在哪里,如何加载?嗯,您可能需要一个依赖注入容器来解析控制器及其依赖关系。但是选哪一个呢?

此外,如果您确实花时间回答所有这些问题并成功创建您的应用程序,那么对下一个开发者的影响是什么?当您有四个这样的基于定制框架的应用程序,或者十二个时,您需要记住每个应用程序中控制器的位置或路由语法是什么?

一致性与灵活性

框架通过提供慎重考虑的答案来解决“我们应该在这里使用哪个组件?”的问题,并确保所选组件能够很好地协同工作。此外,框架提供的约定减少了新项目的开发者需要理解的代码量——例如,如果你理解一个 Laravel 项目中的路由工作原理,那么你就理解了所有 Laravel 项目中它是如何工作的。

当有人建议为每个新项目定制框架时,他们实际上是在主张能够控制应用程序基础中包含和排除什么。这意味着最好的框架不仅会为你提供一个坚实的基础,还会让你有自由进行定制。正如我将在本书的其余部分中展示的那样,这是使 Laravel 如此特别的部分。

能够回答“为什么选择 Laravel?”的一个重要部分是了解 Laravel 的历史——以及它之前的发展。在 Laravel 兴起之前,PHP 和其他 Web 开发领域有各种框架和其他运动。

Ruby on Rails

David Heinemeier Hansson 于 2004 年发布了 Ruby on Rails 的第一个版本,自那以后,很难找到一种 Web 应用框架不受 Rails 影响的情况。

Rails 推广了 MVC、RESTful JSON API、约定优于配置、ActiveRecord 等许多工具和惯例,对 Web 开发者处理他们的应用程序的方式产生了深远影响——特别是快速应用程序开发方面。

PHP 框架的激增

大多数开发者很清楚,Rails 和类似的 Web 应用框架是未来的趋势,包括那些明显模仿 Rails 的 PHP 框架迅速涌现。

CakePHP 是 2005 年的第一款,很快又有 Symfony、CodeIgniter、Zend Framework 和 Kohana(CodeIgniter 的一个分支)。Yii 在 2008 年出现,Aura 和 Slim 则在 2010 年。2011 年推出的 FuelPHP 和 Laravel 既不是 CodeIgniter 的分支,而是提出的替代方案。

这些框架中有些更像 Rails,专注于数据库对象关系映射(ORM)、MVC 结构和其他旨在快速开发的工具。另一些像 Symfony 和 Zend 则更专注于企业设计模式和电子商务。

CodeIgniter 的优劣

CakePHP 和 CodeIgniter 是最早公开承认从 Rails 获得灵感的两个早期 PHP 框架。CodeIgniter 迅速走红,并且到 2010 年,可以说是独立 PHP 框架中最受欢迎的。

CodeIgniter 简单易用,拥有出色的文档和强大的社区。但它的现代技术和模式使用进展缓慢;随着框架世界的发展和 PHP 工具的进步,CodeIgniter 在技术进步和开箱即用功能方面开始落后。与许多其他框架不同,CodeIgniter 由一家公司管理,它在适应 PHP 5.3 的新功能(如命名空间以及后来的 GitHub 和 Composer)方面进展缓慢。正是在 2010 年,Laravel 的创始人 Taylor Otwell 对 CodeIgniter 不满意到足以自己动手写框架。

Laravel 1、2 和 3

Laravel 1 的第一个 beta 版于 2011 年 6 月发布,完全从头开始编写。它具有自定义 ORM(Eloquent)、基于闭包的路由(受 Ruby Sinatra 启发)、用于扩展的模块系统以及表单、验证、认证等助手。

早期的 Laravel 开发进展迅速,Laravel 2 和 3 分别于 2011 年 11 月和 2012 年 2 月发布。它们引入了控制器、单元测试、命令行工具、控制反转(IoC)容器、Eloquent 关系和迁移。

Laravel 4

到了 Laravel 4,Taylor 从头重新编写了整个框架。此时的 Composer,PHP 现在普遍使用的包管理器,显示出成为行业标准的迹象,Taylor 看到了将框架重写为 Composer 分发和捆绑的组件集合的价值。

Taylor 开发了一组名为Illuminate的组件,并于 2013 年 5 月发布了全新结构的 Laravel 4。现在,Laravel 不再将大部分代码捆绑为下载包,而是通过 Composer 从 Symfony(另一个将其组件释放供他人使用的框架)和 Illuminate 组件中拉取大部分组件。

Laravel 4 还引入了队列、邮件组件、门面和数据库种子。因为 Laravel 现在依赖于 Symfony 组件,因此宣布 Laravel 将会(并非完全一样,但很快)效仿 Symfony 的六个月发布计划。

Laravel 5

Laravel 4.3 原定于 2014 年 11 月发布,但随着开发的进展,其变化的重要性变得明显,因此 Laravel 5 于 2015 年 2 月发布。

Laravel 5 采用了全新的目录结构,删除了表单和 HTML 助手,引入了契约接口,大量新的视图,Socialite 用于社交媒体认证,Elixir 用于资产编译,Scheduler 简化了 cron 任务,dotenv 简化了环境管理,还有全新的 REPL(read–evaluate–print loop)。从那时起,它在功能和成熟度上都有所增长,但没有像以前版本那样有重大变化。

Laravel 6

在 2019 年 9 月,引入了 Laravel 6,并带来了两个主要变化:首先,移除了 Laravel 提供的字符串和数组全局助手(改用 Facade);其次,转向语义化版本控制(SemVer)进行版本编号。这种变化的实际影响意味着,对于 5 之后的所有 Laravel 版本,无论是主要版本(6、7 等)还是次要版本(6.1、6.2 等),发布频率都大大增加了。

Laravel 在新 SemVer 世界(6+)的版本

从版本 6 开始,由于新的 SemVer 发布计划,Laravel 的发布不再像过去那样具有里程碑意义。因此,未来的发布将更多地关注时间流逝,而不是非常具体的全新大功能。

那么,是什么让 Laravel 与众不同?为什么在任何时候都值得拥有超过一个 PHP 框架?他们毕竟都使用 Symfony 的组件,不是吗?让我们稍微谈谈是什么让 Laravel 如此“tick”。

Laravel 的哲学

您只需阅读 Laravel 的市场材料和 README 就可以开始看到它的价值。Taylor 使用像“Illuminate”和“Spark”这样与光有关的词语。然后还有这些:“Artisans”。“优雅”。还有这些:“一股清新的空气”。“新的开始”。最后还有:“快速”。“光速”。

框架最强烈传达的两个价值观是提高开发速度和开发者的幸福感。Taylor 将“Artisan”语言描述为故意与更实用主义的价值观对立。你可以在他 2011 年在 StackExchange 上的提问中看到这种思维方式的起源,他说:“有时我花费了大量时间(几个小时)来苦恼地让代码‘看起来漂亮’”,仅仅是为了改善看代码本身的体验。他经常谈论简化和加快开发者将想法变成现实的过程的价值,摒弃创建优秀产品的不必要障碍。

Laravel 的核心是装备和使开发者能力。其目标是提供清晰、简单和优美的代码和功能,帮助开发者快速学习、启动、开发和编写简单、清晰且持久的代码。

Laravel 文档明确表达了面向开发者的概念。"开心的开发者编写最佳代码" 已经写入文档。"从下载到部署的开发者幸福" 曾是一段时间内的非官方口号。当然,任何工具或框架都会声称希望开发者开心。但将开发者幸福作为首要关注点,而不是次要,对 Laravel 的风格和决策进展产生了巨大影响。其他框架可能将架构纯净性作为首要目标,或者与企业开发团队的目标和价值兼容,但 Laravel 的主要关注点是为个体开发者服务。这并不意味着你不能在 Laravel 中编写架构纯净或企业级的应用程序,但这不会以损害代码库的可读性和理解性为代价。

Laravel 如何实现开发者的幸福感

仅仅说你想让开发者开心是一回事。实现它是另一回事,它要求你质疑框架中最有可能让开发者不开心的因素,以及最有可能让他们开心的因素。Laravel 试图通过多种方式使开发者的生活更轻松。

首先,Laravel 是一个快速应用开发框架。这意味着它专注于浅显易懂的学习曲线,并尽量减少从开始新应用到发布的步骤。Laravel 提供了构建 Web 应用程序中最常见任务的所有组件,从数据库交互到身份验证、队列、电子邮件到缓存,都由 Laravel 提供简化。但 Laravel 的组件不仅仅在其自身上很棒;它们在整个框架中提供了一致的 API 和可预测的结构。这意味着,当你在 Laravel 中尝试新事物时,你很可能会说,“...它就这样运行了。”

这并不仅限于框架本身。Laravel 提供了一个完整的工具生态系统,用于构建和发布应用程序。你有 Sail 和 Valet 和 Homestead 用于本地开发,Forge 用于服务器管理,Envoyer 和 Vapor 用于高级部署。还有一套附加包:Cashier 用于付款和订阅,Echo 用于 WebSockets,Scout 用于搜索,Sanctum 和 Passport 用于 API 认证,Dusk 用于前端测试,Socialite 用于社交登录,Horizon 用于监控队列,Nova 用于构建管理面板,以及 Spark 用于启动你的 SaaS。Laravel 努力减少开发者工作中的重复性工作,以便他们可以做一些独特的事情。

其次,Laravel 注重“约定优于配置”——这意味着如果你愿意使用 Laravel 的默认设置,你将比其他需要你声明所有设置的框架少做很多工作,即使你使用推荐的配置。使用 Laravel 构建的项目所需时间比大多数其他 PHP 框架构建的项目要少。

Laravel 也深入关注简洁性。如果你愿意,你可以在 Laravel 中使用依赖注入、模拟和数据映射器模式、仓储模式和命令查询职责分离等各种复杂的架构模式。但是,而其他框架可能建议在每个项目中使用这些工具和结构,Laravel 及其文档和社区更倾向于从最简单的实现开始——这里用一个全局函数,那里用一个外观模式,再在那里用 ActiveRecord。这使开发人员能够创建最简单的应用程序来满足他们的需求,而不会限制其在复杂环境中的有用性。

Laravel 与其他 PHP 框架不同的一个有趣之处在于,它的创作者和社区更多地受到 Ruby 和 Rails 以及函数式编程语言的影响和启发,而不是 Java。在现代 PHP 中,有一种强烈的潮流倾向于冗长和复杂,接纳 PHP 更类似于 Java 的方面。但是 Laravel 往往站在另一边,拥抱富有表现力、动态和简单的编码实践和语言特性。

Laravel 社区

如果这本书是你第一次接触 Laravel 社区,你有特别的期待。Laravel 的一个显著特点之一是其欢迎、教育氛围,这一点促成了它的成长和成功。从 Jeffrey Way 的Laracasts 视频教程Laravel News,再到 Slack、IRC 和 Discord 频道,从 Twitter 的朋友到博客作者、播客到 Laracon 大会,Laravel 拥有一个充满活力和丰富多样的社区,其中既有从一开始就参与的人,也有刚刚开始的新人。“这并非偶然:

从 Laravel 的最初开始,我就有这样一个想法,即所有人都希望感觉自己是某个群体的一部分。想要归属并被其他志同道合的人接受,是人类的自然本能。因此,通过在 web 框架中注入个性并积极参与社区,这种感觉可以在社区中蔓延。

Taylor Otwell,产品与支持采访

Taylor 从 Laravel 早期就明白,一个成功的开源项目需要两样东西:良好的文档和一个友好的社区。而这两点现在已成为 Laravel 的标志性特征。

直到现在,我在这里分享的一切都是完全抽象的。你可能会问,关于代码呢?让我们深入一个简单的应用程序(示例1-1)来看看日常使用 Laravel 到底是怎样的体验。

示例 1-1. 在 routes/web.php 中的“Hello, World”
<?phpRoute::get('/', function () { return 'Hello, World!';});

在 Laravel 应用中,你可以采取的最简单的操作就是定义一个路由并在访问该路由时返回结果。如果在你的机器上初始化一个全新的 Laravel 应用程序,在 示例1-1 中定义路由,然后从 public 目录提供站点服务,你将拥有一个完全运作的“Hello, World”示例(见 图1-1)。

Laravel-启动指南-全- - 绝不原创的飞龙 - 博客园 (1)

图 1-1. 使用 Laravel 返回“Hello, World!”

它看起来非常类似于控制器,正如你在 示例1-2 中所看到的(如果你想立即测试,请首先运行 php artisan make:controller WelcomeController 创建控制器)。

示例 1-2. 使用控制器打印“Hello, World”
// File: routes/web.php<?phpuse App\Http\Controllers\WelcomeController;Route::get('/', [WelcomeController::class, 'index']);
// File: app/Http/Controllers/WelcomeController.php<?phpnamespace App\Http\Controllers;class WelcomeController extends Controller{ public function index() { return 'Hello, World!'; }}

如果你将问候语存储在数据库中,它看起来也会非常相似(参见 示例1-3)。

示例 1-3. 多问候语“Hello, World”并访问数据库
// File: routes/web.php<?phpuse App\Greeting;Route::get('create-greeting', function () { $greeting = new Greeting; $greeting->body = 'Hello, World!'; $greeting->save();});Route::get('first-greeting', function () { return Greeting::first()->body;});
// File: app/Models/Greeting.php<?phpnamespace App\Models;use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model;class Greeting extends Model{ use HasFactory;}
// File: database/migrations/2023_03_12_192110_create_greetings_table.php<?phpuse Illuminate\Database\Migrations\Migration;use Illuminate\Database\Schema\Blueprint;use Illuminate\Support\Facades\Schema;return new class extends Migration{ /** * Run the migrations. */ public function up(): void { Schema::create('greetings', function (Blueprint $table) { $table->id(); $table->string('body'); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('greetings'); }};

示例1-3 可能会有些令人不知所措,如果是这样,请跳过。在后面的章节中你将了解到这里发生的一切,但你已经可以看到,仅仅几行代码就可以设置数据库迁移和模型,并提取记录。就是这么简单。

所以,为什么选择 Laravel?

因为 Laravel 能帮助你将想法变为现实,无需编写多余的代码,采用现代编码标准,支持活跃的社区,并拥有强大的工具生态系统。

还有因为你,亲爱的开发者,值得快乐。

PHP 之所以成功的一部分原因是几乎找不到不能运行 PHP 的网络服务器。然而,现代 PHP 工具对于过去的要求更为严格。为了更好地为 Laravel 开发,最好保持代码在本地和远程服务器环境的一致性,幸运的是,Laravel 生态系统为此提供了一些工具。

本章中涵盖的所有内容在 Windows 机器上都是可能的,但你需要几十页的定制说明和注意事项。我将这些说明和注意事项留给真正的 Windows 用户,因此本书的例子将专注于 Unix/Linux/macOS 开发者。

无论你选择通过在本地机器上安装 PHP 和其他工具来为你的网站提供服务,还是通过 Vagrant 或 Docker 在虚拟机中提供开发环境,或者依赖像 MAMP/WAMP/XAMPP 这样的工具,你的开发环境都需要安装以下所有内容才能为 Laravel 站点提供服务:

  • PHP >= 8.1

  • OpenSSL PHP 扩展

  • PDO PHP 扩展

  • Mbstring PHP 扩展

  • Tokenizer PHP 扩展

  • XML PHP 扩展

  • Ctype PHP 扩展

  • JSON PHP 扩展

  • BCMath PHP 扩展

无论你用什么机器开发,都需要全局安装ComposerComposer是现代大多数 PHP 开发的基础工具。它是 PHP 的依赖管理器,类似于 Node 的 NPM(Node Package Manager)或 Ruby 的 RubyGems。但与 NPM 一样,Composer 也是我们测试、本地脚本加载、安装脚本等许多基础工具的基础。安装 Laravel、更新 Laravel 和引入外部依赖都需要 Composer。

对于许多项目,使用更简单的工具集来托管你的开发环境可能已经足够了。如果你的系统上已经安装了 MAMP 或 WAMP 或 XAMPP,那很可能能够运行 Laravel。

你也可以通过 PHP 的内置 Web 服务器来运行 Laravel。在你的 Laravel 站点根目录下运行 php -S localhost:8000 -t public,PHP 的内置 Web 服务器将在http://localhost:8000/上为你提供站点服务。

然而,如果你希望在你的开发环境中拥有更多的功能(每个项目的不同本地域名、像 MySQL 这样的依赖管理等),你将需要一个比 PHP 内置服务器更强大的工具。

Laravel 提供了五种本地开发工具:Artisan serve、Sail、Valet、Herd 和 Homestead。我们会简要介绍每一种。如果你不确定该使用哪一种,我个人推荐 Mac 用户使用 Valet,其他人使用 Sail。

Artisan Serve

如果你在设置 Laravel 应用程序后运行 php artisan serve,它将在 http://localhost:8000 上提供服务,就像我们之前使用 PHP 的内置 Web 服务器设置的那样。在这里你并没有得到其他免费的东西,因此它唯一有意义的好处是更容易记住。

Laravel Sail

Sail 是开始本地 Laravel 开发的最简单方法,无论你使用的是什么操作系统,它都是相同的。它带有一个 PHP Web 服务器、数据库以及许多其他方便的功能,使得运行单个 Laravel 安装变得非常容易,这对于项目中的每个开发人员都是一致的,不论项目的依赖项或开发人员的工作环境如何。

为什么我不使用 Sail?它使用 Docker 来完成上述任务,而 macOS 上的 Docker 速度刚好足够慢,我更喜欢 Valet。但如果你是 Laravel 的新手,特别是如果你不使用 Mac,Sail 就是最简单的开始构建 Laravel 应用程序的方式。

Laravel Valet

如果你是 macOS 用户(也有非官方的 Windows 和 Linux 版本),Laravel Valet 可以轻松地为你的每一个本地 Laravel 应用程序(以及大多数其他静态和基于 PHP 的应用程序)提供服务在不同的本地域名上。

你需要使用 Homebrew 安装一些工具,文档将引导你完成这些步骤,但从初始安装到提供服务你的应用程序,步骤非常少。

安装 Valet——请查阅 Valet 文档 获取最新的安装说明——并将其指向一个或多个存放站点的目录。我从我的 ~/Sites 目录运行了 valet park,这是我放置所有正在开发中的应用程序的地方。现在,你只需在目录名后加上 .test,就可以在浏览器中访问它了。

Valet 可以轻松为 Laravel 应用提供服务;我们可以使用 valet park 将给定文件夹中的所有子文件夹作为 {foldername}.test 提供服务,使用 valet link 只服务一个单独的文件夹,使用 valet open 打开浏览器显示 Valet 服务的域名,使用 valet secure 以 HTTPS 方式提供 Valet 网站,使用 valet share 打开一个 ngrok 或 Expose 隧道,这样你可以与他人共享你的站点。

Laravel Herd

Herd 是一个原生的 macOS 应用程序,它将 Valet 及其所有依赖项捆绑在一个单独的安装程序中。虽然 Herd 不像 Valet CLI 那样可定制,但它省去了使用 Homebrew、Docker 或任何其他依赖管理器的必要,并且允许你通过一个漂亮的图形界面与 Valet 的核心功能进行交互。

Laravel Homestead

Homestead 是另一个你可能想用来设置本地开发环境的工具。它是一个配置工具,基于 Vagrant(一个管理虚拟机的工具),提供了一个预配置的虚拟机镜像,完美地设置了 Laravel 开发环境,并且反映了许多 Laravel 网站运行的最常见生产环境。

Homestead 文档非常全面,并且始终保持更新,所以如果你想了解它的工作原理和设置方法,我建议你直接查阅这些文档。

创建新的 Laravel 项目有两种方式,都可以通过命令行运行。第一种是全局安装 Laravel 安装工具(使用 Composer);第二种是使用 Composer 的create-project功能。

你可以在安装文档页面上详细了解这两种选项,但我建议使用 Laravel 安装工具。

使用 Laravel 安装工具安装 Laravel

如果你已经全局安装了 Composer,安装 Laravel 安装工具就像运行以下命令一样简单:

composer global require "laravel/installer"

一旦安装了 Laravel 安装工具,启动一个新的 Laravel 项目就很简单。只需从命令行运行此命令:

laravel new projectName

这将在当前目录下创建一个名为{projectName}的新子目录,并在其中安装一个裸的 Laravel 项目。

使用 Composer 的 create-project 功能安装 Laravel

Composer 还提供了一个称为create-project的功能,用于使用特定骨架创建新项目。要使用此工具创建新的 Laravel 项目,请发出以下命令:

composer create-project laravel/laravel projectName

就像安装工具一样,这将在当前目录下创建一个名为{projectName}的子目录,其中包含一个 Laravel 安装的骨架,准备好供您开发。

使用 Sail 安装 Laravel

如果你计划使用 Laravel Sail 工作,可以同时安装 Laravel 应用程序并开始其 Sail 安装过程。确保你的计算机上已安装了 Docker,然后使用以下命令,将*example-app*替换为你的应用程序名称:

curl -s "https://laravel.build/example-app" | bash

这将把 Laravel 安装到当前文件夹下的*example-app*文件夹中,然后开始 Sail 安装过程。

安装过程完成后,切换到新目录并启动 Sail:

cd example-app./vendor/bin/sail up
注意

第一次运行sail up时,它会比其他安装过程花费更长时间,因为它需要构建初始 Docker 镜像。

当你打开一个包含骨架 Laravel 应用程序的目录时,你会看到以下文件和目录:

app/bootstrap/config/database/public/resources/routes/storage/tests/vendor/.editorconfig.env.env.example.gitattributes.gitignoreartisancomposer.jsoncomposer.lockpackage.jsonphpunit.xmlreadme.mdvite.config.js

让我们一一详细介绍它们,以便熟悉。

文件夹

根目录默认包含以下文件夹:

app

实际应用程序的大部分内容将存放在这里。模型、控制器、命令和 PHP 领域代码都在这里。

bootstrap

包含 Laravel 框架每次运行时使用的文件。

config

所有配置文件所在位置。

database

数据库迁移、种子和工厂所在位置。

public

当服务器为网站提供服务时指向的目录。这包含 index.php,它是启动引导过程并适当路由所有请求的前端控制器。也是任何公开文件(如图像、样式表、脚本或下载文件)的位置。

资源

其他脚本所需文件的位置。视图,以及(可选)源 CSS 和源 JavaScript 文件存放在这里。

路由

所有路由定义的位置,包括 HTTP 路由和“控制台路由”或 Artisan 命令。

storage

缓存、日志和编译的系统文件存放位置。

测试

单元测试和集成测试的位置。

vendor

Composer 安装其依赖项的位置。它被 Git 忽略(标记为从版本控制系统中排除),因为 Composer 预期在任何远程服务器上作为部署过程的一部分运行。

松散的文件

根目录还包含以下文件:

.editorconfig

给你的 IDE/文本编辑器关于 Laravel 编码标准的指令(例如缩进大小、字符集以及是否修剪尾随空白)。

.env 和 .env.example

指定环境变量(在每个环境中预期不同的变量,因此不提交到版本控制)。.env.example 是一个模板,每个环境都应复制它以创建自己的.env文件,该文件被 Git 忽略。

.gitignore 和 .gitattributes

Git 配置文件。

artisan

允许您从命令行运行 Artisan 命令(参见第八章)。

composer.json 和 composer.lock

Composer 的配置文件;composer.json 可由用户编辑,composer.lock 则不可。这些文件共享一些关于项目的基本信息,并定义其 PHP 依赖项。

package.json

类似于 composer.json,但用于前端资产和构建系统的依赖项;它指示 NPM 拉取哪些基于 JavaScript 的依赖项。

phpunit.xml

PHPUnit 的配置文件,Laravel 默认使用该工具进行测试。

readme.md

一份关于 Laravel 的基本介绍的 Markdown 文件。如果使用 Laravel 安装程序,您将看不到此文件。

vite.config.js

(可选的)Vite 的配置文件。此文件指示您的构建系统如何编译和处理前端资产。

您的 Laravel 应用程序的核心设置——数据库连接设置、队列和邮件设置等——存储在 config 文件夹中的文件中。这些文件中的每一个都返回一个 PHP 数组,并且数组中的每个值可以通过由文件名和所有后代键组成的配置键来访问,这些键由点(.)分隔。

因此,如果您在 config/services.php 创建以下内容的文件:

// config/services.php<?phpreturn [ 'sparkpost' => [ 'secret' => 'abcdefg', ],];

您可以使用 config('services.sparkpost.secret') 访问该配置变量。

任何应该对每个环境都不同的配置变量(因此不应提交到源代码控制)将存储在你的 .env 文件中。假设你想为每个环境使用不同的 Bugsnag API 密钥。你可以设置配置文件从 .env 中获取它:

// config/services.php<?phpreturn [ 'bugsnag' => [ 'api_key' => env('BUGSNAG_API_KEY'), ],];

这个 env() 辅助函数从你的 .env 文件中获取相同键的值。所以现在,将该键添加到你的 .env(这个环境的设置)和 .env.example(所有环境的模板)文件中:

# In .envBUGSNAG_API_KEY=oinfp9813410942
# In .env.exampleBUGSNAG_API_KEY=

你的 .env 文件已经包含了框架需要的许多环境特定变量,比如你将使用的邮件驱动程序以及基本的数据库设置。

Laravel 中的某些功能,包括一些缓存和优化功能,如果你在配置文件之外的任何地方使用 env() 调用,则这些功能将不可用。

最佳方法是引入环境变量是设置为任何你想要的环境特定项目的配置项。让这些配置项读取环境变量,然后在你的应用程序的任何地方引用配置变量即可。

// config/services.phpreturn [ 'bugsnag' => [ 'key' => env('BUGSNAG_API_KEY'), ],];// In controller, or whatever$bugsnag = new Bugsnag(config('services.bugsnag.key'));

.env 文件

让我们快速查看一下 .env 文件的默认内容。确切的键名会根据你使用的 Laravel 版本而有所不同,但可以查看 示例2-1 来了解它们的样子。

示例 2-1. Laravel 中的默认环境变量
APP_NAME=LaravelAPP_ENV=localAPP_KEY=APP_DEBUG=trueAPP_URL=http://localhostLOG_CHANNEL=stackLOG_DEPRECATIONS_CHANNEL=nullLOG_LEVEL=debugDB_CONNECTION=mysqlDB_HOST=127.0.0.1DB_PORT=3306DB_DATABASE=laravelDB_USERNAME=rootDB_PASSWORD=BROADCAST_DRIVER=logCACHE_DRIVER=fileFILESYSTEM_DISK=localQUEUE_CONNECTION=syncSESSION_DRIVER=fileSESSION_LIFETIME=120MEMCACHED_HOST=127.0.0.1REDIS_HOST=127.0.0.1REDIS_PASSWORD=nullREDIS_PORT=6379MAIL_MAILER=smtpMAIL_HOST=mailpitMAIL_PORT=1025MAIL_USERNAME=nullMAIL_PASSWORD=nullMAIL_ENCRYPTION=nullMAIL_FROM_ADDRESS="hello@example.com"MAIL_FROM_NAME="${APP_NAME}"AWS_ACCESS_KEY_ID=AWS_SECRET_ACCESS_KEY=AWS_DEFAULT_REGION=us-east-1AWS_BUCKET=AWS_USE_PATH_STYLE_ENDPOINT=falsePUSHER_APP_ID=PUSHER_APP_KEY=PUSHER_APP_SECRET=PUSHER_HOST=PUSHER_PORT=443PUSHER_SCHEME=httpsPUSHER_APP_CLUSTER=mt1VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"VITE_PUSHER_HOST="${PUSHER_HOST}"VITE_PUSHER_PORT="${PUSHER_PORT}"VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

我不会详细讨论所有这些,因为其中有相当多只是各种服务的认证信息组(Pusher、Redis、DB、Mail)。但是,以下是你应该知道的两个重要环境变量:

APP_KEY

一个随机生成的字符串,用于加密数据。如果这个值为空,你可能会遇到“未指定应用加密密钥”的错误。在这种情况下,只需运行 php artisan key:generate,Laravel 将为你生成一个密钥。

APP_DEBUG

一个布尔值,确定你的应用实例的用户是否应该看到调试错误——适用于本地和暂存环境,但对生产环境不适用。

其余的非认证设置(BROADCAST_DRIVERQUEUE_CONNECTION 等)都给定了默认值,尽可能少依赖外部服务,这对刚开始使用时非常合适。

当你启动你的第一个 Laravel 应用时,大多数项目你可能唯一想要更改的是数据库配置设置。我使用 Laravel Valet,所以我将 DB_DATABASE 更改为我的项目名称,将 DB_USERNAME 更改为 root,将 DB_PASSWORD 更改为空字符串:

DB_DATABASE=myProjectDB_USERNAME=rootDB_PASSWORD=

然后,我在我最喜欢的 MySQL 客户端中创建一个与我的项目同名的数据库,然后就可以开始了。

现在,你已经使用裸的 Laravel 安装运行起来了。运行 git init,使用 git add .git commit 提交裸文件,然后你就可以开始编码了。就是这样!如果你使用 Valet,你可以运行以下命令,立即在浏览器中看到你的站点上线:

laravel new myProject && cd myProject && valet open

每次我启动一个新项目时,我都会执行以下步骤:

laravel new myProjectcd myProjectgit initgit add .git commit -m "Initial commit"

我将所有的站点都放在~/Sites文件夹中,这是我设置为主要 Valet 目录的地方,所以在这种情况下,我可以立即在浏览器中访问myProject.test,而无需额外工作。我可以编辑.env并将其指向特定的数据库,在我的 MySQL 应用程序中添加该数据库,然后就可以开始编码了。

此后的每一章,“测试”部分都会展示如何为涵盖的功能编写测试。由于本章不涉及可测试的功能,让我们快速讨论一下测试。 (要了解更多关于在 Laravel 中编写和运行测试的内容,请转到第十二章。)

Laravel 默认带有 PHPUnit 作为依赖项,并配置为在tests目录中任何以Test.php结尾的文件中运行测试(例如tests/UserTest.php)。

所以,编写测试的最简单方法是在名为tests的目录中创建一个以Test.php结尾的文件。而运行它们的最简单方法是从命令行(在项目根目录中)运行./vendor/bin/phpunit

如果任何测试需要访问数据库,请确保从托管数据库的机器上运行您的测试—如果您在 Vagrant 中托管数据库,请确保通过ssh连接到您的 Vagrant 盒子来运行测试。同样,您可以在第十二章中了解更多相关内容。

此外,一些测试部分将使用测试语法和功能,如果您是第一次阅读本书,可能会对其中的代码感到困惑。如果测试部分的代码令人困惑,只需跳过它,并在阅读测试章节后再回头查看。

由于 Laravel 是一个 PHP 框架,因此在本地运行它非常简单。Laravel 还提供了三种工具来管理您的本地开发环境:Sail,一个 Docker 设置;Valet,一个更简单的基于 macOS 的工具;以及 Homestead,一个预配置的 Vagrant 设置。Laravel 依赖于 Composer,并且默认情况下带有一系列反映其约定和与其他开源工具关系的文件和文件夹。

任何 Web 应用程序框架的基本功能是接收用户请求并传递响应,通常通过 HTTP(S)完成。这意味着定义应用程序路由是学习 Web 框架时首先要解决的最重要的项目;没有路由,您几乎无法与最终用户进行交互。

在本章中,我们将探讨 Laravel 中的路由;您将看到如何定义它们,如何将它们指向应执行的代码,并如何使用 Laravel 的路由工具处理各种各样的路由需求。

我们在本章中讨论的大部分内容涉及如何组织 Model–View–Controller(MVC)应用程序的结构,我们将查看许多示例使用类 REST 的路由名称和动词,因此让我们快速看一下两者。

什么是 MVC?

在 MVC 中,您有三个主要概念:

模型

表示一个单独的数据库表(或来自该表的记录)—想象“公司”或“狗”。

视图

表示将数据输出到最终用户的模板—​想象“带有给定 HTML、CSS 和 JavaScript 集的登录页面模板”。

控制器

类似于交通警察,从浏览器接收 HTTP 请求,从数据库和其他存储机制获取正确的数据,验证用户输入,并最终向用户发送响应。

在图3-1 中,您可以看到,最终用户将首先通过其浏览器发送 HTTP 请求与控制器进行交互。控制器响应该请求后,可能会向模型(数据库)写入数据和/或从模型中拉取数据。然后,控制器很可能会向视图发送数据,然后将视图返回给最终用户在其浏览器中显示。

Laravel-启动指南-全- - 绝不原创的飞龙 - 博客园 (2)

图3-1. MVC 的基本示意图

我们将涵盖一些不符合这种相对简单的应用架构方式的 Laravel 用例,所以不要陷入 MVC,但这将至少让您准备好在我们讨论视图和控制器时接近本章的其余部分。

HTTP 动词

最常见的 HTTP 动词是GETPOST,其次是PUTDELETE。还有HEADOPTIONSPATCH,以及两个在正常网页开发中几乎不使用的其他动词,TRACECONNECT

这里是一个快速概述:

GET

请求资源(或资源列表)。

HEAD

请求GET响应的仅包含头信息的版本。

POST

创建资源。

PUT

覆盖资源。

PATCH

修改资源。

DELETE

删除资源。

OPTIONS

询问服务器此 URL 允许哪些动词。

Table3-1 显示了资源控制器上可用的操作(更多详情见 “资源控制器”)。每个动作期望你使用特定的 URL 模式和特定的动词调用,因此你可以了解每个动词的用途。

表 3-1. Laravel 资源控制器的方法

动词URL控制器方法名称描述
GETtasksindex()tasks.index显示所有任务
GETtasks/createcreate()tasks.create显示创建任务表单
POSTtasksstore()tasks.store接受创建任务表单的表单提交
GETtasks/{task}show()tasks.show显示一个任务
GETtasks/{task}/editedit()tasks.edit编辑一个任务
PUT/PATCHtasks/{task}update()tasks.update接受编辑任务表单的表单提交
DELETEtasks/{task}destroy()tasks.destroy删除一个任务

什么是 REST?

我们将在 “REST-Like JSON APIs 基础” 中详细讨论 REST,但简要介绍一下,它是一种构建 API 的架构风格。在本书中讨论 REST 时,主要指一些特征,例如:

  • 每次围绕一个主要资源结构化(例如,tasks

  • 由使用 HTTP 动词与可预测的 URL 结构进行交互组成(如 Table3-1 中所见)

  • 返回 JSON 数据并经常被请求为 JSON

这是更复杂的内容,但通常情况下,本书中使用的 “RESTful” 将意味着 “基于这些基于 URL 结构的模式,因此我们可以像 GET /tasks/14/edit 这样进行可预测的调用”。这很重要(即使不构建 API)因为 Laravel 的路由结构是基于类似 REST 的结构,正如你可以在 Table3-1 中看到的。

基于 REST 的 API 主要遵循相同的结构,除了它们没有 create 路由或 edit 路由,因为 API 只表示动作,而不是为动作准备页面。

在 Laravel 应用中,你将在 routes/web.php 中定义你的 web 路由,而在 routes/api.php 中定义 API 路由。Web 路由 是终端用户访问的路由;API 路由 是你的 API 的路由(如果有的话)。目前,我们主要关注 routes/web.php 中的路由。

定义路由的最简单方法是将路径(例如 /)与闭包匹配,如 Example3-1 中所示。

Example 3-1. 基本路由定义
// routes/web.phpRoute::get('/', function () { return 'Hello, World!';});

现在你已经定义了如果有人访问 /(你域名的根),Laravel 的路由器应该运行在那里定义的闭包并返回结果。注意我们是 return 我们的内容,而不是 echoprint 它。

你可能会想:“为什么我返回 ‘Hello, World!’ 而不是回显它?”

有很多答案,但最简单的答案是 Laravel 的请求和响应周期周围有很多包装,包括一种称为中间件的东西。当路由闭包或控制器方法完成时,现在还不是将输出发送到浏览器的时间;返回内容允许它继续通过响应堆栈和中间件流动,然后再返回给用户。

许多简单的网站完全可以在 web 路由文件中定义。通过一些简单的GET路由结合一些模板,如示例3-2 所示,您可以轻松地提供经典网站服务。

示例 3-2. 示例网站
Route::get('/', function () { return view('welcome');});Route::get('about', function () { return view('about');});Route::get('products', function () { return view('products');});Route::get('services', function () { return view('services');});

如果您有 PHP 开发经验,您可能会惊讶地看到在Route类上进行静态调用。这实际上不是静态方法本身,而是使用 Laravel 的门面进行的服务定位,我们将在第11 章中介绍。

如果您喜欢避免使用门面,您可以通过以下方式完成相同的定义:

$router->get('/', function () { return 'Hello, World!';});

路由动词

你可能已经注意到,我们在路由定义中一直在使用Route::get()。这意味着我们告诉 Laravel 只有当 HTTP 请求使用GET动作时才匹配这些路由。但是如果是表单的POST,或者可能是一些 JavaScript 发送的PUTDELETE请求呢?在路由定义中调用的方法还有几个其他选项,如示例3-3 所示。

示例 3-3. 路由动词
Route::get('/', function () { return 'Hello, World!';});Route::post('/', function () { // Handle someone sending a POST request to this route});Route::put('/', function () { // Handle someone sending a PUT request to this route});Route::delete('/', function () { // Handle someone sending a DELETE request to this route});Route::any('/', function () { // Handle any verb request to this route});Route::match(['get', 'post'], '/', function () { // Handle GET or POST requests to this route});

路由处理

正如您可能已经猜到的那样,将闭包传递给路由定义并不是教它如何解析路由的唯一方法。闭包快速简单,但是随着应用程序的规模变大,将所有路由逻辑放在一个文件中变得越来越笨拙。此外,使用路由闭包的应用程序无法利用 Laravel 的路由缓存(稍后详述),这可以减少每个请求高达数百毫秒的响应时间。

另一个常见选项是在闭包的位置以字符串形式传递控制器名称和方法,如示例3-4 所示。

示例 3-4. 调用控制器方法的路由
use App\Http\Controllers\WelcomeController;Route::get('/', [WelcomeController::class, 'index']);

这告诉 Laravel 将请求传递给该路径的index()方法,该方法位于App\Http\Controllers\WelcomeController控制器中。此方法将接收相同的参数并像您可能替代放置在其中的闭包一样对待它。

路由参数

如果你定义的路由有参数——URL 结构中的可变段落——那么在路由中定义它们并传递给闭包非常简单(见示例3-5)。

示例 3-5. 路由参数
Route::get('users/{id}/friends', function ($id) { //});

您还可以通过在参数名后面加上问号(?)使路由参数变为可选,如示例3-6 所示。在这种情况下,您还应为路由的对应变量提供默认值。

示例 3-6. 可选路由参数
Route::get('users/{id?}', function ($id = 'fallbackId') { //});

你还可以使用正则表达式(regexes)来定义一个路由只有在参数满足特定要求时才匹配,就像在 示例3-7 中一样。

示例 3-7. 正则表达式路由约束
Route::get('users/{id}', function ($id) { //})->where('id', '[0-9]+');Route::get('users/{username}', function ($username) { //})->where('username', '[A-Za-z]+');Route::get('posts/{id}/{slug}', function ($id, $slug) { //})->where(['id' => '[0-9]+', 'slug' => '[A-Za-z]+']);

正如你可能猜到的那样,如果访问的路径匹配了路由字符串但正则表达式不匹配参数,它将不会被匹配。由于路由从上到下匹配,users/abc会跳过 示例3-7 的第一个闭包,但它将会被第二个闭包匹配,因此会被路由到那里。另一方面,posts/abc/123不会匹配任何闭包,因此会返回 404(未找到)错误。

Laravel 还提供了方便的方法来匹配常见的正则表达式模式,正如你在 示例3-8 中看到的那样。

示例 3-8. 正则表达式路由约束辅助函数
Route::get('users/{id}/friends/{friendname}', function ($id, $friendname) { //})->whereNumber('id')->whereAlpha('friendname');Route::get('users/{name}', function ($name) { //})->whereAlphaNumeric('name');Route::get('users/{id}', function ($id) { //})->whereUuid('id');Route::get('users/{id}', function ($id) { //})->whereUlid('id');Route::get('friends/types/{type}', function ($type) { //})->whereIn('type', ['acquaintance', 'bestie', 'frenemy']);

路由名称

在应用程序中最简单的引用这些路由的方式只是使用它们的路径。如果需要的话,在视图中有一个url()全局辅助函数来简化链接;例如,查看 示例3-9。这个辅助函数会在你的路由前加上你的站点的完整域名。

示例 3-9. url()辅助函数
<a href="<?php echo url('/'); ?>">// Outputs <a href="http://myapp.com/">

然而,Laravel 也允许你为每个路由命名,这样你就可以在不显式引用 URL 的情况下引用它们。这很有帮助,因为这意味着你可以给复杂的路由起一个简单的昵称,同时通过名称链接它们意味着如果路径变化,你不必重写前端链接(参见 示例3-10)。

示例 3-10. 定义路由名称
// Defining a route with name() in routes/web.php:Route::get('members/{id}', [\App\Http\Controller\MemberController::class, 'show']) ->name('members.show');// Linking the route in a view using the route() helper:<a href="<?php echo route('members.show', ['id' => 14]); ?>">

这个例子展示了一些新概念。首先,我们使用流畅的路由定义通过在get()方法后链接name()方法来添加名称。这种方法允许我们为路由命名,为其提供一个简短的别名,以便在其他地方更容易引用。

在我们的例子中,我们将这个路由命名为members.show*resourcePlural*.*action*是 Laravel 中用于路由和视图名称的常见约定。

本例还介绍了route()辅助函数。就像url()一样,它旨在在视图中使用,简化链接到命名路由。如果路由没有参数,你可以简单地传递路由名称(route('members.index')),会得到一个路由字符串("http://myapp.com/members")。如果有参数,就像我们在 示例3-10 中所做的那样,将它们作为数组传递到第二个参数中。

一般来说,我建议使用路由名称而不是路径来引用你的路由,因此建议使用route()辅助函数而不是url()辅助函数。有时会显得有些笨拙,比如当你使用多个子域名时,但它提供了极大的灵活性,以便稍后更改应用程序的路由结构而不会受到重大惩罚。

通常,一组路由共享特定的特征—​特定的身份验证要求、路径前缀或者控制器命名空间。在每个路由上重复定义这些共享特征不仅显得乏味,而且还可能使路由文件的结构混乱,并且模糊了应用程序的一些结构。

路由组 允许您通过将多个路由分组在一起并一次性应用任何共享的配置设置来减少这种重复。此外,路由组对未来开发人员(以及您自己)是视觉线索,表明这些路由是一组的。

要将两个或多个路由分组在一起,您可以通过路由组定义周围的路由定义,如 Example3-11 中所示。实际上,您正在将一个闭包传递给组定义,并在该闭包中定义分组的路由。

Example 3-11. 定义一个路由组
Route::group(function () { Route::get('hello', function () { return 'Hello'; }); Route::get('world', function () { return 'World'; });});

默认情况下,路由组实际上并不执行任何操作。在 Example3-11 中使用组与在路由中使用代码注释分离的效果没有区别。

中间件

路由组最常见的用途之一是对一组路由应用中间件。您将在 Chapter10 中学习更多关于中间件的内容,但它们主要用于 Laravel 中对用户进行身份验证和限制访客用户访问站点某些部分。

在 Example3-12 中,我们围绕 dashboardaccount 视图创建了一个路由组,并将 auth 中间件应用于两者。在此示例中,这意味着用户必须登录应用程序才能查看仪表板或帐户页面。

Example 3-12. 限制一组路由仅供已登录用户使用
Route::middleware('auth')->group(function() { Route::get('dashboard', function () { return view('dashboard'); }); Route::get('account', function () { return view('account'); });});

通常,将中间件附加到控制器中的路由比在路由定义时更清晰、更直接。您可以通过在控制器的构造函数中调用 middleware() 方法来实现这一点。传递给 middleware() 方法的字符串是中间件的名称,您还可以可选地链式调用修饰方法 (only()except()) 来定义哪些方法将接收该中间件:

class DashboardController extends Controller{ public function __construct() { $this->middleware('auth'); $this->middleware('admin-auth') ->only('editUsers'); $this->middleware('team-member') ->except('editUsers'); }}

注意,如果您经常进行“only”和“except”自定义,这通常是需要为异常路由新建一个控制器的标志。

路径前缀

如果一组路由共享其路径的一部分—​例如,如果您的站点的仪表板以 /dashboard 为前缀—​您可以使用路由组来简化此结构(参见 Example3-13)。

Example 3-13. 为一组路由添加前缀
Route::prefix('dashboard')->group(function () { Route::get('/', function () { // Handles the path /dashboard }); Route::get('users', function () { // Handles the path /dashboard/users });});

注意,每个带前缀的组还有一个表示前缀根的 / 路由—​在 Example3-13 中即为 /dashboard

子域路由

子域路由与路由前缀相同,但其范围限定为子域而不是路由前缀。这有两个主要用途。首先,您可能希望为应用程序的不同部分(或完全不同的应用程序)提供不同的子域。示例3-14 展示了如何实现这一点。

示例 3-14. 子域路由
Route::domain('api.myapp.com')->group(function () { Route::get('/', function () { // });});

其次,您可能希望将子域的一部分设置为参数,如示例3-15 所示。这在多租户情况下最常见(想想 Slack 或 Harvest,每个公司都有自己的子域,如tighten.slack.co)。

示例 3-15. 参数化子域路由
Route::domain('{account}.myapp.com')->group(function () { Route::get('/', function ($account) { // }); Route::get('users/{id}', function ($account, $id) { // });});

注意,组的任何参数都作为第一个参数传递给组内路由的方法。

名称前缀

路由名称通常会反映路径元素的继承链,因此users/comments/5将由名为users.comments.show的路由提供服务。在这种情况下,通常在所有属于users.comments资源下的路由周围使用路由组。

就像我们可以为 URL 段添加前缀一样,我们也可以为路由名称添加前缀字符串。使用路由组名称前缀,我们可以定义该组内的每个路由名称都应该以给定的字符串前缀"users."开头,然后是"comments."(参见示例3-16)。

示例 3-16. 路由组名称前缀
Route::name('users.')->prefix('users')->group(function () { Route::name('comments.')->prefix('comments')->group(function () { Route::get('{id}', function () { // ... })->name('show'); // Route named 'users.comments.show' Route::destroy('{id}', function () {})->name('destroy'); });});

路由组控制器

当您对由同一控制器提供服务的路由进行分组时,例如我们显示、编辑和删除用户时,可以使用路由组的controller()方法,如示例3-17 所示,避免为每个路由定义完整的元组。

示例 3-17. 路由组控制器
use App\Http\Controllers\UserController;Route::controller(UserController::class)->group(function () { Route::get('/', 'index'); Route::get('{id}', 'show');});

回退路由

在 Laravel 中,您可以定义一个“回退路由”(需要在路由文件末尾定义),以捕获所有未匹配的请求:

Route::fallback(function () { //});

许多应用程序定期发送关于一次性操作的通知(如重置密码、接受邀请等),并提供简单的链接执行这些操作。让我们想象发送一封电子邮件,确认收件人愿意加入邮件列表。

有三种方式发送该链接:

  • 将该 URL 公开,并希望没有其他人发现批准 URL 或修改自己的批准 URL 以批准其他人。

  • 将操作放在身份验证后,链接到操作,并要求用户在尚未登录的情况下登录(在这种情况下,许多邮件列表接收者可能不会是具有帐户的用户,因此可能不可能登录)。

  • “签名”链接,以唯一证明用户从您的电子邮件收到了链接,而无需登录——类似于http://myapp.com/invitations/5816/yes?signature=030ab0ef6a8237bd86a8b8

实现最后一种选项的一种简单方法是使用称为 signed URLs 的功能,它使得为发送验证链接的人员构建签名身份验证系统变得简单。这些链接由正常路由链接组成,附加一个“签名”,证明自链接发送以来未更改该 URL(因此没有人修改了 URL 以访问他人的信息)。

签名路由

要构建一个签名 URL 来访问给定路由,该路由必须有一个名称:

Route::get('invitations/{invitation}/{answer}', InvitationController::class) ->name('invitations');

要生成到此路由的普通链接,您可以使用我们已经介绍的 route() 辅助函数,但您也可以使用 URL 外观执行相同的操作:URL::route('invitations', ['invitation' => 12345, 'answer' => 'yes'])。要生成带有 signed 的链接到此路由,只需使用 signedRoute() 方法。如果您想生成带有过期时间的签名路由,请使用 temporarySignedRoute()

// Generate a normal linkURL::route('invitations', ['invitation' => 12345, 'answer' => 'yes']);// Generate a signed linkURL::signedRoute('invitations', ['invitation' => 12345, 'answer' => 'yes']);// Generate an expiring (temporary) signed linkURL::temporarySignedRoute( 'invitations', now()->addHours(4), ['invitation' => 12345, 'answer' => 'yes']);

Laravel 提供了 now() 辅助函数,相当于 Carbon::now();它返回一个代表当前时间的 Carbon 对象。

Carbon 是 Laravel 自带的日期时间库。

修改路由以允许签名链接

现在您已经生成了到您的签名路由的链接,您需要保护免受任何未签名的访问。最简单的选择是应用 signed 中间件:

Route::get('invitations/{invitation}/{answer}', InvitationController::class) ->name('invitations') ->middleware('signed');

如果您愿意,您可以手动验证使用 Request 对象上的 hasValidSignature() 方法,而不是使用 signed 中间件:

class InvitationController{ public function __invoke(Invitation $invitation, $answer, Request $request) { if (! $request->hasValidSignature()) { abort(403); } // }}

在我们之前查看的一些路由闭包中,我们看到类似 return view('account') 的代码。这里发生了什么?

在 MVC 模式中(见图3-1),视图(或模板)是描述特定输出应该如何看起来的文件。您可能有输出 JSON、XML 或电子邮件的视图,但在 Web 框架中,最常见的视图输出是 HTML。

在 Laravel 中,您可以使用两种视图格式:纯 PHP 和 Blade 模板(参见第四章)。区别在于文件名:about.php 将使用 PHP 引擎呈现,而 about.blade.php 将使用 Blade 引擎呈现。

有三种方法可以返回视图。目前只需要关注 view(),但如果你看到 View::make(),它是一样的,或者你可以注入 Illuminate\View\ViewFactory 如果你更喜欢。

一旦您使用 view() 辅助函数“加载”视图,您可以选择简单地返回它(如示例3-18),如果视图不依赖于控制器中的任何变量,这将运行良好。

示例 3-18. 简单的 view() 使用方法
Route::get('/', function () { return view('home');});

此代码查找 resources/views/home.blade.phpresources/views/home.php 中的视图,并加载其内容并解析任何内联 PHP 或控制结构,直到只剩下视图的输出。一旦返回它,它将传递给响应堆栈的其余部分,并最终返回给用户。

但是如果需要传入变量怎么办?看一下示例3-19。

示例 3-19. 将变量传递给视图
Route::get('tasks', function () { return view('tasks.index') ->with('tasks', Task::all());});

此闭包加载 resources/views/tasks/index.blade.phpresources/views/tasks/index.php 视图,并传递一个名为 tasks 的单一变量,其中包含 Task::all() 方法的结果。 Task::all() 是您将在第五章学习的 Eloquent 数据库查询。

使用 Route::view() 直接返回简单路由

因为路由只返回视图而不传递自定义数据非常常见,所以 Laravel 允许您将路由定义为“视图”路由,甚至不传递闭包或控制器/方法引用,正如您可以在示例3-20 中看到的那样。

示例 3-20. Route::view()
// Returns resources/views/welcome.blade.phpRoute::view('/', 'welcome');// Passing simple data to Route::view()Route::view('/', 'welcome', ['User' => 'Michael']);

使用视图组合器与每个视图共享变量

有时反复传递相同变量可能会变得很麻烦。可能有一个您希望每个网站视图或某个视图类或某个包含的子视图都可以访问的变量,例如与任务相关的所有视图或页眉部分。

可以与每个模板或仅某些模板共享某些变量,如以下代码所示:

view()->share('variableName', 'variableValue');

想了解更多,请参阅“视图组合器和服务注入”。

我已经多次提到控制器,但直到现在,大多数示例都展示了路由闭包。在 MVC 模式中,控制器本质上是组织一个或多个路由逻辑的类,集中在一个地方。控制器倾向于将类似的路由组合在一起,特别是如果您的应用程序结构类似于传统的 CRUD 格式;在这种情况下,控制器可能处理与特定资源相关的所有操作。

CRUD 是 创建读取更新删除 的缩写,这是 Web 应用程序对资源提供的四种主要操作。例如,您可以创建新的博客文章,可以阅读该文章,可以更新它,或者可以删除它。

或许把应用程序所有的逻辑都塞进控制器中是很诱人的,但是将控制器视为路由 HTTP 请求在应用程序中导航的交通警察会更好。由于请求可以通过其他方式进入应用程序——如定时任务、Artisan 命令行调用、队列作业等——因此不要依赖控制器处理太多行为是明智的。这意味着控制器的主要工作是捕获 HTTP 请求的意图并将其传递给应用程序的其他部分。

所以,让我们创建一个控制器。一个简单的方法是使用 Artisan 命令,在命令行中运行以下命令:

php artisan make:controller TaskController

Laravel 捆绑了一个称为 Artisan 的命令行工具。 Artisan 可用于运行迁移,手动创建用户和其他数据库记录,并执行许多其他一次性手动任务。

make命名空间下,Artisan 提供了生成各种系统文件骨架文件的工具。这就是我们能够运行php artisan make:controller的原因。

要了解更多关于这个和其他 Artisan 功能的信息,请参阅第八章。

这将在app/Http/Controllers目录下创建一个名为TaskController.php的新文件,并显示示例 3-21 中的内容。

示例 3-21. 默认生成的控制器
<?phpnamespace App\Http\Controllers;use Illuminate\Http\Request;class TaskController extends Controller{ //}

修改这个文件,如示例 3-22 所示,创建一个名为index()的新公共方法。我们将在那里返回一些文本。

示例 3-22. 简单的控制器示例
<?phpnamespace App\Http\Controllers;class TaskController extends Controller{ public function index() { return 'Hello, World!'; }}

然后,就像我们之前学到的那样,我们将把一个路由链接到它,如示例 3-23 所示。

示例 3-23. 简单控制器的路由
// routes/web.php<?phpuse Illuminate\Support\Facades\Route;use App\Http\Controllers\TaskController;Route::get('/', [TaskController::class, 'index']);

就这样。访问/路由,你将看到“Hello, World!”这几个字。

控制器方法最常见的用法之一将是像示例 3-24 那样,它提供了与我们在示例 3-19 中路由闭包相同的功能。

示例 3-24. 常见的控制器方法示例
// TaskController.php...public function index(){ return view('tasks.index') ->with('tasks', Task::all());}

这个控制器方法加载resources/views/tasks/index.blade.phpresources/views/tasks/index.php视图,并传递一个名为tasks的单个变量,其中包含Task::all() Eloquent 方法的结果。

如果你想要创建一个资源控制器,并为所有基本的资源路由(如create()update())生成自动生成的方法,你可以在使用php artisan make:controller时传递--resource标志:

php artisan make:controller TaskController --resource

获取用户输入

控制器方法中执行的第二个最常见操作是从用户获取输入并对其进行操作。这引入了一些新概念,所以让我们看一些示例代码,并逐步了解新的内容。

首先,让我们绑定我们的路由;参见示例 3-25。

示例 3-25. 绑定基本表单操作
// routes/web.phpRoute::get('tasks/create', [TaskController::class, 'create']);Route::post('tasks', [TaskController::class, 'store']);

注意我们正在绑定tasks/createGET动作(显示创建新任务的表单)和tasksPOST动作(我们创建新任务时将POST到的地方)。我们可以假设我们控制器中的create()方法只是显示一个表单,所以让我们看看示例 3-26 中的store()方法。

示例 3-26. 常见的表单输入控制器方法
// TaskController.php...public function store(){ Task::create(request()->only(['title', 'description'])); return redirect('tasks');}

这个示例使用了 Eloquent 模型和redirect()功能,稍后我们会详细讲解它们,但现在让我们快速看看我们如何在这里获取我们的数据。

我们使用request()助手来表示 HTTP 请求(稍后详细介绍),并使用它的only()方法来提取用户提交的titledescription字段。

然后,我们将这些数据传递给我们的Task模型的create()方法,该方法使用传入的标题设置一个新的Task实例,并将传入的描述设置为description。最后,我们重定向回显示所有任务的页面。

这里有几层抽象在起作用,我们稍后会详细介绍,但需要知道来自 only() 方法的数据来自于 Request 对象上所有常用方法使用的同一组数据池,包括 all()get()。每个方法提取的数据集代表了所有用户提供的数据,无论是来自查询参数还是 POST 值。所以,我们的用户在“添加任务”页面上填写了两个字段:“标题”和“描述”。

简单解释一下,request()->only()接受一个输入名称的关联数组并返回它们:

request()->only(['title', 'description']);// returns:[ 'title' => 'Whatever title the user typed on the previous page', 'description' => 'Whatever description the user typed on the previous page',]

Task::create() 接受一个关联数组并根据此数组创建一个新任务:

Task::create([ 'title' => 'Buy milk', 'description' => 'Remember to check the expiration date this time, Norbert!',]);

结合它们一起,只需用户提供的“标题”和“描述”字段就可以创建一个任务。

将依赖项注入到控制器中

Laravel 的外观和全局助手为 Laravel 代码库中最有用的类提供了一个简单的接口。你可以获取关于当前请求和用户输入、会话、缓存等的信息。

但是如果你更喜欢注入你的依赖项,或者想使用一个没有外观或助手的服务,你需要找到一些方法将这些类的实例引入到你的控制器中。

这是我们第一次接触 Laravel 的服务容器。如果现在这个概念还不熟悉,可以把它想象成是一点点 Laravel 的魔法;或者,如果你想更深入地了解它的实际运作方式,可以跳到第十一章。

所有控制器方法(包括构造函数)都是通过 Laravel 的容器解析出来的,这意味着任何你在容器中能够解析的类型提示将被自动注入。

PHP 中的类型提示 意味着在方法签名中的变量前放置类或接口的名称:

public function __construct(Logger $logger) {}

这个类型提示告诉 PHP,传递到方法中的任何东西 必须Logger 类型,这可以是接口或类。

作为一个好的示例,如果你更喜欢使用 Request 对象的实例而不是使用全局助手,只需在你的方法参数中进行类型提示 Illuminate\Http\Request,就像在示例3-27 中一样。

示例 3-27. 通过类型提示进行控制器方法注入
// TaskController.php...public function store(\Illuminate\Http\Request $request){ Task::create($request->only(['title', 'description'])); return redirect('tasks');}

所以,你定义了一个必须传递到 store() 方法中的参数。由于你进行了类型提示,并且由于 Laravel 知道如何解析该类名,你将在方法中直接得到 Request 对象,而不需要额外工作。没有显式绑定,没有其他任何东西——它只是作为 $request 变量存在。

而且,通过比较示例 3-26 和 3-27,可以看出 request() 助手和 Request 对象的行为完全一样。

资源控制器

有时候,在编写控制器时,为控制器中的方法命名可能是最困难的部分。幸运的是,Laravel 对传统的 REST/CRUD 控制器(在 Laravel 中称为 资源控制器)有一些约定,此外,它还提供了一个默认生成器和一个便捷的路由定义,允许你一次性绑定整个资源控制器。

要查看 Laravel 期望资源控制器的方法,请从命令行生成一个新的控制器:

php artisan make:controller MySampleResourceController --resource

现在打开 app/Http/Controllers/MySampleResourceController.php。你会看到它已经预先填充了许多方法。让我们逐一看看每个方法代表什么。我们将以一个Task为例。

Laravel 资源控制器的方法

还记得前面的表格吗?Table3-1 显示了每个默认生成的方法的 HTTP 动词、URL、控制器方法名以及名称。

绑定资源控制器

所以,我们已经看到这些是在 Laravel 中使用的传统路由名称,也看到可以轻松生成一个带有每个默认路由方法的资源控制器。幸运的是,如果你不想手动为每个控制器方法生成路由,也不必如此。有一个名为 资源控制器绑定 的技巧,看看 Example3-28。

示例 3-28. 资源控制器绑定
// routes/web.phpRoute::resource('tasks', TaskController::class);

这将自动将资源中列出的所有路由绑定到指定控制器上相应的方法名。它还会适当地命名这些路由;例如,tasks资源控制器上的index()方法将被命名为tasks.index

如果你发现自己想知道当前应用程序有哪些可用路由,请使用工具来解决这个问题:从命令行运行 php artisan route:list,你将得到所有可用路由的列表。我更喜欢 php artisan route:list --exclude-vendor,这样我就不会看到所有我依赖项注册的怪异路由(参见图 3-2)。

Laravel-启动指南-全- - 绝不原创的飞龙 - 博客园 (3)

图 3-2. artisan route:list

API 资源控制器

当你使用 RESTful API 时,资源的潜在操作列表与 HTML 资源控制器不同。例如,你可以向 API 发送 POST 请求来创建资源,但在 API 中你不能真正“显示创建表单”。

要生成一个 API 资源控制器,它的结构与资源控制器相同,但不包括 createedit 操作,请在创建控制器时传递 --api 标志:

php artisan make:controller MySampleResourceController --api

要绑定一个 API 资源控制器,请使用 apiResource() 方法,而不是 resource() 方法,如 Example3-29 所示。

示例 3-29. API 资源控制器绑定
// routes/web.phpRoute::apiResource('tasks', TaskController::class);

单一操作控制器

在你的应用程序中会有时候需要一个控制器只服务一个路由。你可能会想知道如何为该路由命名控制器方法。幸运的是,你可以将单个路由指向单个控制器,而无需担心命名该方法。

正如你可能已经知道的那样,__invoke() 方法是 PHP 的一个魔术方法,允许你“调用”类的实例,像调用函数一样调用它。这是 Laravel 的 单操作控制器 使用的工具,允许你将路由指向单个控制器,正如你可以在 示例3-30 中看到的那样。

示例 3-30. 使用 __invoke() 方法
// \App\Http\Controllers\UpdateUserAvatar.phppublic function __invoke(User $user){ // Update the user's avatar image}// routes/web.phpRoute::post('users/{user}/update-avatar', UpdateUserAvatar::class);

最常见的路由模式之一是任何控制器方法的第一行尝试查找具有给定 ID 的资源,例如 示例3-31。

示例 3-31. 为每个路由获取资源
Route::get('conferences/{id}', function ($id) { $conference = Conference::findOrFail($id);});

Laravel 提供了一个简化此模式的功能称为 路由模型绑定。这允许你定义一个特定的参数名称(例如 {conference}),表示路由解析器应该查找具有该 ID 的 Eloquent 数据库记录,然后将其作为参数传递给闭包或控制器方法 而不是 仅仅传递 ID。

有两种类型的路由模型绑定:隐式和自定义(或显式)。

隐式路由模型绑定

使用路由模型绑定的最简单方法是将路由参数命名为该模型独有的名称(例如,将其命名为 $conference 而不是 $id),然后在闭包/控制器方法中对该参数进行类型提示并在那里使用相同的变量名。展示起来比描述容易,因此请查看 示例3-32。

示例 3-32. 使用隐式路由模型绑定
Route::get('conferences/{conference}', function (Conference $conference) { return view('conferences.show')->with('conference', $conference);});

因为路由参数({conference})与方法参数($conference)相同,并且方法参数是用 Conference 模型进行类型提示的(Conference $conference),Laravel 将其视为路由模型绑定。每次访问此路由时,应用程序将假定传入 URL 中的任何内容代替 {conference} 都是一个 ID,应该用于查找 Conference,然后将生成的模型实例传递给你的闭包或控制器方法。

每当通过 URL 段查找 Eloquent 模型(通常是因为路由模型绑定),Eloquent 将使用其主键(ID)进行查找。

要更改你的 Eloquent 模型在所有路由中用于 URL 查找的列,请在模型中添加一个名为 getRouteKeyName() 的方法:

public function getRouteKeyName(){ return 'slug';}

现在,像 conferences/{conference} 这样的 URL 将期望从 slug 列获取条目而不是 ID,并且将根据其进行查找。

在 Laravel 中,你还可以通过在路由定义中追加冒号和列名来在特定路由上更改路由键:

Route::get( 'conferences/{conference:slug}', function (Conference $conference) { return view('conferences.show') ->with('conference', $conference); });

如果你的 URL 中有两个动态段(例如:organizers/{organizer}/conferences/{conference:slug}),Laravel 将自动尝试将第二个模型的查询范围限定为仅与第一个相关联的内容。因此,它将检查 Organizer 模型是否具有 conferences 关系,如果存在,则只返回与第一个段找到的 Organizer 相关联的 Conferences。

use App\Models\Conference;use App\Models\Organizer;Route::get( 'organizers/{organizer}/conferences/{conference:slug}', function (Organizer $organizer, Conference $conference) { return $conference; });

自定义路由模型绑定

要手动配置路由模型绑定,请在 App\Providers\RouteServiceProviderboot() 方法中添加类似 Example3-33 的行。

示例 3-33. 添加路由模型绑定
 public function boot() { // Perform the binding Route::model('event', Conference::class); }

现在你指定了当路由定义中有一个名为 {event} 的参数时(例如 Example3-34 中演示的),路由解析器将返回一个带有该 URL 参数 ID 的 Conference 类的实例。

示例 3-34. 使用显式路由模型绑定
Route::get('events/{event}', function (Conference $event) { return view('events.show')->with('event', $event);});

如果你想要尽可能减少加载时间,你可能需要关注 路由缓存。 Laravel 启动过程中的一个步骤是解析 routes/* 文件,可能需要几十到几百毫秒,而路由缓存可以极大加快这一过程。

要缓存你的路由文件,你需要使用所有控制器、重定向、视图和资源路由(不使用路由闭包)。如果你的应用程序不使用任何路由闭包,你可以运行 php artisan route:cache,Laravel 将序列化你的 routes/* 文件的结果。如果想删除缓存,请运行 php artisan route:clear

这里的问题是:Laravel 现在将匹配路由与缓存文件而不是实际的 routes/* 文件。你可以对路由文件进行无限更改,但在再次运行 route:cache 之前,这些更改不会生效。这意味着每次更改都需要重新缓存,这可能会带来很多混乱的潜力。

我建议的替代方案是:由于 Git 默认会忽略路由缓存文件,因此考虑仅在生产服务器上使用路由缓存,并在每次部署新代码时运行 php artisan route:cache 命令(无论是通过 Git 的后处理挂钩、Forge 部署命令还是其他部署系统的一部分)。这样,你不会在本地开发时遇到混乱的问题,但远程环境仍然可以从路由缓存中受益。

有时你需要手动定义表单应发送的 HTTP 动词。HTML 表单只允许 GETPOST,所以如果你想使用其他动词,就需要自行指定。

Laravel 中的 HTTP 动词

正如我们已经看到的,你可以使用 Route::get()Route::post()Route::any()Route::match() 来定义路由匹配的动词。你还可以使用 Route::patch()Route::put()Route::delete() 进行匹配。

但是如何通过 Web 浏览器发送除了 GET 之外的请求呢?首先,HTML 表单中的 method 属性决定了它的 HTTP 动词:如果你的表单的 method"GET",它将通过查询参数和 GET 方法提交;如果表单的 method"POST",它将通过 post body 和 POST 方法提交。

JavaScript 框架使得发送其他请求变得容易,比如 DELETEPATCH。但是如果你发现自己需要在 Laravel 中提交除了 GETPOST 之外的动词的 HTML 表单,你需要使用 表单方法伪造(form method spoofing),这意味着在 HTML 表单中伪造 HTTP 方法。

HTML 表单中的 HTTP 方法伪造

要告诉 Laravel 当前提交的表单应被视为非 POST 方法的请求,需添加一个名为 _method 的隐藏变量,并赋值为 "PUT", "PATCH""DELETE",Laravel 将会根据该动词匹配和路由该表单提交。

示例 3-35 中的表单,因为它传递了方法 "DELETE" 给 Laravel,将匹配使用 Route::delete() 定义的路由,但不会匹配使用 Route::post() 定义的路由。

示例 3-35. 表单方法伪造
<form action="/tasks/5" method="POST"> <input type="hidden" name="_method" value="DELETE"> <!-- or: --> @method('DELETE')</form>

如果你已经尝试在 Laravel 应用中提交表单,包括 示例 3-35 中的表单,你很可能遇到了可怕的 TokenMismatchException

在 Laravel 中,默认情况下,除了“只读”路由(使用 GETHEADOPTIONS)之外的所有路由都受到跨站请求伪造(CSRF)攻击的保护,要求在每个请求中传递一个名为 _token 的输入。此 token 在每个会话开始时生成,并且每个非“只读”路由会将提交的 _token 与会话 token 进行比较。

跨站请求伪造 是指一个网站假装成另一个网站。其目的是通过在已登录用户的浏览器中从 他们 的网站提交到 的网站上的表单,来劫持你用户对你网站的访问权限。

避免 CSRF 攻击的最佳方法是默认保护所有入站路由——POSTDELETE 等——通过一个 token,Laravel 已经内置支持。

你有两种方法可以解决 CSRF 错误。第一种,也是首选的方法,是在每个提交中添加 _token 输入。在 HTML 表单中,有一种简单的方法,如 示例 3-36 中所示。

示例 3-36. CSRF tokens
<form action="/tasks/5" method="POST"> @csrf</form>

在 JavaScript 应用中,需要更多的工作,但并不复杂。对于使用 JavaScript 框架的站点,最常见的解决方案是在每个页面的 <meta> 标签中存储 token,例如:

<meta name="csrf-token" content="<?php echo csrf_token(); ?>">

将 token 存储在 <meta> 标签中使得可以轻松地绑定到正确的 HTTP 头部,你可以像在 示例 3-37 中一样,全局为你的 JavaScript 框架的所有请求绑定它。

示例 3-37. 全局绑定 CSRF 的头部
// In jQuery:$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') }});// With Axios: it automatically retrieves it from a cookie. Nothing to do!

Laravel 将在每个请求中检查 X-CSRF-TOKEN(以及 Axios 和其他 JavaScript 框架如 Angular 使用的 X-XSRF-TOKEN),并且传递有效的令牌将标记 CSRF 保护为满足状态。

将 CSRF 令牌引导到 Vue Resource 看起来与 Laravel 的方式有所不同;查看 Vue Resource 文档 获取示例。

到目前为止,我们明确讨论过从控制器方法或路由定义中返回的仅仅是视图。但是还有一些其他结构可以返回,以便向浏览器提供行为指令。

首先,让我们来讨论 重定向。你在其他示例中已经看到了一些这样的示例。生成重定向有两种常见的方法;我们将在这里使用 redirect() 全局辅助方法,但你可能更喜欢门面。两者都会创建一个 Illuminate\Http\RedirectResponse 实例,对其执行一些便捷方法,然后返回它。你也可以手动执行这些操作,但你将需要做更多的工作。看看 示例 3-38,你可以看到几种返回重定向的方法。

示例 3-38. 返回重定向的不同方式
// Using the global helper to generate a redirect responseRoute::get('redirect-with-helper', function () { return redirect()->to('login');});// Using the global helper shortcutRoute::get('redirect-with-helper-shortcut', function () { return redirect('login');});// Using the facade to generate a redirect responseRoute::get('redirect-with-facade', function () { return Redirect::to('login');});// Using the Route::redirect shortcutRoute::redirect('redirect-by-route', 'login');

请注意,redirect() 辅助方法暴露了与 Redirect 门面相同的方法,但它还有一个快捷方式;如果你直接将参数传递给辅助方法,而不是在其后链接方法,那么这是 to() 重定向方法的快捷方式。

还要注意,Route::redirect() 路由辅助方法的(可选的)第三个参数可以是重定向的状态码(例如,302)。

redirect()->to()

用于重定向的 to() 方法的方法签名如下:

function to($to = null, $status = 302, $headers = [], $secure = null)

$to 是有效的内部路径,$status 是 HTTP 状态(默认为 302),$headers 允许你定义随重定向发送的 HTTP 头,$secure 允许你覆盖默认的 httphttps 选择(通常基于当前请求 URL 设置)。示例 3-39 展示了其使用示例。

示例 3-39. redirect()->to()
Route::get('redirect', function () { return redirect()->to('home'); // Or same, using the shortcut: return redirect('home');});

redirect()->route()

route() 方法与 to() 方法相同,但不是指向特定路径,而是指向特定路由名称(参见 示例 3-40)。

示例 3-40. redirect()->route()
Route::get('redirect', function () { return redirect()->route('conferences.index');});

请注意,由于某些路由名称需要参数,其参数顺序有点不同。route() 方法有一个可选的第二个参数用于路由参数:

function route($to = null, $parameters = [], $status = 302, $headers = [])

因此,使用它可能看起来有点像 示例 3-41。

示例 3-41. redirect()->route() 带有参数
Route::get('redirect', function () { return to_route('conferences.show', [ 'conference' => 99, ];});

你可以使用 to_route() 辅助方法作为 redirect()->route() 方法的别名。它们的签名都是一样的:

Route::get('redirect', function () { return to_route('conferences.show', ['conference' => 99]);});

redirect()->back()

由于 Laravel 会话实现的一些内置便利性,您的应用程序始终知道用户之前访问的页面是什么。这就开启了redirect()->back()重定向的机会,它简单地将用户重定向到他们来自的页面。这也有一个全局快捷方式:back()

其他重定向方法

重定向服务提供了其他一些较少使用但仍可用的方法:

refresh()

重定向到用户当前正在访问的同一页面。

away()

允许重定向到外部 URL,而不进行默认 URL 验证。

secure()

to()一样,secure参数设置为"true"

action()

允许您以两种方式之一链接到控制器和方法:作为字符串(redirect()->action('MyController@myMethod'))或作为元组(r⁠e⁠d⁠i⁠r⁠e⁠c⁠t⁠(⁠)​\-⁠>⁠a⁠c⁠t⁠i⁠o⁠n⁠([MyController::class, 'myMethod']))。

guest()

在认证系统内部使用(在第九章中讨论);当用户访问他们未经身份验证的路由时,这会捕获“预期”路由,然后重定向用户(通常是到登录页面)。

intended()

也在认证系统内部使用;成功认证后,它会获取guest()方法存储的“预期”URL,并将用户重定向到那里。

redirect()->with()

虽然它的结构与您在redirect()上调用的其他方法类似,但with()不同之处在于它不定义您要重定向到哪里,而是定义您要传递的数据。当您将用户重定向到不同页面时,通常希望将某些数据传递给他们。您可以手动将数据闪存到会话中,但 Laravel 提供了一些便利方法来帮助您完成这些操作。

最常见的是,您可以使用with()传递键和值的数组或单个键和值,就像示例3-42 中的一样。这会将您的with()数据保存到会话中,只用于下一次页面加载。

示例 3-42. 带数据的重定向
Route::get('redirect-with-key-value', function () { return redirect('dashboard') ->with('error', true);});Route::get('redirect-with-array', function () { return redirect('dashboard') ->with(['error' => true, 'message' => 'Whoops!']);});

与许多其他门面一样,Redirect门面的大多数调用可以接受流畅的方法链,就像示例3-42 中的with()调用一样。您将在“什么是流畅接口?”中了解更多信息。

您还可以使用withInput(),如示例3-43 中所示,以闪存用户的表单输入重定向;这在验证错误的情况下最常见,您希望将用户发送回他们刚刚来自的表单。

示例 3-43. 带表单输入的重定向
Route::get('form', function () { return view('form');});Route::post('form', function () { return redirect('form') ->withInput() ->with(['error' => true, 'message' => 'Whoops!']);});

获取通过withInput()传递的闪存输入的最简单方法是使用old()辅助函数,它可以用于获取所有旧输入(old())或只是特定键的值,如下例所示,如果没有旧值,则第二个参数作为默认值。你通常会在视图中看到这一点,这使得这段 HTML 可以在此表单的“创建”和“编辑”视图中通用:

<input name="username" value="<?= old('username', 'Default username instructions here');?>">

谈到验证,还有一种有用的方法可以将错误与重定向响应一起传递:withErrors()。你可以传递任何“错误提供者”,它可以是一个错误字符串,一个错误数组,或者,最常见的情况是,Illuminate Validator 的一个实例,我们将在第十章中详细介绍。示例3-44 展示了其使用示例。

示例 3-44. 带错误的重定向
Route::post('form', function (Illuminate\Http\Request $request) { $validator = Validator::make($request->all(), $this->validationRules); if ($validator->fails()) { return back() ->withErrors($validator) ->withInput(); }});

withErrors()会自动与它重定向到的页面的视图共享一个$errors变量,以便你可以按照自己的意愿处理。

不喜欢示例3-44 的外观?有一个简单而强大的工具,可以帮助你轻松清理代码。在“请求对象上的 validate()”中详细了解更多。

除了返回视图和重定向之外,退出路由的最常见方式是中止。有几种全局可用的方法(abort()abort_if()abort_unless()),可以选择使用 HTTP 状态码、消息和头数组作为参数。

如示例 3-45 所示,abort_if()abort_unless()接受一个首要参数,该参数根据其真实性进行评估,并根据结果执行中止。

示例 3-45. 403 Forbidden 中止
Route::post('something-you-cant-do', function (Illuminate\Http\Request $request) { abort(403, 'You cannot do that!'); abort_unless($request->has('magicToken'), 403); abort_if($request->user()->isBanned, 403);});

对于我们返回的几种其他选项,让我们先了解一下最常见的视图、重定向和中止响应之后的响应。与重定向一样,你可以在response()辅助函数或Response外观上运行这些方法。

response()->make()

如果你想手动创建 HTTP 响应,只需将你的数据传递给response()->make()的第一个参数:例如 return response()->make(*Hello, World!*)。再次提醒,第二个参数是 HTTP 状态代码,第三个是你的头部。

response()->json()和->jsonp()

要手动创建 JSON 编码的 HTTP 响应,请将你的可 JSON 化内容(数组、集合或其他内容)传递给json()方法:例如 return response()->json(User::all())。它与make()类似,只是json_encode了你的内容并设置了适当的头。

response()->download()、->streamDownload()和->file()

要发送文件供最终用户下载,请将download()传递给SplFileInfo实例或字符串文件名,第二个可选参数是下载文件名:例如,return response()->download('file501751.pdf', 'myFile.pdf'),这将发送名为file501751.pdf的文件,并在发送时重命名为myFile.pdf

要在浏览器中显示相同的文件(如果是 PDF 或浏览器可以处理的图像或其他内容),请改用response()->file(),它接受与response->download()相同的参数。

如果您希望将外部服务的某些内容作为下载可用,而无需直接将其写入服务器磁盘,则可以使用response()->streamDownload()来流式下载。该方法期望的参数包括一个回调函数(回显一个字符串)、一个文件名,以及可选的头部数组;参见示例3-46。

示例 3-46. 从外部服务器进行流式下载
return response()->streamDownload(function () { echo DocumentService::file('myFile')->getContent();}, 'myFile.pdf');

在其他一些社区中,单元测试控制器方法的想法很常见,但在 Laravel(以及大多数 PHP 社区)中,通常依赖应用程序测试来测试路由的功能。

例如,要验证POST路由是否正常工作,我们可以编写类似于示例3-47 的测试。

示例 3-47. 编写简单的POST路由测试
// tests/Feature/AssignmentTest.phppublic function test_post_creates_new_assignment(){ $this->post('/assignments', [ 'title' => 'My great assignment', ]); $this->assertDatabaseHas('assignments', [ 'title' => 'My great assignment', ]);}

我们直接调用了控制器方法吗?没有。但我们确保了该路由的目标——接收POST并将其重要信息保存到数据库中——得到了实现。

您还可以使用类似的语法访问一个路由,并验证页面上是否显示了某些文本,或者点击某些按钮是否执行了某些操作(参见示例3-48)。

示例 3-48. 编写简单的GET路由测试
// AssignmentTest.phppublic function test_list_page_shows_all_assignments(){ $assignment = Assignment::create([ 'title' => 'My great assignment', ]); $this->get('/assignments') ->assertSee('My great assignment');}

Laravel 的路由定义在routes/web.phproutes/api.php中。您可以为每个路由定义预期的路径,哪些段是静态的,哪些是参数,哪些 HTTP 动词可以访问路由,以及如何解析它。您还可以将中间件附加到路由上,对它们进行分组,并为它们命名。

路由闭包或控制器方法返回的内容决定了 Laravel 如何响应用户。如果是字符串或视图,它会呈现给用户;如果是其他类型的数据,它会转换为 JSON 并呈现给用户;如果是重定向,它会强制进行重定向。

Laravel 提供了一系列工具和便利功能,用于简化常见的与路由相关的任务和结构。这些包括资源控制器、路由模型绑定和表单方法欺骗。

与大多数其他后端语言相比,PHP 实际上作为模板语言运行相对良好。但它也有其缺点,而且在代码中到处使用 <?php 简直丑陋不堪,因此你可以期待大多数现代框架提供一种模板语言。

Laravel 提供了一个名为Blade的自定义模板引擎,受.NET 的 Razor 引擎启发。它拥有简洁的语法、浅显的学习曲线、强大直观的继承模型以及易于扩展的特点。

要快速了解 Blade 的编写方式,请查看示例4-1。

示例 4-1 刀片示例
<h1>{{ $group->title }}</h1>{!! $group->heroImageHtml() !!}@forelse ($users as $user) • {{ $user->first_name }} {{ $user->last_name }}<br>@empty No users in this group.@endforelse

正如你所见,Blade 使用花括号来进行“echo”,并引入了一种约定:其自定义标签称为“指令”,以@作为前缀。你将使用指令来处理所有的控制结构,还可以用于继承以及任何想要添加的自定义功能。

Blade 的语法简洁明了,因此在核心上,与其他选择相比,使用起来更加愉悦和整洁。但是一旦在模板中需要复杂的任何内容——如嵌套继承、复杂条件或递归——Blade 便开始展现其真正的优势。就像最好的 Laravel 组件一样,它可以处理复杂的应用程序需求,使其变得简单易操作。

另外,由于所有 Blade 语法都会被编译成普通的 PHP 代码并进行缓存,因此它非常快速,并且如果需要的话,可以在 Blade 文件中使用原生 PHP。但我建议尽可能避免使用 PHP——通常,如果需要做任何 Blade 或自定义 Blade 指令无法实现的事情,那就不应该放在模板中。

与许多基于 Symfony 的框架不同,Laravel 默认不使用 Twig。但是如果你非常喜欢 Twig,可以使用TwigBridge 包,它可以轻松地在 Blade 之外使用 Twig。

正如在示例4-1 中所见,{{}} 用于包装你想要输出的 PHP 部分。 {{ `*$variable*` }} 类似于纯 PHP 中的 <?= $variable ?>

但有一个方面是不同的,你可能已经猜到了:Blade 默认使用 PHP 的htmlentities()来转义所有的输出,以保护用户免受恶意脚本的插入。这意味着 {{ `*$variable*` }} 在功能上等同于 <?= htmlentities(`*$variable*`) ?>。如果希望不转义地进行输出,请改用 {!!!!}

Blade 中的大多数控制结构都非常熟悉,很多直接回显相同 PHP 标签的名称和结构。

Blade 提供了一些便利的辅助函数,但总体来说,控制结构看起来比在 PHP 中更清晰。

条件语句

首先,让我们看一下允许逻辑的控制结构。

@if

Blade 的 @if (*$condition*) 编译为 <?php if (*$condition*): ?>@else@elseif@endif 也编译成 PHP 中完全相同的语法样式。查看 示例4-2 获取一些示例。

示例 4-2. @if@else@elseif@endif
@if (count($talks) === 1) There is one talk at this time period.@elseif (count($talks) === 0) There are no talks at this time period.@else There are {{ count($talks) }} talks at this time period.@endif

就像原生 PHP 条件语句一样,你可以随意混合和匹配这些。它们没有任何特殊逻辑;字面上有一个解析器寻找 @if (`*$condition*`) 的形状,并用适当的 PHP 代码替换它。

@unless@endunless

@unless,另一种新语法,PHP 中没有直接的等价物。它是 @if 的直接反义词。@unless (*$condition*) 等同于 <?php if (! *$condition*): ?>。你可以在 示例4-3 中看到它的使用。

示例 4-3. @unless@endunless
@unless ($user->hasPaid()) You can complete your payment by switching to the payment tab.@endunless

循环

接下来,让我们看看循环。

@for@foreach@while

@for@foreach@while 在 Blade 中与 PHP 中的工作方式相同;参见示例 4-4、4-5 和 4-6。

示例 4-4. @for@endfor
@for ($i = 0; $i < $talk->slotsCount(); $i++) The number is {{ $i }}<br>@endfor
示例 4-5. @foreach@endforeach
@foreach ($talks as $talk) • {{ $talk->title }} ({{ $talk->length }} minutes)<br>@endforeach
示例 4-6. @while@endwhile
@while ($item = array_pop($items)) {{ $item->orSomething() }}<br>@endwhile

@forelse@endforelse

@forelse@foreach,如果你正在迭代的对象为空,还允许你编写回退。我们在本章开始时看到它的运作;示例4-7 展示了另一个例子。

示例 4-7. @forelse
@forelse ($talks as $talk) • {{ $talk->title }} ({{ $talk->length }} minutes)<br>@empty No talks this day.@endforelse

Blade 提供了一种模板继承结构,允许视图扩展、修改和包含其他视图。

让我们看看 Blade 如何结构化继承。

使用 @section/@show 和 @yield 定义区段

让我们从顶层 Blade 布局开始,就像在 示例4-8 中一样。这是一个通用页面包装器的定义,稍后我们将在其中放置特定页面内容。

示例 4-8. Blade 布局
<!-- resources/views/layouts/master.blade.php --><html> <head> <title>My Site | @yield('title', 'Home Page')</title> </head> <body> <div class="container"> @yield('content') </div> @section('footerScripts') <script src="app.js"></script> @show </body></html>

这看起来有点像普通的 HTML 页面,但你可以看到我们在两个地方 yieldedtitlecontent),并在第三个地方定义了 sectionfooterScripts)。这里有三个 Blade 指令:@yield('content') 单独使用,@yield('title', 'Home Page') 带有定义的默认值,以及带有实际内容的 @section/@show

尽管它们看起来各有些不同,这三者本质上是一样的。它们都定义了一个具有给定名称的区段(第一个参数),稍后可以扩展,并且都定义了如果未扩展该部分要执行的操作。它们可以通过提供字符串回退('Home Page')、无回退(如果未扩展,则什么也不显示)、或整个块回退(在这种情况下是 <script src="app.js"></script>)来做到这一点。

有什么不同?显然,@yield('content') 没有默认内容。但是另外,@yield('title') 中的默认内容只有在未被扩展时才会显示。如果已被扩展,其子部分将无法通过程序访问默认值。另一方面,@section/@show 不仅定义了一个默认值,而且以这种方式定义的默认内容将通过@parent提供给其子级。

一旦你有了像这样的父布局,你可以在新的模板文件中扩展它,就像在示例4-9 中一样。

示例 4-9. 扩展 Blade 布局
<!-- resources/views/dashboard.blade.php -->@extends('layouts.master')@section('title', 'Dashboard')@section('content') Welcome to your application dashboard!@endsection@section('footerScripts') @parent <script src="dashboard.js"></script>@endsection

你可能已经注意到,示例4-8 使用了@section/@show,但示例4-9 使用了@section/@endsection。它们有什么区别?

在父模板中定义一个部分的位置时,请使用@show。在子模板中定义模板的内容时,请使用@endsection

此子视图允许我们涵盖 Blade 继承中的一些新概念。

@extends

在示例4-9 中,通过@extends('layouts.master'),我们定义了这个视图不应单独呈现,而是应扩展另一个视图。这意味着它的角色是定义各个部分的内容,而不是独立存在。它几乎更像是一系列内容桶,而不是一个 HTML 页面。这行还定义了它扩展的视图位于resources/views/layouts/master.blade.php

每个文件应仅扩展另一个文件,并且@extends调用应为文件的第一行。

@section 和 @endsection

使用@section('title', '仪表板'),我们为第一个部分title提供了内容。由于内容很短,我们不是使用@section@endsection,而是直接使用了一个快捷方式。这允许我们将内容作为@section的第二个参数传递,然后继续进行。如果看到没有@endsection@section有点令人不安,可以使用常规语法。

对于@section('content')及其后续内容,我们使用常规语法来定义content部分的内容。现在我们只是简单地添加一个问候语。请注意,当在子视图中使用@section时,应使用@endsection(或其别名@stop),而不是保留用于在父视图中定义部分的@show

@parent

最后,使用@section('footerScripts')及其后续内容,我们使用常规语法来定义footerScripts部分的内容。

但请记住,我们实际上已经在主布局中定义了该内容(或者至少是它的“默认”)。所以这一次,我们有两个选择:我们可以覆盖父视图中的内容,或者我们可以添加到其中。

你可以看到,通过在部分内部使用@parent指令,我们可以选择包含父级的内容。如果没有这样做,此部分的内容将完全覆盖父级为该部分定义的任何内容。

包含视图部分

现在我们已经建立了继承的基础,我们可以执行一些更多的技巧。

@include

如果我们在一个视图中并且想要引入另一个视图,该怎么办?也许我们有一个“注册”按钮的呼叫到动作,我们想在整个站点重复使用它。也许我们想每次使用时自定义按钮文本。看看示例4-10。

示例 4-10. 使用@include包含视图局部文件
<!-- resources/views/home.blade.php --><div class="content" data-page-name="{{ $pageName }}"> <p>Here's why you should sign up for our app: <strong>It's Great.</strong></p> @include('sign-up-button', ['text' => 'See just how great it is'])</div><!-- resources/views/sign-up-button.blade.php --><a class="button button--callout" data-page-name="{{ $pageName }}"> <i class="exclamation-icon"></i> {{ $text }}</a>

@include引入局部文件,并可选择将数据传递给它。请注意,不仅可以通过@include的第二个参数显式地向包含传递数据,而且还可以引用包含文件中任何可用于包含视图的变量(例如本示例中的$pageName)。再次强调,您可以随心所欲地做任何操作,但是我建议您始终明确传递您打算使用的每个变量,以确保清晰性。

您还可以使用@includeIf@includeWhen@includeFirst指令,如示例4-11 所示。

示例 4-11. 有条件地包含视图
{{-- Include a view if it exists --}}@includeIf('sidebars.admin', ['some' => 'data']){{-- Include a view if a passed variable is truth-y --}}@includeWhen($user->isAdmin(), 'sidebars.admin', ['some' => 'data']){{-- Include the first view that exists from a given array of views --}}@includeFirst(['customs.header', 'header'], ['some' => 'data'])

@each

您可能会想象一些情况,您需要遍历数组或集合并@include每个项目的局部文件。有一个指令可以做到:@each

假设我们有一个由模块组成的侧边栏,并且我们想包含多个模块,每个模块都有不同的标题。看看示例4-12。

示例 4-12. 在循环中使用视图局部文件@each
<!-- resources/views/sidebar.blade.php --><div class="sidebar"> @each('partials.module', $modules, 'module', 'partials.empty-module')</div><!-- resources/views/partials/module.blade.php --><div class="sidebar-module"> <h1>{{ $module->title }}</h1></div><!-- resources/views/partials/empty-module.blade.php --><div class="sidebar-module"> No modules :(</div>

考虑@each语法。第一个参数是视图局部文件的名称。第二个是要迭代的数组或集合。第三个是每个项目的变量名(在这种情况下,是$modules数组中的每个元素),将作为视图传递给视图。第四个可选参数是在数组或集合为空时显示的视图(或者,您可以在此处传递一个字符串,该字符串将用作模板)。

使用组件

Laravel 还提供了另一种在视图之间包含内容的模式:组件。组件在您发现自己使用视图局部文件并将大块内容作为变量传递给它们的情况下最有意义。看看示例4-13,以查看一个模态框或弹出窗口的示例,可能会在响应错误或其他操作时向用户发出警告。

示例 4-13. 将模态框作为一个奇怪的视图局部文件
<!-- resources/views/partials/modal.blade.php --><div class="modal"> <h2>{{ $title }}</h2> <div>{!! $content !!}</div> <div class="close button etc">...</div></div><!-- in another template -->@include('partials.modal', [ 'title' => 'Insecure password', 'content' => '<p>The password you have provided is not valid. Here are the rules for valid passwords: [...]</p><p><a href="#">...</a></p>'])

这对于这些可怜的变量来说太多了,它正好适合成为一个组件。

Laravel 的组件是结构化视图局部文件的另一种方式,更接近于前端框架(如 Vue)中组件的工作方式。它们可能更熟悉于前端开发者,但与视图局部文件相比,它们也有一些显著的好处,包括更容易将大段的模板代码传递给它们。

看看示例4-14,看看如何使用组件重构示例4-13。

示例 4-14. 一个更合适的组件作为模态框
<!-- resources/views/components/modal.blade.php --><div class="modal"> <h2>{{ $title }}</h2> <div>{{ $slot }}</div> <div class="close button etc">...</div></div><!-- in another template --><x-modal title="Insecure password"> <p>The password you have provided is not valid. Here are the rules for valid passwords: [...]</p> <p><a href="#">...</a></p></x-modal>

正如您在示例4-14 中所看到的,组件允许我们将 HTML 从拥挤的变量字符串中提取出来,并重新回到模板空间。

让我们深入了解组件的更多功能、它们的结构以及我们如何编写它们。

创建组件

组件可以存在为纯粹的 Blade 模板(匿名组件),或者作为由 PHP 类支持的 Blade 模板,该类注入数据和功能(基于类的组件)。

如果您只需要模板,可以使用--view标志生成您的组件:

php artisan make:component modal --view

如果您还想生成 PHP 类,请排除该标志:

php artisan make:component modal

如果您希望将组件分组到文件夹中,可以使用.分隔符:

# To create it:php artisan make:component modals.cancellation
// To use it:<x-modals.cancellation />

将数据传递给组件

有四种方法可以将数据传递给组件:字符串属性、PHP 属性、默认插槽和命名插槽。

通过属性将数据传递给组件

让我们从属性开始。您可以通过不带前缀的属性直接将字符串传递给组件,或者您可以使用冒号前缀传递 PHP 变量和表达式,就像您在示例4-15 中看到的那样。

示例 4-15. 通过属性将数据传递给组件
<!-- Passing the data in --><x-modal title="Title here yay" :width="$width" />
<!-- Accessing the data in the template --><div style="width: {{ $width }}"> <h1>{{ $title }}</h1></div>

对于基于类的组件,您需要在 PHP 类中定义每个属性,并将其设置为类的公共属性,就像在示例4-16 中所示。

示例 4-16. 在组件类上将属性定义为公共属性
class Modal extends Component{ public function __construct( public string $title, public string $width, ) {}}

对于匿名组件,您需要在模板顶部的props数组中定义属性:

@props([ 'width', 'title',])<div style="width: {{ $width }}"> <h1>{{ $title }}</h1></div>

通过插槽将数据传递给组件

在示例4-14 中,您可能已经注意到模态框的内容被称为变量$slot。但是这是从哪里来的?

默认情况下,每个组件在引用时具有开放和关闭标记时都有一个$slot变量,并且它填充了这两个标记之间的所有 HTML。在示例4-14 中,$slot变量包含两个<p>标记及其之间的所有内容。

但是如果您需要两个或更多插槽怎么办?您可以添加不止一个默认插槽,为每个插槽分配自己的名称和变量。让我们重新设计示例4-14,假设我们想在一个插槽中定义标题;看看示例4-17。

示例 4-17. 定义多个插槽
<x-modal> <x-slot:title> <h2 class="uppercase">Password requirements not met</h2> </x-slot> <p>The password you have provided is not valid. Here are the rules for valid passwords: [...]</p> <p><a href="#">...</a></p></x-modal>

这个新的$slot变量的内容将作为$title变量在组件模板中可访问,就像之前的属性一样。

组件方法

有时,在组件中添加一个执行某些逻辑的辅助方法可能会有所帮助。一个常见的模式是将这些方法用于您希望保持模板之外的复杂逻辑检查。

组件允许您在模板中通过在方法名称前加上$来调用其关联的 PHP 类上的任何公共方法,就像您在示例4-18 中看到的那样。

示例 4-18. 定义和调用组件方法
// in the component definitionpublic function isPromoted($item){ return $item->promoted_at !== null && ! $item->promoted_at->isPast();}
<!-- in the template --><div> @if ($isPromoted($item)) <!-- show promoted badge --> @endif <!-- ... --></div>

属性收集袋

我们将传递给组件的大多数属性都将是命名的、具体的,并且类似于向 PHP 函数传递参数。

但有时我们只需传递一些松散的 HTML 属性,几乎总是为了将它们分配给模板的根元素。

使用组件,你可以一次性获取所有这些属性,使用$attributes变量。这个变量捕获所有未定义为属性的属性,并允许你输出它们(将其视为字符串)或者使用它的一些方法来获取或检查数据。

查看文档以了解与$attributes对象交互的所有方式,但这里有一个非常有用的技巧:

<!-- Merge default classes with passed-in classes --><!-- Definition --><div {{ $attributes->merge(['class' => 'p-4 m-4']) }}> {{ $message }}</div><!-- Usage --><x-notice class="text-blue-200"> Message here</x-notice><!-- Outputs: --><div class="p-4 m-4 text-blue-200"> Message here</div>

使用堆栈

一个常见的模式是使用基本的 Blade 包含时可能难以管理的情况,即每个 Blade 包含层次结构中的视图都需要向某个特定部分添加内容 —— 几乎就像向数组中添加条目一样。

这种情况最常见的情况是某些页面(有时更广泛地说是网站的某些部分)需要加载特定的、唯一的 CSS 和 JavaScript 文件。想象一下,你有一个全站“全局”CSS 文件,一个“职位部分”CSS 文件,和一个“申请职位”页面的 CSS 文件。

Blade 的堆栈专门为这种情况设计。在父模板中定义一个堆栈,这只是一个占位符。然后,在每个子模板中,你可以使用@push/@endpush将条目“推送”到堆栈中,在最终渲染时它们被添加到堆栈的底部。你也可以使用@prepend/@endprepend将它们添加到堆栈的顶部。例4-19 进行了说明。

例4-19. 使用 Blade 堆栈
<!-- resources/views/layouts/app.blade.php --><html><head> <link href="/css/global.css"> <!-- the placeholder where stack content will be placed --> @stack('styles')</head><body> <!-- // --></body></html><!-- resources/views/jobs.blade.php -->@extends('layouts.app')@push('styles') <!-- push something to the bottom of the stack --> <link href="/css/jobs.css">@endpush<!-- resources/views/jobs/apply.blade.php -->@extends('jobs')@prepend('styles') <!-- push something to the top of the stack --> <link href="/css/jobs--apply.css">@endprepend

这些生成以下结果:

<html><head> <link href="/css/global.css"> <!-- the placeholder where stack content will be placed --> <!-- push something to the top of the stack --> <link href="/css/jobs--apply.css"> <!-- push something to the bottom of the stack --> <link href="/css/jobs.css"></head><body> <!-- // --></body></html>

正如我们在第三章中介绍的那样,从路由定义向视图传递数据是很简单的(参见例4-20)。

例4-20. 如何向视图传递数据的提醒
Route::get('passing-data-to-views', function () { return view('dashboard') ->with('key', 'value');});

然而,有时你可能发现自己需要将相同的数据多次传递给多个视图。或者你可能发现自己使用了一个需要某些数据的页眉部分或类似部件;你是否需要从每个可能加载该页眉部分的路由定义中传递数据?

使用视图组合器将数据绑定到视图

幸运的是,有一种更简单的方法。解决方案称为视图组合器,它允许你定义,每次加载特定视图时,都应该将某些数据传递给它 —— 而不必显式从路由定义中传递该数据。

假设你在每个页面上都有一个侧边栏,在一个名为partials.sidebar的部分中定义(resources/views/partials/sidebar.blade.php),然后在每个页面上包含它。这个侧边栏显示了你网站上发布的最近七篇帖子的列表。如果它出现在每个页面上,每个路由定义通常都需要获取该列表并传递它,就像在例4-21 中一样。

示例 4-21. 从每个路由中传递侧边栏数据
Route::get('home', function () { return view('home') ->with('posts', Post::recent());});Route::get('about', function () { return view('about') ->with('posts', Post::recent());});

这可能很快就会变得很烦人。因此,我们将使用视图组合器来“共享”这个变量给一组预定的视图。我们可以通过几种方式来实现这一点,所以让我们从简单的方式开始,然后逐步提升。

全局共享变量

首先,最简单的选项:只需在你的应用程序的每个视图中全局“共享”一个变量,就像在示例4-22 中一样。

示例 4-22. 全局共享一个变量
// Some service providerpublic function boot(){ ... view()->share('recentPosts', Post::recent());}

如果你想使用view()->share(),最好的地方是服务提供者的boot()方法,这样绑定就会在每次页面加载时运行。你可以创建一个自定义的ViewComposerServiceProvider(详见“服务提供者”),但现在只需将其放在App\Providers\AppServiceProviderboot()方法中即可。

使用view()->share()使得该变量在整个应用程序的每个视图中都可以访问,但可能有些过头了。

使用闭包的视图作用域视图组合器

下一个选项是使用基于闭包的视图组合器来与单个视图共享变量,就像在示例4-23 中一样。

示例 4-23. 创建基于闭包的视图组合器
view()->composer('partials.sidebar', function ($view) { $view->with('recentPosts', Post::recent());});

如你所见,我们在第一个参数中定义了要与之共享的视图的名称(partials.sidebar),然后在第二个参数中传递了一个闭包;在闭包中,我们使用$view->with()来共享一个变量,但只针对特定视图。

使用类的视图作用域视图组合器

最后,最灵活但也最复杂的选项是为你的视图组合器创建一个专用的类。

首先,让我们创建视图组合器类。虽然没有正式定义视图组合器的存放位置,但文档建议放在App\Http\ViewComposers中。因此,让我们创建App\Http\ViewComposers\RecentPostsComposer,就像在示例4-24 中一样。

示例 4-24. 一个视图组合器
<?phpnamespace App\Http\ViewComposers;use App\Post;use Illuminate\Contracts\View\View;class RecentPostsComposer{ public function compose(View $view) { $view->with('recentPosts', Post::recent()); }}

如你所见,当调用这个组合器时,它会执行compose()方法,在该方法中,我们将recentPosts变量绑定到运行Post模型的recent()方法的结果上。

就像其他共享变量的方法一样,这个视图组合器需要在某处进行绑定。你很可能会创建一个自定义的ViewComposerServiceProvider,但现在,就像在示例4-25 中看到的那样,我们将它放在App\Providers\AppServiceProviderboot()方法中。

示例 4-25. 在AppServiceProvider中注册视图组合器
public function boot(): void{ view()->composer( 'partials.sidebar', \App\Http\ViewComposers\RecentPostsComposer::class );}

注意,这种绑定方式与基于闭包的视图组合器相同,但不是传递闭包,而是传递我们视图组合器的类名。现在,每当 Blade 渲染partials.sidebar视图时,它将自动运行我们的提供者,并将一个recentPosts变量传递给视图,该变量设置为在我们的Post模型上运行recent()方法的结果。

Blade 服务注入

视图中最有可能注入的数据主要有三种类型:需要迭代的数据集合、在页面上展示的单个对象以及生成数据或视图的服务。

使用服务时,模式很可能会像示例 4-26 那样,我们通过在路由定义的方法签名中进行类型提示将我们的分析服务的实例注入路由,并将其传递到视图中。

示例 4-26. 通过路由定义的构造函数将服务注入视图
Route::get('backend/sales', function (AnalyticsService $analytics) { return view('backend.sales-graphs') ->with('analytics', $analytics);});

就像视图组合器一样,Blade 的服务注入为你的路由定义提供了一个便捷的快捷方式来减少重复。通常情况下,使用我们的分析服务的视图内容可能会像示例 4-27 那样。

示例 4-27. 在视图中使用注入的导航服务
<div class="finances-display"> {{ $analytics->getBalance() }} / {{ $analytics->getBudget() }}</div>

Blade 服务注入使得直接将容器中的类的实例注入到视图中变得容易,就像示例 4-28 中展示的一样。

示例 4-28. 将服务直接注入到视图中
@inject('analytics', 'App\Services\Analytics')<div class="finances-display"> {{ $analytics->getBalance() }} / {{ $analytics->getBudget() }}</div>

正如你所看到的,这个 @inject 指令实际上已经使得 $analytics 变量可用,我们稍后在视图中使用它。

@inject 的第一个参数是你要注入的变量名称,第二个参数是你想要注入实例的类或接口。这与在 Laravel 中在构造函数中声明依赖项的方式相似;如果你对其工作原理不熟悉,请查看第十一章以了解更多信息。

就像视图组合器一样,Blade 服务注入使得可以很容易地使特定数据或功能对每个视图实例可用,而无需每次通过路由定义来注入。

到目前为止,我们已经介绍了 Blade 的所有内置语法——@if@unless等等都称为指令。每个 Blade 指令都是一个模式(例如 @if (*$condition*))与 PHP 输出(例如 <?php if (*$condition*): ?>)之间的映射。

指令不仅仅局限于核心功能;你实际上可以创建自己的指令。你可能认为指令适合用来创建代码的小快捷方式——比如,使用 @button('buttonName') 并将其扩展为更大的按钮 HTML 代码块。这并不是一个糟糕的想法,但对于这样简单的代码扩展,包含视图部分可能会更好一些。

当定制指令能简化某种重复逻辑时,它们往往最有用。假设我们厌倦了需要用 @if (auth()->guest()) 来包装代码(用于检查用户是否已登录),我们想要一个自定义的 @ifGuest 指令。就像视图组合器一样,值得注意的是,也许最好有一个自定义服务提供者来注册这些指令,但现在让我们将其放在 App\Providers\AppServiceProviderboot() 方法中。查看示例 4-29 以查看这个绑定的具体内容。

示例 4-29. 在服务提供者中绑定自定义 Blade 指令
public function boot(): void{ Blade::directive('ifGuest', function () { return "<?php if (auth()->guest()): ?>"; });}

我们现在注册了一个自定义指令 @ifGuest,它将被替换为 PHP 代码 <?php if (auth()->guest()): ?>

这可能会感觉奇怪。你正在编写一个字符串,它将被返回并作为 PHP 执行。但这意味着你现在可以将 PHP 模板代码中复杂、丑陋、不清晰或重复的部分隐藏在清晰、简单和表达性强的语法背后。

你可能会想要通过在绑定中执行操作来加速你的自定义指令,并将结果嵌入返回的字符串中:

Blade::directive('ifGuest', function () { // Antipattern! Do not copy. $ifGuest = auth()->guest(); return "<?php if ({$ifGuest}): ?>";});

这个想法的问题在于,它假设这个指令将在每次页面加载时重新创建。然而,Blade 会进行积极的缓存,所以如果你尝试这样做,你会发现自己处于一个困境之中。

自定义 Blade 指令中的参数

如果你想在自定义逻辑中接受参数,可以查看示例4-30。

示例 4-30. 创建带参数的 Blade 指令
// BindingBlade::directive('newlinesToBr', function ($expression) { return "<?php echo nl2br({$expression}); ?>";});// In use<p>@newlinesToBr($message->body)</p>

闭包接收的 $expression 参数表示括号内的内容。正如你所看到的,我们生成了一个有效的 PHP 代码片段并返回它。

如果你发现自己一遍又一遍地写相同的条件逻辑,那么你应该考虑使用 Blade 指令。

示例:在多租户应用程序中使用自定义 Blade 指令

假设我们正在构建一个支持多租户的应用程序,这意味着用户可能会从www.myapp.comclient1.myapp.comclient2.myapp.com或其他地方访问该网站。

假设我们编写了一个类来封装某些多租户逻辑,并命名为 Context。该类将捕获有关当前访问上下文的信息和逻辑,例如认证用户是谁,以及用户是访问公共网站还是客户子域。

我们可能会经常在视图中解析 Context 类,并对其执行条件操作,就像示例4-31 中那样。app('context') 是从容器中获取类实例的快捷方式,我们将在第十一章中进一步学习容器。

示例 4-31. 在上下文中进行条件判断,无需自定义 Blade 指令
@if (app('context')->isPublic()) &copy; Copyright MyApp LLC@else &copy; Copyright {{ app('context')->client->name }}@endif

如果我们能将 @if (app('context')->isPublic()) 简化为 @ifPublic,该有多好?让我们来做吧。查看示例4-32。

示例 4-32. 使用自定义 Blade 指令进行上下文条件判断
// BindingBlade::directive('ifPublic', function () { return "<?php if (app('context')->isPublic()): ?>";});// In use@ifPublic &copy; Copyright MyApp LLC@else &copy; Copyright {{ app('context')->client->name }}@endif

由于这解析为简单的 if 语句,我们仍然可以依赖于原生的 @else@endif 条件。但如果我们愿意,我们也可以创建一个自定义 @elseIfClient 指令,或者单独的 @ifClient 指令,或者任何其他我们想要的东西。

更容易定制的“if”语句指令

尽管自定义 Blade 指令功能强大,但它们最常见的用途是处理 if 语句。所以创建自定义if指令的简单方法是使用 Blade::if()。示例4-33 展示了我们如何使用 Blade::if() 方法重构示例4-32:

Example 4-33. 定义自定义“if” Blade 指令
// BindingBlade::if('ifPublic', function () { return (app('context'))->isPublic();});

你将以完全相同的方式使用这些指令,但是正如你所看到的,定义它们要简单一些。你不必手动键入 PHP 大括号,只需编写返回布尔值的闭包即可。

测试视图的最常见方法是通过应用程序测试,这意味着你实际上调用显示视图的路由,并确保视图包含特定内容(参见示例4-34)。你还可以点击按钮或提交表单,并确保你被重定向到某个页面或看到某个特定的错误。(你将在第十二章中了解更多关于测试的内容。)

Example 4-34. 测试一个视图是否显示特定内容
// EventsTest.phppublic function test_list_page_shows_all_events(){ $event1 = Event::factory()->create(); $event2 = Event::factory()->create(); $this->get('events') ->assertSee($event1->title) ->assertSee($event2->title);}

你还可以测试某个视图是否已经传递了特定的数据,如果达到了你的测试目标,这比检查页面上的某些文本更加健壮。示例4-35 演示了这种方法。

Example 4-35. 测试一个视图是否传递了特定内容
// EventsTest.phppublic function test_list_page_shows_all_events(){ $event1 = Event::factory()->create(); $event2 = Event::factory()->create(); $response = $this->get('events'); $response->assertViewHas('events', Event::all()); $response->assertViewHasAll([ 'events' => Event::all(), 'title' => 'Events Page', ]); $response->assertViewMissing('dogs');}

使用assertViewHas(),我们可以传递一个闭包,这意味着我们可以自定义如何检查更复杂的数据结构。示例4-36 说明了我们可能如何使用这个功能。

Example 4-36. 向 assertViewHas() 传递闭包
// EventsTest.phppublic function test_list_page_shows_all_events(){ $event1 = Event::factory()->create(); $response = $this->get("events/{ $event1->id }"); $response->assertViewHas('event', function ($event) use ($event1) { return $event->id === $event1->id; });}

Blade 是 Laravel 的模板引擎。它的主要关注点是清晰、简洁和表达力强大的语法,具有强大的继承性和可扩展性。它的“安全输出”括号是{{}},其不受保护的输出括号是{!!!!},它还有一系列称为“指令”的自定义标签,所有这些标签都以@开头(例如@if@unless)。

你可以定义一个父模板,并在其中留下“洞口”以用于内容,使用@yield@section/@show。然后,你可以教会其子视图通过@extends('*parent.view*')扩展父视图,并使用@section/@endsection定义它们的部分。你可以使用@parent引用块的父级内容。

视图组合器使得在每次加载特定视图或子视图时定义特定信息变得轻松。服务注入允许视图本身直接从应用程序容器请求数据。

Laravel 提供了一套工具来与你的应用程序数据库进行交互,其中最显著的是 Eloquent,Laravel 的 ActiveRecord ORM。

Eloquent 是 Laravel 最受欢迎和有影响力的功能之一。它是 Laravel 与大多数 PHP 框架不同之处的一个很好的例子;在强大但复杂的 DataMapper ORM 的世界中,Eloquent 因其简洁而脱颖而出。每个表格对应一个类,负责在该表格中检索、表示和持久化数据。

无论你选择使用 Eloquent 与否,你仍然会从 Laravel 提供的其他数据库工具中获得很多好处。因此,在深入研究 Eloquent 之前,我们将首先介绍 Laravel 数据库功能的基础:迁移、填充器和查询构建器。

然后我们将涵盖 Eloquent:定义你的模型;插入、更新和删除;使用访问器、修改器和属性转换自定义你的响应;最后是关系。这里涉及很多内容,很容易感到不知所措,但如果我们一步一个脚印地进行,我们一定能够成功。

在我们深入了解 Laravel 的数据库工具如何使用之前,让我们暂停片刻,回顾一下如何配置你的数据库凭据和连接。

数据库访问的配置位于 config/database.php.env 中。像 Laravel 的许多其他配置区域一样,你可以定义多个“连接”,然后决定代码默认使用哪一个。

数据库连接

默认情况下,每个驱动程序都有一个连接,如你可以在 示例5-1 中看到的。

示例 5-1. 默认的数据库连接列表
'connections' => [ 'sqlite' => [ 'driver' => 'sqlite', 'url' => env('DATABASE_URL'), 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), ], 'mysql' => [ 'driver' => 'mysql', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], 'pgsql' => [ 'driver' => 'pgsql', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '5432'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', 'prefix_indexes' => true, 'search_path' => 'public', 'sslmode' => 'prefer', ], 'sqlsrv' => [ 'driver' => 'sqlsrv', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', 'localhost'), 'port' => env('DB_PORT', '1433'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', 'prefix_indexes' => true, // 'encrypt' => env('DB_ENCRYPT', 'yes'), // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), ],]

没有什么能阻止你删除或修改这些命名连接或创建自己的连接。你可以创建新的命名连接,并且可以在其中设置驱动程序(MySQL、PostgreSQL 等)。因此,尽管默认情况下每个驱动程序只有一个连接,但这并不是限制;如果你愿意,你可以拥有五个不同的连接,都使用 mysql 驱动程序。

每个连接都允许你定义连接到和定制每种连接类型所需的属性。

对于多驱动程序的想法有几个原因。首先,“连接”部分是一个简单的模板,使得启动使用任何支持的数据库连接类型的应用程序变得简单。在许多应用程序中,你可以选择你将使用的数据库连接,填写它的信息,甚至如果愿意,删除其他的连接。我通常会把它们都保留在那里,以防以后可能会用到它们。

但也有一些情况下,你可能需要在同一个应用程序中使用多个连接。例如,你可能会为两种不同类型的数据使用不同的数据库连接,或者你可能会从一个数据库读取并向另一个数据库写入。支持多连接使这成为可能。

URL 配置

像 Heroku 这样的服务通常会提供一个环境变量,其中包含连接到数据库所需的所有信息。它看起来像这样:

mysql://root:password@127.0.0.1/forge?charset=UTF-8

您无需编写代码来解析此 URL;相反,将其作为 DATABASE_URL 环境变量传递给 Laravel,它将理解它。

其他数据库配置选项

config/database.php 配置部分有许多其他配置设置。您可以配置 Redis 访问、自定义迁移表名称、确定默认连接,并切换非 Eloquent 调用返回 stdClass 或数组实例。

在 Laravel 中,任何允许来自多个源的连接的服务 —— 会话可以由数据库或文件存储支持,缓存可以使用 Redis 或 Memcached,数据库可以使用 MySQL 或 PostgreSQL —— 您可以定义多个连接,并选择特定连接将成为“默认”连接,这意味着任何时候不显式请求特定连接时将使用该连接。以下是如何请求特定连接的示例:

$users = DB::connection('secondary')->select('select * from users');

现代框架如 Laravel 使得使用代码驱动的迁移轻松定义数据库结构。每个新表、列、索引和键都可以在代码中定义,任何新环境都可以在几秒钟内从空白数据库带到应用程序的完美架构。

定义迁移

迁移是一个单独的文件,定义了两件事情:运行此迁移时所需的修改(向上),以及可选的运行此迁移时所需的修改(向下)。

示例5-2 展示了 Laravel 默认的“创建用户表”迁移的样子。

示例 5-2. Laravel 默认的“创建用户表”迁移
<?phpuse Illuminate\Database\Migrations\Migration;use Illuminate\Database\Schema\Blueprint;use Illuminate\Support\Facades\Schema;return new class extends Migration{ /** * Run the migrations. * * @return void */ public function up(): void { Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); $table->rememberToken(); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down(): void { Schema::dropIfExists('users'); }};

email_verified_at 列存储了用户验证其电子邮件地址的时间戳。

如您所见,我们有一个 up() 方法和一个 down() 方法。up() 告诉迁移创建一个名为 users 的新表,并带有几个字段,而 down() 则告诉它删除 users 表。

创建迁移

正如您将在第8章中看到的,Laravel 提供了一系列命令行工具,您可以使用它们与应用程序交互并生成样板文件。其中一个命令允许您创建迁移文件。您可以使用 php artisan make:migration 运行它,并且它有一个参数,即迁移的名称。例如,要创建我们刚刚讨论过的表,您可以运行 php artisan make:migration create_users_table

可以选择向此命令传递两个标志。--create=*table_name* 参数将填充迁移,用于创建名为 *table_name* 的表,而 --table=*table_name* 则仅填充迁移以修改现有表。

php artisan make:migration create_users_tablephp artisan make:migration add_votes_to_users_table --table=usersphp artisan make:migration create_users_table --create=users

创建表

我们已经在默认的create_users_table迁移中看到,我们的迁移依赖于Schema门面及其方法。我们可以在这些迁移中做的一切都依赖于Schema的方法。

要在迁移中创建新表格,请使用create()方法—第一个参数是表名,第二个是定义其列的闭包:

Schema::create('users', function (Blueprint $table) { // Create columns here});

创建列

要在表格中创建新列,无论是在创建表格调用还是修改表格调用中,都可以使用传递到闭包中的Blueprint实例:

Schema::create('users', function (Blueprint $table) { $table->string('name');});

让我们看看Blueprint实例上可用的各种方法来创建列。我将描述它们在 MySQL 中的工作方式,但如果您使用的是其他数据库,Laravel 将使用最接近的等效方法。

以下是简单字段Blueprint方法:

id()

$table->bigIncrements('id')的别名

integer(*colName*), tinyInteger(*colName*), smallInteger(*colName*), mediumInteger(*colName*), bigInteger(*colName*), unsignedTinyInteger(*colName*), unsignedSmallInteger(*colName*), unsignedMediumInteger(*colName*),

unsignedBigInteger(*colName*)

添加一个INTEGER类型列,或其许多变体之一

string(*colName*, *length*)

添加一个带有可选长度的VARCHAR类型列

binary(*colName*)

添加一个BLOB类型列

boolean(*colName*)

添加一个BOOLEAN类型列(在 MySQL 中为TINYINT(1)

char(*colName*, *length*)

添加一个带有可选长度的CHAR类型列

date(*colName*), datetime(*colName*), dateTimeTz(*colName*)

添加一个DATEDATETIME类型列;如果需要时区感知,请使用dateTimeTz()方法来创建带有时区的DATETIME

decimal(*colName*, *precision*, *scale*),

unsignedDecimal(*colName*, *precision*, *scale*)

添加一个带有精度和比例的DECIMAL类型列—例如,decimal('*amount*', *5*, *2*)指定了精度为 5,比例为 2;对于无符号列,请使用unsignedDecimal方法

double(*colName*, *total digits*, *digits after decimal*)

添加一个DOUBLE类型的列—例如,double('*tolerance*', *12*, *8*)指定了总长度为 12 位,其中小数点后有 8 位,比如7204.05691739

enum(*colName*, [*choiceOne*, *choiceTwo*])

添加一个带有提供的选择的ENUM类型列

float(*colName*, *precision*, *scale*)

添加一个FLOAT类型列(与 MySQL 中的double相同)

foreignId(*colName*), foreignUuid(*colName*)

添加一个UNSIGNED BIGINT类型或UUID列,提供了选择

foreignIdFor(*colName*)

使用名称为colNameUNSIGNED BIG INT类型列

geometry(*colName*), geometryCollection(*colName*)

添加一个GEOMETRYGEOMETRYCOLLECTION类型列

ipAddress(*colName*)

添加一个VARCHAR类型列

json(*colName*), jsonb(*colName*)

添加一个JSONJSONB类型列

lineString(*colName*), multiLineString(*colName*)

使用给定的colName添加一个LINESTRINGMULTILINESTRING类型列

text(*colName*)tinyText(*colName*)mediumText(*colName*)longText(*colName*)

添加一个TEXT列(或其不同大小的变体)

macAddress(*colName*)

在支持它的数据库(如 PostgreSQL)中添加一个MACADDRESS列;在其他数据库系统中,它创建一个字符串等效

multiPoint(*colName*)multiPolygon(*colName*)polygon(*colName*)

point(*colName*)

分别添加MULTIPOINTMULTIPOLYGONPOLYGONPOINT类型的列

set(*colName*, *membersArray*)

创建一个名为colNameSET列,membersArray作为成员

time(*colName*, *precision*), timeTz(*colName*, *precision*)

添加一个带有colName名称的TIME列;要进行时区感知,请使用timeTz()方法

timestamp(*colName*, *precision*)

timestampTz(*colName*, *precision*)

添加一个TIMESTAMP列;要进行时区感知,请使用timestampTz()方法

uuid(*colName*)

添加一个UUID列(在 MySQL 中为CHAR(36)

year()

添加一个YEAR

这些是特殊(联合)的Blueprint方法:

increments(*colName*)tinyIncrements(*colName*)smallIncrements(*colName*)mediumIncrements(*colName*)bigIncrements(*colName*)

添加一个无符号增量的INTEGER主键 ID,或其多种变体之一

timestamps(*precision*)nullableTimestamps(*precision*)

timestampsTz(*precision*)

添加具有可选精度、可空和时区感知变体的created_atupdated_at时间戳列

rememberToken()

为用户“记住我”令牌添加一个remember_token列(VARCHAR(100)

softDeletes(*colName*, *precision*)softDeletesTz(*colName*, *precision*)

添加一个deleted_at时间戳以用于软删除,具有可选的精度和时区感知变体

morphs(*colName*), nullableMorphs(*colName*), uuidMorphs(*relationshipName*),

nullableUuidMorphs(*relationshipName*)

对于提供的colName,添加一个整数colName_id和一个字符串colName_type(例如,morphs(tag)添加整数tag_id和字符串tag_type);用于多态关系中使用 ID 或 UUID,并可以根据方法名称设置为可空

流畅地构建额外的属性

字段定义的大多数属性——例如其长度——作为字段创建方法的第二个参数设置,就像我们在前一节中看到的那样。但是还有一些其他属性,我们将在创建列后通过更多方法调用链设置。例如,此email字段可空,并将(在 MySQL 中)放在last_name字段后面:

Schema::table('users', function (Blueprint $table) { $table->string('email')->nullable()->after('last_name');});

以下方法是用于设置字段的附加属性的一些方法;请参阅migrations docs获取详尽列表。

nullable()

允许将NULL值插入此列

default('*default content*')

指定此列的默认内容(如果未提供值)

unsigned()

将整数列标记为无符号(不是负数或正数,而是整数)

first()(仅适用于 MySQL)

将列名放在列顺序的最前面

after(*colName*)(仅适用于 MySQL)

将列放在列顺序的另一列之后

charset(*charset*)(仅适用于 MySQL)

为列设置字符集

collation(*collation*)

为列设置排序规则

invisible()(仅适用于 MySQL)

将列对SELECT查询不可见

useCurrent()

用于TIMESTAMP列,使用CURRENT_TIMESTAMP作为默认值

isGeometry()(仅适用于 PostgreSQL)

将列类型设置为GEOMETRY(默认为GEOGRAPHY

unique()

添加UNIQUE索引

primary()

添加主键索引

index()

添加基本索引

请注意,unique()primary()index()也可以在流畅的列构建上下文之外使用,我们稍后会讨论这一点。

删除表

如果要删除表,请在Schema上使用dropIfExists()方法,该方法接受一个参数,即表名:

Schema::dropIfExists('contacts');

修改列

要修改列,只需编写代码,就像创建新列一样,然后在其后追加一个change()方法的调用。

如果您没有使用原生支持重命名和删除列的数据库(大多数常见数据库的最新版本支持这些操作),在修改任何列之前,您需要运行composer require doctrine/dbal

所以,如果我们有一个名为name的字符串列,长度为255,我们想把它的长度改为100,写法如下:

Schema::table('users', function (Blueprint $table) { $table->string('name', 100)->change();});

如果我们要调整未在方法名中定义的任何字段属性,使其可为空,我们可以这样做:

Schema::table('contacts', function (Blueprint $table) { $table->string('deleted_at')->nullable()->change();});

这是如何重命名列的方法:

Schema::table('contacts', function (Blueprint $table){ $table->renameColumn('promoted', 'is_promoted');});

这是如何删除列的方法:

Schema::table('contacts', function (Blueprint $table){ $table->dropColumn('votes');});

压缩迁移

如果迁移过多而无法理解,可以将它们全部合并到一个单独的 SQL 文件中,Laravel 将在运行任何未来迁移之前运行该文件。这称为“压缩”您的迁移。

// Squash the schema but keep your existing migrationsphp artisan schema:dump// Dump the current database schema and delete all existing migrationsphp artisan schema:dump --prune

Laravel 仅在检测到到目前为止未运行迁移时才运行这些转储。这意味着您可以压缩您的迁移,而不会破坏已部署的应用程序。

警告

如果您使用模式转储,则无法在内存中使用 SQLite;它仅适用于 MySQL、PostgreSQL 和本地文件 SQLite。

索引和外键

我们已经讨论了如何创建、修改和删除列。现在让我们继续索引和关联它们。

如果您对索引不熟悉,您的数据库可以在不使用它们的情况下运行,但它们对性能优化非常重要,并且对于一些关于相关表的数据完整性控制也很重要。我建议您详细了解一下它们,但如果您确实需要,您可以暂时跳过此部分。

添加索引

查看示例 5-3 了解如何向列添加索引的示例。

示例 5-3。在迁移中添加列索引
// After columns are created...$table->primary('primary_id'); // Primary key; unnecessary if used increments()$table->primary(['first_name', 'last_name']); // Composite keys$table->unique('email'); // Unique index$table->unique('email', 'optional_custom_index_name'); // Unique index$table->index('amount'); // Basic index$table->index('amount', 'optional_custom_index_name'); // Basic index

请注意,如果你使用increments()bigIncrements()方法创建索引,那么primary()的第一个示例是不必要的;这会自动为你添加一个主键索引。

移除索引

我们可以如示例 5-4 中所示移除索引(#rm-col-ix)。

示例 5-4. 在迁移中移除列索引
$table->dropPrimary('contacts_id_primary');$table->dropUnique('contacts_email_unique');$table->dropIndex('optional_custom_index_name');// If you pass an array of column names to dropIndex, it will// guess the index names for you based on the generation rules$table->dropIndex(['email', 'amount']);

添加和移除外键

要添加一个外键,定义一个特定列引用另一张表上的列,Laravel 的语法简单清晰:

$table->foreign('user_id')->references('id')->on('users');

在这里,我们在user_id列上添加一个foreign索引,显示它引用了users表上的id列。再简单不过了。

如果我们想指定外键约束,也可以,比如cascadeOnUpdate()restrictOnUpdate()cascadeOnDelete()restrictOnDelete()nullOnDelete()。例如:

$table->foreign('user_id') ->references('id') ->on('users') ->cascadeOnDelete();

还有一个别名用于创建外键约束。使用它,上面的示例可以这样写:

$table->foreignId('user_id')->constrained()->cascadeOnDelete();

要删除外键,我们可以通过引用其索引名称来删除它(该名称是通过组合引用的列和表的名称自动生成的):

$table->dropForeign('contacts_user_id_foreign');

或者通过传递一个数组,其中包含它在本地表中引用的字段:

$table->dropForeign(['user_id']);

运行迁移

定义了迁移之后,如何运行它们?有一个 Artisan 命令可以做到:

php artisan migrate

此命令运行所有“未完成”的迁移(通过在每个上运行up()方法)。Laravel 会跟踪你已经运行和未运行的迁移。每次运行此命令时,它会检查是否已运行所有可用的迁移,如果没有,则运行任何未完成的迁移。

在这个命名空间中有几个选项可以使用。首先,你可以运行你的迁移你的种子(我们将在下文中介绍):

php artisan migrate --seed

你也可以运行以下任何命令:

migrate:install

创建一个数据库表,用来跟踪你已经和未运行的迁移;当你运行迁移时,这会自动运行,所以基本上你可以忽略它。

migrate:reset

回滚你在此实例上运行的每个数据库迁移。

migrate:refresh

回滚你在此实例上运行的每个数据库迁移,然后运行所有可用的迁移。这与运行migrate:reset然后migrate相同。

migrate:fresh

删除所有表并再次运行每个迁移。这与refresh相同,但不涉及“下”迁移,它只是删除表然后再次运行“上”迁移。

migrate:rollback

仅回滚上次运行migrate的迁移,或者使用添加的选项--step=*n*,回滚指定数量的迁移。

migrate:status

显示一个表格,列出每个迁移,每个旁边都有一个YN,表示此环境中是否已经运行。

如果您在本地计算机上运行迁移,并且您的 .env 文件指向 Vagrant 盒子中的数据库,则迁移将失败。您需要通过 SSH 进入您的 Vagrant 盒子,然后从那里运行迁移。对于种子和任何其他会影响或从数据库读取的 Artisan 命令也是如此。

如果您想深入了解数据库的状态或定义,包括其表和模型,那么有一些专门的 Artisan 命令可以帮到您:

db:show

显示整个数据库的表概览,包括连接详细信息、表、大小和开放连接数

db:table {*tableName*}

给定表名,显示大小并列出列

db:monitor

列出数据库的开放连接数目

使用 Laravel 进行填充是如此简单,以至于它作为正常开发工作流程的一部分得到了广泛采用,这在以前的 PHP 框架中并不常见。有一个 database/seeders 文件夹,其中包含一个 DatabaseSeeder 类,该类在调用填充器时会调用其 run() 方法。

运行填充器有两种主要方式:与迁移一起或单独运行。

要与迁移一起运行填充器,只需在任何迁移调用中添加 --seed

php artisan migrate --seedphp artisan migrate:refresh --seed

要独立运行填充器:

php artisan db:seedphp artisan db:seed VotesTableSeeder

这将默认调用 DatabaseSeederrun() 方法,或者在传递类名时指定的填充器类。

创建一个填充器

要创建一个填充器,请使用 make:seeder Artisan 命令:

php artisan make:seeder ContactsTableSeeder

您现在将在 database/seeders 目录中看到一个 ContactsTableSeeder 类。在我们编辑它之前,让我们将它添加到 DatabaseSeeder 类中,就像 示例 5-5 中所示的那样,这样当我们运行填充器时它就会运行。

示例 5-5. 从 DatabaseSeeder.php 调用自定义填充器
// database/seeders/DatabaseSeeder.php...public function run(): void{ $this->call(ContactsTableSeeder::class);}

现在让我们编辑填充器本身。在那里,我们可以做的最简单的事情是使用DB门面手动插入记录,就像 示例 5-6 中所示的那样。

示例 5-6. 在自定义填充器中插入数据库记录
<?phpnamespace Database\Seeders;use Illuminate\Database\Seeder;class ContactsTableSeeder extends Seeder{ public function run(): void { DB::table('contacts')->insert([ 'name' => 'Lupita Smith', 'email' => 'lupita@gmail.com', ]); }}

这将为我们获取一条记录,这是一个很好的开始。但是对于真正功能齐全的填充器,您可能希望循环使用某种随机生成器并多次运行insert(),对吧?Laravel 就有这样的功能。

模型工厂

模型工厂定义了创建数据库表中虚假条目的模式之一(或更多)。默认情况下,每个工厂的命名都是根据 Eloquent 类命名的。

理论上,您可以随意命名这些工厂,但将工厂命名为您的 Eloquent 类的方式是最习惯的方法。如果您遵循不同的约定来命名您的工厂,您可以在相关模型中设置工厂类名。

创建一个模型工厂

模型工厂位于 database/factories。每个工厂都在自己的类中定义,具有一个定义方法。在此方法中,您定义要在工厂创建模型时使用的属性及其值。

要生成一个新的工厂类,使用 Artisan 的make:factory命令;通常将工厂类命名为它们要生成实例的 Eloquent 模型:

php artisan make:factory ContactFactory

这将在database/factories目录中生成一个名为ContactFactory.php的新文件。我们为联系人定义的最简单的工厂可能看起来像 Example5-7:

Example 5-7. 最简单的工厂定义
<?phpnamespace Database\Factories;use App\Models\Contact;use Illuminate\Database\Eloquent\Factories\Factory;class ContactFactory extends Factory{ public function definition(): array { return [ 'name' => 'Lupita Smith', 'email' => 'lupita@gmail.com', ]; }}

现在,你需要在你的模型中使用Illuminate\Database\Eloquent\Factories\HasFactory特性。

namespace App\Models;use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model;class Contact extends Model{ use HasFactory;}

HasFactory特性提供了一个静态的factory()方法,它使用 Laravel 的约定来确定模型的适当工厂。它将在Database\Factories命名空间中寻找一个类名与模型名匹配且以Factory结尾的工厂。如果你不遵循这些约定,你可以在你的模型中覆盖newFactory()方法来指定应该使用的工厂类:

// app/Models/Contact.php... * Create a new factory instance for the model. * * @return \Illuminate\Database\Eloquent\Factories\Factory */protected static function newFactory(){ return \Database\Factories\Base\ContactFactory::new();}

现在我们可以在模型上调用静态的factory()方法,在我们的种子和测试中创建一个Contact的实例:

// Create one$contact = Contact::factory()->create();// Create manyContact::factory()->count(20)->create();

然而,如果我们使用该工厂创建了 20 个联系人,那么所有 20 个联系人将具有相同的信息。这就没那么有用了。

当我们利用Faker全球可用于 Laravel 的fake()助手时,我们将从模型工厂中获得更多好处;Faker 使得随机生成结构化虚假数据变得轻而易举。之前的例子现在变成了 Example5-8。

Example 5-8. 一个简单的工厂,修改以使用 Faker
<?phpnamespace Database\Factories;use App\Models\Contact;use Illuminate\Database\Eloquent\Factories\Factory;class ContactFactory extends Factory{ public function definition(): array { return [ 'name' => fake()->name(), 'email' => fake()->email(), ]; }}

现在,每次我们使用这个模型工厂创建一个虚假联系人时,所有属性都将被随机生成。

模型工厂至少需要返回此表所需的数据库字段。

如果你希望保证任何给定条目的随机生成值在 PHP 进程中与其他随机生成值不同,你可以使用 Faker 的unique()方法:

return ['email' => fake()->unique()->email()];

使用模型工厂

有两种主要情境下我们会使用模型工厂:测试,我们将在 Chapter12 中介绍;以及种子,我们在这里介绍。让我们使用模型工厂编写一个种子程序;看一看 Example5-9。

Example 5-9. 使用模型工厂
$post = Post::factory()->create([ 'title' => 'My greatest post ever',]);// Pro-level factory; but don't get overwhelmed!User::factory()->count(20)->has(Address::factory()->count(2))->create()

要创建一个对象,我们在模型上使用factory()方法。然后我们可以在它上面运行两种方法之一:make()create()

这两种方法都会使用工厂类中的定义生成指定模型的实例。区别在于,make()创建实例但不立即保存到数据库,而create()则立即保存到数据库。

在调用模型工厂时覆盖属性

如果你将数组传递给make()create(),你可以像我们在 Example5-9 中所做的那样,手动设置帖子上的title来覆盖特定的键。

使用模型工厂生成多个实例

如果在调用factory()方法后调用count()方法,可以指定要创建多个实例。它不会返回单个实例,而是返回一组实例。这意味着您可以像处理数组一样处理结果,迭代它们或将它们传递给接受多个对象的任何方法:

$posts = Post::factory()->count(6);

您还可以选择定义每个覆盖的“序列”:

$posts = Post::factory() ->count(6) ->state(new Sequence( ['is_published' => true], ['is_published' => false], )) ->create();

专业级模型工厂

现在我们已经涵盖了模型工厂的最常见用途和排列方式,让我们深入一些更复杂的使用方法。

在定义模型工厂时附加关系

有时,您需要创建一个与您正在创建的项目相关的项目。您可以调用相关模型上的工厂方法以获取其 ID,如示例 5-10 所示。

示例 5-10. 在工厂中创建相关项
<?phpnamespace Database\Factories;use App\Models\Contact;use Illuminate\Database\Eloquent\Factories\Factory;class ContactFactory extends Factory{ protected $model = Contact::class; public function definition(): array { return [ 'name' => 'Lupita Smith', 'email' => 'lupita@gmail.com', 'company_id' => \App\Models\Company::factory(), ]; }}

您还可以传递一个闭包,闭包将传递一个参数,其中包含到目前为止生成项目的数组形式。这可以在其他地方使用,如示例 5-11 所示。

示例 5-11. 在工厂中使用其他参数的值
// ContactFactory.phppublic function definition(): array{ return [ 'name' => 'Lupita Smith', 'email' => 'lupita@gmail.com', 'company_id' => Company::factory(), 'company_size' => function (array $attributes) { // Uses the "company_id" property generated above return Company::find($attributes['company_id'])->size; }, ];}

在生成模型工厂实例时附加相关项

虽然我们已经讨论了如何在工厂定义中定义关系,但更常见的情况是我们在创建实例时定义实例的相关项。

在这方面,我们将使用两种主要方法:has()for()has()允许我们定义我们正在创建的实例“有”子项或其他项目的“hasMany”类型关系,而for()允许我们定义我们正在创建的实例“belongsTo”另一个项目。让我们看几个示例,以更好地了解它们的工作方式。

在示例 5-12 中,让我们假设Contact有许多Addresses

示例 5-12. 在生成相关模型时使用has()
// Attach 3 addressesContact::factory() ->has(Address::factory()->count(3)) ->create()// Accessing information about each user in the child factory$contact = Contact::factory() ->has( Address::factory() ->count(3) ->state(function (array $attributes, User $user) { return ['label' => $user->name . ' address']; }) ) ->create();

现在让我们假设我们正在创建子实例而不是父实例。让我们生成一个地址。

在这些情况下,您通常可以假设子工厂定义将负责生成父实例。那么,for()的用途是什么?如果您想要特别定义父实例的某些内容,通常是其一个或多个属性,或者传递特定的模型实例,这将非常有帮助。看看示例 5-13,看看它是如何最常用的。

示例 5-13. 在生成相关模型时使用for()
// Specify details about the created parentAddress::factory() ->count(3) ->for(Contact::factory()->state([ 'name' => 'Imani Carette', ])) ->create();// Use an existing parent model (assuming we already have it as $contact)Address::factory() ->count(3) ->for($contact) ->create();

定义和访问多个模型工厂状态

让我们回顾一下ContactFactory.php(来自示例 5-7 和 5-8)。我们定义了一个基础的Contact工厂:

class ContactFactory extends Factory{ protected $model = Contact::class; public function definition(): array { return [ 'name' => 'Lupita Smith', 'email' => 'lupita@gmail.com', ]; }}

有时候,你需要为某个类的对象创建多个工厂。如果我们需要添加一些非常重要的联系人(VIP),我们可以使用state()方法为此定义第二个工厂状态,正如在示例 5-14中所见。state()方法接收一个属性数组,你可以为这个状态专门设置任何属性。

示例 5-14. 为同一模型定义多个工厂状态
class ContactFactory extends Factory{ protected $model = Contact::class; public function definition(): array { return [ 'name' => 'Lupita Smith', 'email' => 'lupita@gmail.com', ]; } public function vip() { return $this->state(function (array $attributes) { return [ 'vip' => true, // Uses the "company_id" property from the $attributes 'company_size' => function () use ($attributes) { return Company::find($attributes['company_id'])->size; }, ]; }); }}

现在,让我们创建一个特定状态的实例:

$vip = Contact::factory()->vip()->create();$vips = Contact::factory()->count(3)->vip()->create();

在复杂工厂设置中使用相同模型作为关系

有时候你有一个工厂通过它们的工厂创建相关项,其中两个或更多有相同的关系。也许通过你的工厂生成Trip会自动创建ReservationReceipt,并且所有三者都应该附属于同一个User。当你去创建Trip时,除非告诉它们要做其他事情,否则工厂将每个手动创建它们自己的用户。

使用recycle()方法,你可以指示每个调用链中的工厂使用给定对象的同一个实例。正如你在示例 5-15中看到的,这为确保整个工厂链中使用同一模型提供了简单的语法。

示例 5-15. 使用recycle()在工厂链中的每个关系中使用相同的实例
$user = User::factory()->create();$trip = Trip::factory() ->recycle($user) ->create();

哇,这真是太多了。如果你觉得理解起来有些困难,不要担心——最后一部分确实是更高级的内容。让我们回到基础,谈谈 Laravel 数据库工具的核心:查询构建器。

现在你已经连接并且迁移和填充了你的表,让我们开始学习如何使用数据库工具。在每一个 Laravel 数据库功能的核心是查询构建器,它是一个流畅的接口,用于与多种不同类型的数据库进行交互,具有清晰的单一 API。

Laravel 的数据库架构可以通过单一接口连接到 MySQL、PostgreSQL、SQLite 和 SQL Server,只需改变几个配置设置。

如果你曾经使用过 PHP 框架,你可能使用过一个工具,允许你运行带有基本转义的“原始”SQL 查询,以确保安全性。查询构建器就是这样一个工具,在其上还有许多便利层和帮助程序。因此,让我们从一些简单的调用开始。

DB门面的基本用法

在我们开始使用流畅方法链构建复杂查询之前,让我们看一些查询构建器命令的示例。DB门面用于查询构建器链式调用和更简单的原始查询,如示例 5-16所示。

示例 5-16. 示例原始 SQL 和查询构建器的使用
// Basic statementDB::statement('drop table users');// Raw select, and parameter bindingDB::select('select * from contacts where validated = ?', [true]);// Select using the fluent builder$users = DB::table('users')->get();// Joins and other complex callsDB::table('users') ->join('contacts', function ($join) { $join->on('users.id', '=', 'contacts.user_id') ->where('contacts.type', 'donor'); }) ->get();

原始 SQL

正如你在示例 5-16中看到的,可以使用DB门面和statement()方法对数据库进行任何原始调用:DB::statement('*SQL statement here*')

但也有特定于各种常见操作的方法:select()insert()update()delete()。这些仍然是原始调用,但有所不同。首先,使用update()delete()将返回受影响的行数,而statement()则不会;其次,使用这些方法可以让未来的开发人员更清楚地了解您正在进行的语句类型。

原始选择

具体的DB方法中最简单的是select()。您可以在不添加任何额外参数的情况下运行它:

$users = DB::select('select * from users');

这将返回一个stdClass对象的数组。

参数绑定和命名绑定

Laravel 的数据库架构允许使用 PDO(PHP 数据对象,PHP 的本地数据库访问层)参数绑定,这可以保护您的查询免受潜在的 SQL 攻击。将参数传递给语句就像在语句中用?替换值,然后将值添加到调用的第二个参数中:

$usersOfType = DB::select( 'select * from users where type = ?', [$type]);

您还可以为了清晰起见为这些参数命名:

$usersOfType = DB::select( 'select * from users where type = :type', ['type' => $userType]);

原始插入

从这里开始,原始命令基本上都是相同的。原始插入如下所示:

DB::insert( 'insert into contacts (name, email) values (?, ?)', ['sally', 'sally@me.com']);

原始更新

更新操作如下:

$countUpdated = DB::update( 'update contacts set status = ? where id = ?', ['donor', $id]);

原始删除

删除操作如下所示:

$countDeleted = DB::delete( 'delete from contacts where archived = ?', [true]);

使用查询构建器进行链式调用

到目前为止,我们实际上还没有使用查询构建器。我们只是在DB门面上使用了简单的方法调用。让我们实际构建一些查询。

查询构建器使得可以将方法链接在一起,以构建查询。在链的末尾,您将使用某些方法——很可能是get()——触发刚刚构建的查询的实际执行。

让我们来看一个快速的示例:

$usersOfType = DB::table('users') ->where('type', $type) ->get();

在这里,我们构建了我们的查询——users表,$type类型——然后执行了查询并得到了结果。请注意,与之前的调用不同,这将返回collection类型的stdClass对象,而不是数组。

让我们看看查询构建器允许您链式调用哪些方法。这些方法可以分为我称之为约束方法、修改方法、条件方法和结束/返回方法。

约束方法

这些方法以当前查询为基础,将其约束为返回可能数据的更小子集:

select()

允许您选择要选择的列:

$emails = DB::table('contacts') ->select('email', 'email2 as second_email') ->get();// Or$emails = DB::table('contacts') ->select('email') ->addSelect('email2 as second_email') ->get();

where()

使用WHERE可以限制返回内容的范围。默认情况下,where()方法的签名需要三个参数——列名、比较运算符和数值:

$newContacts = DB::table('contact') ->where('created_at', '>', now()->subDay()) ->get();

然而,如果您的比较是=, 这是最常见的比较,您可以省略第二个操作符:

$vipContacts = DB::table('contacts')->where('vip',true)->get();

如果要组合where()语句,可以将它们依次链接在一起,或者传递一个数组的数组:

$newVips = DB::table('contacts') ->where('vip', true) ->where('created_at', '>', now()->subDay());// Or$newVips = DB::table('contacts')->where([ ['vip', true], ['created_at', '>', now()->subDay()],]);

orWhere()

创建简单的OR WHERE语句:

$priorityContacts = DB::table('contacts') ->where('vip', true) ->orWhere('created_at', '>', now()->subDay()) ->get();

要创建具有多个条件的更复杂的OR WHERE语句,请将orWhere()传递给闭包:

$contacts = DB::table('contacts') ->where('vip', true) ->orWhere(function ($query) { $query->where('created_at', '>', now()->subDay()) ->where('trial', false); }) ->get();

whereBetween(*colName*, [*low*, *high*])

允许您将查询范围限定为只返回某一列在两个值之间的行(包括两个值):

$mediumDrinks = DB::table('drinks') ->whereBetween('size', [6, 12]) ->get();

whereNotBetween() 同样有效,但会选择其反向操作。

whereIn(*colName*, [*1*, *2*, *3*])

允许你将查询范围限定为仅返回列值在显式提供的选项列表中的行:

$closeBy = DB::table('contacts') ->whereIn('state', ['FL', 'GA', 'AL']) ->get();

whereNotIn() 同样有效,但会选择其反向操作。

whereNull(*colName*), whereNotNull(*colName*)

允许你仅选择给定列为 NULLNOT NULL 的行。

whereRaw()

允许你传递一个原始、未转义的字符串,添加到 WHERE 语句之后:

$goofs = DB::table('contacts')->whereRaw('id = 12345')->get();

传递给 whereRaw() 的任何 SQL 查询都不会被转义。请谨慎使用这个方法,并且使用频率不高;这是你的应用中 SQL 注入攻击的一个主要机会。

whereExists()

允许你仅选择那些在提供的子查询中至少返回一行的行。想象一下,你只想获取那些至少留下一条评论的用户:

$commenters = DB::table('users') ->whereExists(function ($query) { $query->select('id') ->from('comments') ->whereRaw('comments.user_id = users.id'); }) ->get();

distinct()

仅选择与返回数据中其他行相比具有唯一选择数据的行。通常与 select() 配对使用,因为如果使用主键,就不会有重复行:

$lastNames = DB::table('contacts')->select('city')->distinct()->get();

修改方法

这些方法改变了查询结果输出的方式,而不仅仅限制其结果:

orderBy(*colName*, *direction*)

对结果进行排序。第二个参数可以是 asc(默认,升序)或 desc(降序):

$contacts = DB::table('contacts') ->orderBy('last_name', 'asc') ->get();

groupBy(), having(), havingRaw()

将你的结果按列分组。可选地,having()havingRaw() 允许你根据组的属性过滤结果。例如,你可以只查找至少有 30 个人口的城市:

$populousCities = DB::table('contacts') ->groupBy('city') ->havingRaw('count(contact_id) > 30') ->get();

skip(), take()

最常用于分页,允许你定义要返回的行数以及在开始返回之前要跳过的行数 —— 就像分页系统中的页码和页面大小:

// returns rows 31-40$page4 = DB::table('contacts')->skip(30)->take(10)->get();

latest(*colName*), oldest(*colName*)

按传递的列排序(如果没有传递列名,则按 created_at),支持降序(latest())或升序(oldest()):

inRandomOrder()

随机排序结果。

条件方法

有两种方法允许你根据传入值的布尔状态有条件地应用它们的“内容”(你传递给它们的闭包):

when()

给定一个真值的第一个参数,应用包含在闭包中的查询修改;给定一个假值的第一个参数,它什么也不做。注意,第一个参数可以是布尔值(例如,设置为 truefalse$ignoreDrafts)、可选值(从用户输入中提取的 $status,默认为 null)或返回布尔值的闭包;重要的是它评估为真或假。例如:

$status = request('status'); // Defaults to null if not set$posts = DB::table('posts') ->when($status, function ($query) use ($status) { return $query->where('status', $status); }) ->get();// Or$posts = DB::table('posts') ->when($ignoreDrafts, function ($query) { return $query->where('draft', false); }) ->get();

你还可以传递第三个参数,另一个闭包,只有在第一个参数为假时才会应用。

unless()

when() 的确切反向操作。如果第一个参数为假,它将运行第二个闭包。

结束/返回方法

这些方法停止查询链并触发 SQL 查询的执行。如果在查询链的末尾没有这些方法之一,则返回值始终只是查询构建器的实例;将其中一个链到查询构建器上,你将获得一个实际的结果:

get()

获取构建查询的所有结果:

$contacts = DB::table('contacts')->get();$vipContacts = DB::table('contacts')->where('vip', true)->get();

first()firstOrFail()

仅获取第一个结果——类似于 get(),但添加了 LIMIT 1

$newestContact = DB::table('contacts') ->orderBy('created_at', 'desc') ->first();

如果没有结果,first() 会静默失败,而 firstOrFail() 将抛出异常。

如果你向任一方法传递一个列名数组,它将返回仅这些列的数据,而不是所有列。

find(*id*)findOrFail(*id*)

类似于 first(),但你传递一个与主键对应的 ID 值来查找。如果不存在具有该 ID 的行,则 find() 会静默失败,而 findOrFail() 将抛出异常:

$contactFive = DB::table('contacts')->find(5);

value()

从第一行中的单个字段中提取值。类似于 first(),但如果你只想要单个列:

$newestContactEmail = DB::table('contacts') ->orderBy('created_at', 'desc') ->value('email');

count()

返回所有匹配结果的整数计数:

$countVips = DB::table('contacts') ->where('vip', true) ->count();

min()max()

返回特定列中的最小或最大值:

$highestCost = DB::table('orders')->max('amount');

sum()avg()

返回特定列中所有值的总和或平均值:

$averageCost = DB::table('orders') ->where('status', 'completed') ->avg('amount');

dd()dump()

显示底层的 SQL 查询和绑定,并且如果使用 dd(),则结束脚本。

DB::table('users')->where('name', 'Wilbur Powery')->dd();// "select * from `users` where `name` = ?"// array:1 [ 0 => "Wilbur Powery"]

explain() 方法返回 SQL 将如何执行查询的解释。你可以将其与 dd()dump() 方法一起使用来调试查询:

User::where('name', 'Wilbur Powery')->explain()->dd();/*array:1 [ 0 => {#5111 +"id": 1 +"select_type": "SIMPLE" +"table": "users" +"type": "ALL" +"possible_keys": null +"key": null +"key_len": null +"ref": null +"rows": "209" +"Extra": "Using where" }]*/

使用 DB::raw 在查询构建器方法中编写原始查询

你已经看到了一些用于原始语句的自定义方法,例如 select() 有一个 selectRaw() 对应方法,允许你传递一个字符串给查询构建器,放置在 WHERE 语句之后。

你也可以将 DB::raw() 调用的结果传递给查询构建器中的几乎任何方法来实现相同的结果:

$contacts = DB::table('contacts') ->select(DB::raw('*, (score * 100) AS integer_score')) ->get();

连接

连接有时可能很难定义,框架可以尽力简化它们,但查询构建器会尽最大努力。让我们看一个示例:

$users = DB::table('users') ->join('contacts', 'users.id', '=', 'contacts.user_id') ->select('users.*', 'contacts.name', 'contacts.status') ->get();

join() 方法创建内连接。你还可以依次链接多个连接,或使用 leftJoin() 获取左连接。

最后,通过将闭包传递给 join() 方法,可以创建更复杂的连接:

DB::table('users') ->join('contacts', function ($join) { $join ->on('users.id', '=', 'contacts.user_id') ->orOn('users.id', '=', 'contacts.proxy_user_id'); }) ->get();

联合

你可以通过首先创建它们,然后使用 union()unionAll() 方法将两个查询联合起来(将它们的结果合并为一个结果集):

$first = DB::table('contacts') ->whereNull('first_name');$contacts = DB::table('contacts') ->whereNull('last_name') ->union($first) ->get();

插入

insert() 方法非常简单。将其作为数组传递以插入单行,或者作为数组的数组插入多行,并使用 insertGetId() 而不是 insert() 来获取自增的主键 ID 作为返回:

$id = DB::table('contacts')->insertGetId([ 'name' => 'Abe Thomas', 'email' => 'athomas1987@gmail.com',]);DB::table('contacts')->insert([ ['name' => 'Tamika Johnson', 'email' => 'tamikaj@gmail.com'], ['name' => 'Jim Patterson', 'email' => 'james.patterson@hotmail.com'],]);

更新

更新也很简单。创建更新查询,而不是使用 get()first(),只需使用 update() 并传递参数数组:

DB::table('contacts') ->where('points', '>', 100) ->update(['status' => 'vip']);

你还可以使用 increment()decrement() 方法快速增加和减少列。每个方法的第一个参数是列名,第二个(可选)参数是增加/减少的数字:

DB::table('contacts')->increment('tokens', 5);DB::table('contacts')->decrement('tokens');

删除操作

删除操作更加简单。构建你的查询,然后以 delete() 结束:

DB::table('users') ->where('last_login', '<', now()->subYear()) ->delete();

你还可以截断表,这会删除每一行,并且重置自增 ID:

DB::table('contacts')->truncate();

JSON 操作

如果你有 JSON 列,可以使用箭头语法遍历子元素的方面来更新或选择行:

// Select all records where the "isAdmin" property of the "options"// JSON column is set to trueDB::table('users')->where('options->isAdmin', true)->get();// Update all records, setting the "verified" property// of the "options" JSON column to trueDB::table('users')->update(['options->isVerified', true]);

事务

数据库事务 是一种工具,允许你将一系列数据库查询封装成批处理,你可以选择回滚,撤销整个查询系列。事务通常用于确保一系列相关查询的全部操作完成——如果其中一个失败,ORM 将回滚整个查询系列。

使用 Laravel 查询构建器的事务功能,如果事务闭包内的任何异常被抛出,事务中的所有查询将会回滚。如果事务闭包成功完成,所有查询将会提交而不会回滚。

让我们看一下 示例5-17 中的样本事务。

示例 5-17. 一个简单的数据库事务
DB::transaction(function () use ($userId, $numVotes) { // Possibly failing DB query DB::table('users') ->where('id', $userId) ->update(['votes' => $numVotes]); // Caching query that we don't want to run if the above query fails DB::table('votes') ->where('user_id', $userId) ->delete();});

在这个例子中,我们可以假设之前的某个过程总结了 votes 表中给定用户的投票数。我们想把这个数字缓存到 users 表中,然后从 votes 表中清除这些投票。但是,当然,在成功运行到 users 表的更新之前,我们不想清除投票。如果 votes 表的删除操作失败,我们也不想在 users 表中保留更新后的投票数。

如果任何一个查询出现问题,另一个都不会被应用。这就是数据库事务的魔力。

注意,你也可以手动开始和结束事务——这对于查询构建器和 Eloquent 查询都适用。以 DB::beginTransaction() 开始,以 DB::commit() 结束,以 DB::rollBack() 中止:

DB::beginTransaction();// Take database actionsif ($badThingsHappened) { DB::rollBack();}// Take other database actionsDB::commit();

现在我们已经介绍了查询构建器,让我们来谈谈 Eloquent,Laravel 的旗舰数据库工具,它是建立在查询构建器基础上的。

Eloquent 是一个 ActiveRecord ORM,这意味着它是一个数据库抽象层,提供了与多种数据库类型交互的单一接口。“ActiveRecord”意味着单个 Eloquent 类不仅负责提供与整个表交互的能力(例如 User::all() 获取所有用户),还代表一个单独的表行(例如 $sharon = new User)。此外,每个实例都能够管理自己的持久性;你可以调用 $sharon->save()$sharon->delete()

Eloquent 主要关注简单性,并且像框架的其余部分一样,它依赖于“约定优于配置”,以允许您用最少的代码构建强大的模型。

例如,你可以使用在示例 5-18 中定义的模型执行示例 5-19 中的所有操作。

示例 5-18。最简单的 Eloquent 模型
<?phpuse Illuminate\Database\Eloquent\Model;class Contact extends Model {}
示例 5-19。使用最简单的 Eloquent 模型可以实现的操作
// In a controllerpublic function save(Request $request){ // Create and save a new contact from user input $contact = new Contact(); $contact->first_name = $request->input('first_name'); $contact->last_name = $request->input('last_name'); $contact->email = $request->input('email'); $contact->save(); return redirect('contacts');}public function show($contactId){ // Return a JSON representation of a contact based on a URL segment; // if the contact doesn't exist, throw an exception return Contact::findOrFail($contactId);}public function vips(){ // Unnecessarily complex example, but still possible with basic Eloquent // class; adds a "formalName" property to every VIP entry return Contact::where('vip', true)->get()->map(function ($contact) { $contact->formalName = "The exalted {$contact->first_name} of the {$contact->last_name}s"; return $contact; });}

怎么做?约定。Eloquent 假设表名(Contact变成contacts),然后你就有了一个完全功能的 Eloquent 模型。

让我们介绍如何使用 Eloquent 模型。

创建和定义 Eloquent 模型

首先,让我们创建一个模型。有一个 Artisan 命令可以做到这一点:

php artisan make:model Contact

这是我们将得到的,在app/Models/Contact.php中:

<?phpnamespace App\Models;use Illuminate\Database\Eloquent\Model;class Contact extends Model{ //}

如果你想在创建模型时自动创建一个迁移,请传递-m--migration标志:

php artisan make:model Contact --migration

表名

表名的默认行为是,Laravel 会将你的类名“蛇形命名”并将其复数化,因此SecondaryContact将访问名为secondary_contacts的表。如果你想自定义名称,请在模型上显式设置$table属性:

 protected $table = 'contacts_secondary';

主键

默认情况下,Laravel 假定每个表都将具有自动递增的整数主键,并将其命名为id

如果你想更改主键的名称,请更改$primaryKey属性:

 protected $primaryKey = 'contact_id';

如果你想将其设置为非自增,使用:

 public $incrementing = false;

时间戳

Eloquent 期望每个表都有created_atupdated_at时间戳列。如果你的表不需要它们,可以禁用$timestamps功能:

 public $timestamps = false;

您可以通过将$dateFormat类属性设置为自定义字符串来自定义 Eloquent 用于将时间戳存储到数据库中的格式。该字符串将使用 PHP 的date()语法进行解析,因此以下示例将日期存储为自 Unix 纪元以来的秒数:

 protected $dateFormat = 'U';

使用 Eloquent 检索数据

当你使用 Eloquent 从数据库中提取数据时,大多数时候会在你的 Eloquent 模型上使用静态调用。

让我们从获取所有内容开始:

$allContacts = Contact::all();

那很容易。让我们稍微过滤一下:

$vipContacts = Contact::where('vip', true)->get();

我们可以看到,EloquentFacade 使我们能够链式约束,从而使约束变得非常熟悉:

$newestContacts = Contact::orderBy('created_at', 'desc') ->take(10) ->get();

结果表明,一旦你超越了最初的 Facade 名称,你只是在使用 Laravel 的查询构建器。你可以做更多的事情——我们很快会涵盖这一点——但是你可以在DBFacade 上使用查询构建器的一切操作,同样也可以在你的 Eloquent 对象上使用。

获取一个

正如我们在本章早些时候介绍的,你可以使用 first() 只返回查询的第一条记录,或者使用 find() 仅检索提供的 ID 对应的记录。对于任何一个方法,如果在方法名称后添加“OrFail”,则如果没有匹配结果,它将抛出异常。这使得 findOrFail() 成为通过 URL 段查找实体(或者如果找不到匹配的实体则抛出异常)的常用工具,正如你可以在 示例5-20 中看到的。

示例 5-20. 在控制器方法中使用 Eloquent 的 OrFail() 方法
// ContactControllerpublic function show($contactId){ return view('contacts.show') ->with('contact', Contact::findOrFail($contactId));}

任何意图返回单个记录的方法(first()firstOrFail()find()findOrFail())将返回 Eloquent 类的实例。因此,Contact::first() 将返回填充了表中第一行数据的 Contact 类的实例。

你还可以使用 firstWhere() 方法,它是 where()first() 结合的快捷方式:

// With where() and first()Contact::where('name', 'Wilbur Powery')->first();// With firstWhere()Contact::firstWhere('name', 'Wilbur Powery');

正如你可以在 示例5-20 中看到的,我们不需要在我们的控制器中捕获 Eloquent 的模型未找到异常(Illuminate\Database\Eloquent\ModelNotFoundException);Laravel 的路由系统将捕获它并为我们抛出 404。

当然,你可以捕获特定的异常并处理它,如果你愿意的话。

获取多个

get() 在 Eloquent 中的工作方式与普通查询构建器调用中的工作方式相同——构建一个查询,并在末尾调用 get() 来获取结果:

$vipContacts = Contact::where('vip', true)->get();

然而,还有一个仅限于 Eloquent 的方法 all(),当人们希望获取表中所有数据的未过滤列表时,经常会看到它:

$contacts = Contact::all();

任何时候你可以使用 all(),你也可以使用 get()Contact::get() 的响应与 Contact::all() 相同。然而,一旦你开始修改你的查询——例如添加一个 where() 过滤器,all() 将不再工作,但 get() 仍将继续工作。

所以,尽管 all() 非常常见,我建议一切都使用 get(),并忽略 all() 的存在。

使用 chunk() 分块响应

如果你曾经需要一次处理大量(数千或更多)的记录,可能会遇到内存或锁定问题。Laravel 允许你将请求分成较小的片段(块)并批量处理它们,从而减少大请求的内存负载。示例5-21 演示了如何使用 chunk() 将查询分成每个包含 100 条记录的“块”。

示例 5-21. 将 Eloquent 查询分块以限制内存使用
Contact::chunk(100, function ($contacts) { foreach ($contacts as $contact) { // Do something with $contact }});

聚合

查询构建器上可用的聚合函数也可以在 Eloquent 查询上使用。例如:

$countVips = Contact::where('vip', true)->count();$sumVotes = Contact::sum('votes');$averageSkill = User::avg('skill_level');

使用 Eloquent 进行插入和更新

插入和更新值是 Eloquent 开始与普通查询构建器语法分离的地方之一。

插入

使用 Eloquent 插入新记录有两种主要方式。

首先,你可以创建你的 Eloquent 类的新实例,手动设置属性,然后在该实例上调用save(),就像在示例 5-22 中一样。

示例 5-22. 通过创建新实例插入 Eloquent 记录
$contact = new Contact;$contact->name = 'Ken Hirata';$contact->email = 'ken@hirata.com';$contact->save();// or$contact = new Contact([ 'name' => 'Ken Hirata', 'email' => 'ken@hirata.com',]);$contact->save();// or$contact = Contact::make([ 'name' => 'Ken Hirata', 'email' => 'ken@hirata.com',]);$contact->save();

直到你调用save()之前,这个Contact实例完全代表了这个联系人,但它尚未保存到数据库中。这意味着它没有id,如果应用程序退出,它将不会持久存在,并且它的created_atupdated_at值也没有设置。

你也可以像在示例 5-23 中展示的那样,将数组传递给Model::create()。与make()不同,create()在调用时会将实例保存到数据库中。

示例 5-23. 通过将数组传递给create()插入 Eloquent 记录
$contact = Contact::create([ 'name' => 'Keahi Hale', 'email' => 'halek481@yahoo.com',]);

请注意,在任何传递数组的上下文中(例如new Model()Model::make()Model::create()Model::update()),你通过Model::create()设置的每个属性都必须经过“批量赋值”批准,我们稍后会介绍。在示例 5-22 中的第一个示例中,这是不必要的,因为你可以单独分配每个属性。

注意,如果你使用Model::create(),你不需要调用save()方法—​这是作为模型的create()方法的一部分处理的。

更新

更新记录看起来与插入非常相似。你可以获取一个特定实例,更改其属性,然后保存,或者你可以进行一次调用并传递一组更新后的属性。示例 5-24 说明了第一种方法。

示例 5-24. 通过更新实例并保存来更新 Eloquent 记录
$contact = Contact::find(1);$contact->email = 'natalie@parkfamily.com';$contact->save();

由于此记录已经存在,它已经具有created_at时间戳和id,这些将保持不变,但updated_at字段将更改为当前日期和时间。示例 5-25 说明了第二种方法。

示例 5-25. 通过将数组传递给update()方法更新一个或多个 Eloquent 记录
Contact::where('created_at', '<', now()->subYear()) ->update(['longevity' => 'ancient']);// or$contact = Contact::find(1);$contact->update(['longevity' => 'ancient']);

此方法期望一个数组,其中每个键是列名,每个值是列值。

批量赋值

我们已经看过几个例子,演示了如何将值数组传递给 Eloquent 类方法。然而,在你定义模型上哪些字段是“fillable”之前,这些例子都不会真正起作用。

这样做的目的是保护你免受(可能是恶意的)用户输入意外设置你不想更改的字段的新值。考虑在示例 5-26 中的常见场景。

示例 5-26. 使用请求输入的全部内容更新 Eloquent 模型
// ContactControllerpublic function update(Contact $contact, Request $request){ $contact->update($request->all());}

Illuminate 的Request对象在示例 5-26 中将用户输入的每一部分传递给update()方法。该all()方法包括诸如 URL 参数和表单输入之类的内容,因此恶意用户可以轻松添加一些东西,例如idowner_id,这些你可能不希望更新。

幸运的是,在定义模型的可填充字段之前,这实际上不起作用。您可以定义允许的“可填充字段或不允许的 受保护 字段,以确定哪些字段可以或不可以通过批量 赋值(即通过将值数组传递到 create()update())进行编辑。请注意,非可填充属性仍然可以通过直接赋值进行更改(例如,$contact->password = '*abc*';)。示例5-27 展示了两种方法。

示例 5-27. 使用 Eloquent 的可填充或受保护属性来定义可批量赋值的字段
class Contact extends Model{ protected $fillable = ['name', 'email']; // or protected $guarded = ['id', 'created_at', 'updated_at', 'owner_id'];}

在 示例5-26 中,我们需要使用 Eloquent 的批量赋值保护,因为我们正在使用 Request 对象上的 all() 方法来传递用户输入的 全部内容

Eloquent 的批量赋值保护在这里是一个很好的工具,但还有一个有用的技巧可以防止您接受用户输入的任何旧数据。

Request 类具有 only() 方法,允许您从用户输入中仅提取几个键。现在您可以这样做:

Contact::create($request->only('name', 'email'));

firstOrCreate() 和 firstOrNew()

有时您希望告诉应用程序,“使用这些属性获取一个实例,如果不存在,则创建它。”这就是 firstOr*() 方法发挥作用的地方。

firstOrCreate()firstOrNew() 方法将一个键值对数组作为它们的第一个参数:

$contact = Contact::firstOrCreate(['email' => 'luis.ramos@myacme.com']);

它们都会查找并检索与这些参数匹配的第一条记录,如果没有匹配的记录,则会创建一个具有这些属性的实例;firstOrCreate() 将将该实例持久化到数据库中然后返回它,而 firstOrNew() 则会返回它而不保存它。

如果您将值数组作为第二个参数传递,这些值将被添加到创建的条目中(如果已创建),但 用于查找条目是否存在。

使用 Eloquent 进行删除

使用 Eloquent 进行删除与使用 Eloquent 进行更新非常相似,但是通过(可选的)软删除,您可以将已删除的项目归档以供以后检查甚至恢复。

普通删除

删除模型记录的最简单方法是在实例本身上调用 delete() 方法:

$contact = Contact::find(5);$contact->delete();

但是,如果您只有 ID,则没有理由查找实例只是为了删除它;您可以将 ID 或 ID 数组传递给模型的 destroy() 方法,直接删除它们:

Contact::destroy(1);// orContact::destroy([1, 5, 7]);

最后,您可以删除查询的所有结果:

Contact::where('updated_at', '<', now()->subYear())->delete();

软删除

软删除 将数据库行标记为已删除,但实际上并未从数据库中删除它们。这使您可以以后检查它们,以便在显示历史信息时显示比“无信息,已删除”更多的记录,并允许用户(或管理员)恢复一些或所有数据。

手动编写启用软删除的应用程序的难点在于,每个查询你所写的都需要排除软删除的数据。幸运的是,如果你使用 Eloquent 的软删除,除非显式要求将它们重新引入,否则你所做的每个查询都会默认排除软删除的数据。

Eloquent 的软删除功能要求在表中添加deleted_at列。一旦在 Eloquent 模型上启用了软删除,除非显式包含软删除的记录,否则你编写的每个查询都将被作用域限制以忽略软删除的行。

启用软删除

要启用软删除,需要做两件事情:在迁移中添加deleted_at列,并在模型中导入SoftDeletes特性。在模式生成器上有一个softDeletes()方法可以用来向表中添加deleted_at列,就像你在示例 5-28 中看到的那样。示例 5-29 展示了启用软删除的 Eloquent 模型。

示例 5-28. 添加软删除列到表的迁移
Schema::table('contacts', function (Blueprint $table) { $table->softDeletes();});
示例 5-29. 启用软删除的 Eloquent 模型
<?phpuse Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\SoftDeletes;class Contact extends Model{ use SoftDeletes; // use the trait}

一旦你进行这些更改,每次调用delete()destroy()将会将你的行的deleted_at列设置为当前日期和时间,而不是删除该行。随后的所有查询将会因此排除该行。

使用软删除进行查询

那么,我们如何获取软删除的条目呢?

首先,你可以将软删除的条目添加到查询中:

$allHistoricContacts = Contact::withTrashed()->get();

接下来,你可以使用trashed()方法来查看特定实例是否已被软删除:

if ($contact->trashed()) { // do something}

最后,你可以仅仅获取软删除的条目:

$deletedContacts = Contact::onlyTrashed()->get();

恢复软删除的实体

如果你想要恢复一个软删除的条目,可以在实例或查询上运行restore()

$contact->restore();// orContact::onlyTrashed()->where('vip', true)->restore();

强制删除软删除的实体

你可以通过在实体或查询上调用forceDelete()来删除软删除的实体:

$contact->forceDelete();// orContact::onlyTrashed()->forceDelete();

作用域

我们已经涵盖了“过滤”查询,意味着任何不仅仅返回表中每个结果的查询。但到目前为止,在本章中编写它们时,使用的都是手动的查询构建器过程。

Eloquent 中的局部和全局作用域允许你定义预定义的作用域(过滤器),你可以在每次查询模型时使用(全局),或者在使用特定方法链查询时使用(局部)。

局部作用域

局部作用域是最容易理解的。让我们来看这个例子:

$activeVips = Contact::where('vip', true)->where('trial', false)->get();

首先,如果我们一遍又一遍地编写这些查询方法的组合,将会变得很烦琐。而且,定义“活跃 VIP”的知识现在散布在我们的整个应用程序中。我们希望将这些知识集中化。如果我们可以只写这样一段代码呢?

$activeVips = Contact::activeVips()->get();

我们可以这样做——称之为局部作用域。并且在Contact类上定义起来很容易,就像你在示例 5-30 中看到的那样。

示例 5-30. 在模型上定义局部作用域
class Contact extends Model{ public function scopeActiveVips($query) { return $query->where('vip', true)->where('trial', false); }

要定义一个本地作用域,我们在 Eloquent 类中添加一个以“scope”开头并包含作用域名称的 Pascal case 版本的方法。该方法传递一个查询构建器,并需要返回一个查询构建器,当然你可以在返回之前修改查询——这就是整个意义所在。

你也可以定义接受参数的作用域,示例见示例5-31。

示例 5-31. 向作用域传递参数
class Contact extends Model{ public function scopeStatus($query, $status) { return $query->where('status', $status); }

你可以以相同的方式使用它们,只需将参数传递给作用域即可:

$friends = Contact::status('friend')->get();

你也可以在两个本地作用域之间链式使用orWhere()

$activeOrVips = Contact::active()->orWhere()->vip()->get();

全局作用域

记住我们之前谈到的软删除只在将每个模型上的每个查询范围限制为忽略软删除项时才起作用?这就是一个全局作用域。我们可以定义自己的全局作用域,将其应用于来自给定模型的每个查询。

定义全局作用域有两种方法:使用闭包或使用整个类。在每种情况下,你都将在模型的booted()方法中注册定义的作用域。让我们从闭包方法开始,示例见示例5-32。

示例 5-32. 使用闭包添加全局作用域
...class Contact extends Model{ protected static function booted() { static::addGlobalScope('active', function (Builder $builder) { $builder->where('active', true); }); }

就这样。我们刚刚添加了一个名为active的全局作用域,现在这个模型上的每个查询都将仅限于active设置为true的行。

接下来,让我们尝试更长的方法,如示例5-33 所示。运行以下命令来创建一个名为 ActiveScope 的类。

php artisan make:scope ActiveScope

它将具有一个apply()方法,该方法接受一个查询构建器的实例和模型的实例。

示例 5-33. 创建一个全局作用域类
<?phpnamespace App\Models\Scopes;use Illuminate\Database\Eloquent\Builder;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Scope;class ActiveScope implements Scope{ public function apply(Builder $builder, Model $model): void { $builder->where('active', true); }}

要将此作用域应用于模型,再次重写父类的booted()方法,并在类上使用static调用addGlobalScope(),示例见示例5-34。

示例 5-34. 应用基于类的全局作用域
<?phpuse App\Models\Scopes;use Illuminate\Database\Eloquent\Model;class Contact extends Model{ protected static function booted() { static::addGlobalScope(new ActiveScope); }}

你可能已经注意到,几个示例中都使用了没有命名空间的Contact类。这是不正常的,我之所以这样做是为了节省书中的空间。通常,即使是你顶层的模型也应该位于像App\Models\Contact这样的命名空间下。

移除全局作用域

有三种方法可以移除全局作用域,所有方法都使用withoutGlobalScope()withoutGlobalScopes()。如果你要移除基于闭包的作用域,那么该作用域的addGlobalScope()注册的第一个参数将是你用来启用它的键:

$allContacts = Contact::withoutGlobalScope('active')->get();

如果你要移除单个基于类的全局作用域,你可以将类名传递给withoutGlobalScope()withoutGlobalScopes()

Contact::withoutGlobalScope(ActiveScope::class)->get();Contact::withoutGlobalScopes([ActiveScope::class, VipScope::class])->get();

或者,你可以仅在查询中禁用所有全局作用域:

Contact::withoutGlobalScopes()->get();

使用访问器、修改器和属性类型转换自定义字段交互

现在我们已经讨论了如何使用 Eloquent 将记录存入和从数据库中取出,接下来让我们来讨论如何装饰和操作你的 Eloquent 模型的各个属性。

访问器、变动器和属性转换都允许你自定义 Eloquent 实例的各个属性的输入或输出方式。如果不使用这些功能,你的每个 Eloquent 实例的属性都将被视为字符串,且不能在模型上有任何数据库中不存在的属性。但我们可以改变这一点。

访问器

访问器 允许你为从模型实例 读取 数据时在你的 Eloquent 模型上定义自定义属性。这可能是因为你想改变特定列的输出方式,或者因为你想创建一个数据库表中根本不存在的自定义属性。

通过在模型上创建一个方法来定义访问器,方法名与属性名相同,但采用驼峰命名。因此,如果你的属性名是 first_name,访问器方法将命名为 firstName。然后,该方法需要有返回类型,表明它返回 Illuminate\Database\Eloquent\Casts\Attribute 的实例。

让我们试一试。首先,我们将装饰一个预先存在的列(Example5-35)。

Example 5-35. 使用 Eloquent 访问器装饰预先存在的列
// Model definition:use Illuminate\Database\Eloquent\Casts\Attribute;class Contact extends Model{ protected function name(): Attribute { return Attribute::make( get: fn (string $value) => $value ?: '(No name provided)', ); }}// Accessor usage:$name = $contact->name;

但我们也可以使用访问器定义从未在数据库中存在的属性,正如在 Example5-36 中所见。

Example 5-36. 使用 Eloquent 访问器定义没有后备列的属性
// Model definition:class Contact extends Model{ protected function fullName(): Attribute { return Attribute::make( get: fn () => $this->first_name . ' ' . $this->last_name, ); }}// Accessor usage:$fullName = $contact->full_name;

变动器

变动器 与访问器的工作方式相同,不同之处在于它们用于确定如何处理 设置 数据,而不是 获取 数据。与访问器一样,你可以使用它们修改写入现有列数据的过程,或者允许设置数据库中不存在的列。

变动器的定义方式与访问器相同,但不使用 get 参数,而是使用 set 参数。

让我们试一试。首先,我们将添加对更新预先存在列的约束(Example5-37)。

Example 5-37. 修改使用 Eloquent 变动器设置属性的值
// Defining the mutatorclass Order extends Model{ protected function amount(): Attribute { return Attribute::make( set: fn (string $value) => $value > 0 ? $value : 0, ); }}// Using the mutator$order->amount = '15';

现在让我们添加一个代理列用于设置,如 Example5-38 所示。如果我们一次设置多个列的值,或者我们自定义要设置的列的名称,我们可以从 set() 方法返回一个数组。

Example 5-38. 使用 Eloquent 变动器允许设置不存在属性的值
// Defining the mutatorclass Order extends Model{ protected function workgroupName(): Attribute { return Attribute::make( set: fn (string $value) => [ 'email' => "{$value}@ourcompany.com", ], ); }}// Using the mutator$order->workgroup_name = 'jstott';

如你所料,为不存在的列创建变动器相对较少见,因为设置一个属性并改变另一列可能会令人困惑,但这是可能的。

属性转换

你可以想象编写访问器以将所有整数类型字段强制转换为整数,将 JSON 编码和解码以存储在 TEXT 列中,或者将 TINYINT 01 转换为布尔值。

幸运的是,Eloquent 已经为此设计了一个系统。它被称为 属性转换,它允许您定义任何列始终被视为特定数据类型,无论读取还是写入时都如此。选项列在 表5-1 中。

表 5-1. 可能的属性转换列类型

类型描述
int&#124;integer使用 PHP (int) 进行转换
real&#124;float&#124;double使用 PHP (float) 进行转换
decimal:<digits>使用 PHP 的 number_format(),并指定小数位数进行转换。
string使用 PHP (string) 进行转换
bool&#124;boolean使用 PHP (bool) 进行转换
object&#124;json解析为 JSON,并作为 stdClass 对象
array解析为 JSON,并作为数组
collection解析为 JSON,并作为集合
date&#124;datetime在数据库 DATETIME 和 Carbon 之间进行解析
timestamp在数据库 TIMESTAMP 和 Carbon 之间进行解析
encrypted处理字符串的加密和解密
enum转换为枚举类型
hashed处理字符串的哈希值

示例5-39 展示了如何在您的模型中使用属性转换。

示例 5-39. 在 Eloquent 模型上使用属性转换
use App\Enums\SubscriptionStatus;class Contact extends Model{ protected $casts = [ 'vip' => 'boolean', 'children_names' => 'array', 'birthday' => 'date', 'subscription' => SubscriptionStatus::class ];}

自定义属性转换

如果内置的属性类型不够用,我们可以构建自定义转换类型,并在 $casts 数组中使用它们。

可以将 自定义转换类型 定义为一个常规的 PHP 类,具有 getset 方法。当从 Eloquent 模型检索给定属性时,将调用 get 方法。在将属性保存到数据库之前,将调用 set 方法,如您可以在 示例5-40 中看到的。

示例 5-40. 一个样例自定义转换类型
<?phpnamespace App\Casts;use Carbon\Carbon;use Illuminate\Support\Facades\Crypt;use Illuminate\Contracts\Database\Eloquent\CastsAttributes;use Illuminate\Database\Eloquent\Model;class Encrypted implements CastsAttributes{ /** * Cast the given value. * * @param array<string, mixed> $attributes */ public function get(Model $model, string $key, mixed $value, array $attributes) { return Crypt::decrypt($value); } /** * Prepare the given value for storage. * * @param array<string, mixed> $attributes */ public function set(Model $model, string $key, mixed $value, array $attributes) { return Crypt::encrypt($value); }}

您可以在您的 Eloquent 模型的 $casts 属性中使用自定义转换:

protected $casts = [ 'ssn' => \App\Casts\Encrypted::class,];

Eloquent 集合

当您在 Eloquent 中进行任何可能返回多行结果的查询调用时,它们将以 Eloquent 集合的形式返回,这是一种专门的集合类型。让我们来看看集合和 Eloquent 集合,以及它们比纯数组更好的地方。

引入基础集合

Laravel 的 Collection 对象 (Illuminate\Support\Collection) 类似于增强型数组。它们在类似数组的对象上公开的方法非常有用,使用一段时间后,您可能会希望将它们引入非 Laravel 项目中 —— 您可以通过 Illuminate/Collections package 来实现这一点。

创建集合的最简单方法是使用 collect() 辅助函数。可以传入一个数组,也可以不带参数使用它,后续再将项目推送到其中。让我们试试:

$collection = collect([1, 2, 3]);

现在假设我们想要过滤掉任何偶数:

$odds = $collection->reject(function ($item) { return $item % 2 === 0;});

或者,如果我们想要得到每个项目乘以 10 的版本,可以这样做:

$multiplied = $collection->map(function ($item) { return $item * 10;});

我们甚至可以仅获取偶数,将它们全部乘以10,并通过sum()将它们缩减为一个单一的数字:

$sum = $collection ->filter(function ($item) { return $item % 2 == 0; })->map(function ($item) { return $item * 10; })->sum();

如你所见,集合提供了一系列方法,可以选择链式调用以在数组上执行功能操作。它们提供了与本地 PHP 方法(如 array_map()array_reduce())相同的功能,但你无需记忆 PHP 不可预测的参数顺序,而且方法链语法更加可读。

Collection 类中提供了超过 60 个方法,包括 max()whereIn()flatten()flip() 等方法——这里没有足够的空间来覆盖它们所有。我们将在第十七章讨论更多方法,或者你可以查看Laravel 集合文档以查看所有方法。

集合还可以在任何你可以使用数组的地方(除了类型提示)使用。它们允许迭代,所以你可以将它们传递给 foreach;它们还允许数组访问,所以如果它们有键,你可以尝试 $a = $collection['a']

延迟集合

延迟集合利用 PHP 生成器的强大功能来处理非常大的数据集,同时保持你的应用程序内存使用量非常低。

想象一下需要遍历数据库中的 100,000 个联系人。如果你使用 Laravel 的普通 Collections,很快就会遇到内存问题;所有 100,000 条记录将加载到内存中,这对你的机器来说是一个很大的负担:

$verifiedContacts = App\Contact::all()->filter(function ($contact) { return $contact->isVerified();});

Eloquent 可以轻松地使用延迟集合来处理你的 Eloquent 模型。如果你使用 cursor 方法,Eloquent 模型将返回一个 LazyCollection 实例,而不是默认的 Collection 类。通过使用延迟集合,你的应用程序每次只会将一条记录加载到内存中:

$verifiedContacts = App\Contact::cursor()->filter(function ($contact) { return $contact->isVerified();});

Eloquent 集合增加了什么

每个 Eloquent 集合都是一个普通集合,但扩展了对 Eloquent 结果集合特定需求的支持。

再次说明,这里没有足够的空间来覆盖所有新增功能,但它们的核心是与集合交互的独特方面,不仅仅是一般对象的集合,而是用于表示数据库行的对象。

例如,每个 Eloquent 集合都有一个名为 modelKeys() 的方法,返回集合中每个实例的主键数组。find($id) 查找具有主键 $id 的实例。

这里还有一个额外的功能,就是定义任何给定模型应该返回其结果的特定集合类。因此,如果你想向你的 Order 模型的任何对象集合中添加特定方法——可能与汇总订单财务详情相关——你可以创建一个自定义的 OrderCollection,扩展 Illuminate\Database\Eloquent\Collection,然后在你的模型中注册它,如示例5-41 所示。

示例 5-41. 用于 Eloquent 模型的自定义Collection
...class OrderCollection extends Collection{ public function sumBillableAmount() { return $this->reduce(function ($carry, $order) { return $carry + ($order->billable ? $order->amount : 0); }, 0); }}...class Order extends Model{ public function newCollection(array $models = []) { return new OrderCollection($models); }

现在,每当你获取Order集合(例如,从Order::all()获取)时,它实际上是OrderCollection类的一个实例:

$orders = Order::all();$billableAmount = $orders->sumBillableAmount();

Eloquent 序列化

序列化是指当你拿到一个复杂的东西——比如一个数组或对象——然后将其转换成字符串的过程。在基于 Web 的情境中,这个字符串通常是 JSON,但也可能采用其他形式。

序列化复杂的数据库记录可能会很复杂,这是许多 ORM 在某些地方不足的地方之一。幸运的是,你可以免费使用 Eloquent 提供的两种强大方法:toArray()toJson()。集合也有toArray()toJson(),因此所有这些都是有效的:

$contactArray = Contact::first()->toArray();$contactJson = Contact::first()->toJson();$contactsArray = Contact::all()->toArray();$contactsJson = Contact::all()->toJson();

你还可以将一个 Eloquent 实例或集合转换为字符串($string = (string) $contact;),但是模型和集合都只会运行toJson()并返回结果。

从路由方法直接返回模型

Laravel 的路由最终将路由方法返回的所有内容转换为字符串,因此你可以使用一个巧妙的技巧。如果在控制器中返回 Eloquent 调用的结果,它将自动转换为字符串,因此以 JSON 返回的路由可以简单地像示例5-42 中的任意一个那样简单。

示例 5-42. 直接从路由返回 JSON
// routes/web.phpRoute::get('api/contacts', function () { return Contact::all();});Route::get('api/contacts/{id}', function ($id) { return Contact::findOrFail($id);});

从 JSON 中隐藏属性

在 API 中使用 JSON 返回是非常常见的,而在这些上下文中隐藏某些属性也很常见,因此 Eloquent 使得每次转换为 JSON 时隐藏任何属性变得非常容易。

你可以禁止特定的属性,隐藏你列出的那些:

class Contact extends Model{ public $hidden = ['password', 'remember_token'];

或者允许指定的属性,仅显示你列出的那些:

class Contact extends Model{ public $visible = ['name', 'email', 'status'];

这也适用于关系:

class User extends Model{ public $hidden = ['contacts']; public function contacts() { return $this->hasMany(Contact::class); }

默认情况下,当你获取数据库记录时,关系的内容是不会被加载的,因此无论你是否隐藏它们都无所谓。但是,就像你很快就会了解到的那样,可以获取带有其相关项的记录,并且在这种情况下,如果选择隐藏该关系,则这些项目将不会包含在记录的序列化副本中。

如果你现在好奇的话,你可以获取一个带有所有联系人的User——假设你已经正确设置了关系——使用以下调用:

$user = User::with('contacts')->first();

可能会有时候,你想要使一个属性仅在单个调用中可见。这是可能的,使用 Eloquent 方法makeVisible()

$array = $user->makeVisible('remember_token')->toArray();

如果你为不存在的列创建了一个访问器——例如,我们的full_name列来自示例5-36——你可以将其添加到模型的$appends数组中,这样会将其添加到数组和 JSON 输出中:

class Contact extends Model{ protected $appends = ['full_name']; public function getFullNameAttribute() { return "{$this->first_name} {$this->last_name}"; }}

Eloquent 关系

在关系数据库模型中,预计会有表彼此相关——因此得名。Eloquent 提供了简单而强大的工具,使得关联数据库表的过程比以往更容易。

本章中许多示例都围绕一个 用户 拥有多个 联系人,这是一个相对常见的情况。

在像 Eloquent 这样的 ORM 中,您会将其称为 一对多 关系:一个用户 拥有多个 联系人。

如果这是一个客户关系管理系统(CRM),其中一个联系人可以分配给多个用户,那么这将是一个多对多关系:多个用户可以关联到一个联系人,每个用户可以与多个联系人相关联。一个用户 拥有并且属于多个 联系人。

如果每个联系人可以有多个电话号码,用户想要一个他们 CRM 中每个电话号码的数据库,您可以说用户 通过 联系人 拥有多个 电话号码——也就是说,一个用户 拥有多个 联系人,每个联系人 拥有多个 电话号码,因此联系人是一种中间介质。

如果每个联系人都有一个地址,但您只想追踪一个地址怎么办?您可以在Contact上拥有所有地址字段,但您也可以创建一个Address模型——这意味着联系人 拥有一个 地址。

最后,如果您想要能够收藏联系人,还有事件怎么办?这将是一个多态关系,其中一个用户 拥有多个 星标,但有些可能是联系人,有些可能是事件。

让我们深入了解如何定义和访问这些关系。

一对一

让我们从简单的开始:一个Contact 拥有一个 PhoneNumber。这个关系在示例5-43 中定义。

示例 5-43. 定义一对一关系
class Contact extends Model{ public function phoneNumber() { return $this->hasOne(PhoneNumber::class); }

正如您所看到的,定义关系的方法在 Eloquent 模型本身上($this->hasOne())并且在这种情况下至少需要将与之相关的类的完全限定类名传递进去。

在数据库中如何定义这一点?由于我们已经定义Contact拥有一个PhoneNumber,Eloquent 期望支持PhoneNumber类的表(可能是phone_numbers)有一个contact_id列。如果您使用不同的名称(例如owner_id),您需要更改您的定义:

return $this->hasOne(PhoneNumber::class, 'owner_id');

这是我们如何访问ContactPhoneNumber

$contact = Contact::first();$contactPhone = $contact->phoneNumber;

注意,我们在示例5-43 中定义了phoneNumber()方法,但我们使用->phoneNumber访问它。这就是魔力所在。您还可以使用->phone_number访问它。这将返回与相关PhoneNumber记录的完整 Eloquent 实例。

但如果我们想要从PhoneNumber访问Contact怎么办?也有一个方法(参见示例5-44)。

示例 5-44. 定义一对一关系的逆向
class PhoneNumber extends Model{ public function contact() { return $this->belongsTo(Contact::class); }

然后我们以同样的方式访问它:

$contact = $phoneNumber->contact;

一对多

最常见的是一对多关系。让我们看看如何定义我们的User 拥有多个 Contacts (示例5-45)。

示例 5-45. 定义一对多关系
class User extends Model{ public function contacts() { return $this->hasMany(Contact::class); }

再次,这期望Contact模型的后台表(可能是contacts)有一个user_id列。如果没有,请通过将正确的列名作为hasMany()的第二个参数来覆盖它。

我们可以获取UserContact如下:

$user = User::first();$usersContacts = $user->contacts;

就像一对一关系一样,我们使用关系方法的名称,并将其调用为属性而不是方法。但是,这个方法返回的是一个集合而不是一个模型实例。这是一个普通的 Eloquent 集合,所以我们可以做各种有趣的事情:

$donors = $user->contacts->filter(function ($contact) { return $contact->status == 'donor';});$lifetimeValue = $contact->orders->reduce(function ($carry, $order) { return $carry + $order->amount;}, 0);

就像一对一关系一样,我们也可以定义反向关系(Example5-46)。

Example 5-46. 定义一对多关系的反向关系
class Contact extends Model{ public function user() { return $this->belongsTo(User::class); }

就像一对一关系一样,我们可以从Contact中访问User

$userName = $contact->user->name;

大多数情况下,我们通过在父级上运行save()并传入相关项来附加项目,例如$⁠u⁠s⁠e⁠r⁠-⁠>​c⁠o⁠n⁠t⁠a⁠c⁠t⁠s⁠(⁠)​-⁠>⁠s⁠a⁠v⁠e⁠($contact)。但是,如果你想在已附加的(“子”)项目上执行这些行为,可以在返回belongsTo关系的方法上使用associate()dissociate()

$contact = Contact::first();$contact->user()->associate(User::first());$contact->save();// and later$contact->user()->dissociate();$contact->save();

使用关系作为查询构建器

到目前为止,我们将方法名(例如contacts())作为属性调用(例如$user->contacts)。如果我们将其作为方法调用会发生什么?它不会处理关系,而是返回一个预设的查询构建器。

所以,如果你有User 1,并调用其contacts()方法,你现在将得到一个查询构建器,预设为“所有user_id字段值为1的联系人”。然后你可以从这里构建出一个功能性的查询:

$donors = $user->contacts()->where('status', 'donor')->get();

仅选择具有相关项的记录

你可以选择只选择符合特定条件的与相关项目的记录,使用has()

$postsWithComments = Post::has('comments')->get();

你也可以进一步调整条件:

$postsWithManyComments = Post::has('comments', '>=', 5)->get();

你可以嵌套条件:

$usersWithPhoneBooks = User::has('contacts.phoneNumbers')->get();

最后,你可以在相关项目上编写自定义查询:

// Gets all contacts with a phone number containing the string "867-5309"$jennyIGotYourNumber = Contact::whereHas('phoneNumbers', function ($query) { $query->where('number', 'like', '%867-5309%');})->get();// Shortened version of the same code above$jennyIGotYourNumber = Contact::whereRelation( 'phoneNumbers', 'number', 'like','%867-5309')->get();

一对多关系的 has one of many

从一对多关系中检索记录的常见情况是,你只想从该关系中检索一个项目,通常是最新的或最旧的。Laravel 为这些情况提供了一个方便的工具:has one of many。

一对多关系允许你定义一个给定方法应该检索相关集合中的最新项目,或者最旧项目,或者具有任何特定列的最小或最大值的项目,如 Example5-47 所示。

Example 5-47. 定义一对多关系的 has-one-of-many 关系
class User extends Model{ public function newestContact(): HasOne { return $this->hasOne(Contact::class)->latestOfMany(); } public function oldestContact(): HasOne { return $this->hasOne(Contact::class)->oldestOfMany(); } public function emergencyContact(): HasOne { return $this->hasOne(Contact::class)->ofMany('priority', 'max'); }

多对多关系

hasManyThrough()实际上是一个方便的方法,用于获取关系的关系。想象一下我之前给出的例子,一个User有许多Contact,每个Contact有许多PhoneNumber。如果你想获取用户的联系电话列表怎么办?这就是一个多对多的关系。

这个结构假设你的contacts表有一个user_id来将联系人与用户关联起来,而phone_numbers表有一个contact_id将其与联系人关联起来。然后,我们在User上定义关系,如 Example5-48 所示。

Example 5-48. 定义一个多对多关系
class User extends Model{ public function phoneNumbers() { // Newer string-based syntax return $this->through('contact')->has('phoneNumber'); // Traditional syntax return $this->hasManyThrough(PhoneNumber::class, Contact::class); }

您可以使用$user->phone_numbers访问此关系。如果需要在中间或远程模型上自定义关系键,请使用传统语法;您可以在中间模型上定义关键字(使用h⁠a⁠s​M⁠a⁠n⁠y⁠Through()的第三个参数)和在远程模型上定义关系键(使用第四个参数)。

一对一通过

hasOneThrough()hasManyThrough()类似,但不是通过中间项目访问许多相关项目,而是仅通过单个中间项目访问单个相关项目。

如果每个用户属于一个公司,而公司有一个单独的电话号码,并且您希望能够通过获取用户公司的电话号码来获取用户的电话号码,那就是一对一通过关系,如 Example5-49 所示。

Example 5-49. 定义一对一通过关系
class User extends Model{ public function phoneNumber() { // Newer string-based syntax return $this->through('company')->has('phoneNumber'); // Traditional syntax return $this->hasOneThrough(PhoneNumber::class, Company::class); }

多对多

这是事情开始变得复杂的地方。让我们以允许User拥有多个Contact的 CRM 示例为例,每个Contact又与多个User相关联。

首先,我们像在 Example5-50 中一样在User上定义关系。

Example 5-50. 定义多对多关系
class User extends Model{ public function contacts() { return $this->belongsToMany(Contact::class); }}

由于这是多对多关系,逆关系看起来完全相同(Example5-51)。

Example 5-51. 定义多对多关系的逆
class Contact extends Model{ public function users() { return $this->belongsToMany(User::class); }}

由于单个Contact不能有user_id列,单个User也不能有contact_id列,多对多关系依赖于连接这两者的中间表。这种表的常规命名是将两个单数表名按字母顺序排列在一起,并用下划线分隔。

因此,由于我们正在连接userscontacts,我们的中间表应该命名为contact_user(如果你想自定义表名,请将其作为第二个参数传递给belongsToMany()方法)。它需要两列:contact_iduser_id

就像使用hasMany()一样,我们可以从两个方向访问相关项的集合,这一次是从双方面(Example5-52)。

Example 5-52. 访问多对多关系的双方相关项
$user = User::first();$user->contacts->each(function ($contact) { // do something});$contact = Contact::first();$contact->users->each(function ($user) { // do something});$donors = $user->contacts()->where('status', 'donor')->get();

从中间表获取数据

多对多关系的一个独特之处在于它是我们第一个具有中间表的关系。在中间表中数据越少越好,但也有一些情况下在中间表中存储信息很有价值,比如,你可能想要存储一个created_at字段,以查看这个关系是何时创建的。

为了存储这些字段,您必须将它们添加到关系定义中,就像在 Example5-53 中一样。您可以使用withPivot()定义特定字段,或者使用withTimestamps()添加created_atupdated_at时间戳。

Example 5-53. 向中间记录添加字段
public function contacts(){ return $this->belongsToMany(Contact::class) ->withTimestamps() ->withPivot('status', 'preferred_greeting');}

当通过关系获取模型实例时,它将在其上有一个 pivot 属性,该属性将表示它在刚刚提取的枢纽表中的位置。因此,您可以执行类似于 示例 5-54 的操作。

示例 5-54. 从相关项目的枢纽条目获取数据
$user = User::first();$user->contacts->each(function ($contact) { echo sprintf( 'Contact associated with this user at: %s', $contact->pivot->created_at );});

如果您愿意,您可以使用 as() 方法自定义 pivot 键的名称,如 示例 5-55 中所示。

示例 5-55. 自定义 pivot 属性名称
// User modelpublic function groups(){ return $this->belongsToMany(Group::class) ->withTimestamps() ->as('membership');}
// Using this relationship:User::first()->groups->each(function ($group) { echo sprintf( 'User joined this group at: %s', $group->membership->created_at );});

多态

请记住,我们的多态关系是指我们有多个对应于相同关系的 Eloquent 类。我们现在要使用的是 Star(例如收藏夹)。用户可以同时收藏 ContactEvent,这就是 多态 名称的由来:有多种类型的对象使用单一接口。

因此,我们需要三个表(starscontactsevents)和三个模型(StarContactEvent)。实际上,您需要四个,因为我们还需要 usersUser,但我们马上会解决这个问题。contactsevents 表将保持正常,而 stars 表将包含 idstarrable_idstarrable_type 字段。对于每个 Star,我们将定义它是哪种“类型”(例如 ContactEvent)以及该类型的哪个 ID(例如 1)。

让我们创建我们的模型,如 示例 5-56 中所示。

示例 5-56. 创建多态星标系统的模型
class Star extends Model{ public function starrable() { return $this->morphTo(); }}class Contact extends Model{ public function stars() { return $this->morphMany(Star::class, 'starrable'); }}class Event extends Model{ public function stars() { return $this->morphMany(Star::class, 'starrable'); }}

那么,我们如何创建一个 Star 呢?

$contact = Contact::first();$contact->stars()->create();

就是这么简单。现在 Contact 已经被加星了。

要找到给定 Contact 上的所有 Star,我们像 示例 5-57 中那样调用 stars() 方法。

示例 5-57. 检索多态关系的实例
$contact = Contact::first();$contact->stars->each(function ($star) { // Do stuff});

如果我们有一个 Star 实例,我们可以通过调用用于定义其 morphTo 关系的方法来获取其目标,在这种情况下是 starrable()。请查看 示例 5-58。

示例 5-58. 检索多态实例的目标
$stars = Star::all();$stars->each(function ($star) { var_dump($star->starrable); // An instance of Contact or Event});

最后,您可能会问:“如果我想知道谁收藏了这个联系人怎么办?”这是一个很好的问题。只需将 user_id 添加到您的 stars 表中,然后设置 User 有多个 StarStar 属于 一个 User——一个一对多的关系(示例 5-59)。stars 表几乎成为您的 User 和您的 ContactEvent 之间的枢纽表。

示例 5-59. 扩展多态系统以区分用户
class Star extends Model{ public function starrable() { return $this->morphsTo; } public function user() { return $this->belongsTo(User::class); }}class User extends Model{ public function stars() { return $this->hasMany(Star::class); }}

就这样!您现在可以运行 $star->user$user->stars 来查找 UserStar 列表,或者从 Star 中查找星标的 User。此外,当您创建一个新的 Star 时,现在您需要传递 User

$user = User::first();$event = Event::first();$event->stars()->create(['user_id' => $user->id]);

多对多多态

关系类型中最复杂且最不常见的多对多多态关系就像多态关系一样,但不是一对多,而是多对多。

这种关系类型的最常见示例是标签,所以我将保持它的安全性并将其用作我们的示例。假设您希望能够为ContactEvent打标签。多对多多态关系的独特之处在于它是多对多的:每个标签可以应用于多个项目,并且每个标记的项目可能有多个标签。而且,它是多态的:标签可以与多种不同类型的项目相关联。对于数据库,我们将从多态关系的正常结构开始,但还将添加一个透视表。

这意味着我们需要一个contacts表,一个events表和一个tags表,都像正常的表一样具有 ID 和您想要的任何属性,还有一个新的taggables表,它将具有tag_idtaggable_idtaggable_type字段。taggables表中的每个条目将关联一个标签与一个可标记的内容类型。

现在让我们像示例 5-60 中所示,在我们的模型上定义这个关系。

示例 5-60. 定义多态多对多关系
class Contact extends Model{ public function tags() { return $this->morphToMany(Tag::class, 'taggable'); }}class Event extends Model{ public function tags() { return $this->morphToMany(Tag::class, 'taggable'); }}class Tag extends Model{ public function contacts() { return $this->morphedByMany(Contact::class, 'taggable'); } public function events() { return $this->morphedByMany(Event::class, 'taggable'); }}

这是如何创建您的第一个标签:

$tag = Tag::firstOrCreate(['name' => 'likes-cheese']);$contact = Contact::first();$contact->tags()->attach($tag->id);

我们像正常情况下获取这个关系的结果,就像示例 5-61 中所示。

示例 5-61. 从多对多多态关系的双方访问相关项目
$contact = Contact::first();$contact->tags->each(function ($tag) { // Do stuff});$tag = Tag::first();$tag->contacts->each(function ($contact) { // Do stuff});

子记录更新父记录时间戳

请记住,默认情况下,任何 Eloquent 模型都将具有created_atupdated_at时间戳。每当对记录进行更改时,Eloquent 都会自动设置updated_at时间戳。

当相关项目与另一个项目具有belongsTobelongsToMany关系时,每当更新相关项目时标记另一个项目可能非常有价值。例如,如果更新了PhoneNumber,也许应该标记其连接的Contact已更新。

我们可以通过将该关系的方法名添加到子类的$touches数组属性中来实现这一点,就像示例 5-62 中所示。

示例 5-62. 每当更新子记录时更新父记录
class PhoneNumber extends Model{ protected $touches = ['contact']; public function contact() { return $this->belongsTo(Contact::class); }}

急加载

默认情况下,Eloquent 使用lazy loading加载关系。这意味着当您首次加载模型实例时,它的相关模型不会随之加载。相反,只有在您访问它们时才会加载它们;它们是“懒惰的”,直到被调用时才做任何工作。

如果您正在迭代一系列模型实例,并且每个实例都有一个您正在处理的相关项目(或项目),这可能会成为问题。懒加载的问题在于它可能引入显著的数据库负载(通常是N+1 问题,如果您熟悉这个术语的话;如果不熟悉,可以忽略这个括号内的备注)。例如,每次运行示例 5-63 中的循环时,它都会执行一个新的数据库查询来查找Contact的电话号码。

示例 5-63. 检索列表中每个项目的一个相关项目(N+1)
$contacts = Contact::all();foreach ($contacts as $contact) { foreach ($contact->phone_numbers as $phone_number) { echo $phone_number->number; }}

如果你加载一个模型实例并且你知道你将使用它的关系,你可以选择急加载一个或多个相关项目集:

$contacts = Contact::with('phoneNumbers')->get();

使用with()方法和检索一起获取所有与已拉取项目相关的项目;正如你在这个例子中看到的,你传递给它的是关系定义的方法名称。

当我们使用急加载时,而不是在请求时逐个拉取相关项目(例如,每次foreach循环运行时选择一个联系人的电话号码),我们只有一个查询来拉取初始项目(选择所有联系人),并且第二个查询来拉取所有他们的相关项目(选择刚刚拉取的联系人拥有的所有电话号码)。

通过将要急加载的多个关系作为数组传递给with()调用,你可以急加载多个关系:

$contacts = Contact::with(['phoneNumbers', 'addresses'])->get();

你可以嵌套急加载以急加载关系的关系:

$authors = Author::with('posts.comments')->get();

限制急加载

如果你想要急加载一个关系,但不是所有项目,你可以传递一个闭包给with()来定义确切要急加载的相关项目:

$contacts = Contact::with(['addresses' => function ($query) { $query->where('mailable', true);}])->get();

惰性急加载

我知道这听起来很疯狂,因为我们刚刚定义了急加载,就好像是惰加载的反义词,但有时在初始实例被拉取后你才意识到你想要执行急加载查询。在这种情况下,你仍然能够做一次查询来查找所有相关项目,避免N+1 的成本。我们称之为惰性急加载

$contacts = Contact::all();if ($showPhoneNumbers) { $contacts->load('phoneNumbers');}

当关系尚未被加载时才加载它,请使用loadMissing()方法:

$contacts = Contact::all();if ($showPhoneNumbers) { $contacts->loadMissing('phoneNumbers');}

防止惰性加载

因为惰性加载通常是一种不良模式,你可以一次为整个应用程序禁用惰性加载。建议你在AppServiceProviderboot()方法中执行此操作:

use Illuminate\Database\Eloquent\Model; public function boot() { Model::preventLazyLoading(! $this->app->isProduction()); }

仅急加载计数

如果你想要急加载关系,但只是为了访问每个关系中项目的计数,你可以尝试使用withCount()

$authors = Author::withCount('posts')->get();// Adds a "posts_count" integer to each Author with a count of that// author's related posts

Eloquent 模型在发生某些操作时向应用程序的虚空中发出事件,无论你是否在监听。如果你熟悉发布/订阅,它是相同的模型(你将在第十六章中学到更多关于 Laravel 整个事件系统的内容)。

这里是绑定在创建新的Contact时触发时的监听器的快速概述。我们将在AppServiceProviderboot()方法中绑定它,假设我们在每次创建新的Contact时通知第三方服务(示例 5-64)。

示例 5-64。将监听器绑定到 Eloquent 事件
class AppServiceProvider extends ServiceProvider{ public function boot(): void { $thirdPartyService = new SomeThirdPartyService; Contact::creating(function ($contact) use ($thirdPartyService) { try { $thirdPartyService->addContact($contact); } catch (Exception $e) { Log::error('Failed adding contact to ThirdPartyService; canceled.'); return false; // Cancels Eloquent create() } }); }

我们可以在 Example5-64 中看到几个东西。首先,我们使用 *Modelname*`::`*eventName()* 作为方法,并传递一个闭包。闭包可以访问正在操作的模型实例。其次,我们需要在某个服务提供者中定义此监听器。第三,如果返回 false,操作将被取消,save()update() 将被取消。

这里是每个 Eloquent 模型触发的事件:

  • creating

  • created

  • updating

  • updated

  • saving

  • saved

  • deleting

  • deleted

  • restoring

  • restored

  • retrieved

大多数这些应该很明确,除了可能 restoringrestored,它们在恢复软删除的行时触发。此外,saving 会在 creatingupdating 时触发,savedcreatedupdated 时触发。

retrieved 事件在从数据库中检索到现有模型时触发。

Laravel 的整个应用测试框架使得测试数据库变得非常简单——不是通过编写针对 Eloquent 的单元测试,而是通过愿意测试整个应用程序。

想象这种情况。您想测试确保特定页面显示一个联系人但不显示另一个联系人。其中一些逻辑涉及 URL、控制器和数据库之间的相互作用,因此最佳的测试方法是应用测试。您可能正在考虑模拟 Eloquent 调用并尝试避免系统访问数据库。不要这样做。 请尝试使用 Example5-65。

Example 5-65. 使用简单的应用测试测试数据库交互
public function test_active_page_shows_active_and_not_inactive_contacts(){ $activeContact = Contact::factory()->create(); $inactiveContact = Contact::factory()->inactive()->create(); $this->get('active-contacts') ->assertSee($activeContact->name) ->assertDontSee($inactiveContact->name);}

正如您所见,模型工厂和 Laravel 的应用测试功能非常适合测试数据库调用。

或者,您可以直接在数据库中查找该记录,就像在 Example5-66 中一样。

Example 5-66. 使用 assertDatabaseHas() 来检查数据库中的特定记录
public function test_contact_creation_works(){ $this->post('contacts', [ 'email' => 'jim@bo.com' ]); $this->assertDatabaseHas('contacts', [ 'email' => 'jim@bo.com' ]);}

Eloquent 和 Laravel 的数据库框架经过了广泛测试。您无需测试它们。 您不需要模拟它们。如果您确实想避免访问数据库,可以使用存储库,然后返回未保存的 Eloquent 模型实例。但最重要的消息是:测试您的应用程序如何使用您的数据库逻辑。

如果您有自定义的访问器、修改器、作用域或其他内容,也可以直接测试它们,就像在 Example5-67 中一样。

Example 5-67. 测试访问器、修改器和作用域
public function test_full_name_accessor_works(){ $contact = Contact::factory()->make([ 'first_name' => 'Alphonse', 'last_name' => 'Cumberbund' ]); $this->assertEquals('Alphonse Cumberbund', $contact->fullName);}public function test_vip_scope_filters_out_non_vips(){ $vip = Contact::factory()->vip()->create(); $nonVip = Contact::factory()->create(); $vips = Contact::vips()->get(); $this->assertTrue($vips->contains('id', $vip->id)); $this->assertFalse($vips->contains('id', $nonVip->id));}

Just avoid writing tests that leave you creating complex “Demeter chains” to assert that a particular fluent stack was called on some database mock. If your testing starts to get overwhelming and complex around the database layer, it’s because you’re allowing preconceived notions to force you into unnecessarily complex systems. Keep it simple.

Laravel 自带一套强大的数据库工具,包括迁移(migrations)、填充(seeding)、优雅的查询构建器以及 Eloquent,一个功能强大的 ActiveRecord ORM。Laravel 的数据库工具并不要求您完全使用 Eloquent —— 您可以通过一个薄层的便利操作访问和操作数据库,而无需直接编写 SQL。但是,添加 ORM(无论是 Eloquent 还是 Doctrine 或其他)都很容易,并且可以与 Laravel 的核心数据库工具很好地配合。

Eloquent 遵循 ActiveRecord 模式,这使得定义支持数据库的对象类变得简单,包括它们存储在哪个表中以及列的形状、访问器和修改器。Eloquent 可以处理各种常规的 SQL 操作,也可以处理复杂的关系,包括多态多对多关系。

Laravel 还为数据库测试提供了强大的系统,包括模型工厂。

Laravel 主要被认为是一个 PHP 框架,但它也是 全栈 的,意味着它有一系列组件和约定,专注于生成前端代码。其中一些组件,如分页和消息包,是针对前端的 PHP 帮助程序,但 Laravel 还提供基于 Vite 的前端构建系统,一些非 PHP 资产的约定以及几个起始套件。

在开箱即用的情况下,Laravel 提供了一个完整的构建系统,我们很快就会介绍,但它还包括易于安装的起始套件,其中包含模板、认证、样式、JavaScript 和用户注册及管理工作流程。

Laravel 的两个起始套件称为 Breeze 和 Jetstream。

Breeze 是一个更简单的选择;它提供了 Laravel 认证系统所需的所有路由、视图和样式,包括注册、登录、密码重置、密码确认、电子邮件确认以及“编辑个人资料”页面。Breeze 包含 Tailwind 样式,您可以选择使用 Blade 模板或者 Inertia 配合 React 或 Vue。

Jetstream 更加复杂和强大;它提供了 Breeze 所有的功能,但还增加了双因素认证、会话管理、API 令牌管理和团队管理功能。Jetstream 包含 Tailwind 样式,您可以选择 Livewire 或者 Inertia 配合 Vue。

注意

Inertia 是一个前端工具,允许您在 JavaScript 中构建单页面应用程序,同时使用 Laravel 路由和控制器为每个视图提供路由和数据,就像传统的服务器渲染应用程序一样。了解更多,请访问 inertiajs.com

如果您刚开始使用 Laravel,Breeze 更容易理解,并且可以仅使用 Blade。大多数 Laravel 应用程序仅使用 Breeze 就能正常工作。

Jetstream 没有仅限于 Blade 的选项,也没有 React 的选项;您需要使用某种前端框架。您的选择是 Vue/Inertia 或者 Livewire,后者允许您主要编写后端代码,但在 Laravel 应用程序中获取前端交互性。然而,Jetstream 更为强大,因此如果您熟悉 Laravel 并且了解 Livewire 或 Inertia,并且您的项目需要这些额外的功能,Jetstream 可能是您的最佳选择。

Laravel Breeze

Laravel Breeze 是一个简单的起始套件,为普通的 Laravel 应用程序提供了一切所需,允许用户注册、登录和管理他们的个人资料。

安装 Breeze

Breeze 旨在安装在新应用程序上,因此通常是您启动新应用程序时安装的第一个内容:

laravel new myProjectcd myProjectcomposer require laravel/breeze --dev

一旦将Breeze添加到您的项目中,您将运行其安装程序:

php artisan breeze:install

一旦运行安装程序,您将提示选择一个堆栈:Blade、Inertia 配合 React、Inertia 配合 Vue 或者 API,后者用于支持像 Next.js 这样的非 Inertia 前端。这些堆栈在下一节中有详细解释。

安装了 Breeze 后,请确保运行您的迁移并构建您的前端:

php artisan migratenpm installnpm run dev

Breeze 所带来的内容

Breeze 自动注册了用于注册、登录、注销、密码重置、电子邮件验证和密码确认页面的路由。这些路由位于新的 routes/auth.php 文件中。

Breeze 的非 API 形式还为用户仪表板和“编辑个人资料”页面注册了路由,并直接添加到 routes/web.php 文件中。

Breeze 的非 API 形式还发布了用于“编辑个人资料”页面、电子邮件验证、密码重置以及其他几个与身份验证相关的功能的控制器。此外,它还添加了 Tailwind、Alpine.js 和 PostCSS(用于 Tailwind)。除了这些共享的文件和依赖项外,每个堆栈还根据自身需求添加了独特的文件:

Breeze Blade

Breeze Blade 包含一系列 Blade 模板,涵盖了上述所有功能,你可以在 resources/views/authresources/view/componentsresources/views/profile 等位置找到它们。

Breeze Inertia

两种 Inertia 堆栈都引入了 Inertia、Ziggy(用于在 JavaScript 中生成到 Laravel 路由的 URL 的工具)、Tailwind 的“forms”组件,以及使它们各自的前端框架功能正常运行所需的 JavaScript 包。它们还都发布了一个基本的 Blade 模板,该模板加载了 Inertia,以及在 resources/js 目录中发布页面的一系列 React/Vue 组件。

Breeze API

Breeze 的 API 堆栈安装的代码和包明显较少,但它也删除了所有新 Laravel 应用程序的现有引导文件。API 堆栈旨在准备一个应用程序仅作为独立的 Next.js 应用程序的 API 后端,因此它删除了 package.json、所有 JavaScript 和 CSS 文件,以及所有前端模板。

Laravel Jetstream

Jetstream 延续了 Breeze 的功能,并增加了更多用于启动新应用的工具;但是,它的设置更复杂,配置选项更少,因此在选择 Jetstream 而不是 Breeze 之前,您需要知道自己确实需要它。

与 Breeze 类似,Jetstream 也发布路由、控制器、视图和配置文件。与 Breeze 一样,Jetstream 使用 Tailwind,并提供不同的技术“堆栈”选项。

然而,与 Breeze 不同,Jetstream 需要互动性,因此没有仅限于 Blade 的堆栈。相反,您有两个选择:Livewire(这是带有一些由 PHP 驱动的 JavaScript 互动功能的 Blade)或 Inertia/Vue(Jetstream 没有 React 形式)。

Jetstream 还通过引入团队管理功能、双因素认证、会话管理和个人 API 令牌管理扩展了 Breeze 的功能。

安装 Jetstream

Jetstream 旨在安装到新的 Laravel 应用程序中,您可以使用 Composer 安装它:

laravel new myProjectcd myProjectcomposer require laravel/jetstream

一旦将 Jetstream 添加到您的项目中,您将运行其安装程序。与 Breeze 不同的是,您不会被要求选择堆栈;相反,您需要将堆栈 (livewireinertia) 作为第一个参数传入。

php artisan jetstream:install livewire

如果你想在 Jetstream 安装中添加团队管理功能,请在安装步骤中加入--teams标志:

php artisan jetstream:install livewire --teams

安装完 Jetstream 后,请确保运行你的迁移并构建你的前端:

php artisan migratenpm installnpm run dev

Jetstream 包含了什么

Jetstream 发布了大量的代码;以下是一个快速总结:

  • 为用户模型添加双因素认证和个人资料照片功能(并添加/修改所需的迁移)

  • 登录用户的仪表板

  • Tailwind, Tailwind forms, Tailwind typography

  • Laravel Fortify,Jetstream 构建在其上的后端身份验证组件

  • app/Actions 中的 Fortify 和 Jetstream 的“操作”

  • resources/markdown 中的条款和政策页面的 Markdown 文本

  • 一个庞大的测试套件

Fortify 是一个无头身份验证系统。它为 Laravel 所需的所有身份验证功能提供路由和控制器,从登录和注册到密码重置等,供你选择的任何前端消费。

Jetstream 建立在 Fortify 之上,因此你实际上可以将 Jetstream 视为 Fortify 的众多可能前端之一。Jetstream 还添加了后端功能,因此显示了 Fortify 支持的身份验证系统可以有多强大。

Jetstream 的 Livewire 和 Inertia 配置分别具有稍有不同的依赖项和模板位置:

Jetstream Livewire

Jetstream 的 Livewire 模板为你的应用程序设置了与 Livewire 和 Alpine 协作的基础,并发布了前端的 Livewire 组件。它提供了:

  • Livewire

  • Alpine.js

  • app/View/Components 中的 Livewire 组件

  • resources/views 中的前端模板

Jetstream Inertia

Jetstream 的 Inertia 模板为你的应用程序设置了与 Inertia 和 Vue 协作的基础,并发布了前端的 Vue 组件。它提供了:

  • Inertia

  • Vue

  • resources/js 中的 Vue 模板

自定义你的 Jetstream 安装

Jetstream 构建在 Fortify 基础之上,因此有时自定义 Jetstream 就意味着要自定义 Fortify。你可以在 config/fortify.php, config/jetstream.php, FortifyServiceProviderJetstreamServiceProvider 中更新任何配置设置。

虽然 Breeze 为你发布控制器以修改其行为,Jetstream 发布了动作,每个动作都是一个一次性的行为块,名称如 ResetUserPassword.phpDeleteUser.php

更多 Jetstream 特性

Jetstream 使你的应用程序能够管理团队、个人 API 令牌、双因素认证以及跟踪和断开所有活动会话。你还可以在自己的代码中使用 Jetstream 的一些 UI 精美功能,如自定义闪存横幅。

要了解更多关于这一切是如何工作的信息,请查看详尽的 Laravel Jetstream 文档

Vite 是一个本地前端开发环境,结合了开发服务器和基于 Rollup 的构建工具链。这听起来可能很多,但在 Laravel 中,主要用于将 CSS 和 JavaScript 资源捆绑在一起。

Laravel 提供了一个 NPM 插件和一个 Blade 指令,使得与 Vite 协作变得容易。它们默认包含在 Laravel 应用程序中,还有一个配置文件:vite.config.js

看一下 Example6-1 来查看默认 vite.config.js 文件的内容。

示例 6-1. 默认的 vite.config.js
import { defineConfig } from 'vite';import laravel from 'laravel-vite-plugin';export default defineConfig({ plugins: [ laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true, }), ],});

我们定义插件应该从中构建的文件 (input),并说我们希望启用 “每次保存视图文件时刷新我的页面” 的功能 (refresh)。

默认情况下,Vite 从 Example6-1 中列出的两个文件中拉取,并在这些文件夹中的任何文件更改时自动刷新:

  • app/View/Components/

  • lang/

  • resources/lang/

  • resources/views/

  • routes/

现在,我们已经将 Vite 配置指向我们的 CSS 和 JavaScript 入口文件,我们将使用 @vite Blade 指令引用这些文件,正如你在 Example6-2 中所看到的。

示例 6-2. 使用 @vite Blade 指令
<html><head> @vite(['resources/css/app.css', 'resources/js/app.js'])

就这样!接下来,让我们看看如何使用 Vite 打包文件。

注意

如果你的本地开发域名是安全的(HTTPS),你需要修改你的 vite.config.js 文件,指向你的凭据。如果你使用 Valet,这里有一个特殊的配置选项:

// ...export default defineConfig({ plugins: [ laravel({ // ... valetTls: 'name-of_my-app-here.test', }), ],});

使用 Vite 打包文件

最后,是时候打包我们的资产了。使用 Vite 有两种打包资产的方式:“build” 和 “dev”。

如果你想构建文件一次,无论是交付到生产环境还是进行本地测试,运行 npm run build,Vite 将会打包你的资产。然而,如果你在本地开发,可能更喜欢让 Vite 启动一个进程,监视你的视图文件变化,每当检测到视图文件变化时重新触发构建,并在浏览器中刷新页面。这就是 npm run dev 为你做的事情。

你的构建文件将会存放在应用的 public/build/assets 文件夹中,同时还有一个位于 public/build/manifest.json 的文件,它告诉 Laravel 和 Vite 如何从非构建路径引用到每一个构建文件。

注意

默认情况下,Laravel 的 .gitignore 忽略 public/build 文件夹,所以确保在部署过程中运行 npm run build

Vite 开发服务器

当你运行 npm run dev 时,你会启动一个由 Vite 提供支持的实际 HTTP 服务器。Vite Blade 辅助程序会重写你的资产 URL,指向开发服务器上的相同位置,而不是你的本地域名,这使得 Vite 能够更快地更新和刷新你的依赖。

这意味着,如果你编写以下 Blade 调用:

@vite(['resources/css/app.css', 'resources/js/app.js'])

在你的生产应用程序上看起来会是这样:

<link rel="preload" as="style" href="http://my-app.test/build/assets/app-1c09da7e.css" /><link rel="modulepreload" href="http://my-app.test/build/assets/app-ea0e9592.js" /><link rel="stylesheet" href="http://my-app.test/build/assets/app-1c09da7e.css" /><script type="module" src="http://my-app.test/build/assets/app-ea0e9592.js"></script>

但是,如果你的 Vite 服务器在本地运行,情况会是这样的:

<script type="module" src="http://127.0.0.1:5173/@vite/client"></script><link rel="stylesheet" href="http://127.0.0.1:5173/resources/css/app.css" /><script type="module" src="http://127.0.0.1:5173/resources/js/app.js"></script>

使用静态资产和 Vite

到目前为止,我们只覆盖了使用 Vite 加载 JavaScript 和 CSS。但是 Laravel 的 Vite 配置可以处理和版本化你的静态资产(如图像)。

如果你在 JavaScript 模板中工作,Vite 将会抓取任何相对静态资产的链接,并处理和版本化它们。任何绝对静态资产 Vite 都会忽略。

这意味着如果它们在 JavaScript 模板中,以下图像将会接受不同的处理。

<!-- Ignored by Vite --><img src="/resources/images/soccer.jpg"><!-- Processed by Vite --><img src="../resources/images/soccer.jpg">

如果你在 Blade 模板中工作,你需要采取两步来让 Vite 处理你的静态资产。首先,你需要使用Vite::asset门面调用来链接你的资产:

<img src="{{ Vite::asset('resources/images/soccer.jpg') }}">

其次,你需要在resources/js/app.js文件中添加配置步骤,向 Vite 展示要导入的文件或文件夹:

import.meta.glob([ // Imports all the files in /resources/images/ '../images/**',]);
警告

如果你使用npm run dev运行 Vite 服务器,服务器可以加载你的静态资产,无需你添加import.meta.glob配置。这意味着你可能认为它会出现,但在你的生产构建中将会失败。

与 JavaScript 框架和 Vite 一起工作

如果你想要与 Vue、React、Inertia 和/或单页面应用程序(SPA)一起工作,你可能需要引入一些特定的插件或设置一些特定的配置项。这里是你在最常见场景下所需的基本内容。

Vite 和 Vue

要与 Vite 和 Vue 一起工作,首先安装 Vite 的 Vue 插件:

npm install --save-dev @vitejs/plugin-vue

然后,你需要修改你的vite.config.js文件来调用 Vue 插件,并向其传递两个配置设置。第一个,template.transformAssetUrls.base=null,允许 Laravel 插件而不是 Vue 插件处理重写 URL。第二个,template.transformAssetUrls.includeAbsolute=false,允许 Vue 模板内的 URL 引用公共目录中的文件:

import { defineConfig } from 'vite';import laravel from 'laravel-vite-plugin';import vue from '@vitejs/plugin-vue';export default defineConfig({ plugins: [ laravel(['resources/js/app.js']), vue({ template: { transformAssetUrls: { base: null, includeAbsolute: false, }, }, }), ],});

Vite 和 React

要与 Vite 和 React 一起工作,首先安装 Vite 的 React 插件:

npm install --save-dev @vitejs/plugin-react

然后,你需要修改你的vite.config.js文件来调用 React 插件:

import { defineConfig } from 'vite';import laravel from 'laravel-vite-plugin';import react from '@vitejs/plugin-react';export default defineConfig({ plugins: [ laravel(['resources/js/app.js']), react(), ],});

最后,在你导入 JavaScript 文件之前,在模板中添加@viteReactRefresh Blade 指令:

@viteReactRefresh@vite('resources/js/app.jsx')

Vite 和 Inertia

如果你正在自行设置 Inertia,你需要 Inertia 能够解析你的页面组件。

这里是你可能会在resources/js/app.js文件中编写的代码示例,但你最好的选择是使用 Breeze、Jetstream 或 Inertia 文档安装 Inertia。

import { createApp, h } from 'vue'import { createInertiaApp } from '@inertiajs/vue3'createInertiaApp({ resolve: name => { const pages = import.meta.glob('./Pages/**/*.vue', { eager: true }) return pages[`./Pages/${name}.vue`] }, setup({ el, App, props, plugin }) { createApp({ render: () => h(App, props) }) .use(plugin) .mount(el) },})

Vite 和 SPAs

如果你正在构建 SPA,请从你的vite.config.js文件中移除resources/css/app.css,这将它从入口点中移除。

相反,通过在resources/js/app.js文件中在引入 bootstrap 之下添加这一行,将你的 CSS 导入到 JavaScript 中:

import './bootstrap';import '../css/app.css';

在 Vite 中使用环境变量

如果你想在你的 JavaScript 文件中使用环境变量,请以VITE_为前缀变量名,就像你在示例6-3 中看到的那样。

示例 6-3. 在 vite.config.js 中引用环境变量
// .envVITE_BASE_URL=http://local-development-url.test
// resources/js/app.jsconst baseUrl = import.meta.env.VITE_BASE_URL;

每次运行npm run devnpm run build时,它都会从.env中加载该环境变量,并将其注入到你的脚本中。

尽管在 Web 应用程序中分页是如此常见,但实现起来仍然可能非常复杂。幸运的是,Laravel 默认已经集成了分页的概念,并且默认已经与 Eloquent 结果和路由器关联起来。

分页数据库结果

您最常见到分页的地方是当您显示数据库查询结果并且结果太多以至于无法在单个页面中显示时。Eloquent 和查询构建器都从当前页面请求的 page 查询参数中读取,并使用它在任何结果集上提供一个 paginate() 方法;您应该传递给 paginate() 的单个参数指示您希望每页显示多少结果。查看示例6-4 了解其工作原理。

示例 6-4. 对查询构建器响应进行分页
// PostControllerpublic function index(){ return view('posts.index', ['posts' => DB::table('posts')->paginate(20)]);}

示例6-4 指定该路由应每页返回 20 篇文章,并根据 URL 的 page 查询参数定义当前用户所在的“页面”结果。所有 Eloquent 模型都具有相同的 paginate() 方法。

当您在视图中显示结果时,您的集合现在将具有一个 links() 方法,该方法将输出分页控件。(参见示例6-5,我已将其简化以便包含在本书中。)

示例 6-5. 在模板中呈现分页链接
// posts/index.blade.php<table>@foreach ($posts as $post) <tr><td>{{ $post->title }}</td></tr>@endforeach</table>{{ $posts->links() }}// By default, $posts->links() will output something like this:<div class="..."> <div> <p class="..."> Showing <span class="...">1</span> to <span class="...">2</span> of <span class="...">5</span> results </p> </div> <div> <span class="..."> <span aria-disabled="true" aria-label="&amp;laquo; Previous"> <!-- SVG here for the ... ellipsis --> </span> <span class="...">1</span> <a href="http://myapp.com/posts?page=2" class="..." aria-label="..."> 2 </a> <a href="http://myapp.com/posts?page=3" class="..." aria-label="..."> 3 </a> <a href="http://myapp.com/posts?page=2" class="..." rel="next" aria-label="Next &amp;raquo;"> <!-- SVG here for the ... ellipsis --> </a> </span> </div></div>

分页器使用 TailwindCSS 进行默认样式设置。如果您想使用 Bootstrap 样式,请在 AppServiceProvider 中调用 Paginator::useBootstrap()

use Illuminate\Pagination\Paginator;public function boot(): void{ Paginator::useBootstrap();}

如果您希望控制当前页面两侧显示多少链接,您可以使用 onEachSide() 方法轻松自定义此数字:

DB::table('posts')->paginate(10)->onEachSide(3);// Outputs:// 5 6 7 [8] 9 10 11

手动创建分页器

如果您不使用 Eloquent 或查询构建器,或者正在使用复杂的查询(例如使用 groupBy 的查询),您可能会发现自己需要手动创建分页器。幸运的是,您可以使用 Illuminate\Pagination\PaginatorIlluminate\Pagination\LengthAwarePaginator 类来实现这一点。

这两个类的区别在于 Paginator 只提供上一页和下一页按钮,但没有每一页的链接;LengthAwarePaginator 需要知道完整结果的长度,以便可以为每个单独的页面生成链接。您可能会发现在大结果集上使用 Paginator 是有用的,这样您的分页器不必了解可能昂贵运行的大量结果数量。

PaginatorLengthAwarePaginator 都要求您手动提取您希望传递给视图的内容子集。查看示例6-6 了解示例。

示例 6-6. 手动创建分页器
use Illuminate\Http\Request;use Illuminate\Pagination\Paginator;Route::get('people', function (Request $request) { $people = [...]; // huge list of people $perPage = 15; $offsetPages = $request->input('page', 1) - 1; // The Paginator will not slice your array for you $people = array_slice( $people, $offsetPages * $perPage, $perPage ); return new Paginator( $people, $perPage );});

Another common-but-painful feature in web applications is passing messages between various components of the app, when the end goal is to share them with the user. Your controller, for example, might want to send a validation message: “The email field must be a valid email address.” However, that particular message doesn’t just need to make it to the view layer; it actually needs to survive a redirect and then end up in the view layer of a different page. How do you structure this messaging logic?

The Illuminate\Support\MessageBag class is tasked with storing, categorizing, and returning messages that are intended for the end user. It groups all messages by key, where the keys are likely to be something like errors or messages, and it provides convenience methods for getting either all its stored messages or only those for a particular key, and outputting these messages in various formats.

You can spin up a new instance of MessageBag manually like in Example6-7. To be honest, though, you likely won’t ever do this manually—​this is just a thought exercise to show how it works.

Example 6-7. Manually creating and using a message bag
$messages = [ 'errors' => [ 'Something went wrong with edit 1!', ], 'messages' => [ 'Edit 2 was successful.', ],];$messagebag = new \Illuminate\Support\MessageBag($messages);// Check for errors; if there are any, decorate and echoif ($messagebag->has('errors')) { echo '<ul id="errors">'; foreach ($messagebag->get('errors', '<li><b>:message</b></li>') as $error) { echo $error; } echo '</ul>';}

Message bags are also closely connected to Laravel’s validators (you’ll learn more about these in “Validation”): when validators return errors, they actually return an instance of MessageBag, which you can then pass to your view or attach to a redirect using redirect('route')->withErrors($messagebag).

Laravel passes an empty instance of MessageBag to every view, assigned to the variable $errors; if you’ve flashed a message bag using withErrors() on a redirect, it will get assigned to that $errors variable instead. That means every view can always assume it has an $errors MessageBag that it can check wherever it handles validation, which leads to Example6-8 as a common snippet developers place on every page.

Example 6-8. Error bag snippet
// partials/errors.blade.php@if ($errors->any()) <div class="alert alert-danger"> <ul> @foreach ($errors as $error) <li>{{ $error }}</li> @endforeach </ul> </div>@endif

If you have any routes that aren’t under the web middleware group, they won’t have the session middleware, which means they won’t have this $errors variable available.

Sometimes you need to differentiate message bags not just by key (notices versus errors) but also by component. Maybe you have a login form and a signup form on the same page; how do you differentiate them?

When you send errors along with a redirect using withErrors(), the second parameter is the name of the bag: redirect('dashboard')->withErrors($validator, 'login'). Then, on the dashboard, you can use $errors->login to call all of the methods you saw before: any(), count(), and more.

As developers, we tend to look at blocks of text as big placeholder divs, waiting for the client to put real content into them. Seldom are we involved in any logic inside these blocks.

但在一些情况下,你会感谢 Laravel 提供的字符串操作工具。

字符串助手和复数形式

Laravel 提供了一系列用于操作字符串的助手。它们作为 Str 类的方法可用(例如,Str::plural())。

旧版本的 Laravel 包括全局辅助函数,它们是 StrArr 方法的别名。这些全局的 str_array_ 辅助函数在 Laravel 6 版本中被移除,并导出到一个单独的包中。如果你愿意,你可以通过 Composer 安装 laravel/helpers 包:composer require laravel/helpers

Laravel 的 文档 详细介绍了所有字符串助手,但以下是一些常用的助手:

e()

html_entities() 的快捷方式;对所有 HTML 实体进行编码,以确保安全性。

Str::startsWith(), Str::endsWith(), Str::contains()

检查字符串(第一个参数)是否以另一个字符串(第二个参数)开头、结尾或包含。

Str::is()

检查字符串(第二个参数)是否与特定模式(第一个参数)匹配 —— 例如,foo* 将匹配 foobarfoobaz

Str::slug()

将字符串转换为带连字符的 URL 类型的 slug。

Str::plural(*word*, *count*), Str::singular()

使单词变为复数或单数;仅支持英语(例如,Str::plural('dog') 返回 dogsStr::plural('dog,' 1')) 返回 dog)。

Str::camel(), Str::kebab(), Str::snake(), Str::studly(), Str::title()

将提供的字符串转换为不同的大小写 "case"。

Str::after(), Str::before(), Str::limit()

对字符串进行修剪并提供子字符串。Str::after() 返回给定字符串之后的所有内容,而 Str::before() 返回给定字符串之前的所有内容(两者都接受完整的字符串作为第一个参数,以及作为第二个参数用于切割的字符串)。Str::limit() 将字符串(第一个参数)截断为指定数量的字符(第二个参数)。

Str::markdown(*string*, *options*)

将 Markdown 转换为 HTML。您可以在 PHP League 网站 上阅读有关您可以传递的选项的更多信息。

Str::replace(*search*, *replace*, *subject*, *caseSensitive*)

在主题字符串中查找搜索字符串出现的位置,并用替换字符串替换它。如果 caseSensitive 参数为 true,则只有当出现与搜索案例匹配时才进行替换(例如,Str::replace('Running', 'Going', 'Laravel Up and Running', true) 返回 'Laravel Up and Going')。

本地化

本地化使您能够定义多种语言,并将任何字符串标记为翻译目标。您可以设置一个回退语言,甚至处理复数形式的变化。

在 Laravel 中,您需要在页面加载过程中的某个时刻设置“应用程序区域设置”,以便本地化助手知道从哪个翻译桶中获取翻译。每个“区域设置”通常与一个翻译相关联,通常看起来像“en”(英语)。您可以使用App::setLocale($localeName)来实现这一点,您可能会将其放在服务提供程序中。现在,您可以将其放在AppServiceProviderboot()方法中,但如果您最终拥有多个与区域设置相关的绑定,可能需要创建一个LocaleServiceProvider

您可以在config/app.php中定义您的回退区域设置,那里应该有一个fallback_locale键。这允许您为应用程序定义一个默认语言,在无法找到请求的区域设置的翻译时,Laravel 将使用它。

基本本地化

那么,我们如何调用翻译字符串呢?有一个辅助函数,__($key),它将为传递的键在当前区域设置中获取字符串,或者如果不存在,则从默认区域设置中获取。在 Blade 中,您还可以使用@lang()指令。示例6-9 演示了基本翻译的工作方式。我们将使用“返回仪表板”链接的示例作为详细页面顶部的示例。

示例 6-9. __() 的基本使用
// Normal PHP<?php echo __('navigation.back'); ?>
// Blade{{ __('navigation.back') }}// Blade directive@lang('navigation.back')

假设我们现在正在使用es区域设置。首先,我们需要发布用于修改的lang文件:

php artisan lang:publish

此命令会将默认的 Laravel lang文件发布到应用程序的根目录。您需要创建一个文件来定义与导航相关的翻译,lang/en/navigation.php,并返回一个包含键名为back的 PHP 数组,如示例6-10 所示。

示例 6-10. 示例 lang/en/navigation.php 文件
<?phpreturn [ 'back' => 'Return to dashboard',];

现在,为了使其可翻译,让我们在lang下创建一个es目录,并创建其自己的navigation.php文件,正如您在示例6-11 中所见。

示例 6-11. 示例 lang/es/navigation.php 文件
<?phpreturn [ 'back' => 'Volver al panel',];

现在让我们尝试在我们的应用程序中使用该翻译键,在示例6-12 中。

示例 6-12. 使用翻译
// routes/web.phpRoute::get('/es/contacts/show/{id}', function () { // Set the locale manually, for this example, instead of in a service provider App::setLocale('es'); return view('contacts.show');});// resources/views/contacts/show.blade.php<a href="/contacts">{{ __('navigation.back') }}</a>

本地化中的参数

前面的示例比较简单。让我们深入了解一些更复杂的内容。如果我们想要定义我们要返回的哪一个仪表板呢?看看示例6-13。

示例 6-13. 翻译中的参数
// lang/en/navigation.phpreturn [ 'back' => 'Back to :section dashboard',];// resources/views/contacts/show.blade.php{{ __('navigation.back', ['section' => 'contacts']) }}

如您所见,用冒号(:section)标记一个单词,将其视为可以替换的占位符。__()的第二个可选参数是一个替换占位符的值数组。

本地化中的复数形式

我们已经讨论了复数形式,现在想象一下您正在定义自己的复数形式规则。有两种方法可以做到这一点;我们将从最简单的方法开始,如示例6-14 所示。

示例 6-14. 定义具有复数形式选项的简单翻译
// lang/en/messages.phpreturn [ 'task-deletion' => 'You have deleted a task|You have successfully deleted tasks',];// resources/views/dashboard.blade.php@if ($numTasksDeleted > 0) {{ trans_choice('messages.task-deletion', $numTasksDeleted) }}@endif

正如您所见,我们有一个trans_choice()方法,它将受影响项目的计数作为其第二个参数;从中,它将确定要使用哪个字符串。

你还可以使用与 Symfony 的更复杂的Translation组件兼容的任何翻译定义;参见示例6-15 作为示例。

示例 6-15. 使用 Symfony Translation组件
// lang/es/messages.phpreturn [ 'task-deletion' => "{0} You didn't manage to delete any tasks.|" . "[1,4] You deleted a few tasks.|" . "[5,Inf] You deleted a whole ton of tasks.",];

使用 JSON 存储默认字符串作为键

本地化的一个常见难点是确保有一个良好的定义键命名空间的系统——例如,记住三四级嵌套的键或不确定站点中使用两次的短语应该使用哪个键。

与基于 slug 键/字符串值对系统的另一种选择是使用主语言字符串作为键存储您的翻译,而不是使用虚构的 slug。您可以通过在lang目录中以 JSON 格式存储翻译文件,并使用反映区域设置的文件名来指示 Laravel,以表明您正在这样工作(参见示例6-16)。

示例 6-16. 使用 JSON 翻译和__()助手
// In Blade{{ __('View friends list') }}
// lang/es.json{ 'View friends list': 'Ver lista de amigos'}

这是利用了__()翻译助手的一个事实,如果它找不到当前语言的匹配键,它将只显示键。如果您的键是应用程序默认语言中的字符串,那么这比如widgets.friends.title之类的做法更合理。

在本章中,我们主要关注了 Laravel 的前端组件。这些组件不太可能成为单元测试的对象,但有时可能会在集成测试中使用。

测试消息和错误包

传递消息和错误包的消息的主要测试方法有两种。首先,您可以在应用程序测试中执行一个行为,设置最终将在某个地方显示的消息,然后重定向到该页面,并断言显示适当的消息。

其次,对于错误(这是最常见的用例),您可以使用$this->assertSessionHasErrors($bindings = [])断言会话存在错误。看一下示例6-17,看看可能的显示方式。

示例 6-17. 断言会话存在错误
public function test_missing_email_field_errors(){ $this->post('person/create', ['name' => 'Japheth']); $this->assertSessionHasErrors(['email']);}

为了使示例6-17 通过,您需要在该路由上添加输入验证。我们将在第七章中介绍这个。

翻译和本地化

测试本地化的最简单方法是使用应用程序测试。设置适当的上下文(无论是通过 URL 还是会话),使用get()“访问”页面,并断言您看到适当的内容。

在测试中禁用 Vite

如果您想在测试期间禁用 Vite 的资产解析,可以通过在测试顶部调用withoutVite()方法完全禁用 Vite:

public function test_it_runs_without_vite(){ $this->withoutVite(); // Test stuff}

作为一个全栈框架,Laravel 提供了用于前端和后端的工具和组件。

Vite 是一个构建工具和开发服务器,Laravel 在其上构建,帮助处理、压缩和版本化 JavaScript、CSS 和静态资源(如图像)。

Laravel 还提供了针对前端的其他内部工具,包括用于实现分页、消息和错误包以及本地化的工具。

像 Laravel 这样的框架受益于的网站通常不仅提供静态内容。许多处理复杂和混合数据源,其中最常见(也最复杂)的是各种形式的用户输入:URL 路径、查询参数、POST 数据和文件上传。

Laravel 提供了一组工具,用于收集、验证、规范化和过滤用户提供的数据。我们将在这里看看这些工具。

在 Laravel 中访问用户数据最常见的工具是注入 Illuminate\Http\Request 对象的实例。它为您提供了轻松访问用户在您的站点上提供输入的所有方式:POST 表单数据或 JSON、GET 请求(查询参数)和 URL 段。

还有一个 request() 全局助手和一个 Request 门面,两者都公开相同的方法。每个选项都公开了整个 Illuminate Request 对象,但现在我们只会涵盖与用户数据特别相关的方法。

因为我们计划注入一个 Request 对象,让我们快速看一下如何获取我们将在其上调用所有这些方法的 $request 对象:

Route::post('form', function (Illuminate\Http\Request $request) { // $request->etc()});

$request->all()

就像名字所暗示的那样,$request->all() 提供了一个包含用户从每个来源提供的所有输入的数组。假设出于某种原因,您决定让一个表单 POST 到一个带有查询参数的 URL——例如,向 http://myapp.com/signup?utm=12345 发送一个 POST。查看 Example7-1 来看看从 $request->all() 中得到了什么。($request->all() 也包含有关上传的任何文件的信息,但我们将在本章后面介绍这部分内容。)

Example 7-1. $request->all()
<!-- GET route form view at /get-route --><form method="post" action="/signup?utm=12345"> @csrf <input type="text" name="first_name"> <input type="submit"></form>
// routes/web.phpRoute::post('signup', function (Request $request) { var_dump($request->all());});// Outputs:/** * [ * '_token' => 'CSRF token here', * 'first_name' => 'value', * 'utm' => 12345, * ] */

$request->except() 和 ->only()

$request->except() 提供与 $request->all() 相同的输出,但您可以选择排除一个或多个字段——例如 _token。您可以将其传递为字符串或字符串数组。

Example7-2 显示了在我们使用 $request->except() 在 Example7-1 中相同表单时的情况。

Example 7-2. $request->except()
Route::post('post-route', function (Request $request) { var_dump($request->except('_token'));});// Outputs:/** * [ * 'firstName' => 'value', * 'utm' => 12345 * ] */

$request->only()$request->except() 的反义词,如您在 Example7-3 中所见。

Example 7-3. $request->only()
Route::post('post-route', function (Request $request) { var_dump($request->only(['firstName', 'utm']));});// Outputs:/** * [ * 'firstName' => 'value', * 'utm' => 12345 * ] */

$request->has() 和 ->missing()

使用 $request->has(),您可以检测特定的用户输入是否可用,而不管输入中是否实际包含值。查看 Example7-4 来查看前面示例中我们的 utm 查询字符串参数的分析示例。

Example 7-4. $request->has()
// POST route at /post-routeif ($request->has('utm')) { // Do some analytics work}

$request->missing() 是它的反义词。

$request->whenHas()

使用 $request->whenHas(),您可以定义当请求提供了字段或未提供字段时的行为。第一个闭包参数在字段存在时返回,第二个在字段不存在时返回。

查看带有utm查询字符串参数的示例7-5。

示例 7-5. $request->whenHas()
// POST route at /post-route$utm = $request->whenHas('utm', function($utm) { return $utm;}, function() { return 'default';});

$request->filled()

使用$request->filled()方法,可以检查请求中是否存在并填充了特定字段。filled()has()相同,但它还要求字段中实际存在值。在示例7-6 中,您可以看到如何使用此方法的示例。

示例 7-6. $request->filled()
// POST route at /post-routeif ($request->filled('utm')) { // Do some analytics work}

$request->whenFilled()

whenHas()方法类似,$request->whenFilled()方法允许您在字段填充或未填充时定义值。第一个闭包参数在字段填充时运行,第二个在未填充时运行。请参阅示例7-7 了解如何使用此方法的示例。

示例 7-7. $request->whenFilled()
// POST route at /post-route$utm = $request->whenFilled('utm', function ($utm) { return $utm;}, function() { return 'default';});

$request->mergeIfMissing()

使用mergeIfMissing()方法,您可以在请求中添加字段,当字段不存在时定义其值。例如,当字段来自复选框时,只有在选中时才存在。您可以在示例7-8 中看到一个实现。

示例 7-8. $request->mergeIfMissing()
// POST route at /post-route$shouldSend = $request->mergeIfMissing('send_newsletter', 0);

$request->input()

$request->all()$request->except()$request->only()操作于用户提供的完整输入数组上,$request->input()允许您仅获取单个字段的值。示例7-9 提供了一个例子。注意第二个参数是默认值,所以如果用户没有传递值,您可以有一个合理(且不会中断流程)的回退。

示例 7-9. $request->input()
Route::post('post-route', function (Request $request) { $userName = $request->input('name', 'Matt');});

$request->method()->isMethod()

$request->method()返回请求的 HTTP 动词,$request->isMethod()检查它是否与指定的动词匹配。示例7-10 说明了它们的用法。

示例 7-10. $request->method()$request->isMethod()
$method = $request->method();if ($request->isMethod('patch')) { // Do something if request method is PATCH}

$request->integer()->float()->string()->enum()

当您分别使用这些方法时,它们将直接将输入转换为整数、浮点数、字符串或枚举。查看示例7-11 获取使用示例。

示例 7-11. $request->integer()$request->float()$request->string()$request->enum()
dump(is_int($request->integer('some_integer'));// truedump(is_float($request->float('some_float'));// truedump(is_string($request->string('some_string'));// truedump($request->enum('subscription', SubscriptionStatusEnum::class));// 'active', assuming that's a valid status for the SubscriptionStatusEnum

$request->dump()->dd()

$request->dump()$request->dd() 是用于展示请求的辅助方法。对于两者,您可以通过不传递任何参数来展示整个请求,或者通过传递数组来展示选择的字段。$request->dump()展示后继续执行,而$request->dd()展示后停止脚本的执行。示例7-12 展示了它们的用法。

示例 7-12. $request->dump()$request->dd()
// dumping the whole request$request->dump()$request->dd();// dumping just two fields$request->dump(['name', 'utm']);$request->dd(['name', 'utm']);

数组输入

Laravel 还提供了方便的帮助程序,用于访问来自用户提供的数组输入的数据。只需使用“点”符号来指示进入数组结构,例如在示例7-13 中。

示例 7-13. 用于访问用户数据中数组值的点符号表示法
<!-- GET route form view at /employees/create --><form method="post" action="/employees/"> @csrf <input type="text" name="employees[0][firstName]"> <input type="text" name="employees[0][lastName]"> <input type="text" name="employees[1][firstName]"> <input type="text" name="employees[1][lastName]"> <input type="submit"></form>
// POST route at /employeesRoute::post('employees', function (Request $request) { $employeeZeroFirstName = $request->input('employees.0.firstName'); $allLastNames = $request->input('employees.*.lastName'); $employeeOne = $request->input('employees.1'); var_dump($employeeZeroFirstname, $allLastNames, $employeeOne);});// If forms filled out as "Jim" "Smith" "Bob" "Jones":// $employeeZeroFirstName = 'Jim';// $allLastNames = ['Smith', 'Jones'];// $employeeOne = ['firstName' => 'Bob', 'lastName' => 'Jones'];

JSON 输入(和 $request->json()

到目前为止,我们已经涵盖了从查询字符串(GET)和表单提交(POST)获取输入的内容。但是随着 JavaScript SPA 的出现,还有一种更常见的用户输入形式:JSON 请求。它本质上只是一个 POST 请求,但其主体设置为 JSON,而不是传统的表单 POST

让我们看一下向 Laravel 路由提交 JSON 的情况以及如何使用 $request->input() 提取数据(参见 示例7-14)。

示例 7-14. 使用 $request->input() 从 JSON 中获取数据
POST /post-route HTTP/1.1Content-Type: application/json{ "firstName": "Joe", "lastName": "Schmoe", "spouse": { "firstName": "Jill", "lastName":"Schmoe" }}
// Post-routeRoute::post('post-route', function (Request $request) { $firstName = $request->input('firstName'); $spouseFirstname = $request->input('spouse.firstName');});

由于 $request->input() 足够智能,可以从 GETPOST 或 JSON 中提取用户数据,你可能会想知道为什么 Laravel 还提供 $request->json()。你可能更喜欢 $request->json() 的两个原因。首先,你可能希望对项目中其他程序员更明确地表明你期望数据来自何处。其次,如果 POST 没有正确的 application/json 头部,$request->input() 将无法将其识别为 JSON,但 $request->json() 可以。

当你想象“用户数据”时,URL 可能不是你首先想到的,但是在本章中,URL 与其他任何内容一样都是用户数据。

从 URL 获取数据的两种主要方式是通过 Request 对象和路由参数。

来自请求

注入的 Request 对象(以及 Request 外观和 request() 助手)有几种方法可用于表示当前页面 URL 的状态,但现在让我们专注于获取关于 URL 片段的信息。

URL 中域名后的每组字符称为 片段。因此,http://www.myapp.com/users/15 具有两个片段:users15

正如你可能猜到的,我们有两种可用的方法:$request->segments() 返回所有片段的数组,并且 $request->segment($segmentId) 允许我们获取单个片段的值。请注意,片段是基于 1 的索引返回的,因此在上面的示例中,$request->segment(1) 将返回 users

Request 对象、Request 外观和 request() 全局助手提供了更多方法来帮助我们从 URL 中获取数据。要了解更多,请参阅 第十章。

来自路由参数

获取关于 URL 的另一种主要方法是从路由参数中获取,这些参数被注入到正在服务当前路由的控制器方法或闭包中,如 示例7-15 所示。

示例 7-15. 从路由参数获取 URL 详细信息
// routes/web.phpRoute::get('users/{id}', function ($id) { // If the user visits myapp.com/users/15/, $id will equal 15});

要了解更多关于路由和路由绑定的信息,请参阅 第三章。

我们已经讨论了与用户文本输入的不同交互方式,但还有要考虑的文件上传问题。Request对象使用$request->file()方法提供对任何上传文件的访问,该方法以文件的输入名称作为参数,并返回Symfony\Component\HttpFoundation\File\UploadedFile的实例。让我们通过一个示例来说明。首先,我们的表单在示例7-16 中。

示例 7-16. 用于上传文件的表单
<form method="post" enctype="multipart/form-data"> @csrf <input type="text" name="name"> <input type="file" name="profile_picture"> <input type="submit"></form>

现在让我们看看运行$request->all()后我们得到什么,如示例7-17 所示。请注意,$request->input('profile_picture')将返回null;我们需要使用$request->file('profile_picture')

示例 7-17. 提交表单后的输出在示例7-16 中
Route::post('form', function (Request $request) { var_dump($request->all());});// Output:// [// "_token" => "token here",// "name" => "asdf",// "profile_picture" => UploadedFile {},// ]Route::post('form', function (Request $request) { if ($request->hasFile('profile_picture')) { var_dump($request->file('profile_picture')); }});// Output:// UploadedFile (details)

Laravel 还提供了文件特定的验证规则,允许您要求文件上传匹配特定的 mime 类型、文件大小或长度等。查看验证文档以了解更多信息。

Symfony 的UploadedFile类通过允许您轻松检查和操作文件的方法扩展了 PHP 的本机SplFileInfo。这个列表并不详尽,但它让您体验到了您可以做什么的一部分:

  • guessExtension()

  • getMimeType()

  • store(*$path*, *$storageDisk = default disk*)

  • storeAs(*$path*, *$newName*, *$storageDisk = default disk*)

  • storePublicly(*$path*, *$storageDisk = default disk*)

  • storePubliclyAs(*$path*, *$newName*, *$storageDisk = default disk*)

  • move(*$directory*, *$newName = null*)

  • getClientOriginalName()

  • getClientOriginalExtension()

  • getClientMimeType()

  • guessClientExtension()

  • getClientSize()

  • getError()

  • isValid()

如您所见,大多数方法与获取上传文件的信息有关,但有一个您可能比其他所有方法都更常用:store(),它接受通过请求上传的文件,并将其存储在服务器上指定的目录中。它的第一个参数是目标目录,可选的第二个参数是要用于存储文件的存储磁盘(s3local等)。您可以在示例7-18 中看到一个常见的工作流程。

示例 7-18. 常见文件上传工作流程
if ($request->hasFile('profile_picture')) { $path = $request->profile_picture->store('profiles', 's3'); auth()->user()->profile_picture = $path; auth()->user()->save();}

如果需要指定文件名,您可以使用storeAs()代替store()。第一个参数仍然是路径;第二个是文件名,可选的第三个参数是要使用的存储磁盘。

如果在尝试从请求中获取文件的内容时得到null,可能是您忘记在表单上设置编码类型。确保在表单上添加属性 enctype="multipart/form-data"

<form method="post" enctype="multipart/form-data">

Laravel 有很多方法可以验证传入的数据。我们将在下一节中讨论表单请求,因此我们现在有两个主要选项:手动验证或在Request对象上使用validate()方法。让我们从更简单、更常见的validate()方法开始。

在请求对象上进行验证()

Request 对象有一个 validate() 方法,提供了最常见的验证工作流的便捷方式。 请参阅示例 7-19。

示例 7-19. 请求验证的基本用法
// routes/web.phpRoute::get('recipes/create', [RecipeController::class, 'create']);Route::post('recipes', [RecipeController::class, 'store']);
// app/Http/Controllers/RecipeController.phpclass RecipeController extends Controller{ public function create() { return view('recipes.create'); } public function store(Request $request) { $request->validate([ 'title' => 'required|unique:recipes|max:125', 'body' => 'required' ]); // Recipe is valid; proceed to save it }}

我们在这里只有四行代码运行我们的验证,但它们确实做了很多事情。

首先,我们明确定义我们期望的字段,并分别应用规则(这里用管道字符|分隔)。

接下来,validate()方法检查来自$request的传入数据,并确定其是否有效。

如果数据有效,则validate()方法结束,我们可以继续使用控制器方法保存数据或其他操作。

但是,如果数据无效,则会抛出ValidationException。 这包含有关如何处理此异常的路由器指令。 如果请求来自 JavaScript(或者请求 JSON 作为响应),则异常将创建一个包含验证错误的 JSON 响应。 如果不是,则异常将返回一个重定向到前一页的页面,以及所有用户输入和验证错误,非常适合重新填充失败的表单并显示一些错误。

更多关于 Laravel 验证规则的信息

在我们这里的示例中(就像文档中一样),我们使用“管道”语法:'*fieldname*': '*rule*|*otherRule*|*anotherRule*'。 但是,您也可以使用数组语法来执行相同的操作:'*fieldname*': ['*rule*', '*otherRule*', '*anotherRule*']

此外,您还可以验证嵌套属性。 如果您使用 HTML 的数组语法,这就很重要,该语法允许您例如,在 HTML 表单上具有多个“用户”,每个用户都有一个关联的名称。 以下是验证方法:

$request->validate([ 'user.name' => 'required', 'user.email' => 'required|email',]);

我们没有足够的空间来涵盖这里的每一个可能的验证规则,但以下是一些最常见规则及其功能:

要求字段

required; required_if:*anotherField,equalToThisValue*;

required_unless:*anotherField,equalToThisValue*

排除请求输出中的字段

exclude_if:*anotherField,equalToThisValue*;

exclude_unless:*anotherField,equalToThisValue*

字段必须包含某些类型的字符

alpha; alpha_dash; alpha_num; numeric; integer

字段必须包含特定的模式

email; active_url; ip

日期

after:*date*; before:*date**date*可以是strtotime()可以处理的任何有效字符串)

数字

between:*min*,*max*; min:*num*; max:*num*; size:*num*size测试字符串长度、整数值、数组计数或文件大小(KB))

图像尺寸

dimensions:min_width=*XXX*; 也可以与max_widthmin_heightmax_heightwidthheightratio结合使用

数据库

exists:*tableName*; unique:*tableName*(期望在与字段名相同的表列中查找;请查看验证文档以了解如何自定义)

你可以在数据库验证规则中指定 Eloquent 模型,而不是表名:

'name' => 'exists:App\Models\Contact,name','phone' => 'unique:App\Models\Contact,phone',

手动验证

如果您不在控制器中工作,或者由于某些其他原因,先前描述的流程不适合,您可以使用Validator门面手动创建Validator实例,并像示例 7-20 中那样检查成功或失败。

示例 7-20. 手动验证
Route::get('recipes/create', function () { return view('recipes.create');});Route::post('recipes', function (Illuminate\Http\Request $request) { $validator = Validator::make($request->all(), [ 'title' => 'required|unique:recipes|max:125', 'body' => 'required' ]); if ($validator->fails()) { return redirect('recipes/create') ->withErrors($validator) ->withInput(); } // Recipe is valid; proceed to save it});

如您所见,我们通过将输入作为第一个参数和验证规则作为第二个参数传递来创建验证器的实例。验证器公开了一个fails()方法,我们可以进行检查,并可以将其传递给重定向的withErrors()方法。

使用验证数据

在验证数据后,您可以从请求中提取数据,确保只使用验证过的数据。有两个主要选项:validated()safe()。您可以在$request对象上运行这些方法,或者如果您创建了手动验证器,则在$validator实例上运行。

validated()方法返回已验证数据的数组,如示例 7-21 所示。

示例 7-21. 使用validated()获取验证数据
// Both return an array of validated user input$validated = $request->validated();$validated = $validator->validated();

另一方面,safe()方法返回一个对象,该对象使您可以访问all()only()except()方法,正如您在示例 7-22 中所看到的。

示例 7-22. 使用safe()获取验证数据
$validated = $request->safe()->only(['name', 'email']);$validated = $request->safe()->except(['password']);$validated = $request->safe()->all();

自定义规则对象

如果 Laravel 中不存在您需要的验证规则,您可以创建自己的规则。要创建自定义规则,请运行php artisan make:rule *RuleName*,然后编辑位于app/Rules/{RuleName}.php中的文件。

您将获得默认提供的validate()方法。validate()方法应接受属性名称作为第一个参数,用户提供的值作为第二个参数,并在验证失败时接受一个闭包;您可以在消息中使用:attribute作为属性名称的占位符。

请查看示例 7-23 作为示例。

示例 7-23. 示例自定义规则
class AllowedEmailDomain implements ValidationRule{ public function validate(string $attribute, mixed $value, Closure $fail): void { if(! in_array(Str::after($value, '@'), ['tighten.co'])){ $fail('The :attribute field is not from an allowed email provider.'); } }}

要使用此规则,只需将规则对象的实例传递给您的验证器:

$request->validate([ 'email' => new AllowedEmailDomain,]);

显示验证错误消息

我们已经在第六章中详细介绍了这一点,但这里是如何从验证中显示错误的快速复习。

请求上的validate()方法(以及它依赖的重定向的withErrors()方法)将任何错误闪存到会话中。这些错误可以在您重定向到的视图中通过$errors变量访问。请记住,作为 Laravel 魔术的一部分,即使是空的,每次加载视图时$errors变量也会可用,因此您不必使用isset()来检查其是否存在。

这意味着您可以在每个页面上执行类似示例 7-24 的操作。

示例 7-24. 回显验证错误
@if ($errors->any()) <ul id="errors"> @foreach ($errors->all() as $error) <li>{{ $error }}</li> @endforeach </ul>@endif

您还可以有条件地回显单个字段的错误消息。为此,您将使用@error Blade 指令来检查给定字段是否存在错误。

@error('first_name') <span>{{ $message }}</span>@enderror

在构建应用程序时,您可能会注意到控制器方法中出现了一些模式。有些模式是重复的——例如,输入验证、用户身份验证和授权以及可能的重定向。如果您希望有一种结构来规范化和提取这些常见行为,以便从控制器方法中提取出来,您可能会对 Laravel 的表单请求感兴趣。

表单请求是一个自定义的请求类,旨在映射到表单的提交,请求负责验证请求、授权用户,并在验证失败时可选择重定向用户。每个表单请求通常但并非总是显式映射到单个 HTTP 请求,例如,“创建评论”。

创建表单请求

您可以通过命令行创建一个新的表单请求:

php artisan make:request CreateCommentRequest

现在你可以在 app/Http/Requests/CreateCommentRequest.php 中使用表单请求对象。

每个表单请求类都提供一个或两个公共方法。第一个是 rules(),它需要返回此请求的验证规则数组。第二个(可选)方法是 authorize();如果返回 true,则用户被授权执行此请求,如果返回 false,则用户被拒绝。查看 示例7-25 以查看一个表单请求的样本。

示例 7-25. 样本表单请求
<?phpnamespace App\Http\Requests;use App\BlogPost;use Illuminate\Foundation\Http\FormRequest;class CreateCommentRequest extends FormRequest{ public function authorize(): bool { $blogPostId = $this->route('blogPost'); return auth()->check() && BlogPost::where('id', $blogPostId) ->where('user_id', auth()->id())->exists(); } public function rules(): array { return [ 'body' => 'required|max:1000', ]; }}

示例7-25 的 rules() 部分非常简单明了,但让我们简要了解一下 authorize()

我们正在从名为 blogPost 的路由中获取段落。这暗示了该路由的定义可能看起来像这样:Route::post('blogPosts/*`blogPost`*', function () *`{ // Do stuff }`*)。正如您所见,我们将路由参数命名为 blogPost,这使得它可以在我们的 Request 中通过 $⁠t⁠h⁠i⁠s​-⁠>⁠r⁠o⁠u⁠t⁠e⁠('blogPost') 访问。

然后我们检查用户是否已登录,如果是,则查看是否存在以该标识符标记的任何博客文章,这些文章是当前登录用户拥有的。您已经学会了一些更简单的方法来检查所有权,例如 第五章 中的方法,但我们在这里保持更加明确以保持清洁。我们将很快讨论这样做的影响,但重要的是要知道,返回 true 表示用户被授权执行指定的操作(在本例中是创建评论),而 false 表示用户未被授权。

使用表单请求

现在我们已经创建了一个表单请求对象,那么我们如何使用它呢?这是 Laravel 的一点魔法。任何路由(闭包或控制器方法),如果将表单请求作为其参数类型提示,将从该表单请求的定义中受益。

让我们试试,在 示例7-26 中。

示例 7-26. 使用表单请求
Route::post('comments', function (App\Http\Requests\CreateCommentRequest $request) { // Store comment});

您可能想知道我们何时调用表单请求,但 Laravel 会为我们执行这一步骤。它验证用户输入并授权请求。如果输入无效,它会像 Request 对象的 validate() 方法一样操作,重定向用户到前一页并保留其输入,同时传递适当的错误消息。如果用户未经授权,则 Laravel 将返回“403 禁止访问”错误并不执行路由代码。

到目前为止,我们一直在控制器级别进行验证,这绝对是开始的最佳位置。但您也可以在模型级别过滤传入的数据。

将表单的整体输入直接传递给数据库模型是一种常见(但不推荐)的模式。在 Laravel 中,可能看起来像 Example7-27。

示例 7-27. 将表单的整体内容传递给 Eloquent 模型
Route::post('posts', function (Request $request) { $newPost = Post::create($request->all());});

我们在这里假设最终用户是善良的,而非恶意的,并且仅保留了我们希望他们编辑的字段——也许是帖子的 titlebody

但是,如果我们的最终用户能够猜测或识别出我们在 posts 表中有一个 author_id 字段,该怎么办呢?如果他们使用浏览器工具添加了一个 author_id 字段,并将 ID 设置为别人的 ID,然后冒充其他人创建假的博客文章呢?

Eloquent 有一个称为“大规模赋值”的概念,允许您通过将数组传递给 create()update() 方法,要么定义应填充的字段列表(使用模型的 $fillable 属性),要么定义不应填充的字段列表(使用模型的 $guarded 属性)。有关更多信息,请参阅“大规模赋值”。

在我们的示例中,我们可能想要像 Example7-28 中那样填充模型,以确保我们的应用程序安全。

示例 7-28. 保护 Eloquent 模型免受恶意的大规模赋值攻击
<?phpnamespace App;use Illuminate\Database\Eloquent\Model;class Post extends Model{ // Disable mass assignment on the author_id field protected $guarded = ['author_id'];}

通过将 author_id 设置为 guarded,我们确保恶意用户将无法通过将其手动添加到发送给我们应用程序的表单内容中,覆盖此字段的值。

尽管我们需要保护我们的模型免受大规模赋值的攻击,但在赋值端也值得小心。与其使用 $request->all(),不如考虑使用 $request->only(),这样您可以指定要传递到模型中的字段:

Route::post('posts', function (Request $request) { $newPost = Post::create($request->only([ 'title', 'body', ]));});

每当在网页上显示由用户创建的内容时,您都需要防范恶意输入,例如脚本注入。

假设您允许用户在您的网站上撰写博客文章。您可能不希望他们能够注入恶意的 JavaScript 代码,从而在您的访客浏览器中运行,对吗?因此,您需要转义任何显示在页面上的用户输入,以避免这种情况发生。

幸运的是,这几乎完全为您覆盖了。如果您使用 Laravel 的 Blade 模板引擎,默认的“echo”语法({{ *$stuffToEcho* }})会自动通过 htmlentities()(PHP 最佳的安全输出用户内容的方式)运行输出。实际上,您需要额外的工作来避免转义输出,方法是使用 {!! *$stuffToEcho* !!} 语法。

如果您对测试用户输入交互感兴趣,您可能最关心的是模拟有效和无效的用户输入,并确保如果输入无效,则用户被重定向,如果输入有效,则进入正确的位置(例如数据库)。

Laravel 的应用程序测试框架使得这变得简单。

这些测试测试的是您应用程序的 HTTP 层,但并非实际的表单字段和交互。如果您想要测试页面上特定的用户交互及其与您的表单的交互,您将需要引入 Laravel 的 Dusk 测试包。

查看 “使用 Dusk 进行测试” 了解如何在您的测试中安装和使用 Dusk。

让我们从一个无效的路由开始,我们预期会被拒绝,就像 示例7-29 中一样。

示例 7-29. 测试无效输入是否被拒绝
public function test_input_missing_a_title_is_rejected(){ $response = $this->post('posts', ['body' => 'This is the body of my post']); $response->assertRedirect(); $response->assertSessionHasErrors();}

在这里,我们断言在无效输入后,用户会被重定向,并附加错误信息。您可以看到我们在这里使用了一些 Laravel 添加的自定义 PHPUnit 断言。

那么,我们如何测试我们路由的成功?请查看 示例7-30。

示例 7-30. 测试处理有效输入
public function test_valid_input_should_create_a_post_in_the_database(){ $this->post('posts', ['title' => 'Post Title', 'body' => 'This is the body']); $this->assertDatabaseHas('posts', ['title' => 'Post Title']);}

请注意,如果您正在使用数据库进行测试,您需要了解更多关于数据库迁移和事务的知识。关于这方面的更多内容请参见 第12 章。

有很多方法可以获取相同的数据:使用 Request 门面、使用 request() 全局助手函数以及注入 Illuminate\Http\Request 实例。每个都提供了获取所有输入、部分输入或特定数据片段的能力,并且对于文件和 JSON 输入可能存在一些特殊考虑。

URL 路径段也是用户输入的一个可能来源,并且它们也可以通过请求工具访问。

可以使用 Validator::make() 手动执行验证,也可以使用 validate() 请求方法或表单请求自动执行验证。每个自动工具在验证失败时都会将用户重定向到上一页,并传递所有旧输入和错误信息。

视图和 Eloquent 模型也需要保护免受恶意用户输入的影响。您可以通过使用双花括号语法({{ }})来转义用户输入来保护 Blade 视图。您可以通过仅将特定字段传递给模型的批量方法(使用 $request->only())并在模型本身上定义批量赋值规则来保护模型。

从安装开始,现代 PHP 框架期望在命令行上进行许多交互。Laravel 提供了三种主要的命令行交互工具:Artisan,一组内置命令行操作,具有添加更多功能的能力;Tinker,用于应用程序的 REPL 或交互式 shell;以及安装程序,在第二章中已经介绍过。

如果您已经逐章阅读本书,已经学会如何使用 Artisan 命令。它们看起来像这样:

php artisan make:controller PostController

如果您查看应用程序的根文件夹,您会看到artisan实际上只是一个 PHP 文件。这就是为什么您要以php artisan开头进行调用;您将该文件传递给 PHP 进行解析。之后的所有内容只是作为参数传递给 Artisan。

实际上,Artisan 是建立在Symfony Console 组件之上的一层;因此,如果您熟悉编写 Symfony Console 命令,那么您应该会感觉如同在家一样。

由于应用程序的 Artisan 命令列表可能会被包或特定应用程序代码更改,因此值得检查您遇到的每个新应用程序以查看可用的命令。

要获取所有可用 Artisan 命令的列表,可以从项目根目录运行php artisan list(尽管如果只运行php artisan而不带参数,它将执行相同的操作)。

这里没有足够的空间来涵盖所有的 Artisan 命令,但我们将涵盖其中的许多命令。让我们从基本命令开始:

clear-compiled

移除 Laravel 的编译类文件,这类似于内部 Laravel 缓存;当事情出现问题并且您不知道原因时,首先尝试运行此命令。

downup

将您的应用程序置于“维护模式”中,以便您可以修复错误,运行迁移或其他操作,并将应用程序从维护模式恢复。

dump-server

启动转储服务器(参见“Laravel Dump Server”)以收集和输出转储的变量。

env

显示 Laravel 当前运行的环境;这相当于在应用中回显app()->environment()

help

为命令提供帮助;例如,php artisan help *commandName*

migrate

运行所有数据库迁移。

optimize

清除并刷新配置和路由文件。

serve

localhost:8000上启动 PHP 服务器。(您可以使用--host--port自定义主机和/或端口。)

tinker

启动 Tinker REPL,我们将在本章后面介绍它。

stub:publish

发布所有可用于自定义的存根。

docs

为您提供快速访问 Laravel 文档的途径;传递一个参数,您将被提示打开这些文档的 URL,或者不传递参数,您将能够浏览文档主题列表以选择。

about

显示项目环境、通用配置、包等的概述。

Laravel 生命周期内 Artisan 命令及其名称略有变化。在撰写本书时,此列表尽可能是最新的。但是,了解可用内容的最佳方法是从您的应用程序中运行 php artisan

选项

在我们介绍其余命令之前,让我们看一下您在运行 Artisan 命令时可以随时传递的一些显著选项:

-q

抑制所有输出

-v-vv-vvv

指定输出详细程度(正常、详细和调试)

--no-interaction

抑制交互式问题,因此命令不会中断正在运行它的自动化过程

--env

允许您定义 Artisan 命令应在哪个环境中运行(localproduction 等)。

--version

显示您的应用程序正在运行的 Laravel 版本

您可能已经从这些选项中猜到,Artisan 命令的使用方式类似于基本的 shell 命令:您可以手动运行它们,但它们也可以作为某些自动化过程的一部分运行。

例如,许多自动化部署流程可能会从某些 Artisan 命令中受益。每次部署应用程序时,您可能希望运行 php artisan config:cache。像 -q--no-interaction 这样的标志确保您的部署脚本可以顺利运行,而无需人类干预。

分组命令

提供了默认情况下可用的其余命令,这些命令根据上下文进行分组。我们不会在此处详细介绍所有命令,但我们将广泛涵盖每个上下文:

auth

这里仅有 auth:clear-resets,该命令从数据库中清除所有过期的密码重置令牌。

cache

cache:clear 清除缓存,cache:forget 从缓存中删除单个项,并且 cache:table 如果您计划使用 database 缓存驱动程序,则创建数据库迁移。

config

config:cache 缓存您的配置设置以加快查找速度;要清除缓存,请使用 config:clear

db

db:seed 如果已配置数据库填充器,则向数据库中填充数据。

event

event:list 列出应用程序中的所有事件和监听器,event:cache 缓存该列表,event:clear 清除该缓存,event:generate 根据 EventServiceProvider 中的定义构建缺失的事件和事件监听器文件。您将在 第十六章 中了解更多关于事件的信息。

key

key:generate 在您的 .env 文件中创建一个随机的应用程序加密密钥。

如果在你的应用程序上多次运行php artisan key:generate,每个当前已登录的用户都将被注销。此外,任何您手动加密的数据将无法解密。要了解更多信息,请查看由同事 Tightenite Jake Bathman 撰写的文章“APP_KEY and You”

make

每个make:操作都从一个存根创建一个单独的项目,并具有相应变化的参数。要了解有关任何单个命令参数的更多信息,请使用help来阅读其文档。

例如,您可以运行php artisan help make:migration,了解到可以传递--create=*tableNameHere*来创建一个已经包含创建表语法的迁移文件,如下所示:php artisan make:migration create_posts_table --create=posts

migrate

之前提到的用于运行所有迁移的migrate命令,请参阅“Running Migrations”以获取有关所有与迁移相关的命令的详细信息。

notifications

notifications:table 生成一个创建数据库通知表的迁移。

package

Laravel 通过其“autodiscover”功能生成的清单。这在您首次安装第三方包时为您注册服务提供程序。package:discover 重新构建 Laravel 的“已发现”清单,其中包含来自外部包的服务提供程序。

queue

我们将在第十六章介绍 Laravel 的队列,但基本思想是您可以将作业推送到远程队列,由工作进程依次执行。此命令组提供了与队列交互所需的所有工具,如queue:listen用于开始监听队列,queue:table用于创建支持数据库的队列的迁移,queue:flush用于刷新所有失败的队列作业。还有更多命令,您将在第十六章中了解到。

route

如果运行route:list,您将看到应用程序中定义的每个路由的定义,包括每个路由的动词、路径、名称、控制器/闭包动作和中间件。您可以使用route:cache缓存路由定义以加快查找速度,并使用route:clear清除缓存。

schedule

我们将在第十六章介绍 Laravel 的类似于 cron 的调度器,但为了使其工作,您需要设置系统 cron 每分钟运行schedule:run一次:

* * * * * php /home/myapp.com/artisan schedule:run >> /dev/null 2>&1

正如您所看到的,此 Artisan 命令旨在定期运行,以支持 Laravel 核心服务。

session

session:table 为使用数据库支持会话的应用程序创建迁移。

storage

storage:link 创建一个符号链接,将public/storage链接到storage/app/public。这是 Laravel 应用程序中的常见约定,可以轻松地将用户上传的文件(或其他通常保存在storage/app中的文件)放在可以通过公共 URL 访问的地方。

vendor

一些特定于 Laravel 的包需要“发布”它们的一些资源,这样它们可以从你的 public 目录提供或者你可以修改它们。无论哪种方式,这些包都会向 Laravel 注册这些“可发布的资源”,当你运行 vendor:publish 时,它们就会发布到指定的位置。

view

Laravel 的视图渲染引擎会自动缓存你的视图。通常它处理自己的缓存失效工作做得不错,但如果你注意到有时候卡住了,可以运行 view:clear 来清除缓存。

现在我们已经讨论了 Laravel 开箱即用的 Artisan 命令,让我们来谈谈如何编写你自己的命令。

首先,你应该知道:有一个专门的 Artisan 命令来处理这个!运行 php artisan make:command *YourCommandName* 会在 app/Console/Commands/{YourCommandName}.php 中生成一个新的 Artisan 命令。

你的第一个参数应该是命令的类名,你还可以选择性地传递一个 --command 参数来定义终端命令将是什么(例如 appname:action)。所以,让我们来做吧:

php artisan make:command WelcomeNewUsers --command=email:newusers

查看 示例8-1 以查看你将得到什么。

示例 8-1. Artisan 命令的默认骨架
<?phpnamespace App\Console\Commands;use Illuminate\Console\Command;class WelcomeNewUsers extends Command{ /** * The name and signature of the console command * * @var string */ protected $signature = 'email:newusers'; /** * The console command description * * @var string */ protected $description = 'Command description'; /** * Execute the console command. */ public function handle(): void { // }}

正如你所看到的,定义命令签名、命令列表中显示的帮助文本以及命令执行时的行为 (handle()) 非常简单。

一个示例命令

在本章中我们还没有涵盖邮件或 Eloquent(查看 第十五章 获取邮件和 第五章 获取 Eloquent),但 示例8-2 中的示例 handle() 方法应该读起来很清晰。

示例 8-2. 一个样例 Artisan 命令 handle() 方法
// ...class WelcomeNewUsers extends Command{ public function handle(): void { User::signedUpThisWeek()->each(function ($user) { Mail::to($user)->send(new WelcomeEmail); }); }

现在每当你运行 php artisan email:newusers 命令时,该命令将获取本周注册的每个用户,并发送给他们欢迎邮件。

如果你更喜欢注入你的邮件和用户依赖项而不是使用门面模式,你可以在命令构造函数中使用类型提示,当命令实例化时,Laravel 的容器会自动注入它们给你。

查看 示例8-3 以查看使用依赖注入和将其行为提取到服务类中的 示例8-2 是什么样子的。

示例 8-3. 同一个命令,重构后
...class WelcomeNewUsers extends Command{ public function __construct(UserMailer $userMailer) { parent::__construct(); $this->userMailer = $userMailer } public function handle(): void { $this->userMailer->welcomeNewUsers(); }

参数和选项

新命令的 $signature 属性看起来可能只包含命令名称。但这个属性也是你定义命令的参数和选项的地方。你可以使用特定而简单的语法来向你的 Artisan 命令添加参数和选项。

在我们深入研究语法之前,先看一个例子来获得一些上下文:

protected $signature = 'password:reset {userId} {--sendEmail}';

参数 — 必填、可选、或者带有默认值

要定义一个必填参数,用大括号括起来:

password:reset {userId}

要使参数变成可选的,添加一个问号:

password:reset {userId?}

要使它可选并提供默认值,使用:

password:reset {userId=1}

选项 — 必填值、默认值和快捷方式

选项类似于参数,但它们以 -- 作为前缀,并且可以不带值使用。要添加基本选项,请用大括号括起来:

password:reset {userId} {--sendEmail}

如果您的选项需要一个值,请在其签名中添加一个 =

password:reset {userId} {--password=}

如果要传递默认值,请在 = 之后添加它:

password:reset {userId} {--queue=default}

数组参数和数组选项

无论是对于参数还是选项,如果要接受数组作为输入,请使用 * 字符:

password:reset {userIds*}password:reset {--ids=*}

使用数组参数和参数看起来有点像 8-4 示例。

示例 8-4. 在 Artisan 命令中使用数组语法
// Argumentphp artisan password:reset 1 2 3// Optionphp artisan password:reset --ids=1 --ids=2 --ids=3

由于数组参数捕获其定义后的每个参数,并将它们作为数组项添加,因此数组参数必须是 Artisan 命令签名中的最后一个参数。

输入描述

还记得内置的 Artisan 命令如何在使用 artisan help 时能为我们提供有关其参数的更多信息吗?我们可以为我们的自定义命令提供相同的信息。只需在大括号中加上冒号和描述文本,就像 8-5 示例中一样。

示例 8-5. 为 Artisan 参数和选项定义描述文本
protected $signature = 'password:reset {userId : The ID of the user} {--sendEmail : Whether to send user an email}';

使用输入

现在我们已经提示了这个输入,那么我们如何在命令的 handle() 方法中使用它呢?我们有两套方法来检索参数和选项的值。

argument() 和 arguments()

$this->arguments() 返回所有参数的数组(第一个数组项将是命令名称)。没有参数调用的 $this->argument() 返回相同的响应;我更喜欢复数形式的方法,仅仅是为了更好的可读性。

要仅获取单个参数的值,请将参数名称作为参数传递给 $this->argument(),如示例 8-6 所示。

示例 8-6. 在 Artisan 命令中使用 $this->arguments()
// With definition "password:reset {userId}"php artisan password:reset 5// $this->arguments() returns this array[ "command": "password:reset", "userId": "5",]// $this->argument('userId') returns this string"5"

option() 和 options()

$this->options() 返回一个包含所有选项的数组,其中一些默认为 falsenull。没有参数调用的 $this->option() 返回相同的响应;我更喜欢复数形式的方法,仅仅是为了更好的可读性。

要仅获取单个选项的值,请将参数名称作为参数传递给 $this->option(),如示例 8-7 所示。

示例 8-7. 在 Artisan 命令中使用 $this->options()
// With definition "password:reset {--userId=}"php artisan password:reset --userId=5// $this->options() returns this array[ "userId" => "5", "help" => false, "quiet" => false, "verbose" => false, "version" => false, "ansi" => false, "no-ansi" => false, "no-interaction" => false, "env" => null,]// $this->option('userId') returns this string"5"

8-8 示例展示了在其 handle() 方法中使用 argument()option() 的 Artisan 命令。

示例 8-8. 从 Artisan 命令获取输入
public function handle(): void{ // All arguments, including the command name $arguments = $this->arguments(); // Just the 'userId' argument $userid = $this->argument('userId'); // All options, including some defaults like 'no-interaction' and 'env' $options = $this->options(); // Just the 'sendEmail' option $sendEmail = $this->option('sendEmail');}

提示

在执行命令期间,有几种方法可以从 handle() 代码中获取用户输入:

ask()

提示用户输入自由格式文本:

$email = $this->ask('What is your email address?');

secret()

提示用户输入自由格式文本,但用星号隐藏输入:

$password = $this->secret('What is the DB password?');

confirm()

提示用户回答是或否,并返回布尔值:

if ($this->confirm('Do you want to truncate the tables?')) { //}

除了 yY 之外的所有答案都将被视为“否”。

anticipate()

提示用户输入自由格式文本,并提供自动完成建议。仍然允许用户输入他们想要的任何内容:

$album = $this->anticipate('What is the best album ever?', [ "The Joshua Tree", "Pet Sounds", "What's Going On"]);

choice()

提示用户从提供的选项中选择一个。如果用户没有选择,则使用最后一个参数作为默认值:

$winner = $this->choice( 'Who is the best football team?', ['Gators', 'Wolverines'], 0);

请注意,最后一个参数,默认应为数组键。由于我们传递了非关联数组,因此 Gators 的键是 0。如果您愿意,也可以对数组进行键分配:

$winner = $this->choice( 'Who is the best football team?', ['gators' => 'Gators', 'wolverines' => 'Wolverines'], 'gators');

输出

在执行命令期间,您可能希望向用户写入消息。实现这一最基本的方法是使用 $this->info() 输出基本的绿色文本:

$this->info('Your command has run successfully.');

您还可以使用 comment()(橙色)、question()(高亮青色)、error()(高亮红色)、line()(未着色)和 newLine()(未着色)方法在命令行输出。

请注意,确切的颜色可能因机器而异,但它们试图符合本地机器与最终用户之间的标准沟通。

表格输出

table() 方法使得创建包含数据的 ASCII 表格变得简单。查看 示例8-9。

示例 8-9. 使用 Artisan 命令输出表格
$headers = ['Name', 'Email'];$data = [ ['Dhriti', 'dhriti@amrit.com'], ['Moses', 'moses@gutierez.com'],];// Or, you could get similar data from the database:$data = App\User::all(['name', 'email'])->toArray();$this->table($headers, $data);

注意 示例8-9 包含两组数据:标题和数据本身。每行都包含两个“单元格”;每行的第一个单元格是名称,第二个单元格是电子邮件。这样,来自 Eloquent 调用的数据(限制为仅提取名称和电子邮件)与标题相匹配。

查看 示例8-10 以查看表格输出的样子。

示例 8-10. Artisan 表格的示例输出
+---------+--------------------+| Name | Email |+---------+--------------------+| Dhriti | dhriti@amrit.com || Moses | moses@gutierez.com |+---------+--------------------+

进度条

如果您曾经运行过 npm install,您之前见过命令行进度条。让我们在 示例8-11 中构建一个。

示例 8-11. Artisan 进度条示例
$totalUnits = 350;$this->output->progressStart($totalUnits);for ($i = 0; $i < $totalUnits; $i++) { sleep(1); $this->output->progressAdvance();}$this->output->progressFinish();

我们在这里做了什么?首先,我们告诉系统需要处理多少“单位”。也许一个单位是一个用户,您有 350 个用户。进度条然后将屏幕上可用的整个宽度除以 350,并且每次运行 progressAdvance() 时递增 1/350。完成后,请运行 progressFinish() 以通知它已完成显示进度条。

编写基于闭包的命令

如果您更喜欢保持命令定义过程简单,可以将命令编写为闭包而不是类,方法是在 routes/console.php 中定义和注册它们。本章中讨论的所有内容都将同样适用,但您将在该文件中的单个步骤中定义和注册命令,如 示例8-12 所示。

示例 8-12. 使用闭包定义 Artisan 命令
// routes/console.phpArtisan::command( 'password:reset {userId} {--sendEmail}', function ($userId, $sendEmail) { $userId = $this->argument('userId'); // Do something... });

虽然 Artisan 命令设计用于从命令行运行,但您也可以从其他代码中调用它们。

最简单的方法是使用 Artisan 门面。您可以使用 Artisan::call() 调用命令(这将返回命令的退出代码),或者使用 Artisan::queue() 将命令排队。

两者都接受两个参数:第一个是终端命令(password:reset);第二个是要传递给它的参数数组。查看示例8-13 以了解如何使用参数和选项。

示例 8-13. 从其他代码调用 Artisan 命令
Route::get('test-artisan', function () { $exitCode = Artisan::call('password:reset', [ 'userId' => 15, '--sendEmail' => true, ]);});

如您所见,参数通过键名传递给参数名,没有值的选项可以传递truefalse

您还可以通过将与命令行中相同的字符串传递到Artisan::call()中,更自然地从您的代码中调用 Artisan 命令:

Artisan::call('password:reset 15 --sendEmail')

您还可以从其他命令中使用$this->call()调用 Artisan 命令(与Artisan::call()相同),或者使用$this->callSilent(),它们的作用相同,但抑制了所有输出。参见示例8-14 作为示例。

示例 8-14. 从其他 Artisan 命令调用 Artisan 命令
public function handle(): void{ $this->callSilent('password:reset', [ 'userId' => 15, ]);}

最后,您可以注入Illuminate\Contracts\Console\Kernel合同的一个实例,并使用它的call()方法。

Tinker 是一个 REPL(交互式环境),或者读取-求值-打印循环。REPL 会给您一个提示符,类似于命令行提示符,模仿应用程序的“等待”状态。您在 REPL 中键入命令,按回车键,然后期待您键入的内容进行评估并打印响应。

示例8-15 提供了一个快速示例,让您了解它的工作方式及其可能的用处。我们使用php artisan tinker启动 REPL,然后看到一个空白提示符(>>>);每个命令的响应都打印在以=>为前缀的行上。

示例 8-15. 使用 Tinker
$ php artisan tinker>>> $user = new App\User;=> App\User: {}>>> $user->email = 'matt@mattstauffer.com';=> "matt@mattstauffer.com">>> $user->password = bcrypt('superSecret');=> "$2y$10$TWPGBC7e8d1bvJ1q5kv.VDUGfYDnE9gANl4mleuB3htIY2dxcQfQ5">>> $user->save();=> true

如您所见,我们创建了一个新用户,设置了一些数据(使用bcrypt()对密码进行了哈希处理以确保安全),并将其保存到数据库中。这是真实的情况。如果这是一个生产应用程序,我们会在系统中创建一个全新的用户。

这使得 Tinker 成为一个用于简单数据库交互、尝试新想法以及在应用程序源文件中找不到放置位置时运行代码片段的绝佳工具。

Tinker 由Psy Shell提供支持,因此请查看它,看看您还可以使用 Tinker 做什么。

在开发过程中,调试数据状态的一种常见方法是使用 Laravel 的dump()助手函数,它对任何您传递给它的内容运行装饰过的var_dump()。这很好用,但它经常会遇到视图问题。

您可以启用 Laravel 转储服务器,它会捕获那些dump()语句,并在控制台中显示它们,而不是将它们渲染到页面中。

要在本地控制台中运行转储服务器,请导航至项目的根目录并运行php artisan dump-server

$ php artisan dump-serverLaravel Var Dump Server======================= [OK] Server listening on tcp://127.0.0.1:9912 // Quit the server with CONTROL-C.

现在,请尝试在您的代码中某个地方使用dump()助手函数。要测试它,请在您的routes/web.php文件中尝试以下代码:

Route::get('/', function () { dump('Dumped Value'); return 'Hello World';});

没有 dump 服务器,你会同时看到 dump 和你的“Hello World”。但是有 dump 服务器运行时,你只会在浏览器中看到“Hello World”。在你的控制台中,你会看到 dump 服务器捕捉到了 dump(),你可以在那里检查它:

GET http://myapp.test/-------------------- ------------ --------------------------------- date Tue, 18 Sep 2018 22:43:10 +0000 controller "Closure" source web.php on line 20 file routes/web.php ------------ ---------------------------------"Dumped Value"

任何生成文件的 Artisan 命令(例如 make:modelmake:controller)都使用“存根”文件,命令会复制并修改这些文件以创建新生成的文件。你可以在你的应用程序中自定义这些存根。

要在你的应用程序中自定义存根,请运行 php artisan stub:publish,它将把存根文件导出到一个 stub/ 目录中,你可以在那里自定义它们。

由于你知道如何从代码中调用 Artisan 命令,因此在测试中执行这些操作并确保你期望的行为已经正确执行很容易,就像 示例8-16 中展示的那样。在我们的测试中,我们使用 $this->artisan() 而不是 Artisan::call(),因为它具有相同的语法但添加了一些与测试相关的断言。

示例 8-16. 在测试中调用 Artisan 命令
public function test_empty_log_command_empties_logs_table(){ DB::table('logs')->insert(['message' => 'Did something']); $this->assertCount(1, DB::table('logs')->get()); $this->artisan('logs:empty'); // Same as Artisan::call('logs:empty'); $this->assertCount(0, DB::table('logs')->get());}

你可以链式调用一些新的断言到你的 $this->artisan() 调用中,这使得测试 Artisan 命令变得更加容易——不仅仅是它们对你的应用程序的影响,还有它们的实际操作。看看 示例8-17 来看看这种语法的一个示例。

示例 8-17. 对 Artisan 命令的输入和输出进行断言
public function testItCreatesANewUser(){ $this->artisan('myapp:create-user') ->expectsQuestion("What's the name of the new user?", "Wilbur Powery") ->expectsQuestion("What's the email of the new user?", "wilbur@thisbook.co") ->expectsQuestion("What's the password of the new user?", "secret") ->expectsOutput("User Wilbur Powery created!"); $this->assertDatabaseHas('users', [ 'email' => 'wilbur@thisbook.co' ]);}

Artisan 命令是 Laravel 的命令行工具。Laravel 自带了一些命令,但也很容易创建你自己的 Artisan 命令并从命令行或你自己的代码中调用它们。

Tinker 是一个 REPL,使得进入你的应用程序环境并与真实代码和真实数据交互变得简单,而 dump 服务器允许你在不停止代码执行的情况下调试你的代码。

设置基本的用户认证系统,包括注册、登录、会话、密码重置和访问权限,通常是创建应用程序基础的更耗时的部分之一。这是将功能提取到库中的一个主要候选项,而且有许多这样的库可供选择。

但由于项目的认证需求可能存在较大差异,大多数认证系统很快就会变得笨重且难以使用。幸运的是,Laravel 已经找到了一种方法,可以创建一套易于使用和理解的认证系统,同时灵活到可以适应各种设置。

Laravel 的每一个新安装都包含一个 create_users_table 迁移和一个内置的 User 模型。如果引入了 Breeze(参见 “Laravel Breeze”)或 Jetstream(参见 “Laravel Jetstream”),它们将为您的应用程序提供一系列与认证相关的视图、路由、控制器/动作和其他功能。API 是清晰易懂的,所有约定都协同工作,提供了一个简单且无缝的认证和授权系统。

当您创建一个新的 Laravel 应用程序时,您将看到的第一个迁移和模型是 create_users_table 迁移和 App\User 模型。示例9-1 直接展示了从迁移中获取的 users 表中的字段。

示例 9-1. Laravel 的默认用户迁移
Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); $table->rememberToken(); $table->timestamps();});

我们有一个自增主键 ID,一个名称,一个唯一的电子邮件,一个密码,一个“记住我”令牌,以及创建和修改的时间戳。这涵盖了大多数应用程序中处理基本用户认证所需的一切内容。

认证 意味着验证某人是谁,并允许他们在您的系统中以此身份行事。这包括登录和注销过程,以及任何允许用户在使用应用程序期间识别自己的工具。

授权 意味着确定经过身份验证的用户是否被允许(授权)执行特定行为。例如,授权系统允许您禁止非管理员查看站点的收入情况。

User 模型略微复杂,您可以在 示例9-2 中看到。App\User 类本身很简单,但它扩展了 Illuminate\Foundation\Auth\User 类,后者引入了几个特性。

示例 9-2. Laravel 的默认 User 模型
<?php// App\Usernamespace App\Models;// use Illuminate\Contracts\Auth\MustVerifyEmail;use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Foundation\Auth\User as Authenticatable;use Illuminate\Notifications\Notifiable;use Laravel\Sanctum\HasApiTokens;class User extends Authenticatable{ use HasApiTokens, HasFactory, Notifiable; /** * The attributes that are mass assignable. * * @var array<int, string> */ protected $fillable = [ 'name', 'email', 'password', ]; /** * The attributes that should be hidden for serialization. * * @var array<int, string> */ protected $hidden = [ 'password', 'remember_token', ]; /** * The attributes that should be cast. * * @var array<string, string> */ protected $casts = [ 'email_verified_at' => 'datetime', ];}
<?php// Illuminate\Foundation\Auth\Usernamespace Illuminate\Foundation\Auth;use Illuminate\Auth\Authenticatable;use Illuminate\Auth\MustVerifyEmail;use Illuminate\Auth\Passwords\CanResetPassword;use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;use Illuminate\Database\Eloquent\Model;use Illuminate\Foundation\Auth\Access\Authorizable;class User extends Model implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract{ use Authenticatable, Authorizable, CanResetPassword, MustVerifyEmail;}

如果这些内容对您完全陌生,请考虑在继续学习如何使用 Eloquent 模型之前阅读第五章。

那么,我们从这个模型中能学到什么?首先,用户存储在 users 表中;Laravel 将从类名推断出这一点。创建新用户时,我们可以填写 nameemailpassword 属性,而在将用户输出为 JSON 时,则会排除 passwordremember_token 属性。目前看起来一切都很好。

我们还可以从 Illuminate\Foundation\Auth 版本的 User 中的合约和特性中看到,框架中有一些功能(例如身份验证、授权和密码重置的能力),理论上可以应用于其他模型,而不仅仅是 User 模型,并且可以单独或集体应用。

Authenticatable 合约要求方法(例如 getAuthIdentifier()),允许框架对此模型的实例进行身份验证到身份验证系统;Authenticatable 特性包含了满足普通 Eloquent 模型此合约所需的方法。

Authorizable 合约要求一个方法 (can()),允许框架在不同上下文中授权此模型的实例以获取其访问权限。毫不奇怪,Authorizable 特性提供了方法,这些方法将为普通的 Eloquent 模型满足 Authorizable 合约。

最后,CanResetPassword 合约要求方法 (g⁠⁠e⁠t⁠E⁠m⁠a⁠i⁠l⁠F⁠o⁠r​P⁠a⁠s⁠s⁠w⁠o⁠r⁠d⁠R⁠e⁠s⁠e⁠t⁠(⁠)sendPasswordResetNotification()),允许框架重置任何满足此合约的实体的密码。CanResetPassword 特性提供了方法,以满足普通 Eloquent 模型的这一合约。

到目前为止,我们能够轻松地在数据库中表示个别用户(通过迁移),并使用可以进行身份验证(登录和注销)、授权(检查对特定资源的访问权限)和发送密码重置电子邮件的模型实例。

auth() 全局辅助函数是在整个应用程序中与已验证用户的状态交互的最简单方法。您还可以注入一个 Illuminate\Auth\AuthManager 实例并获得相同的功能,或者使用 Auth 门面。

最常见的用法是检查用户是否已登录(如果当前用户已登录,则 auth()->check() 返回 true;如果用户未登录,则 auth()->guest() 返回 true)以及获取当前已登录用户(使用 auth()->user(),或仅获取 ID 使用 auth()->id();如果没有用户登录,则两者都返回 null)。

查看示例 Example9-3 了解控制器中全局辅助函数的示例用法。

示例 9-3. 在控制器中使用 auth() 全局辅助函数的示例用法
public function dashboard(){ if (auth()->guest()) { return redirect('sign-up'); } return view('dashboard') ->with('user', auth()->user());}

如果你正在使用 Laravel 的其中一个入门工具包,你会发现使用内置的身份验证路由(例如登录、注册和重置密码)需要路由、控制器和视图。

Breeze 和 Jetstream 都使用自定义路由文件定义您的路由:routes/auth.php。它们并不完全相同,但可以查看 示例 9-4 以了解 Breeze 的认证路由文件的一部分,以便了解它们的一般情况。

示例 9-4. Breeze 的路由/auth.php 的一部分
Route::middleware('guest')->group(function () { Route::get('register', [RegisteredUserController::class, 'create']) ->name('register'); Route::post('register', [RegisteredUserController::class, 'store']); Route::get('login', [AuthenticatedSessionController::class, 'create']) ->name('login'); Route::post('login', [AuthenticatedSessionController::class, 'store']); Route::get('forgot-password', [PasswordResetLinkController::class, 'create']) ->name('password.request'); Route::post('forgot-password', [PasswordResetLinkController::class, 'store']) ->name('password.email'); Route::get('reset-password/{token}', [NewPasswordController::class, 'create']) ->name('password.reset'); Route::post('reset-password', [NewPasswordController::class, 'store']) ->name('password.store');});

Breeze 在 Auth 命名空间下发布控制器,您可以根据需要进行配置:

  • AuthenticatedSessionController.php

  • ConfirmablePasswordController.php

  • EmailVerificationNotificationController.php

  • EmailVerificationPromptController.php

  • NewPasswordController.php

  • PasswordController.php

  • PasswordResetLinkController.php

  • RegisteredUserController.php

  • VerifyEmailController.php

Jetstream(以及它依赖的 Fortify)不发布控制器,而是发布您可以自定义的“操作”:

app/Actions/Fortify/CreateNewUser.phpapp/Actions/Fortify/PasswordValidationRules.phpapp/Actions/Fortify/ResetUserPassword.phpapp/Actions/Fortify/UpdateUserPassword.phpapp/Actions/Fortify/UpdateUserProfileInformation.phpapp/Actions/Jetstream/DeleteUser.php

到此为止,您的认证系统已经有了迁移、模型、控制器/操作和路由。但是您的视图呢?

您可以在 “Laravel Breeze” 和 “Laravel Jetstream” 中了解更多信息,但每个工具都提供多种不同的堆栈,并且每个堆栈将其模板放置在不同的位置。

一般来说,基于 JavaScript 的堆栈将其模板放置在 resources/js 中,而基于 Blade 的堆栈将其放置在 resources/views 中。

每个功能(登录、注册、重置密码等)至少有一个视图,并且它们都采用了流畅的基于 Tailwind 的设计生成,可以直接使用或自定义。

Breeze 和 Jetstream 都已经默认实现了此功能,但是了解其工作原理以及如何在自己的项目中使用仍然是值得的。如果您想要实现“记住我”风格的长期访问令牌,请确保您的 users 表中有一个 remember_token 列(如果您使用了默认迁移,那么这个列应该已经存在)。

当您正常登录用户时(这是 LoginController 使用 AuthenticatesUsers trait 所做的方式),您将“尝试”使用用户提供的信息进行认证,就像在 示例 9-5 中所示。

示例 9-5. 尝试用户认证
if (auth()->attempt([ 'email' => request()->input('email'), 'password' => request()->input('password'),])) { // Handle the successful login}

这为您提供了一个与用户会话同久的用户登录。如果您希望 Laravel 使用 Cookie 无限期延长登录时间(只要用户在同一台计算机上且不退出登录),您可以将布尔值 true 作为 auth()->attempt() 方法的第二个参数传递。查看 示例 9-6 以了解该请求的外观。

示例 9-6. 使用“记住我”复选框进行用户认证尝试
if (auth()->attempt([ 'email' => request()->input('email'), 'password' => request()->input('password'),], request()->filled('remember'))) { // Handle the successful login}

您可以看到,我们检查了输入是否具有非空(“filled”)remember 属性,该属性将返回一个布尔值。这允许我们的用户通过登录表单中的复选框决定是否要记住登录状态。

后来,如果你需要手动检查当前用户是否通过记住令牌进行了认证,有一个方法可以做到:auth()->viaRemember() 返回一个布尔值,指示当前用户是否通过记住令牌进行了认证。这使你可以防止通过记住令牌访问某些更高敏感度功能;而是,你可以要求用户重新输入他们的密码。

在你的应用程序的某些部分访问之前,用户可能需要重新确认他们的密码。例如,如果用户已经登录了一段时间,然后尝试访问你站点的账单部分,你可能希望他们验证他们的密码。

你可以在你的路由上附加 password.confirm 中间件来强制这种行为。一旦他们确认了密码,用户将被发送到他们最初尝试访问的路由。此后,用户在 3 小时内不需要重新确认密码;你可以在 auth.password_timeout 配置设置中更改这个时间。

用户认证的最常见情况是,允许用户提供他们的凭证,然后使用 auth()->attempt() 来查看提供的凭证是否与任何真实用户匹配。如果匹配,则登录他们。

但有时候,在某些情境下,你能够选择自己选择性地登录一个用户,这是非常有价值的。例如,你可能希望允许管理员用户切换用户。

有四种方法可以实现这一点。首先,你可以只传递一个用户 ID:

auth()->loginUsingId(5);

其次,你可以传递一个 User 对象(或者任何实现 Illuminate\Contracts\Auth\Authenticatable 合约的对象):

auth()->login($user);

第三和第四,你可以选择仅为当前请求验证给定用户,这不会影响你的会话或者 cookie,可以使用 once()onceUsingId()

auth()->once(['username' => 'mattstauffer']);// orauth()->onceUsingId(5);

请注意,你传递给 once() 方法的数组可以包含任何键值对来唯一标识你想要认证的用户。如果适合你的项目,你甚至可以传递多个键和值。例如:

auth()->once([ 'last_name' => 'Stauffer', 'zip_code' => 90210,])

如果你需要手动登出用户,只需调用 logout()

auth()->logout();

使其他设备上的会话失效

如果你想要在任何其他设备上登出用户的当前会话 —— 例如,在他们更改密码后 —— 你需要提示用户输入他们的密码并将其传递给 logoutOtherDevices() 方法。为此,你需要将 auth.session 中间件应用到你想让他们退出登录的所有路由上(对于大多数项目而言,这是整个应用程序)。

然后你可以在任何需要的地方内联使用它:

auth()->logoutOtherDevices($password);

如果你想让用户详细查看其他活动会话,Jetstream(参见“Laravel Jetstream”)默认提供了一个页面,列出所有活动会话,并提供一个按钮可以登出所有会话。

在示例9-3 中,您看到如何检查访客是否已登录,并在未登录时重定向他们。您可以在应用程序的每个路由上执行这些检查,但很快会变得乏味。事实证明,路由中间件(详见第10章以了解其工作原理)非常适合将某些路由限制为仅限访客或经过身份验证的用户。

再次,Laravel 默认即可提供我们所需的中间件。您可以查看您在App\Http\Kernel中定义的路由中间件:

protected $middlewareAliases = [ 'auth' => \App\Http\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, 'signed' => \App\Http\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,];

六种默认的路由中间件与身份验证相关:

auth

限制路由访问权限仅限经过身份验证的用户

auth.basic

通过 HTTP 基本身份验证限制仅限经过身份验证的用户访问

auth.session

使路由可供其他设备登出使用Auth::logoutOtherDevices

can

用于授权用户访问指定路由

guest

限制未经身份验证的用户访问

password.confirm

要求用户最近重新确认其密码

对于需要仅限经过身份验证的用户访问的部分,最常见的做法是使用auth,而对于不希望经过身份验证的用户看到的任何路由(如登录表单),则使用guestauth.basicauth.session用于认证的中间件则较少使用。

示例9-7 展示了几个由auth中间件保护的路由示例。

示例9-7. 受auth中间件保护的示例路由
Route::middleware('auth')->group(function () { Route::get('account', [AccountController::class, 'dashboard']);});Route::get('login', [LoginController::class, 'getLogin'])->middleware('guest');

如果您希望要求用户验证他们注册时使用的电子邮件地址的访问权限,则可以使用 Laravel 的电子邮件验证功能。

要启用电子邮件验证,请更新您的App\User类,并使其实现Illuminate\Contracts\Auth\MustVerifyEmail合同,如示例9-8 所示。

示例9-8. 将MustVerifyEmail特性添加到Authenticatable模型中
class User extends Authenticatable implements MustVerifyEmail{ use Notifiable; // ...}

users表还必须包含一个名为email_verified_at的可空时间戳列,这是默认的CreateUsersTable迁移已经为您提供的。

最后,您需要在控制器中启用电子邮件验证路由。最简单的方法是在路由文件中使用Auth::routes(),并将verify参数设置为true

Auth::routes(['verify' => true]);

现在,您可以保护任何希望不被尚未验证其电子邮件地址的任何用户访问的路由:

Route::get('posts/create', function () { // Only verified users may enter...})->middleware('verified');

您可以自定义在验证后重定向用户的路由Verification``Controller

protected $redirectTo = '/profile';

如果您想检查用户是否经过身份验证,而不是在路由级别进行检查,而是在视图中进行检查,您可以使用@auth@guest(参见示例9-9)。

示例9-9. 在模板中检查用户的身份验证状态
@auth // The user is authenticated@endauth@guest // The user is not authenticated@endguest

您还可以通过将守卫名称作为参数传递给这两种方法来指定您想要使用的守卫,如示例9-10 所示。

示例9-10. 在模板中检查特定认证保护的身份验证
@auth('trainees') // The user is authenticated@endauth@guest('trainees') // The user is not authenticated@endguest

Laravel 认证系统的每个方面都通过称为守卫的东西路由。每个守卫由两个部分组成:定义它如何持久化和检索认证状态的驱动程序(例如 session),以及允许你按某些条件获取用户的提供者(例如 users)。

开箱即用,Laravel 有两个守卫:webapiweb 是更传统的认证样式,使用 session 驱动程序和基本用户提供者。api 使用相同的用户提供者,但它使用 token 驱动程序而不是 session 在每个请求中进行认证。

如果你想以不同方式处理用户身份的识别和持久性(例如,从长时间运行的会话更改为每页加载提供的令牌),你会更改驱动程序;如果你想更改用户的存储类型或检索方法(例如,将用户存储在 Mongo 而不是 MySQL 中),你会更改提供者。

更改默认守卫

守卫在 config/auth.php 中定义,你可以在那里更改它们、添加新的守卫,并定义默认的守卫。就其价值而言,这是一种相对不常见的配置;大多数 Laravel 应用程序只使用一个守卫。

“默认”守卫是在没有指定守卫的情况下使用任何认证功能时将使用的守卫。例如,auth()->user() 将使用默认守卫拉取当前认证的用户。你可以通过更改 config/auth.php 中的 auth.defaults.guard 设置来更改此守卫:

'defaults' => [ 'guard' => 'web', // Change the default here 'passwords' => 'users',],

你可能已经注意到,我用 auth.defaults.guard 等引用来引用配置部分。这意味着在 config/auth.php 中,在以 defaults 键为键的数组部分中,应该有一个以 guard 为键的属性。

在不更改默认值的情况下使用其他守卫

如果你想使用另一个守卫但更改默认值,你可以在 auth() 调用中以 guard() 开头:

$apiUser = auth()->guard('api')->user();

这将在此调用中仅获取使用 api 守卫的当前用户。

添加新的守卫

你可以随时在 config/auth.phpauth.guards 设置中添加新的守卫:

'guards' => [ 'trainees' => [ 'driver' => 'session', 'provider' => 'trainees', ],],

在这里,我们创建了一个新的守卫(除了 webapi)名为 trainees。假设在接下来的这一节中,我们正在构建一个应用程序,其中我们的用户是体育教练,每个教练都有自己的用户——受训者——他们可以登录到他们的子域名。因此,我们需要一个单独的守卫来处理他们。

driver 的唯二选项是 tokensession。开箱即用,provider 的唯一选项是 users,支持对默认的 users 表进行认证,但你可以轻松创建自己的提供者。

闭包请求守卫

如果您想定义一个自定义守卫,并且您的守卫条件(如何查找给定用户的请求)可以简单地在任何给定的 HTTP 请求中响应,您可能只想将用户查找代码放入一个闭包中,而不必创建一个新的自定义守卫类。

viaRequest() 认证方法允许仅通过闭包(定义在第二个参数中)来定义一个守卫(第一个参数中命名的),该闭包接受 HTTP 请求并返回适当的用户。要在 AuthServiceProviderboot() 方法中注册一个闭包请求守卫,如 示例9-11 所示。

示例 9-11. 定义闭包请求守卫
public function boot(): void{ Auth::viaRequest('token-hash', function ($request) { return User::where('token-hash', $request->token)->first(); });}

创建自定义用户提供程序

config/auth.php 中定义守卫的位置下方,有一个 auth.providers 部分,定义了可用的提供程序。让我们创建一个名为 trainees 的新提供程序:

'providers' => [ 'users' => [ 'driver' => 'eloquent', 'model' => App\User::class, ], 'trainees' => [ 'driver' => 'eloquent', 'model' => App\Trainee::class, ],],

driver 的两个选项是 eloquentdatabase。如果使用 eloquent,您将需要一个包含 Eloquent 类名的 model 属性(用于 User 类的模型);如果使用 database,则需要一个 table 属性来定义应该对其进行身份验证的表。

在我们的示例中,您可以看到此应用程序有一个 User 和一个 Trainee,它们需要分别进行身份验证。这样,代码可以区分 auth()->guard('users')auth()->guard('trainees')

最后一点:auth 路由中间件可以接受一个参数,即守卫名称。因此,您可以使用特定的守卫保护某些路由:

Route::middleware('auth:trainees')->group(function () { // Trainee-only routes here});

非关系数据库的自定义用户提供程序

刚才描述的用户提供程序创建流程仍然依赖于相同的 UserProvider 类,这意味着它期望从关系数据库中提取标识信息。但是,如果您使用的是 Mongo 或 Riak 或类似的东西,实际上您需要创建自己的类。

要做到这一点,请创建一个新的类,实现 Illuminate\Contracts\Auth\UserProvider 接口,然后在 AuthServiceProvider@boot 中绑定它:

auth()->provider('riak', function ($app, array $config) { // Return an instance of Illuminate\Contracts\Auth\UserProvider... return new RiakUserProvider($app['riak.connection']);});

我们将在 第十六章 中更多地讨论事件,但 Laravel 的事件系统是一个基本的发布/订阅框架。有系统生成的事件和用户生成的事件进行广播,并且用户可以创建事件监听器以响应某些事件。

那么,如果您想在用户因登录尝试失败次数过多而被锁定后,每次都向特定的安全服务发送一个 ping 呢?也许此服务监视某些地理区域的某个特定数量的登录失败或其他内容。当然,您可以在适当的控制器中注入一个调用。但是通过事件,您可以创建一个监听器来监听“用户被锁定”的事件,并注册它。

查看 示例9-12 以查看身份验证系统发出的所有事件。

示例 9-12. 框架生成的认证事件
protected $listen = [ 'Illuminate\Auth\Events\Attempting' => [], 'Illuminate\Auth\Events\Authenticated' => [], 'Illuminate\Auth\Events\CurrentDeviceLogout' => [], 'Illuminate\Auth\Events\Failed' => [], 'Illuminate\Auth\Events\Lockout' => [], 'Illuminate\Auth\Events\Login' => [], 'Illuminate\Auth\Events\Logout' => [], 'Illuminate\Auth\Events\OtherDeviceLogout' => [], 'Illuminate\Auth\Events\PasswordReset' => [], 'Illuminate\Auth\Events\Registered' => [], 'Illuminate\Auth\Events\Validated' => [], 'Illuminate\Auth\Events\Verified' => [],];

如你所见,有“用户注册”、“用户尝试登录”、“用户验证但未登录”、“用户已认证”、“成功登录”、“登录失败”、“登出”、“从其他设备登出”、“从当前设备登出”、“锁定”、“重置密码”和“用户邮箱验证”等监听器。要了解如何为这些事件构建事件监听器,请查看第十六章。

最后,让我们介绍一下 Laravel 的授权系统。它使你能够确定用户是否被授权执行特定操作,你将使用几个主要动词进行检查:cancannotallowsdenies

大部分授权控制都将使用Gate外观进行,但在你的控制器、User模型、中间件和 Blade 指令中也有便捷的辅助功能可用。查看示例9-13 可以体验我们能做到什么。

示例 9-13. Gate外观的基本用法
if (Gate::denies('edit-contact', $contact)) { abort(403);}if (! Gate::allows('create-contact', Contact::class)) { abort(403);}

定义授权规则

定义授权规则的默认位置是在AuthServiceProviderboot()方法中,在这里你将调用Auth外观的方法。

授权规则称为ability,由两部分组成:一个字符串键(例如,update-contact)和返回布尔值的闭包。示例9-14 展示了更新联系人的 ability。

示例 9-14. 用于更新联系人的样例 ability
class AuthServiceProvider extends ServiceProvider{ public function boot(): void { Gate::define('update-contact', function ($user, $contact) { return $user->id == $contact->user_id; }); }}

让我们来看看定义 ability 的步骤。

首先,你需要定义一个键。在命名这个键时,你应该考虑在你的代码流中哪个字符串对于引用你所提供给用户的 ability 是有意义的。你可以在示例9-14 中看到代码使用了{*verb*}-{*modelName*}的约定:create-contactupdate-contact等。

其次,你要定义闭包。第一个参数将是当前已认证的用户,之后的所有参数将是你要检查访问权限的对象——在本例中是联系人。

因此,考虑到这两个对象,我们可以检查用户是否有权限更新这个联系人。你可以按自己的逻辑编写这段代码,但在我们查看的应用中(在示例9-14 中),授权取决于是否是联系人行的创建者。如果当前用户创建了联系人,闭包将返回true(授权),否则返回false(未授权)。

就像路由定义一样,你也可以使用类和方法而不是闭包来解析这个定义:

$gate->define('update-contact', 'ContactACLChecker@updateContact');

Gate外观(和注入Gate

现在你已经定义了一个 ability,是时候测试它了。最简单的方法是使用Gate外观,就像在示例9-15 中一样(或者你可以注入Illuminate\Contracts\Auth\Access\Gate的实例)。

示例 9-15. Gate外观的基本用法
if (Gate::allows('update-contact', $contact)) { // Update contact}// orif (Gate::denies('update-contact', $contact)) { abort(403);}

您可能还可以定义一个具有多个参数的能力 —— 也许联系人可以分组,并且您希望授权用户是否有权限将联系人添加到组中。Example9-16 展示了如何做到这一点。

Example 9-16. 具有多个参数的能力
// DefinitionGate::define('add-contact-to-group', function ($user, $contact, $group) { return $user->id == $contact->user_id && $user->id == $group->user_id;});// Usageif (Gate::denies('add-contact-to-group', [$contact, $group])) { abort(403);}

如果您需要检查不是当前认证用户的用户的授权,请尝试forUser(),就像 Example9-17 中那样。

Example 9-17. 指定Gate的用户
if (Gate::forUser($user)->denies('create-contact')) { abort(403);}

资源门

访问控制列表最常见的用途是定义对单个“资源”的访问权限(想想一个 Eloquent 模型,或者您允许用户从其管理面板管理的东西)。

resource() 方法使得可以一次将四个最常见的门控(viewcreateupdatedelete)应用于单个资源:

Gate::resource('photos', 'App\Policies\PhotoPolicy');

这相当于定义以下内容:

Gate::define('photos.view', 'App\Policies\PhotoPolicy@view');Gate::define('photos.create', 'App\Policies\PhotoPolicy@create');Gate::define('photos.update', 'App\Policies\PhotoPolicy@update');Gate::define('photos.delete', 'App\Policies\PhotoPolicy@delete');

授权中间件

如果您想授权整个路由,可以使用Authorize中间件(有一个can的快捷方式),就像在 Example9-18 中那样。

Example 9-18. 使用Authorize中间件
Route::get('people/create', function () { // Create a person})->middleware('can:create-person');Route::get('people/{person}/edit', function () { // Edit person})->middleware('can:edit,person');

这里,{person} 参数(无论它是作为字符串定义还是作为绑定路由模型)将作为附加参数传递给能力方法。

Example9-18 中的第一个检查是一个普通的能力,但第二个是一个策略,我们将在“策略”中讨论它。

如果您需要检查不需要模型实例的操作(例如 create,与 edit 不同,不会传递实际的路由模型绑定实例),您可以只传递类名:

Route::post('people', function () { // Create a person})->middleware('can:create,App\Person');

控制器授权

Laravel 中的父类 App\Http\Controllers\Controller 导入了 AuthorizesRequests 特性,提供了三种授权方法:authorize()authorizeForUser()authorizeResource()

authorize() 接受一个能力键和一个对象(或对象数组)作为参数,如果授权失败,它将以 403(未经授权)状态码退出应用程序。这意味着这个特性可以将三行授权代码转换为一行,正如您在 Example9-19 中所看到的。

Example 9-19. 使用authorize()简化控制器授权
// From this:public function edit(Contact $contact){ if (Gate::cannot('update-contact', $contact)) { abort(403); } return view('contacts.edit', ['contact' => $contact]);}// To this:public function edit(Contact $contact){ $this->authorize('update-contact', $contact); return view('contacts.edit', ['contact' => $contact]);}

authorizeForUser() 是相同的,但允许您传递一个 User 对象,而不是默认为当前认证用户:

$this->authorizeForUser($user, 'update-contact', $contact);

authorizeResource() 在控制器构造函数中调用一次,将预定义的一组授权规则映射到该控制器中的每个 RESTful 控制器方法 —— 类似于 Example9-20。

Example 9-20. authorizeResource() 方法的授权到方法映射
...class ContactController extends Controller{ public function __construct() { // This call does everything you see in the methods below. // If you put this here, you can remove all authorize() // calls in the individual resource methods here. $this->authorizeResource(Contact::class); } public function index() { $this->authorize('viewAny', Contact::class); } public function create() { $this->authorize('create', Contact::class); } public function store(Request $request) { $this->authorize('create', Contact::class); } public function show(Contact $contact) { $this->authorize('view', $contact); } public function edit(Contact $contact) { $this->authorize('update', $contact); } public function update(Request $request, Contact $contact) { $this->authorize('update', $contact); } public function destroy(Contact $contact) { $this->authorize('delete', $contact); }}

检查用户实例

如果您不在控制器中,更有可能检查特定用户的能力而不是当前认证的用户。使用Gate外观可以使用forUser()方法实现这一点,但有时语法可能有些奇怪。

User 类上的 Authorizable 特性提供了四种方法来实现更可读的授权功能:$user->can()$user->canAny()$user->cant()$user->cannot()。你可以大概猜到,cant()cannot() 是一样的,而 can() 则完全相反。使用 canAny(),你传递一个权限数组,该方法检查用户是否可以执行其中任何一个。

这意味着你可以做像 示例 9-21 这样的事情。

示例 9-21. 检查 User 实例的授权
$user = User::find(1);if ($user->can('create-contact')) { // Do something}

在幕后,这些方法只是将参数传递给 Gate;在前面的示例中,Gate::forUser($user)->check('create-contact')

Blade 检查

Blade 还有一个小方便的助手:@can 指令。示例 9-22 展示了它的使用方式。

示例 9-22. 使用 Blade 的 @can 指令
<nav> <a href="/">Home</a> @can('edit-contact', $contact) <a href="{{ route('contacts.edit', [$contact->id]) }}">Edit This Contact</a> @endcan</nav>

你还可以在 @can@endcan 之间使用 @else,以及像 示例 9-23 中使用 @cannot@endcannot

示例 9-23. 使用 Blade 的 @cannot 指令
<h1>{{ $contact->name }}</h1>@cannot('edit-contact', $contact) LOCKED@endcannot

拦截检查

如果你曾经用过管理员用户类构建过应用程序,你可能已经看过本章节中所有简单授权闭包,并考虑过如何添加一个超级用户类,在任何情况下都覆盖这些检查。幸运的是,已经有一个工具可以做到这一点。

AuthServiceProvider 中,你已经在定义你的能力,你还可以添加一个 before() 检查,该检查在所有其他检查之前运行,可以选择性地覆盖它们,就像 示例 9-24 中一样。

示例 9-24. 使用 before() 覆盖 Gate 检查
Gate::before(function ($user, $ability) { if ($user->isOwner()) { return true; }});

注意,也会传递能力的字符串名称,因此你可以根据你的能力命名方案区分你的 before() 钩子。

策略

到目前为止,所有的访问控制都要求你手动将 Eloquent 模型与能力名称关联起来。你可以创建一个名为 visit-dashboard 的能力,该能力与特定的 Eloquent 模型无关,但你可能已经注意到,我们大多数示例都涉及对某物做某事,在大多数情况下,受到操作的某物是一个 Eloquent 模型。

授权策略是组织结构,帮助你根据你正在控制访问的资源将授权逻辑分组。它们使得能够轻松管理定义针对特定 Eloquent 模型(或其他 PHP 类)行为的授权规则,全部在一个地方。

生成策略

策略是 PHP 类,可以通过 Artisan 命令生成:

php artisan make:policy ContactPolicy

生成后,需要注册它们。AuthServiceProvider 有一个 $policies 属性,它是一个数组。每个项目的键是受保护资源的类名(几乎总是一个 Eloquent 类),值是策略类名。示例 9-25 显示了这将是什么样子。

示例 9-25. 在AuthServiceProvider中注册策略
class AuthServiceProvider extends ServiceProvider{ protected $policies = [ Contact::class => ContactPolicy::class, ];

由 Artisan 生成的策略类没有任何特殊属性或方法。但是您添加的每个方法现在都映射为此对象的能力键。

Laravel 尝试“猜测”您的策略及其相应模型之间的链接。例如,它将自动将PostPolicy应用于您的Post模型。

如果您需要自定义 Laravel 用于猜测此映射的逻辑,请查看策略文档

让我们定义一个update()方法来看看它的工作原理(参见示例9-26)。

示例 9-26. 一个样本update()策略方法
<?phpnamespace App\Policies;class ContactPolicy{ public function update($user, $contact) { return $user->id == $contact->user_id; }}

注意,该方法的内容看起来与在Gate定义中的内容完全相同。

如果您需要定义一个与类相关但不是特定实例的策略方法——例如,“此用户是否可以创建任何联系人?”而不仅仅是“此用户是否可以查看此特定联系人?”——您可以像处理普通策略方法一样处理它:

...class ContactPolicy{ public function create($user) { return $user->canCreateContacts(); }

检查政策

如果为资源类型定义了策略,则Gate外观将使用第一个参数来确定要在策略上检查哪个方法。如果您运行Gate::allows('update', $contact),它将检查ContactPolicy@update方法的授权情况。

这也适用于Authorize中间件和User模型检查以及 Blade 检查,如示例9-27 所示。

示例 9-27. 对策略进行授权检查
// Gateif (Gate::denies('update', $contact)) { abort(403);}// Gate if you don't have an explicit instanceif (! Gate::check('create', Contact::class)) { abort(403);}// Userif ($user->can('update', $contact)) { // Do stuff}// Blade@can('update', $contact) // Show stuff@endcan

此外,还有一个policy()辅助程序,允许您检索策略类并运行其方法:

if (policy($contact)->update($user, $contact)) { // Do stuff}

覆盖政策

就像普通能力定义一样,政策可以定义一个before()方法,允许您在处理之前覆盖任何调用(参见示例9-28)。

示例 9-28. 使用before()方法覆盖策略
public function before($user, $ability){ if ($user->isAdmin()) { return true; }}

应用程序测试经常需要代表特定用户执行特定行为。因此,在应用程序测试中进行身份验证并测试授权规则和身份验证路由是必要的。

当然,您可以编写一个应用程序测试,手动访问登录页面,然后填写表单并提交,但这并不是必需的。相反,最简单的选项是使用->be()方法模拟作为用户登录。请参阅示例9-29。

示例 9-29. 在应用程序测试中作为用户进行身份验证
public function test_it_creates_a_new_contact(){ $user = User::factory()->create(); $this->be($user); $this->post('contacts', [ 'email' => 'my@email.com', ]); $this->assertDatabaseHas('contacts', [ 'email' => 'my@email.com', 'user_id' => $user->id, ]);}

您还可以使用并链actingAs()方法,而不是be(),如果您更喜欢其阅读方式:

public function test_it_creates_a_new_contact(){ $user = User::factory()->create(); $this->actingAs($user)->post('contacts', [ 'email' => 'my@email.com', ]); $this->assertDatabaseHas('contacts', [ 'email' => 'my@email.com', 'user_id' => $user->id, ]);}

我们也可以像在示例9-30 中那样测试授权。

示例 9-30. 测试授权规则
public function test_non_admins_cant_create_users(){ $user = User::factory()->create([ 'admin' => false, ]); $this->be($user); $this->post('users', ['email' => 'my@email.com']); $this->assertDatabaseMissing('users', [ 'email' => 'my@email.com', ]);}

或者,我们可以像在示例9-31 中那样测试 403 响应。

示例 9-31. 通过检查状态代码测试授权规则
public function test_non_admins_cant_create_users(){ $user = User::factory()->create([ 'admin' => false, ]); $this->be($user); $response = $this->post('users', ['email' => 'my@email.com']); $response->assertStatus(403);}

我们还需要测试我们的认证(注册和登录)路由是否正常工作,如示例9-32 所示。

示例 9-32. 测试认证路由
public function test_users_can_register(){ $this->post('register', [ 'name' => 'Sal Leibowitz', 'email' => 'sal@leibs.net', 'password' => 'abcdefg123', 'password_confirmation' => 'abcdefg123', ]); $this->assertDatabaseHas('users', [ 'name' => 'Sal Leibowitz', 'email' => 'sal@leibs.net', ]);}public function test_users_can_log_in(){ $user = User::factory()->create([ 'password' => Hash::make('abcdefg123') ]); $this->post('login', [ 'email' => $user->email, 'password' => 'abcdefg123', ]); $this->assertTrue(auth()->check()); $this->assertTrue($user->is(auth()->user()));}

我们还可以使用集成测试功能来直接测试“点击”认证字段并“提交”字段以测试整个流程。关于这一点,我们将在第十二章中详细讨论。

在默认的User模型、create_users_table迁移以及 Jetstream 和 Breeze 之间,Laravel 提供了开箱即用的完整用户认证系统选项。Breeze 在控制器中处理认证功能,Jetstream 在操作中处理认证功能,两者都可以根据每个应用程序进行定制。这两个工具还发布了配置文件和模板以进行定制。

Auth门面和全局助手auth()提供了访问当前用户(auth()->user())的途径,并且轻松检查用户是否已登录(auth()->check()auth()->guest())。

Laravel 还内置了一个授权系统,允许您定义特定的权限(create-contactvisit-secret-page)或为用户与整个模型的交互定义策略。

使用Gate门面、User类上的can()cannot()方法、Blade 模板中的@can@cannot指令、控制器中的authorize()方法或can中间件来检查授权。

我们已经谈论过 Illuminate Request对象了。例如,在第三章中,您看到如何在构造函数中使用类型提示来获取实例或使用request()助手来检索它,在第七章中我们讨论了如何使用它来获取关于用户输入的信息。

在本章中,您将了解更多关于Request对象的信息,它是如何生成的,代表什么以及它在应用程序生命周期中扮演的角色。我们还将讨论Response对象以及 Laravel 对中间件模式的实现。

每个进入 Laravel 应用程序的请求,无论是由 HTTP 请求还是命令行交互生成的,都会立即转换为 Illuminate Request对象,然后跨越许多层并最终被应用程序本身解析。然后应用程序生成一个 Illuminate Response对象,该对象通过这些层级返回并最终返回给最终用户。

此请求/响应生命周期见图 10-1。让我们看看如何实现每一个步骤,从第一行代码到最后。

Laravel-启动指南-全- - 绝不原创的飞龙 - 博客园 (4)

图 10-1. 请求/响应生命周期

引导应用程序

每个 Laravel 应用程序都在 Web 服务器级别设置了某种形式的配置,在 Apache 的.htaccess文件或 Nginx 配置设置或类似的地方捕获每个 Web 请求,无论 URL 如何,并将其路由到 Laravel 应用程序目录中的public/index.php

index.php实际上并没有那么多代码。它有三个主要功能。

首先,它加载 Composer 的自动加载文件,注册所有由 Composer 加载的依赖项。

接下来,它启动 Laravel 的引导过程,创建应用程序容器(您将在第十一章中了解更多关于容器的信息),并注册一些核心服务(包括内核,我们马上会谈到)。

最后,它创建内核的一个实例,创建代表当前用户 Web 请求的请求,并将请求传递给内核进行处理。内核响应一个 Illuminate Response对象,index.php将其返回给最终用户。然后,内核终止页面请求。

内核是每个 Laravel 应用程序的核心路由器,负责接收用户请求,通过中间件处理它,处理异常并将其传递给页面路由器,然后返回最终响应。实际上,有两个内核,但每个页面请求只使用一个。一个路由器处理 Web 请求(HTTP 内核),另一个处理控制台、定时任务和 Artisan 请求(控制台内核)。每个都有一个handle()方法,负责接收 Illuminate Request对象并返回 Illuminate Response对象。

内核运行所有在每个请求之前需要运行的引导,包括确定当前请求运行的环境(测试、本地、生产等),以及运行所有服务提供商。HTTP 内核还定义了将包装每个请求的中间件列表,包括负责会话和 CSRF 保护的核心中间件。

服务提供商

尽管这些引导中有一些过程性代码,几乎所有 Laravel 的引导代码都被分离到 Laravel 称为服务提供商的东西中。服务提供商是一个类,封装了各个应用程序部分需要运行的逻辑,以引导它们的核心功能。

例如,有一个AuthServiceProvider,它引导所有 Laravel 认证系统所需的注册,并且有一个RouteServiceProvider,它引导路由系统。

服务提供商的概念一开始可能有点难以理解,所以可以这样考虑:你的应用程序中的许多组件都有引导代码,需要在应用程序初始化时运行。服务提供商是将这些引导代码分组到相关类中的工具。如果你有任何需要在应用程序代码正常工作之前运行的代码,它就是服务提供商的一个强力候选者。

例如,如果你发现你正在开发的功能需要在容器中注册一些类(你将在第十一章中了解更多),你会为该功能创建一个专门的服务提供商。你可能会有一个GitHubServiceProviderMailerServiceProvider

服务提供商有两个重要的方法:boot()register()。还有一个你可能选择使用的DeferrableProvider接口。这里是它们的工作原理。

其次会调用所有服务提供商的boot()方法。现在你可以在这里做任何其他引导,比如绑定事件监听器或定义路由——任何依赖于整个 Laravel 应用程序已经引导的东西。

首先会调用所有服务提供商的register()方法。在这里,你可以将类和别名绑定到容器中。不要在register()中做任何依赖于整个应用程序已经引导的事情。

如果你的服务提供者只会在容器中注册绑定(即教会容器如何解析给定的类或接口),而不执行任何其他引导操作,你可以“延迟”它们的注册,这意味着它们不会运行,除非显式从容器请求它们的绑定。这可以加快应用程序的平均引导时间。

如果你想延迟你的服务提供者的注册,首先要实现 Illuminate\Contracts\Support\DeferrableProvider 接口;然后,给服务提供者一个 provides() 方法,返回该提供者提供的绑定列表,如示例 10-1 所示。

示例 10-1. 延迟服务提供者的注册
...use Illuminate\Contracts\Support\DeferrableProvider;class GitHubServiceProvider extends ServiceProvider implements DeferrableProvider{ public function provides() { return [ GitHubClient::class, ]; }

服务提供者还有一套方法和配置选项,可以在作为 Composer 包的一部分发布时为最终用户提供高级功能。查看Laravel 源中的服务提供者定义,了解更多信息。

现在我们已经涵盖了应用程序引导,让我们来看看 Request 对象,这是引导过程中最重要的输出。

Illuminate\Http\Request 类是 Laravel 特有的 Symfony HttpFoundation 的扩展。

Symfony 的 HttpFoundation 类组件几乎支持目前所有的 PHP 框架;这是 PHP 中表示 HTTP 请求、响应、头部、Cookie 等的最流行和强大的抽象集合。

Request 对象旨在表示你可能关心的用户 HTTP 请求的每个相关信息。

在原生 PHP 代码中,你可能会发现自己查看 $_SERVER$_GET$_POST 等全局变量和处理逻辑的组合,以获取关于当前用户请求的信息。用户上传了哪些文件?他们的 IP 地址是什么?他们提交了哪些字段?所有这些信息都分散在语言和代码中,这使得理解起来困难,而模拟起来更加困难。

Symfony 的 Request 对象将所有表示单个 HTTP 请求所需的信息集成到一个对象中,并添加了便捷方法来轻松获取有用的信息。Illuminate 的 Request 对象增加了更多便捷方法,用于获取它所代表的请求的信息。

在 Laravel 应用中,你几乎不太可能需要这样做,但如果你需要直接从 PHP 的全局变量中捕获自己的 Illuminate Request 对象,你可以使用 capture() 方法:

$request = Illuminate\Http\Request::capture();

在 Laravel 中获取请求对象

Laravel 为每个请求创建一个内部的 Request 对象,你可以通过几种方式来访问它。

首先—再次强调,我们将在第十一章中更详细地介绍—您可以在任何由容器解析的构造函数或方法中对类进行类型提示。这意味着您可以在控制器方法或服务提供程序中进行类型提示,就像在示例 10-2 中看到的那样。

示例 10-2. 在容器解析的方法中对类进行类型提示以接收Request对象
...use Illuminate\Http\Request;class PersonController extends Controller{ public function index(Request $request) { $allInput = $request->all(); }

或者,您可以使用request()全局助手,允许您在其上调用方法(例如,request()->input()),也允许您单独调用它以获取$request的实例:

$request = request();$allInput = $request->all();// or$allInput = request()->all();

最后,您可以使用app()全局方法来获取Request的实例。您可以传递完全限定的类名或简写request

$request = app(Illuminate\Http\Request::class);$request = app('request');

获取请求的基本信息

现在您知道如何获取Request的实例了,您可以做些什么呢?Request对象的主要目的是表示当前的 HTTP 请求,因此Request类提供的主要功能是轻松获取有关当前请求的有用信息。

我已经将这里描述的方法分类,但请注意分类之间肯定存在重叠,并且分类有点随意—例如,查询参数可以与“用户和请求状态”一样轻松地出现在“基本用户输入”中。希望这些分类能让您轻松了解可用内容,然后您可以丢弃这些分类。

还要注意,Request对象上还有许多其他可用的方法;这些只是最常用的方法。

基本用户输入

基本用户输入方法使得获取用户显式提供的信息变得简单—通常通过提交表单或 Ajax 组件。在这里提到“用户提供的输入”时,我指的是来自查询字符串(GET)、表单提交(POST)或 JSON 的输入。基本用户输入方法包括以下内容:

all()

返回所有用户提供的输入的数组。

input(*fieldName*)

返回单个用户提供的输入字段的值。

only(*fieldName*|[*array,of,field,names*])

返回指定字段名(们)的所有用户提供的输入的数组。

except(*fieldName*|[*array,of,field,names*])

返回除指定字段名(们)外的所有用户提供的输入的数组。

exists(*fieldName*)

返回一个布尔值,指示输入中是否存在指定字段。has()是其别名。在输入中存在指定字段时执行给定的回调。

filled(*fieldName*)

返回一个布尔值,指示输入中是否存在指定字段并且不为空(即具有值)。

whenFilled()

在输入中存在指定字段并且不为空(即具有值)时执行给定的回调。

json()

如果页面收到了 JSON,则返回一个ParameterBag

boolean(*fieldName*)

将输入的值作为布尔值返回。将字符串和整数转换为适当的布尔值(使用 FILTER_VALIDATE_BOOLEAN)。如果请求中不存在键,则返回 false

json(*keyName*)

返回从发送到页面的 JSON 中给定键的值。

示例10-3 提供了如何使用请求中提供的用户信息方法的几个快速示例。

示例 10-3. 从请求获取基本用户提供的信息
// form<form method="POST" action="/form"> @csrf <input name="name"> Name<br> <input type="submit"></form>
// Route receiving the formRoute::post('form', function (Request $request) { echo 'name is ' . $request->input('name') . '<br>'; echo 'all input is ' . print_r($request->all()) . '<br>'; echo 'user provided email address: ' . $request->has('email') ? 'true' : 'false';});

用户和请求状态

用户和请求状态方法包括通过表单未显式提供的输入:

method()

返回用于访问此路由的方法(GETPOSTPATCH 等)。

path()

返回用于访问此页面的路径(不包括域名);例如,'http://www.myapp.com/abc/def' 将返回 'abc/def'

url()

返回用于访问此页面的带域名的 URL;例如,'abc' 将返回 'http://www.myapp.com/abc'

is()

返回布尔值,指示当前页面请求是否与提供的字符串模糊匹配(例如,/a/b/c 将被 $request->is('*b*') 匹配,其中 * 表示任意字符);使用在 Str::is() 中找到的自定义正则表达式解析器。

ip()

返回用户的 IP 地址。

header()

返回标题数组(例如 ['accept-language' => ['⁠e⁠n⁠-⁠U⁠S⁠,⁠e⁠n⁠;​q⁠=⁠0⁠.⁠8⁠']]),或者如果作为参数传递了标题名称,则只返回该标题。

server()

返回传统存储在 $_SERVER 中的变量数组(例如 REMOTE_ADDR),或者如果传递了 $_SERVER 变量名称,则只返回该值。

secure()

返回指示此页面是否使用 HTTPS 加载的布尔值。

pjax()

返回指示此页面请求是否使用了 Pjax 加载的布尔值。

wantsJson()

返回指示此请求的 Accept 头中是否有任何 /json 内容类型的布尔值。

isJson()

返回指示此页面请求的 Content-Type 头中是否有任何 /json 内容类型的布尔值。

accepts()

返回指示此页面请求是否接受给定内容类型的布尔值。

文件

到目前为止,我们讨论的所有输入都是显式的(通过诸如 all()input() 等方法检索),或者由浏览器或引用站点定义(通过诸如 pjax() 等方法检索)。文件输入类似于显式用户输入,但处理方式有很大不同:

file()

返回所有已上传文件的数组,或者如果传递了键(文件上传字段名称),则仅返回一个文件。

allFiles()

返回所有已上传文件的数组;与 file() 相比,命名更清晰,非常有用。

hasFile()

返回指定键是否上传了文件的布尔值。

每个上传的文件都将是 Symfony\Component\HttpFoundation\File\UploadedFile 的实例,提供一套工具来验证、处理和存储上传的文件。

请查看 Chapter14 获取有关如何处理上传文件的更多示例。

持久性

请求还可以提供与会话交互的功能。大多数会话功能存放在其他位置,但有几个方法对当前页面请求特别相关:

flash()

将当前请求的用户输入闪存到会话中以供稍后检索,这意味着它保存到会话中,但在下一个请求后消失。

flashOnly()

为提供的数组中的任何键闪存当前请求的用户输入。

flashExcept()

闪存当前请求的用户输入,除了提供的数组中的任何键。

old()

返回所有先前闪存的用户输入的数组,或者如果传递了键,则返回先前闪存的该键的值。

flush()

清除所有先前闪存的用户输入。

cookie()

从请求中检索所有 cookie,或者如果提供了键,则仅检索该 cookie。

hasCookie()

返回一个布尔值,指示请求是否具有给定键的 cookie。

flash*()old() 方法用于存储用户输入,并在稍后检索它,通常在输入经过验证并被拒绝后。

类似于 Request 对象,还有一个 Illuminate Response 对象,表示您的应用程序发送给最终用户的响应,包括标头、cookie、内容和用于发送最终用户浏览器的页面渲染指令的任何其他内容。

就像 Request 一样,Illuminate\Http\Response 类扩展了 Symfony 类:Symfony\Component\HttpFoundation\Response。这是一个基类,具有一系列属性和方法,使得表示和呈现响应成为可能;Illuminate 的 Response 类通过一些有用的快捷方式对其进行装饰。

在控制器中使用和创建响应对象

在谈论如何自定义您的 Response 对象之前,让我们退后一步,看看我们最常用的 Response 对象的工作方式。

最终,从路由定义返回的任何 Response 对象都将转换为 HTTP 响应。它可以定义特定的标头或特定的内容,设置 cookie 或其他任何内容,但最终它将转换为用户浏览器可以解析的响应。

让我们看一下最简单的响应,例如 Example10-4。

示例 10-4. 最简单的可能的 HTTP 响应
Route::get('route', function () { return new Illuminate\Http\Response('Hello!');});// Same, using global function:Route::get('route', function () { return response('Hello!');});

我们创建一个响应,为其提供一些核心数据,然后返回它。我们还可以自定义 HTTP 状态、标头、cookie 等等,例如 Example10-5。

示例 10-5. 具有自定义状态和标头的简单 HTTP 响应
Route::get('route', function () { return response('Error!', 400) ->header('X-Header-Name', 'header-value') ->cookie('cookie-name', 'cookie-value');});

设置标头

我们通过使用 header() 流畅方法在响应上定义一个标头,例如在 Example10-5。第一个参数是标头名称,第二个是标头值。

添加 cookie

如果需要的话,我们还可以直接在Response对象上设置 Cookie。我们将在第14章更详细地讨论 Laravel 的 Cookie 处理,但你可以查看示例10-6 了解如何将 Cookie 附加到响应中的简单用例。

示例 10-6. 将 Cookie 附加到响应
 return response($content) ->cookie('signup_dismissed', true);

专用响应类型

还有一些专门用于视图、下载、文件和 JSON 的特殊响应类型。每种都是预定义的宏,可以轻松重用特定的头部或内容结构模板。

查看响应

在第3章中,我使用全局的view()助手展示如何返回模板,例如view('*view.name.here*')或类似的内容。但如果在返回视图时需要自定义头部、HTTP 状态或其他内容,可以使用view()响应类型,如示例10-7 所示。

示例 10-7. 使用view()响应类型
Route::get('/', function (XmlGetterService $xml) { $data = $xml->get(); return response() ->view('xml-structure', $data) ->header('Content-Type', 'text/xml');});

下载响应

有时候你希望应用程序强制用户的浏览器下载一个文件,无论是在 Laravel 中创建文件还是从数据库或受保护的位置提供文件。使用download()响应类型可以轻松实现这一点。

必需的第一个参数是要浏览器下载的文件路径。如果是生成的文件,你需要将其暂时保存在某个地方。

可选的第二个参数是下载文件的文件名(例如,export.csv)。如果你不在这里传递一个字符串,文件名将会自动生成。可选的第三个参数允许你传递一个头部数组。示例10-8 展示了使用download()响应类型的例子。

示例 10-8. 使用download()响应类型
public function export(){ return response() ->download('file.csv', 'export.csv', ['header' => 'value']);}public function otherExport(){ return response()->download('file.pdf');}

如果希望在返回下载响应后从磁盘删除原始文件,可以在download()方法后链式调用deleteFileAfterSend()方法:

public function export(){ return response() ->download('file.csv', 'export.csv') ->deleteFileAfterSend();}

文件响应

文件响应类似于下载响应,不同之处在于它允许浏览器显示文件而不是强制下载。这在处理图片和 PDF 文件时最常见。

必需的第一个参数是文件名,可选的第二个参数可以是头部数组(参见示例10-9)。

示例 10-9. 使用file()响应类型
public function invoice($id){ return response()->file("./invoices/{$id}.pdf", ['header' => 'value']);}

JSON 响应

JSON 响应非常常见,尽管编程起来并不是特别复杂,但也有一个定制的响应类型。

JSON 响应将传递的数据转换为 JSON(使用json_encode()),并将Content-Type设置为application/json。你还可以选择使用setCallback()方法创建一个 JSONP 响应而不是 JSON,如示例10-10 所示。

示例 10-10. 使用json()响应类型
public function contacts(){ return response()->json(Contact::all());}public function jsonpContacts(Request $request){ return response() ->json(Contact::all()) ->setCallback($request->input('callback'));}public function nonEloquentContacts(){ return response()->json(['Tom', 'Jerry']);}

重定向响应

重定向不常在response()辅助函数中调用,因此它们与我们已经讨论过的其他自定义响应类型有所不同,但它们仍然只是另一种响应。从 Laravel 路由返回的重定向会向用户发送一个重定向(通常是 301),将其导向另一个页面或返回到上一页。

你技术上可以response()中调用重定向,例如return response()->redirectTo('/')。但更常见的做法是使用专门的全局辅助函数。

有一个全局的redirect()函数,用于创建重定向响应,还有一个全局的back()函数,是redirect()->back()的快捷方式。

就像大多数全局辅助函数一样,redirect()全局函数可以传递参数,也可以用来获取其类的实例,然后链式调用方法。如果不链式调用,而只是传递参数,redirect()的行为与redirect()->to()相同;它接受一个字符串并重定向到该字符串的 URL。示例10-11 展示了其使用示例。

示例 10-11. 使用redirect()全局辅助函数的示例
return redirect('account/payment');return redirect()->to('account/payment');return redirect()->route('account.payment');return redirect()->action('AccountController@showPayment');// If redirecting to an external domainreturn redirect()->away('https://tighten.co');// If named route or controller needs parametersreturn redirect()->route('contacts.edit', ['id' => 15]);return redirect()->action('ContactController@edit', ['id' => 15]);

当处理和验证用户输入时,你也可以“返回”到上一页,这在验证上下文中特别有用。示例10-12 展示了验证上下文中的常见模式。

示例 10-12. 带有输入的回跳重定向
public function store(){ // If validation fails... return back()->withInput();}

最后,你可以同时重定向并向会话闪存数据。这在处理错误和成功消息时很常见,例如示例10-13。

示例 10-13. 带有闪存数据的重定向
Route::post('contacts', function () { // Store the contact return redirect('dashboard')->with('message', 'Contact created!');});Route::get('dashboard', function () { // Get the flashed data from session--usually handled in Blade template echo session('message');});

自定义响应宏

你也可以使用创建自己的自定义响应类型。这允许你定义要对响应及其提供的内容进行的一系列修改。

让我们重新创建json()自定义响应类型,只是为了看看它是如何工作的。如常,你应该为这类绑定创建一个自定义服务提供者,但现在我们暂时将其放在AppServiceProvider中,如示例10-14 所示。

示例 10-14. 创建一个自定义响应宏
...class AppServiceProvider{ public function boot() { Response::macro('myJson', function ($content) { return response(json_encode($content)) ->withHeaders(['Content-Type' => 'application/json']); }); }

然后,我们可以像使用预定义的json()宏一样使用它:

return response()->myJson(['name' => 'Sangeetha']);

这将返回一个带有数组主体的 JSON 编码响应,带有适当的 JSON 类型的Content-Type头。

负责任接口

如果你想要自定义如何发送响应,而宏提供的空间或组织不够,或者你希望你的对象能够根据自己的显示逻辑作为“响应”返回,那么Responsable接口适合你。

Responsable接口,Illuminate\Contracts\Support\Responsable,规定其实现类必须有一个toResponse()方法。这需要返回一个 Illuminate Response对象。示例10-15 说明了如何创建一个Responsable对象。

示例 10-15. 创建一个简单的Responsable对象
...use Illuminate\Contracts\Support\Responsable;class MyJson implements Responsable{ public function __construct($content) { $this->content = $content; } public function toResponse() { return response(json_encode($this->content)) ->withHeaders(['Content-Type' => 'application/json']); }

然后,我们可以像使用我们自定义的宏一样使用它:

return new MyJson(['name' => 'Sangeetha']);

相对于之前介绍的响应宏,这可能看起来需要做很多工作。但是在处理更复杂的控制器操作时,Responsable接口真正发挥作用。一个常见的例子是使用它来创建视图模型(或视图对象),就像在示例10-16 中。

示例 10-16. 使用Responsable创建视图对象
...use Illuminate\Contracts\Support\Responsable;class GroupDonationDashboard implements Responsable{ public function __construct($group) { $this->group = $group; } public function budgetThisYear() { // ... } public function giftsThisYear() { // ... } public function toResponse() { return view('groups.dashboard') ->with('annual_budget', $this->budgetThisYear()) ->with('annual_gifts_received', $this->giftsThisYear()); }

在这种情况下,将复杂的视图准备工作移到一个专用的、可测试的对象中,并保持控制器的简洁,这开始变得更有意义。以下是使用那个Responsable对象的控制器:

...class GroupController{ public function index(Group $group) { return new GroupDonationsDashboard($group); }

回顾一下图10-1,这是本章的开头。

我们已经讨论了请求和响应,但实际上还没有深入了解中间件是什么。您可能已经熟悉中间件,这不是 Laravel 独有的,而是一种广泛使用的架构模式。

中间件简介

中间件的概念是,有一系列层包裹在您的应用程序周围,就像一个多层蛋糕或洋葱[¹]一样。正如图10-1 所示,每个请求在进入应用程序时都会经过每个中间件层,然后生成的响应在发送给最终用户之前也会经过中间件层。

中间件通常被视为与应用程序逻辑分离的部分,并且通常设计为理论上适用于任何应用程序,而不仅限于您目前正在开发的应用程序。

中间件可以检查请求并根据其内容装饰或拒绝它。这意味着中间件非常适合像速率限制这样的用例:它们可以检查 IP 地址,查看在最后一分钟内访问此资源的次数,并在超过阈值时返回状态码 429(请求过多)。

因为中间件也可以在应用程序发送响应时访问响应,所以非常适合装饰响应。例如,Laravel 使用中间件将给定请求/响应周期中排队的所有 cookie 添加到响应中,然后再发送给最终用户。

但中间件最强大的用途之一来自于它们几乎可以是请求/响应周期中第一最后的交互对象。这使得中间件非常适合像启用会话这样的功能——PHP 需要您尽早打开会话并在很晚时候关闭会话,而中间件也非常适合这种用途。

创建自定义中间件

假设我们希望有一个中间件,它拒绝使用DELETE HTTP 方法的每个请求,并在每个请求返回时发送一个 cookie。

有一个 Artisan 命令用于创建自定义中间件。让我们试试看:

php artisan make:middleware BanDeleteMethod

您现在可以打开app/Http/Middleware/BanDeleteMethod.php文件。默认内容如示例10-17 所示。

示例 10-17. 默认中间件内容
...class BanDeleteMethod{ public function handle($request, Closure $next) { return $next($request); }}

这个 handle() 方法如何表示处理传入请求 传出响应是最难理解的中间件方面,所以让我们逐步来看一下。

理解中间件的 handle() 方法

首先,要记住中间件是层层叠加的,最后叠加在应用程序之上。注册的第一个中间件在请求进入时最先访问,然后请求依次传递给每个其他中间件,然后到达应用程序。然后通过中间件传递生成的响应,最后第一个中间件在响应输出时再次访问。

假设我们已将 BanDeleteMethod 注册为第一个运行的中间件。这意味着进入它的 $request 是原始请求,没有任何其他中间件的篡改。现在呢?

将该请求传递给 $next() 意味着将其传递给其余的中间件。$next() 闭包只是将该 $request 传递给堆栈中下一个中间件的 handle() 方法。然后它会一直传递到没有更多中间件可传递时,并最终到达应用程序。

接下来,响应是如何出来的?这可能比较难理解。应用程序返回一个响应,它通过中间件链返回上来——因为每个中间件都返回它的响应。因此,在同一个 handle() 方法中,中间件可以修饰 $request 并将其传递给 $next() 闭包,然后可以选择在最终将该输出返回给最终用户之前对接收到的输出做一些处理。让我们看一些伪代码来澄清这一点(参见 Example10-18)。

示例 10-18. 解释中间件调用过程的伪代码
...class BanDeleteMethod{ public function handle($request, Closure $next) { // At this point, $request is the raw request from the user. // Let's do something with it, just for fun. if ($request->ip() === '192.168.1.1') { return response('BANNED IP ADDRESS!', 403); } // Now we've decided to accept it. Let's pass it on to the next // middleware in the stack. We pass it to $next(), and what is // returned is the response after the $request has been passed // down the stack of middleware to the application and the // application's response has been passed back up the stack. $response = $next($request); // At this point, we can once again interact with the response // just before it is returned to the user $response->cookie('visited-our-site', true); // Finally, we can release this response to the end user return $response; }}

最后,让我们确保中间件实现我们实际承诺的功能(参见 Example10-19)。

示例 10-19. 禁止 DELETE 方法的示例中间件
...class BanDeleteMethod{ public function handle($request, Closure $next) { // Test for the DELETE method if ($request->method() === 'DELETE') { return response( "Get out of here with that delete method", 405 ); } $response = $next($request); // Assign cookie $response->cookie('visited-our-site', true); // Return response return $response; }}

绑定中间件

我们还没有完成。我们需要以两种方式之一注册此中间件:全局注册或特定路由注册。

全局中间件适用于每个路由;路由中间件则逐个路由应用。

绑定全局中间件

这两种绑定都发生在 app/Http/Kernel.php 中。要将中间件作为全局添加,只需将其类名添加到 $middleware 属性中,如 Example10-20 所示。

示例 10-20. 绑定全局中间件
// app/Http/Kernel.phpprotected $middleware = [ \App\Http\Middleware\TrustProxies::class, \Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode::class, \App\Http\Middleware\BanDeleteMethod::class,];

绑定路由中间件

用于特定路由的中间件可以作为路由中间件或作为中间件组的一部分添加。让我们从前者开始。

路由中间件被添加到 app/Http/Kernel.php$middlewareAliases 数组中。这类似于将它们添加到 $middleware,但我们必须为每一个中间件指定一个键,当将此中间件应用于特定路由时使用,正如在 Example10-21 中所见。

示例 10-21. 绑定路由中间件
// app/Http/Kernel.phpprotected $middlewareAliases = [ 'auth' => \App\Http\Middleware\Authenticate::class, ... 'ban-delete' => \App\Http\Middleware\BanDeleteMethod::class,];

现在我们可以在路由定义中使用这个中间件,就像在 Example10-22 中所示。

示例 10-22. 在路由定义中应用路由中间件
// Doesn't make much sense for our current example...Route::get('contacts', [ContactController::class, 'index'])->middleware('ban-delete');// Makes more sense for our current example...Route::prefix('api')->middleware('ban-delete')->group(function () { // All routes related to an API});

使用中间件组

中间件组本质上是在特定上下文中一起合理存在的预打包中间件束。

routes/web.php 中的每个路由都位于web中间件组中。 routes/web.php 文件专门用于 web 路由,而routes/api.php 文件则用于 API 路由。如果您想要在其他组中添加路由,请继续阅读。

开箱即用,有两个组:webapiweb组包含几乎每个 Laravel 页面请求都有用的所有中间件,包括用于 cookies、sessions 和 CSRF 保护的中间件。 api组则没有这些——它包含一个节流中间件和一个路由模型绑定中间件,就这些。这些都在app/Http/Kernel.php中定义。

您可以像向路由应用路由中间件一样,使用middleware()流畅方法向路由应用中间件组:

use App\Http\Controllers\HomeController;Route::get('/', [HomeController::class, 'index']);

您还可以创建自己的中间件组,并向预定义的中间件组添加和移除路由中间件。它的工作方式与通常添加路由中间件相同,但您是将它们添加到$middlewareGroups数组中的键组中。

您可能会想知道这些中间件组与两个默认路由文件的对应关系。毫不奇怪,routes/web.php 文件使用web中间件组包裹,而routes/api.php 文件使用api中间件组包裹。

routes/* 文件在RouteServiceProvider中加载。看一下那里的map()方法(示例10-23),您会发现mapWebRoutes()方法和mapApiRoutes()方法,每个方法都已将其各自的文件加载并已包裹在适当的中间件组中。

示例 10-23. 默认路由服务提供者
// App\Providers\RouteServiceProviderpublic const HOME = '/home';// protected $namespace = 'App\\Http\\Controllers';public function boot(): void{ $this->configureRateLimiting(); $this->routes(function () { Route::prefix('api') ->middleware('api') ->namespace($this->namespace) ->group(base_path('routes/api.php')); Route::middleware('web') ->namespace($this->namespace) ->group(base_path('routes/web.php')); });}protected function configureRateLimiting(){ RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60) ->by(optional($request->user())->id ?: $request->ip()); });}

如你所见,我们在示例10-23 中使用路由器加载了一个路由组,其中包括web中间件组和api中间件组下的另一个路由组。

将参数传递给中间件

尽管不常见,但有时您需要向路由中间件传递参数。例如,您可能有一个身份验证中间件,根据您是保护member用户类型还是owner用户类型而采取不同的操作:

Route::get('company', function () { return view('company.admin');})->middleware('auth:owner');

要使此工作正常,您需要向中间件的handle()方法添加一个或多个参数,并相应地更新该方法的逻辑,如示例10-24 所示。

示例 10-24. 定义接受参数的路由中间件
public function handle(Request $request, Closure $next, $role): Response{ if (auth()->check() && auth()->user()->hasRole($role)) { return $next($request); } return redirect('login');}

请注意,您还可以向handle()方法添加多个参数,并通过用逗号分隔它们将多个参数传递给路由定义:

Route::get('company', function () { return view('company.admin');})->middleware('auth:owner,view');

Laravel 默认提供了相当多的中间件。让我们一起来看看每个中间件。

维护模式

我们经常需要临时将应用程序下线以执行某种形式的维护。Laravel 提供了名为“维护模式”的功能,并有一个中间件在每个响应中检查应用程序是否处于该模式下。

您可以使用 down Artisan 命令为您的应用程序启用维护模式:

php artisan down --refresh=5 --retry=30 --secret="long-password"

refresh

发送一个带有响应的头部,以指定秒数后刷新浏览器。

retry

设置 Retry-After 头部,带有指定的秒数。浏览器通常会忽略此头部。

secret

设置一个密码,允许某些用户绕过维护模式。要绕过维护模式,请导航到您的应用程序 URL,后面跟着您设置的秘密(例如 app.url/long-password)。这将重定向您到 / 应用程序 URL,并在您的浏览器上设置一个绕过 cookie,允许您在应用程序处于维护模式时正常访问。

要禁用维护模式,请使用 up Artisan 命令:

php artisan up

速率限制

如果您需要限制用户在特定时间内只能访问某些路由的次数(称为速率限制,在 API 中最常见),那么 Laravel 提供了一个即用即有的中间件:throttle。示例10-25 展示了它的使用,使用 Laravel 提供的“api” RateLimiter 预设。

示例 10-25. 将速率限制中间件应用于路由
Route::middleware(['auth:api', 'throttle:api'])->group(function () { Route::get('/profile', function () { // });});

您可以定义尽可能多的自定义 RateLimiter 配置,查看 RouteServiceProviderconfigureRateLimiting() 方法以获取默认的 api 配置,也可以创建您自己的配置。

正如您在 示例10-26 中看到的,默认的 api 配置限制每分钟请求 60 次,分段为经过身份验证的 ID 或(如果用户未登录)IP 地址。

示例 10-26. 默认速率限制器定义
RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());});

您还可以自定义当速率限制达到时发送的响应,根据用户或应用程序或请求条件指定不同的速率限制,甚至指定一个依次应用的速率限制器堆栈。查看速率限制文档以获取更多信息。

受信任的代理

如果您使用任何 Laravel 工具在应用程序内生成 URL,您会注意到 Laravel 检测当前请求是否通过 HTTP 或 HTTPS,并使用适当的协议生成链接。

然而,当您的应用程序前面有代理(例如负载均衡器或其他基于 Web 的代理)时,这并不总是有效。许多代理会发送非标准的头部,如 X_FORWARDED_PORTX_FORWARDED_PROTO 到您的应用程序,并希望您的应用程序“信任”这些头部,解释它们,并将它们作为解释 HTTP 请求的一部分使用。为了使 Laravel 正确地将代理的 HTTPS 调用视为安全调用,并且为了让 Laravel 处理来自代理请求的其他头部,您需要定义它应该如何处理。

您可能不希望允许任何代理发送流量到您的应用程序;相反,您希望将您的应用程序锁定为仅信任特定代理,并且即使从这些代理中,您可能也只想信任某些转发头部。

Laravel 包含了TrustedProxy 包,它使您能够将某些流量源标记为“可信”,并标记您希望从这些源信任的转发头,并指定如何将它们映射到普通头部。

要配置您的应用程序将信任哪些代理,您可以编辑 App\Http\Middleware\TrustProxies 中间件,并将负载均衡器或代理的 IP 地址添加到 $proxies 数组中,如示例10-27 所示。

示例 10-27. 配置 TrustProxies 中间件
 /** * The trusted proxies for this application. * * @var array<int, string>|string|null */ protected $proxies; /** * The headers that should be used to detect proxies * * @var int */ protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB;

如您所见,$headers 数组默认信任来自可信代理的所有转发头部;如果您想自定义此列表,请查看Symfony 关于信任代理的文档

CORS

希望您从未遇到 CORS(跨源资源共享)的问题。这是我们希望始终正常运行的事情之一,当它不起作用时,会令人痛苦。

Laravel 的内置 CORS 中间件默认运行,并可以在config/cors.php中配置。它的默认配置对大多数应用程序都是合理的,但在其配置文件中,您可以排除 CORS 保护的路由,修改它操作的 HTTP 方法,并配置它如何与 CORS 头部交互。

除了您作为开发人员在自己的测试中使用请求、响应和中间件的上下文之外,Laravel 本身实际上也大量使用它们。

当您像$this->get('/')这样使用应用程序测试调用时,您正在指示 Laravel 的应用程序测试框架生成代表您所描述交互的请求对象。然后,这些请求对象被传递给您的应用程序,就好像它们是实际访问一样。这就是为什么应用程序测试如此准确:您的应用程序实际上并不“知道”它正在与一个真实用户交互。

在这种情况下,您进行的许多断言—比如assertResponseOk()—都是针对应用程序测试框架生成的响应对象的断言。assertResponseOk() 方法只是查看响应对象,并断言其isOk() 方法返回true—这只是检查其状态码是否为 200。最终,在应用程序测试中,一切都像这是一个真实的页面请求一样运作。

发现自己需要在测试中使用一个请求的上下文?您可以随时从容器中获取一个,使用 $request = request()。或者您可以自己创建一个—Request 类的构造函数参数,所有参数都是可选的,如下所示:

$request = new Illuminate\Http\Request( $query, // GET array $request, // POST array $attributes, // "attributes" array; empty is fine $cookies, // Cookies array $files, // Files array $server, // Servers array $content // Raw body data);

如果您真的对一个例子感兴趣,请查看 Symfony 用于从 PHP 提供的全局变量创建新 Request 的方法:Symfony\Component\HttpFoundation\Request@createFromGlobals()

Response 对象如果需要手动创建,甚至更加简单。以下是(可选的)参数:

$response = new Illuminate\Http\Response( $content, // response content $status, // HTTP status, default 200 $headers // array headers array);

最后,如果在应用程序测试期间需要禁用中间件,请在该测试中导入 WithoutMiddleware 特性。你还可以使用 $this->withoutMiddleware() 方法仅在单个测试方法中禁用中间件。

每个进入 Laravel 应用程序的请求都会转换为 Illuminate Request 对象,然后通过所有中间件并由应用程序处理。应用程序生成一个 Response 对象,然后通过所有中间件(以相反的顺序)返回给最终用户。

RequestResponse 对象负责封装和表示关于传入用户请求和传出服务器响应的每一个相关信息。

服务提供者汇集了绑定和注册类以供应用程序使用的相关行为。

中间件包裹应用程序,可以拒绝或装饰任何请求和响应。

¹ 或者一个 ogre

Laravel 的服务容器或依赖注入容器,位于几乎每个其他功能的核心。容器是一个简单的工具,你可以用它来绑定和解析类和接口的具体实例,同时它也是一个强大而微妙的管理器,管理着一个相互关联的依赖网络。在本章中,你将学到更多关于它是什么、如何工作以及如何使用它的知识。

在本书、文档以及其他教育资源中,你会注意到容器有很多称呼,比如:

  • 应用程序容器

  • IoC(控制反转)容器

  • 服务容器

  • DI(依赖注入)容器

所有这些都很有用且有效,但请知道它们指的是同一个东西。它们都是在指服务容器。

依赖注入意味着,与其在类内部实例化(“newed up”),每个类的依赖将会从外部注入进来。这通常发生在构造函数注入中,即在创建对象时注入其依赖。但也有设置器注入,类公开一个专门用于注入特定依赖的方法,以及方法注入,其中一个或多个方法在调用时期望它们的依赖被注入。

查看 示例11-1 以获取构造函数注入的快速示例,这是依赖注入最常见的类型之一。

示例 11-1. 基本依赖注入
<?phpclass UserMailer{ protected $mailer; public function __construct(Mailer $mailer) { $this->mailer = $mailer; } public function welcome($user) { return $this->mailer->mail($user->email, 'Welcome!'); }}

正如你所见,这个 UserMailer 类期望在实例化时注入一个类型为 Mailer 的对象,然后其方法引用该实例。

依赖注入的主要好处在于它赋予了我们改变我们注入的自由,为测试而模拟依赖关系,并且仅为共享使用而实例化共享依赖项一次。

正如你在 示例11-1 中看到的那样,依赖注入最常见的模式是构造函数注入,即在对象实例化(“构造”)时注入其依赖。

让我们从 示例11-1 中获取我们的 UserMailer 类。示例11-2 展示了如何创建和使用它的实例。

示例 11-2. 简单手动依赖注入
$mailer = new MailgunMailer($mailgunKey, $mailgunSecret, $mailgunOptions);$userMailer = new UserMailer($mailer);$userMailer->welcome($user);

现在假设我们希望我们的 UserMailer 类能够记录消息,并且在发送消息时每次都通知 Slack 频道。示例11-3 展示了这将是什么样子。正如你所见,如果我们每次想要创建一个新实例都要做所有这些工作,那将变得非常难以管理—尤其是当你考虑到我们必须从某处获取所有这些参数时。

示例 11-3. 更复杂的手动依赖注入
$mailer = new MailgunMailer($mailgunKey, $mailgunSecret, $mailgunOptions);$logger = new Logger($logPath, $minimumLogLevel);$slack = new Slack($slackKey, $slackSecret, $channelName, $channelIcon);$userMailer = new UserMailer($mailer, $logger, $slack);$userMailer->welcome($user);

想象一下,每次想要UserMailer时都需要编写那些代码。依赖注入很棒,但这样做太乱了。

在我们深入探讨容器的实际工作方式之前,让我们快速看一下从容器中获取对象的最简单方式:app()助手。

将任何字符串传递给该助手,无论是完全合格的类名(FQCN,如*App\ThingDoer*)还是 Laravel 的快捷方式(我们稍后会更多讨论),它都会返回该类的一个实例:

$logger = app(Logger::class);

这是与容器交互的绝对最简单的方式。它为您创建一个类的实例并返回它,非常简单易行。就像new Logger,但是,正如您马上会看到的,要好得多。

正如您在这里所看到的,创建Logger实例似乎很简单,但您可能已经注意到我们在示例 11-3 中的$logger类有两个参数:$logPath$minimumLogLevel。容器如何知道在这里传递什么?

简短的回答是:它不知道。您可以使用app()全局助手创建一个在构造函数中没有参数的类的实例,但此时您可以自己运行new Logger。当构造函数有一些复杂性时,容器才会发挥作用,这时我们需要看看容器如何确切地能够构造具有构造函数参数的类。

在我们进一步挖掘Logger类之前,看看示例 11-4。

示例 11-4. Laravel 自动装配
class Bar{ public function __construct() {}}class Baz{ public function __construct() {}}class Foo{ public function __construct(Bar $bar, Baz $baz) {}}$foo = app(Foo::class);

这看起来与我们邮件发送器示例中的情况类似,但不同之处在于,这些依赖项(BarBaz)都非常简单,容器可以在不需要进一步信息的情况下解决它们。容器读取Foo构造函数中的类型提示,解析BarBaz的实例,然后在创建Foo实例时将它们注入其中。这就是自动装配:根据类型提示解析实例,而开发者无需在容器中显式绑定这些类。

自动装配意味着,如果一个类没有显式绑定到容器(比如在这个上下文中的FooBarBaz),但容器可以找出如何解决它,那么容器就会解决它。这意味着任何没有构造函数依赖的类(比如BarBaz),以及容器可以解决其构造函数依赖的类(比如Foo),都可以从容器中解析出来。

这意味着我们只需要绑定那些具有无法解析的构造函数参数的类——例如,我们在示例 11-3 中的$logger类,它具有与我们的日志路径和日志级别相关的参数。

对于这些内容,我们需要学习如何显式地将某些内容绑定到容器中。

将一个类绑定到 Laravel 的容器本质上是告诉容器:“如果开发者要求Logger的一个实例,这里是实例化它并返回正确的参数和依赖项的代码运行方式。”

我们教容器,当有人请求特定的字符串(通常是类的完全限定类名时),应该以这种方式解析它。

绑定到一个闭包

因此,让我们看看如何绑定到容器。请注意,绑定到容器的适当位置是服务提供者的register()方法中(参见示例11-5)。

示例 11-5. 基本容器绑定
// In any service provider (maybe LoggerServiceProvider)public function register(): void{ $this->app->bind(Logger::class, function ($app) { return new Logger('\log\path\here', 'error'); });}

在这个示例中有几个重要的事情需要注意。首先,我们运行了$this``->app->bind()$this->app是每个服务提供者上始终可用的容器实例。容器的bind()方法是我们用来绑定到容器的方法。

bind()的第一个参数是我们绑定的“键”。在这里,我们使用了类的完全限定类名(FQCN)。第二个参数取决于你正在做什么,但基本上它应该是某些东西,用于告诉容器如何解析绑定键的实例。

因此,在这个例子中,我们传递了一个闭包。现在,每当有人运行app(Logger::class)时,他们将得到这个闭包的结果。闭包被传递了容器本身的实例($app),所以如果你要解析的类有一个依赖项,你希望从容器中解析出来,你可以在你的定义中使用它,就像在示例11-6 中看到的那样。

示例 11-6. 在容器绑定中使用传递的$app实例
// Note that this binding is not doing anything technically useful, since this// could all be provided by the container's autowiring already.$this->app->bind(UserMailer::class, function ($app) { return new UserMailer( $app->make(Mailer::class), $app->make(Logger::class), $app->make(Slack::class) );});

请注意,每次请求你的类的新实例时,这个闭包将被重新运行,并返回新的输出。

绑定到单例、别名和实例

如果你希望绑定闭包的输出被缓存,以便不必每次请求实例时都重新运行该闭包,那就是单例模式,你可以运行$this->app->singleton()来实现。示例11-7 展示了这是什么样子。

示例 11-7. 将单例绑定到容器
public function register(): void{ $this->app->singleton(Logger::class, function () { return new Logger('\log\path\here', 'error'); });}

如果你已经拥有你想让单例返回的对象实例,也可以获得类似的行为,如示例11-8 所示。

示例 11-8. 将现有的类实例绑定到容器
public function register(): void{ $logger = new Logger('\log\path\here', 'error'); $this->app->instance(Logger::class, $logger);}

最后,如果你想将一个类别名为另一个类,或者将一个快捷方式绑定到一个类,你可以简单地传递两个字符串,如示例11-9 所示。

示例 11-9. 类和字符串的别名
// Asked for Logger, give FirstLogger$this->app->bind(Logger::class, FirstLogger::class);// Asked for log, give FirstLogger$this->app->bind('log', FirstLogger::class);// Asked for log, give FirstLogger$this->app->alias(FirstLogger::class, 'log');

请注意,这些快捷方式在 Laravel 的核心中很常见;它提供了一套快捷方式系统,用于提供核心功能的类,使用易于记忆的键,如log

将一个具体实例绑定到接口

就像我们可以将一个类绑定到另一个类,或者将一个类绑定到一个快捷方式一样,我们也可以绑定到一个接口。这非常强大,因为我们现在可以用接口类型提示而不是类名,就像在示例 11-10 中那样。

示例 11-10. 接口类型提示和绑定
...use Interfaces\Mailer as MailerInterface;class UserMailer{ protected $mailer; public function __construct(MailerInterface $mailer) { $this->mailer = $mailer; }}
// Service providerpublic function register(): void{ $this->app->bind(\Interfaces\Mailer::class, function () { return new MailgunMailer(...); });}

您现在可以在代码的各个地方使用MailerLogger接口类型提示,然后在服务提供者中选择一次要在整个应用程序中使用的特定邮件发送器或记录器。这就是控制反转。

使用此模式的一个关键好处是,以后,如果您决定使用不同于 Mailgun 的邮件提供程序,只要您有一个实现Mailer接口的新提供程序的邮件发送器类,您可以在服务提供者中进行一次更改,而其余代码将继续正常工作。

上下文绑定

有时候,您需要根据上下文更改接口的解析方式。您可能希望从一个地方将事件日志记录到本地系统日志,而从其他地方记录到外部服务。因此,让我们告诉容器区分—查看示例 11-11。

示例 11-11. 上下文绑定
// In a service providerpublic function register(): void{ $this->app->when(FileWrangler::class) ->needs(Interfaces\Logger::class) ->give(Loggers\Syslog::class); $this->app->when(Jobs\SendWelcomeEmail::class) ->needs(Interfaces\Logger::class) ->give(Loggers\PaperTrail::class);}

我们已经讨论了构造函数注入的概念,并且已经看过容器如何轻松地从容器中解析类或接口的实例。您看到了使用app()助手来创建实例是多么简单,以及当创建类时,容器将解析其构造函数依赖项。

我们尚未涵盖的是容器还负责解析应用程序核心操作类中的许多类。例如,每个控制器都是由容器实例化的。这意味着如果您希望在控制器中获取日志记录器的实例,您可以简单地在控制器的构造函数中类型提示日志记录器类,当 Laravel 创建控制器时,它将从容器中解析它,并且该日志记录器实例将对您的控制器可用。看看示例 11-12。

示例 11-12. 向控制器注入依赖项
...class MyController extends Controller{ protected $logger; public function __construct(Logger $logger) { $this->logger = $logger; } public function index() { // Do something $this->logger->error('Something happened'); }}

容器负责解析控制器、中间件、队列作业、事件侦听器以及在应用程序生命周期过程中由 Laravel 自动生成的任何其他类—​因此,这些类中的任何一个都可以在其构造函数中类型提示依赖项,并期望它们自动注入。

在应用程序中有一些地方,Laravel 不仅仅读取构造函数签名:它还会读取方法签名,并且还会为您注入依赖项。

在控制器方法中使用方法注入最常见。如果您只想在单个控制器方法中使用某个依赖项,您可以像在示例 11-13 中那样,仅将其注入到该方法中。

示例 11-13. 向控制器方法注入依赖项
...class MyController extends Controller{ // Method dependencies can come after or before route parameters. public function show(Logger $logger, $id) { // Do something $logger->error('Something happened'); }}

你可以在服务提供者的boot()方法中做同样的事情,你也可以使用容器任意调用任何类的方法,在那里可以进行方法注入(参见示例11-14)。

示例 11-14. 使用容器的call()方法手动调用类方法
class Foo{ public function bar($parameter1) {}}// Calls the 'bar' method on 'Foo' with a first parameter of 'value'app()->call('Foo@bar', ['parameter1' => 'value']);

在本书中,我们已经相当详细地介绍了门面,但实际上我们并没有讨论它们的工作原理。

Laravel 的门面是提供对 Laravel 核心功能的简单访问的类。门面有两个显著特点:首先,它们都在全局命名空间中可用(\Log\Illuminate\Support\Facades\Log的别名);其次,它们使用静态方法访问非静态资源。

让我们看一下Log门面,因为在本章中我们已经在讨论日志记录。在你的控制器或视图中,你可以使用这个调用:

Log::alert('Something has gone wrong!');

这是没有使用门面进行相同调用的情况:

$logger = app('log');$logger->alert('Something has gone wrong!');

正如你所看到的,门面将静态调用(在类本身上使用::进行的任何方法调用,而不是在实例上进行的)转换为实例上的普通方法调用。

如果你在有命名空间的类中,你会想确保在顶部导入门面:

...use Illuminate\Support\Facades\Log;class Controller extends Controller{ public function index() { // ... Log::error('Something went wrong!'); }

门面是如何工作的

让我们看一下Cache门面,看看它实际是如何工作的。

首先,打开Illuminate\Support\Facades\Cache类。你会看到类似于示例11-15 的东西。

示例 11-15. Cache门面类
<?phpnamespace Illuminate\Support\Facades;class Cache extends Facade{ protected static function getFacadeAccessor() { return 'cache'; }}

每个门面都有一个单一的方法:getFacadeAccessor()。这定义了 Laravel 应该使用哪个键从容器中查找该门面的后备实例。

在这个例子中,我们可以看到每次调用Cache门面都会被代理为调用容器中cache快捷方式的实例。当然,这不是一个真实的类或接口名称,所以我们知道它是我之前提到的那些快捷方式之一。

所以,这里是实际发生的事情:

Cache::get('key');// Is the same as...app('cache')->get('key');

有几种方法可以查找每个门面访问器实际指向的类,但检查文档是最简单的。在门面文档页面上有一张表格显示了每个门面连接到哪个容器绑定(比如cache这样的快捷方式),以及返回的类。它看起来像这样:

门面服务容器绑定
AppIlluminate\Foundation\Applicationapp
…​…​…​
CacheIlluminate\Cache\CacheManagercache
…​…​…​

现在你有了这个参考,你可以做三件事情。

首先,你可以找出门面上有哪些方法。只需找到其后备类并查看该类的定义,你就会知道其所有公共方法在门面上都是可调用的。

第二,您可以找出如何使用依赖注入注入门面的后备类。如果您希望使用依赖注入而不是门面的功能,只需类型提示门面的后备类或使用app()获取其实例,并调用您本应在门面上调用的相同方法。

第三,您可以看到如何创建自己的门面。创建一个门面类,扩展Illuminate\Support\Facades\Facade,并为其添加一个getFacadeAccessor()方法,该方法返回一个字符串。使该字符串成为可以从容器中解析出您的后备类的东西——也许只是类的完全限定类名。最后,在config/app.php中的aliases数组中添加该门面以注册门面。完成!您刚刚创建了自己的门面。

实时门面

与创建新类使得您的类的实例方法作为静态方法可用不同,实时外观允许您简单地使用Facades\前缀您类的完全限定类名,并像使用门面一样使用它。示例11-16 展示了这如何工作。

示例 11-16. 使用实时外观
namespace App;class Charts{ public function burndown() { // ... }}
<h2>Burndown Chart</h2>{{ Facades\App\Charts::burndown() }}

正如您在这里所看到的,非静态方法burndown()作为实时外观的静态方法变得可访问,我们通过在类的完整名称前加上Facades\来创建。

我们在前一章节中介绍了服务提供者的基础知识(参见“服务提供者”)。关于容器最重要的是您要记住,在某个服务提供者的register()方法中注册您的绑定。

您可以将松散的绑定直接添加到App\Providers\AppServiceProvider中,这有点像万金油,但通常最好为您开发的每一组功能创建一个独特的服务提供者,并在其独特的register()方法中绑定其类。

使用控制反转和依赖注入的能力使得在 Laravel 中进行测试变得非常灵活。例如,您可以根据应用程序是在线还是在测试中绑定不同的日志记录器。或者,您可以将事务性电子邮件服务从 Mailgun 更改为本地电子邮件记录器以便轻松检查。这些交换实际上非常常见,以至于使用 Laravel 的.env配置文件更加简单,但您也可以使用任何您想要的接口或类进行类似的交换。

最简单的方法是在测试中直接重新绑定类和接口,当您需要它们重新绑定时。示例11-17 展示了如何操作。

示例 11-17. 在测试中重写绑定
public function test_it_does_something(){ app()->bind(Interfaces\Logger, function () { return new DevNullLogger; }); // Do stuff}

如果您需要为您的测试全局重新绑定某些类或接口(这并不是特别常见的情况),您可以在测试类的setUp()方法中或在 Laravel 的TestCase基本测试的setUp()方法中进行操作,就像示例11-18 所示。

示例 11-18. 重写所有测试的绑定
class TestCase extends \Illuminate\Foundation\Testing\TestCase{ public function setUp() { parent::setUp(); app()->bind('whatever', 'whatever else'); }}

当使用类似 Mockery 这样的工具时,通常会创建一个类的模拟对象或间谍对象或存根对象,然后将其重新绑定到容器中,以取代其原有的引用。

Laravel 的服务容器有许多名称,但无论你如何称呼它,最终它的目标都是简化定义如何将某些字符串名称解析为具体实例。这些字符串名称可以是类或接口的完全限定类名,或者像 log 这样的快捷方式。

每个绑定都告诉应用程序,给定一个字符串键(例如,app('log')),如何解析出一个具体的实例。

容器足够智能,可以进行递归依赖项解析。因此,如果尝试解析具有构造函数依赖项的实例,容器将尝试基于它们的类型提示解析这些依赖项,然后将它们传递给您的类,并最终返回一个实例。

绑定到容器的方式有几种,但最终它们都定义了在给定特定字符串时返回什么。

门面是简单的快捷方式,使得可以在根命名空间别名类上使用静态调用,以调用容器中解析出的类的非静态方法。实时门面允许您将任何类视为门面,只需在其完全限定类名之前加上 Facades\ 即可。

大多数开发者知道测试代码是一件好事。我们应该这样做。我们可能已经知道为什么这样做是好的,并且我们可能甚至已经阅读了一些关于如何进行测试的教程。

但是,了解为什么要测试和知道如何测试之间的差距很大。幸运的是,诸如 PHPUnit、Mockery 和 PHPSpec 之类的工具为 PHP 提供了大量测试选项,但是要设置好这一切可能仍然会让人感到很困扰。

Laravel 自带与 PHPUnit(单元测试)、Mockery(模拟)和 Faker(生成虚假数据以进行种子和测试)的集成。此外,它还提供了自己简单而强大的应用程序测试工具套件,允许你“爬行”你的站点 URI、提交表单、检查 HTTP 状态码,并针对 JSON 进行验证和断言。它还提供了一个强大的前端测试框架 Dusk,甚至可以与你的 JavaScript 应用程序进行交互并对其进行测试。如果这还不清楚,我们将在本章节中详细介绍。

为了让你轻松上手,Laravel 的测试设置包含可以在创建新应用程序时立即成功运行的示例应用程序测试。这意味着你不需要花时间配置你的测试环境,这就是写你的测试时的一个少了的障碍。

Laravel 的测试位于tests文件夹中。根目录中有两个文件:TestCase.php,这是所有你的测试将扩展的基本根测试,以及CreatesApplication.php,一个 trait(由TestCase.php导入),允许任何类启动一个用于测试的示例 Laravel 应用程序。

Laravel 有一个 Artisan 命令用于运行你的测试:php artisan test。它是./vendor/bin/phpunit命令的包装器,它将为每个测试显示更多输出。

还有两个子文件夹:Features,用于覆盖多个单元之间交互的测试,以及Unit,用于只覆盖你代码的一个单元(类、模块、函数等)的测试。这些文件夹中的每一个都包含一个ExampleTest.php文件,其中每个文件中都有一个准备好运行的单个样本测试。

在你的Unit目录中的ExampleTest包含一个简单的断言:$this``->``assertTrue(true)。你的单元测试中的任何内容可能都是相对简单的 PHPUnit 语法(断言值相等或不同,查找数组中的条目,检查布尔值等),所以在这里没有太多需要学习的内容。

在 PHPUnit 中,我们的大多数断言将在$this对象上使用这种语法运行:

$this->*`assertWHATEVER`*(*`$expected`*, *`$real`*);

因此,例如,如果我们断言两个变量应该相等,我们将首先传入我们预期的结果,然后传入对象或系统实际结果的第二个值进行测试:

$multiplicationResult = $myCalculator->multiply(5, 3);$this->assertEqual(15, $multiplicationResult);

正如你在示例 12-1 中看到的那样,Feature目录中的ExampleTest模拟了对应用程序根路径的页面的 HTTP 请求,并检查其 HTTP 状态是 200(成功)。如果是,它将通过;如果不是,它将失败。不同于普通的 PHPUnit 测试,我们在对测试 HTTP 调用时返回的TestResponse对象上进行这些断言。

示例 12-1. tests/Feature/ExampleTest.php
<?phpnamespace Tests\Feature;// use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase;class ExampleTest extends TestCase{ /** * A basic test example. */ public function test_the_application_returns_a_successful_response(): void { $response = $this->get('/'); $response->assertStatus(200); }}

运行测试时,在应用程序根目录的命令行上运行php artisan test。你应该看到类似示例 12-2 的输出。

示例 12-2. 示例ExampleTest输出
 PASS Tests\Unit\ExampleTest ✓ that true is true PASS Tests\Feature\ExampleTest ✓ the application returns a successful response Tests: 2 passed (2 assertions) Time: 0.25s

你刚刚运行了你的第一个 Laravel 应用程序测试!两个勾号表示你有两个通过的测试。正如你看到的,你不但已默认配置了一个功能完备的 PHPUnit 实例,还配置了一个完整的应用程序测试套件,可以进行模拟 HTTP 调用并测试应用程序的响应。

如果你对 PHPUnit 不太熟悉,让我们看看一个测试失败时的情形。我们将不修改之前的测试,而是自己创建一个。运行php artisan make:test FailingTest。这将创建文件tests/Feature/FailingTest.php;你可以修改其testExample()方法,使其看起来像示例 12-3。

示例 12-3. 修改后的 tests/Feature/FailingTest.php
public function test_example(){ $response = $this->get('/'); $response->assertStatus(301);}

如你所见,它与我们之前运行的测试相同,但我们现在正在测试错误的状态。让我们再次运行 PHPUnit。

如果您 如果你希望测试生成到Unit目录而不是Feature目录,请传递--unit标志:

php artisan make:test SubscriptionTest --unit

哎呀!这次的输出看起来可能像是示例 12-4 那样。

示例 12-4. 示例失败测试输出
 PASS Tests\Unit\ExampleTest ✓ that true is true PASS Tests\Feature\ExampleTest ✓ the application returns a successful response FAIL Tests\Feature\FailingTest ✕ example FAILED Tests\Feature\FailingTest > example Expected status code [301] but received 200\. Failed asserting that 301 is identical to 200. at tests/Feature/FailingTest.php:20 16| public function test_example() 17| { 18| $response = $this->get('/'); 19| > 20| $response->assertStatus(301); 21| } 22| } 23| Tests: 1 failed, 2 passed (3 assertions) Duration: 1.10s

让我们分解一下。上次有两个通过的测试,但这次有一个失败,两个通过。

然后,对于每个错误,我们看到测试名称(在这里是Test\Feature\FailingTest > example),错误消息(“期望状态代码...”),以及部分堆栈跟踪,因此我们可以看到错误发生的行。

现在我们已经运行了通过的测试和失败的测试,是时候了解更多关于 Laravel 的测试环境了。

默认情况下,Laravel 的测试系统会运行任何位于名为tests目录的文件,其名称以Test结尾。那就是为什么tests/ExampleTest.php会被默认运行。

如果你不熟悉 PHPUnit,你可能不知道只有以test为前缀的测试方法或带有@test文档块的方法或docblock才会运行。请参阅示例 12-5,了解哪些方法会运行和不会运行。

示例 12-5. 命名 PHPUnit 方法
class NamingTest{ public function test_it_names_things_well() { // Runs as "It names things well" } public function testItNamesThingsWell() { // Runs as "It names things well" } /** @test */ public function it_names_things_well() { // Runs as "It names things well" } public function it_names_things_well() { // Doesn't run }}

当 Laravel 应用程序正在运行时,它有一个当前的“环境”名称,表示它正在运行的环境。你可以通过运行app()->environment()来检索这个名称,或者你可以运行if (app()->environment('local'))或类似的内容来测试当前环境是否与传递的名称匹配。

当你运行测试时,Laravel 会自动将环境设置为testing。这意味着你可以测试if (app()->environment('testing'))来启用或禁用测试环境中的某些行为。

另外,Laravel 在测试时不会加载来自.env的普通环境变量。如果你想为你的测试设置任何环境变量,请编辑phpunit.xml,在<php>部分为每个想要传递的环境变量添加一个新的<env>,例如,<env name="DB_CONNECTION" value="sqlite"/>

在我们讨论可以用于测试的方法之前,你需要了解可以引入任何测试类的四个测试特性。

RefreshDatabase

Illuminate\Foundation\Testing\RefreshDatabase被导入到每个新生成的测试文件的顶部,是最常用的数据库迁移特性。

这个和其他数据库特性的目的是确保你的数据库表在每个测试开始时正确迁移。

RefreshDatabase执行两个步骤来实现这一点。首先,在每次测试运行开始时(当你运行phpunit时,而不是每个单独的测试方法),它会在你的测试数据库上运行你的迁移一次。其次,它会将每个单独的测试方法包装在一个数据库事务中,并在测试结束时回滚事务。

这意味着你为你的测试迁移了数据库,并且在每次测试运行后清除,而不需要在每个测试之前再次运行你的迁移——这是最快的选项。当你犹豫不决时,坚持使用这个选项。

DatabaseMigrations

如果你导入Illuminate\Foundation\Testing\DatabaseMigrations trait 而不是RefreshDatabase trait,在每次测试之前将会重新运行整个数据库迁移集。Laravel 通过在每次测试运行前的setUp()方法中运行php artisan migrate:fresh来实现这一点。

DatabaseTransactions

另一方面,Illuminate\Foundation\Testing\DatabaseTransactions期望你的数据库在测试开始之前已经正确迁移。它会将每个测试包装在一个数据库事务中,并在每个测试结束时回滚该事务。这意味着,在每次测试结束时,你的数据库将恢复到测试之前的确切状态。

WithoutMiddleware

如果你在测试类中导入Illuminate\Foundation\Testing\WithoutMiddleware,它将为该类中的任何测试禁用所有中间件。这意味着你不需要担心身份验证中间件、CSRF 保护或任何在真实应用中可能有用但在测试中会分散注意力的内容。

如果你想要仅为单个方法而不是整个测试类禁用中间件,请在该测试方法的顶部调用$this->withoutMiddleware()

对于简单的单元测试,你几乎不需要这些特性。你可能需要访问数据库或从容器中注入一些内容,但很可能你的应用程序中的单元测试并不会过多依赖框架。参考示例12-6 来了解一个简单测试可能的样子。

示例 12-6. 一个简单单元测试
class GeometryTest extends TestCase{ public function test_it_calculates_area() { $square = new Square; $square->sideLength = 4; $calculator = new GeometryCalculator; $this->assertEquals(16, $calculator->area($square)); }

显然,这个示例有点牵强。但你可以看到,我们在这里测试了一个单一类(GeometryCalculator)及其单一方法(area()),而且我们这样做时并不担心整个 Laravel 应用程序。

一些单元测试可能会测试一些技术上与框架连接的内容,例如 Eloquent 模型,但你仍然可以在不担心框架的情况下进行测试。例如,在示例12-7 中,我们使用Package::make()而不是Package::create(),这样对象将在内存中创建和评估,而不会真正触及数据库。

示例 12-7. 一个更复杂的单元测试
class PopularityTest extends TestCase{ use RefreshDatabase; public function test_votes_matter_more_than_views() { $package1 = Package::make(['votes' => 1, 'views' => 0]); $package2 = Package::make(['votes' => 0, 'views' => 1]); $this->assertTrue($package1->popularity > $package2->popularity); }

一些人可能会将这称为集成或特性测试,因为这个“单元”在实际使用中可能会触及数据库,并且它连接到整个 Eloquent 代码库。最重要的是,你可以编写简单的测试来测试一个单一类或方法,即使被测试的对象与框架连接。

尽管如此,你的测试——尤其是在开始时——更可能是更广泛和更“应用程序”级别的。因此,在本章的其余部分中,我们将更深入地探讨应用程序测试。

在“测试基础”中,我们看到,通过几行代码,我们可以“请求”应用程序中的 URI,并实际检查响应的状态。但是 PHPUnit 如何能够请求页面,就像它是一个浏览器一样?

任何应用程序测试都应该扩展默认包含在 Laravel 中的TestCase类(tests/TestCase.php)。你的应用程序的TestCase类将扩展抽象的Illuminate\Foundation\Testing\TestCase类,它带来了许多好处。

两个TestCase类(您的类和其抽象父类)的第一件事是为您处理 Illuminate 应用程序实例的引导,因此您可以使用一个完全引导的应用程序实例。它们还在每个测试之间“刷新”应用程序,这意味着它们在测试之间不完全重新创建应用程序,而是确保没有任何残留数据。

TestCase还设置了一个钩子系统,允许在创建应用程序之前和之后运行回调,并导入了一系列提供与应用程序的每个方面交互方法的特性。这些特性包括InteractsWithContainerMakesHttpRequestsInteractsWithConsole,它们引入了各种自定义断言和测试方法。

因此,您的应用程序测试可以访问一个完全引导的应用程序实例和应用程序测试相关的自定义断言,以及一系列简单而强大的包装器,使它们易于使用。

这意味着你可以编写$this->get('/')->assertStatus(200),确保你的应用程序实际上表现得像响应正常的 HTTP 请求一样,并且完全生成并检查响应,就像浏览器检查一样。考虑到你要做的工作很少,这是非常强大的功能。

让我们来看看编写基于 HTTP 的测试的选项。您已经看到了$this->get('/'),但让我们更深入地了解如何使用该调用,如何断言其结果以及您可以进行的其他 HTTP 调用。

使用 $this->get() 和其他 HTTP 调用测试基本页面

在最基本的层面上,Laravel 的 HTTP 测试允许您进行简单的 HTTP 请求(GETPOST等),然后对它们的影响或响应进行简单的断言。

我们稍后会介绍更多工具(“使用 Dusk 进行测试”),这些工具允许进行更复杂的页面交互和断言,但让我们从基础开始。以下是您可以进行的调用:

  • $this->get(*$uri, $headers = []*)

  • $this->post(*$uri, $data = [], $headers = []*)

  • $this->put(*$uri, $data = [], $headers = []*)

  • $this->patch(*$uri, $data = [], $headers = []*)

  • $this->delete(*$uri, $data = [], $headers = []*)

  • $this->option(*$uri, $data = [], $headers = []*)

这些方法是 HTTP 测试框架的基础。每个方法至少需要一个 URI(通常是相对路径)和头部,除了get()方法外,其他方法还允许传递数据。

并且,每个方法返回一个$response对象,代表 HTTP 响应。这个响应对象几乎与 Illuminate Response对象相同,我们在控制器中返回相同的对象。然而,它实际上是Illuminate\Testing\TestResponse的实例,它用一些断言来进行测试。

查看 示例12-8 来查看 post() 的常见用法和常见响应断言。

示例 12-8. 测试中使用 post() 的简单用法
public function test_it_stores_new_packages(){ $response = $this->post(route('packages.store'), [ 'name' => 'The greatest package', ]); $response->assertOk();}

在大多数类似 示例12-8 的示例中,您还将测试记录是否存在于数据库中,并显示在索引页面上,除非您定义了包的作者并且已登录。但不用担心,我们将会涉及到所有这些内容。现在,您可以使用多种不同的动词调用您的应用程序路由,并对响应及应用程序状态进行断言。很棒!

使用 $this->getJson() 和其他 JSON HTTP 调用来测试 JSON API

您还可以使用 JSON API 进行相同类型的所有 HTTP 测试。这也有方便的方法来进行:

  • $this->getJson(*$uri, $headers = []*)

  • $this->postJson(*$uri, $data = [], $headers = []*)

  • $this->putJson(*$uri, $data = [], $headers = []*)

  • $this->patchJson(*$uri, $data = [], $headers = []*)

  • $this->deleteJson(*$uri, $data = [], $headers = []*)

  • $this->optionJson(*$uri, $data = [], $headers = []*)

这些方法的工作方式与普通的 HTTP 调用方法完全相同,但它们还会添加特定于 JSON 的 AcceptCONTENT_LENGTHCONTENT_TYPE 头部。查看 示例12-9 来查看一个示例。

示例 12-9. 测试中使用 postJson() 的简单用法
public function test_the_api_route_stores_new_packages(){ $response = $this->postJson(route('api.packages.store'), [ 'name' => 'The greatest package', ], ['X-API-Version' => '17']); $response->assertOk();}

对 $response 的断言

$response 对象上有超过 50 个断言可用,因此我会指引您查阅 测试文档 以获取所有详情。让我们看一些最重要和最常见的断言:

$response->assertOk()

断言响应的状态码为 200:

$response = $this->get('terms');$response->assertOk();

$response->assertSuccessful()

尽管 assertOk() 断言代码确切为 200,assertSuccessful() 则检查代码是否属于 200 组中的任意值:

$response = $this->post('articles', [ 'title' => 'Testing Laravel', 'body' => 'My article about testing Laravel',]);// Assuming this returns 201 CREATED...$response->assertSuccessful();

$response->assertUnauthorized()

断言响应的状态码为 401:

$response = $this->patch('settings', ['password' => 'abc']);$response->assertUnauthorized();

$response->assertForbidden()

断言响应的状态码为 403:

$response = $this->actingAs($normalUser)->get('admin');$response->assertForbidden();

$response->assertNotFound()

断言响应的状态码为 404:

$response = $this->get('posts/first-post');$response->assertNotFound();

$response->assertStatus(*$status*)

断言响应的状态码等于提供的 *$status*

$response = $this->get('admin');$response->assertStatus(401); // Unauthorized

$response->assertSee(*$text*), $response->assertDontSee(*$text*)

断言响应包含(或不包含)提供的 *$text*

$package = Package::factory()->create();$response = $this->get(route('packages.index'));$response->assertSee($package->name);

$response->assertJson(*array $json*)

断言传递的数组在返回的 JSON 中以 JSON 格式表示:

$this->postJson(route('packages.store'), ['name' => 'GreatPackage2000']);$response = $this->getJson(route('packages.index'));$response->assertJson(['name' => 'GreatPackage2000']);

$response->assertViewHas(*$key, $value = null*)

断言访问页面上的视图在 *$key* 处有可用数据,并可选择检查该变量的值是否为 *$value*

$package = Package::factory()->create();$response = $this->get(route('packages.show'));$response->assertViewHas('name', $package->name);

$response->assertSessionHas(*$key, $value = null*)

断言会话中设置了数据在 *$key*,并可选择检查该数据的值是否为 *$value*

$response = $this->get('beta/enable');$response->assertSessionHas('beta-enabled', true);

$response->assertSessionHasInput(*$key, $value = null*)

断言给定的键和值已在会话数组输入中闪存。在测试验证错误是否返回正确的旧值时,这很有帮助:

$response = $this->post('users', ['name' => 'Abdullah']);// Assuming it errored, check that the entered name is flashed;$response->assertSessionHasInput('name', 'Abdullah');

$response->assertSessionHasErrors()

没有参数时,断言 Laravel 的特殊errors会话容器中至少设置了一个错误。它的第一个参数可以是定义应设置的错误的键/值对数组,第二个参数可以是应检查的错误应格式化的字符串格式,正如此处所示:

// Assuming the "/form" route requires an email field, and we're// posting an empty submission to it to trigger the error$response = $this->post('form', []);$response->assertSessionHasErrors();$response->assertSessionHasErrors([ 'email' => 'The email field is required.', ]);$response->assertSessionHasErrors( ['email' => '<p>The email field is required.</p>'], '<p>:message</p>');

如果您正在使用命名错误包,请将错误包名称作为第三个参数传递。

$response->assertCookie(*$name, $value = null*)

断言响应包含名称为*$name*的 cookie,并可选地检查其值是否为*$value*

$response = $this->post('settings', ['dismiss-warning']);$response->assertCookie('warning-dismiss', true);

$response->assertCookieExpired(*$name*)

断言响应包含名称为*$name*的 cookie,并且它已过期:

$response->assertCookieExpired('warning-dismiss');

$response->assertCookieNotExpired(*$name*)

断言响应包含名称为*$name*的 cookie,并且它未过期:

$response->assertCookieNotExpired('warning-dismiss');

$response->assertRedirect(*$uri*)

断言请求的路由返回重定向到指定的 URI:

$response = $this->post(route('packages.store'), [ 'email' => 'invalid']);$response->assertRedirect(route('packages.create'));

对于这些断言的每一个,您可以假设有许多相关的断言我没有在此列出。例如,除了assertSessionHasErrors()之外,还有assertSessionHasNoErrors()assertSessionHasErrorsIn()断言;除了assertJson()之外,还有assertJsonCount()assertJsonFragment()assertJsonPath()assertJsonMissing()assertJsonMissingExact()assertJsonStructure()assertJsonValidationErrors()断言。再次,查看文档并熟悉整个列表。

验证响应

您的应用程序中的一个常见测试部分是身份验证和授权。大多数情况下,您的需求可以通过actingAs()链式方法满足,该方法接受一个用户(或其他Authenticatable对象,具体取决于系统设置),正如您在示例12-10 中所见。

示例 12-10. 测试中的基本身份验证
public function test_guests_cant_view_dashboard(){ $user = User::factory()->guest()->create(); $response = $this->actingAs($user)->get('dashboard'); $response->assertStatus(401); // Unauthorized}public function test_members_can_view_dashboard(){ $user = User::factory()->member()->create(); $response = $this->actingAs($user)->get('dashboard'); $response->assertOk();}public function test_members_and_guests_cant_view_statistics(){ $guest = User::factory()->guest()->create(); $response = $this->actingAs($guest)->get('statistics'); $response->assertStatus(401); // Unauthorized $member = User::factory()->member()->create(); $response = $this->actingAs($member)->get('statistics'); $response->assertStatus(401); // Unauthorized}public function test_admins_can_view_statistics(){ $user = User::factory()->admin()->create(); $response = $this->actingAs($user)->get('statistics'); $response->assertOk();}

在测试中使用模型工厂(在“模型工厂”中讨论),模型工厂状态使得像创建具有不同访问级别的用户这样的任务变得简单。

对 HTTP 测试的一些其他自定义

如果您希望在请求中设置会话变量,还可以链式调用withSession()

$response = $this->withSession([ 'alert-dismissed' => true,])->get('dashboard');

如果您希望流畅地设置请求标头,请链式调用withHeaders()

$response = $this->withHeaders([ 'X-THE-ANSWER' => '42',])->get('the-restaurant-at-the-end-of-the-universe');

在应用程序测试中处理异常

通常,当您在应用程序中进行 HTTP 调用时抛出异常,将会被 Laravel 的异常处理程序捕获并处理,就像在普通应用程序中一样。因此,示例12-11 中的测试和路由仍将通过,因为异常永远不会完全冒泡到我们的测试中。

示例 12-11. 会被 Laravel 异常处理器捕获并导致测试通过的异常
// routes/web.phpRoute::get('has-exceptions', function () { throw new Exception('Stop!');});// tests/Feature/ExceptionsTest.phppublic function test_exception_in_route(){ $this->get('/has-exceptions'); $this->assertTrue(true);}

在很多情况下,这可能是有意义的;也许你期望一个验证异常,并且希望像框架通常情况下一样被捕获。

但是如果你想临时禁用异常处理,也有一个选项;只需运行 $this->withoutExceptionHandling(),就像 示例12-12 中展示的那样。

示例 12-12. 在单个测试中临时禁用异常处理
// tests/Feature/ExceptionsTest.phppublic function test_exception_in_route(){ // Now throws an error $this->withoutExceptionHandling(); $this->get('/has-exceptions'); $this->assertTrue(true);}

如果由于某种原因你需要重新启用异常处理(也许你在 setUp() 中关闭了它,但只想在一个测试中重新启用),你可以运行 $this->withExceptionHandling()

调试响应

你可以轻松地使用 dumpHeaders() 打印出头信息,或者使用 dump()dd() 打印出主体:

$response = $this->get('/');$response->dumpHeaders();$response->dump();$response->dd();

你还可以轻松地将会话中的所有或仅指定键转储出来:

$response = $this->get('/');$response->dumpSession();$response->dumpSession(['message']);

往往,我们在测试运行后想要测试的效果在数据库中。想象一下,你想要测试“创建包”页面是否正确工作。最好的方法是什么?对“存储包”端点进行 HTTP 调用,然后断言该包在数据库中存在。这比检查生成的“列出包”页面更容易且更安全。

我们有四个主要的数据库断言方法和两个特定于 Eloquent 的断言方法。

针对数据库的断言

对于直接针对数据库的断言,我们有 $this->assertDatabaseHas()$this->assertDatabaseMissing(),还有 $this->assertDeleted()$this->assertSoftDeleted()。对于这些方法,将表名作为第一个参数传递,你要查找的数据作为第二个参数,以及可选的指定数据库连接作为第三个参数。

参考 示例12-13 了解如何使用它们。

示例 12-13. 示例数据库测试
public function test_create_package_page_stores_package(){ $this->post(route('packages.store'), [ 'name' => 'Package-a-tron', ]); $this->assertDatabaseHas('packages', ['name' => 'Package-a-tron']);}

正如你所见,assertDatabaseHas() 的第二个参数结构类似于 SQL 的 WHERE 语句 —— 你传递一个键和一个值(或多个键和值),然后 Laravel 查找与你的键和值匹配的指定数据库表中的任何记录。

如你所料,assertDatabaseMissing() 则是其反向操作。

针对 Eloquent 模型的断言

当使用 assertDatabaseHas()assertDatabaseMissing() 时,你可以通过传递键和值来识别行,而 Laravel 也提供了方便的方法直接断言给定的 Eloquent 记录是否存在或不存在:assertModelExists()assertModelMissing(),正如你在 示例12-14 中所看到的。

示例 12-14. 断言模型存在
public function test_undeletable_packages_cant_be_deleted(){ // Create undeletable model $package = Package::factory()->create([ 'name' => 'Package-a-tron', 'is_deletable' => false, ]); $this->post(route('packages.delete', $package)); // Can check existence or whether it was soft deleted $this->assertModelExists($package); $this->assertNotSoftDeleted($package); $package->update(['is_deletable' => true]); $this->post(route('packages.delete', $package)); // Can check existence or whether it was soft deleted $this->assertModelMissing($package); $this->assertSoftDeleted($package);}

在测试中使用模型工厂

模型工厂是一种令人惊奇的工具,它能够轻松地为测试(或其他目的)种子填充随机化、结构良好的数据库数据。你已经在本章的几个示例中看到它们的使用,我们也已经对其进行了深入的讨论,因此请查看 “模型工厂” 以了解更多信息。

在测试中进行种子填充

如果您的应用程序使用种子数据,您可以通过在测试中运行 $this->seed() 来运行相当于 php artisan db:seed 的操作。

您还可以将种子器类名称传递给仅种子化该类:

$this->seed(); // Seeds all$this->seed(UserSeeder::class); // Seeds users

在测试 Laravel 系统时,通常希望在测试期间暂停其真实功能,而是针对这些系统已发生的情况编写测试。您可以通过“伪造”不同的外观来实现这一点,例如 EventMailNotification。我们将在 “模拟” 中详细讨论伪造的内容,但首先让我们看一些示例。在 Laravel 中,所有这些特性在伪造后都有自己的一套断言,但您也可以选择仅仅伪造它们以限制其影响。

事件伪造

让我们以事件伪造作为 Laravel 如何使模拟其内部系统成为可能的第一个示例。可能会有时候您希望仅仅为了抑制它们的行为而伪造事件。例如,假设您的应用程序在每次新用户注册时都向 Slack 推送通知。当这种情况发生时,您会发出“用户注册”事件,并且有一个监听器通知 Slack 频道有用户注册。您不希望每次运行测试时这些通知都发送到 Slack,但您可能希望断言事件是否已发送,或者监听器是否已触发,或其他某些内容。这是在测试中伪造 Laravel 某些方面的原因之一:暂停默认行为,而是针对正在测试的系统进行断言。

让我们看看如何通过在 Illuminate\Support\Facades\Event 上调用 fake() 方法来抑制这些事件,就像在 示例12-15 中展示的那样。

示例 12-15. 抑制事件而不添加断言
public function test_controller_does_some_thing(){ Event::fake(); // Call controller and assert it does whatever you want without // worrying about it pinging Slack}

一旦我们运行了 fake() 方法,我们还可以在 Event 外观上调用特殊的断言:即 assertDispatched()assertNotDispatched()。请查看 示例12-16 来查看它们的使用方法。

示例 12-16. 对事件进行断言
public function test_signing_up_users_notifies_slack(){ Event::fake(); // Sign user up Event::assertDispatched(UserJoined::class, function ($event) use ($user) { return $event->user->id === $user->id; }); // Or sign multiple users up and assert it was dispatched twice Event::assertDispatched(UserJoined::class, 2); // Or sign up with validation failures and assert it wasn't dispatched Event::assertNotDispatched(UserJoined::class);}

请注意,我们传递给 assertDispatched() 的(可选)闭包意味着我们不仅仅是断言事件是否已被分派,而且断言已分派的事件是否包含某些数据。

Event::fake() 同时还会禁用 Eloquent 模型事件。因此,如果您的代码中有任何重要的代码,例如在模型的 creating 事件中,请确保在调用 Event::fake() 之前创建您的模型(通过工厂或其他方式)。

Bus 和 Queue 的伪造

Bus 外观代表 Laravel 如何调度作业,其工作方式与 Event 相似。您可以在其上运行 fake() 来禁用作业的影响,并在伪造后运行 assertDispatched()assertNotDispatched()

Queue外观表示 Laravel 在将作业推送到队列时如何调度作业。它的可用方法有assertedPushed()assertPushedOn()assertNotPushed()

查看 示例12-17 以了解如何同时使用两者。

示例 12-17. 伪造作业和排队作业
public function test_popularity_is_calculated(){ Bus::fake(); // Synchronize package data... // Assert a job was dispatched Bus::assertDispatched( CalculatePopularity::class, function ($job) use ($package) { return $job->package->id === $package->id; } ); // Assert a job was not dispatched Bus::assertNotDispatched(DestroyPopularityMaybe::class);}public function test_popularity_calculation_is_queued(){ Queue::fake(); // Synchronize package data... // Assert a job was pushed to any queue Queue::assertPushed( CalculatePopularity::class, function ($job) use ($package) { return $job->package->id === $package->id; } ); // Assert a job was pushed to a given queue named "popularity" Queue::assertPushedOn('popularity', CalculatePopularity::class); // Assert a job was pushed twice Queue::assertPushed(CalculatePopularity::class, 2); // Assert a job was not pushed Queue::assertNotPushed(DestroyPopularityMaybe::class);}

邮件虚拟

当伪造Mail外观时,提供四种方法:assertSent()assertNotSent()assertQueued()assertNotQueued()。当你的邮件在队列中时,请使用Queued方法;当它不在队列中时,请使用Sent方法。

就像使用assertDispatched()一样,第一个参数将是可发送邮件的名称,第二个参数可以为空,或者是可发送邮件的次数,或者是一个测试可发送邮件是否包含正确数据的闭包。查看 示例12-18 以看到这些方法的几个示例。

示例 12-18. 对邮件进行断言
public function test_package_authors_receive_launch_emails(){ Mail::fake(); // Make a package public for the first time... // Assert a message was sent to a given email address Mail::assertSent(PackageLaunched::class, function ($mail) use ($package) { return $mail->package->id === $package->id; }); // Assert a message was sent to given email addresses Mail::assertSent(PackageLaunched::class, function ($mail) use ($package) { return $mail->hasTo($package->author->email) && $mail->hasCc($package->collaborators) && $mail->hasBcc('admin@novapackages.com'); }); // Or, launch two packages... // Assert a mailable was sent twice Mail::assertSent(PackageLaunched::class, 2); // Assert a mailable was not sent Mail::assertNotSent(PackageLaunchFailed::class);}

所有检查收件人(hasTo()hasCc()hasBcc())的消息可以接受单个电子邮件地址或电子邮件地址数组或集合。

通知虚拟

当伪造Notification外观时,提供两种方法:assertSentTo()assertNothingSent()

Mail外观不同的是,你不需要在闭包中手动测试通知发送给谁。相反,断言本身要求第一个参数是单个可通知对象或其数组或集合。只有在传入所需的通知目标后,你才能测试通知本身的任何内容。

第二个参数是通知的类名,第三个参数(可选)可以是定义通知更多期望的闭包。查看 示例12-19 以了解更多信息。

示例 12-19. 通知虚拟
public function test_users_are_notified_of_new_package_ratings(){ Notification::fake(); // Perform package rating... // Assert author was notified Notification::assertSentTo( $package->author, PackageRatingReceived::class, function ($notification, $channels) use ($package) { return $notification->package->id === $package->id; } ); // Assert a notification was sent to the given users Notification::assertSentTo( [$package->collaborators], PackageRatingReceived::class ); // Or, perform a duplicate package rating... // Assert a notification was not sent Notification::assertNotSentTo( [$package->author], PackageRatingReceived::class );}

你可能还想要断言你的渠道选择是否有效——即通过正确的渠道发送通知。你也可以进行这方面的测试,正如你可以在 示例12-20 中看到的那样。

示例 12-20. 测试通知渠道
public function test_users_are_notified_by_their_preferred_channel(){ Notification::fake(); $user = User::factory()->create(['slack_preferred' => true]); // Perform package rating... // Assert author was notified via Slack Notification::assertSentTo( $user, PackageRatingReceived::class, function ($notification, $channels) use ($package) { return $notification->package->id === $package->id && in_array('slack', $channels); } );}

存储虚拟

测试文件可能会非常复杂。许多传统方法要求你实际在测试目录中移动文件,并且格式化表单输入和输出可能非常复杂。

幸运的是,如果你使用 Laravel 的Storage外观,测试文件上传和其他与存储相关的项目就会简单得多,正如 示例12-21 所演示的那样。

示例 12-21. 使用存储虚拟测试存储和文件上传
public function test_package_screenshot_upload(){ Storage::fake('screenshots'); // Upload a fake image $response = $this->postJson('screenshots', [ 'screenshot' => UploadedFile::fake()->image('screenshot.jpg'), ]); // Assert the file was stored Storage::disk('screenshots')->assertExists('screenshot.jpg'); // Or, assert a file does not exist Storage::disk('screenshots')->assertMissing('missing.jpg');}

在我们测试应用程序的各个部分与时间交互时,通常希望测试这些部分随时间推移的行为不同。

在我们的测试中,我们可以使用$this->travel()来在测试进行过程中“旅行”时间。我们可以相对于当前时间向前或向后旅行,旅行到特定时刻,或者冻结时间的流逝,这样可以测试组件在时间看起来不同的情况下的行为。

看看示例12-22 以了解您可能希望如何使用此功能,或者查看文档以了解更多与时间交互的所有方式。

示例 12-22. 改变测试中的时间
public function test_posts_are_no_longer_editable_after_thirty_minutes(){ $post = Post::create(); $this->assertTrue($post->isEditable()); $this->travel(30)->seconds(); $this->assertTrue($post->isEditable()); $this->travelTo($post->created_at->copy()->addMinutes(31)); $this->assertFalse($post->isEditable());}

您还可以为每个这些时间旅行方法提供一个闭包;如果这样做,测试的时间只在闭包的持续时间内被修改,允许您更直接地连接您的旅行和结果测试,正如您在示例12-23 中所看到的。

示例 12-23. 使用闭包在测试中改变时间
public function test_posts_are_no_longer_editable_after_thirty_minutes(){ $post = Post::create(); $this->assertTrue($post->isEditable()); $this->travel(30)->seconds(function () { $this->assertTrue($post->isEditable()); }); $this->travelTo($post->created_at->copy()->addMinutes(31), function () { $this->assertFalse($post->isEditable()); });}

在测试中,模拟(及其同类,如间谍、存根、虚拟和伪造以及其他各种工具)是很常见的。我们在前一节中看到了一些伪造的示例。我不会在这里详细展开,但是在任何规模的应用程序中,要彻底测试一个应用程序几乎不可能不模拟至少一些东西。

因此,让我们快速浏览一下在 Laravel 中如何进行模拟以及如何使用 Mockery,这个模拟库。

关于模拟的简短介绍

本质上,模拟和其他类似工具使得可以创建一个在某种程度上模仿真实类的对象,但是出于测试目的并非真实类。有时候这样做是因为实际类太难实例化以便将其注入到测试中,或者可能是因为实际类与外部服务通信。

从接下来的示例中您可能可以看出,Laravel 鼓励尽可能地与真实应用程序一起工作——这意味着避免过多依赖于模拟。但它们确实有其用处,这就是为什么 Laravel 内置了 Mockery,一个模拟库,并且为其核心服务提供了伪造工具的原因。

关于 Mockery 的简短介绍

Mockery 允许您快速轻松地从应用程序中的任何 PHP 类创建模拟。想象一下,您有一个依赖于 Slack 客户端的类,但是您不希望调用实际发送到 Slack。Mockery 使得创建一个用于测试的假 Slack 客户端变得简单,就像您在示例12-24 中所看到的。

示例 12-24. 在 Laravel 中使用 Mockery
// app/SlackClient.phpclass SlackClient{ // ... public function send($message, $channel) { // Actually sends a message to Slack }}// app/Notifier.phpclass Notifier{ private $slack; public function __construct(SlackClient $slack) { $this->slack = $slack; } public function notifyAdmins($message) { $this->slack->send($message, 'admins'); }}// tests/Unit/NotifierTest.phppublic function test_notifier_notifies_admins(){ $slackMock = Mockery::mock(SlackClient::class)->shouldIgnoreMissing(); $notifier = new Notifier($slackMock); $notifier->notifyAdmins('Test message');}

这里有很多因素在起作用,但如果您逐一查看它们,它们是有道理的。我们有一个名为Notifier的类,我们正在测试它。它有一个名为SlackClient的依赖项,在我们运行测试时,我们不希望它执行某些操作:发送实际的 Slack 通知。因此,我们将对其进行模拟。

我们使用 Mockery 来获取我们的SlackClient类的模拟。如果我们不关心该类发生什么——如果它只是存在以防止我们的测试抛出错误——我们可以简单地使用shouldIgnoreMissing()

$slackMock = Mockery::mock(SlackClient::class)->shouldIgnoreMissing();

无论Notifier$slackMock上调用什么,它都会接受并返回null

但是看一下test_notifier_notifies_admins()。到这一点为止,它实际上并不测试任何东西。

我们可以只保留shouldIgnoreMissing(),然后在其下写一些断言。通常我们对shouldIgnoreMissing()这样做,这使得这个对象成为一个“伪造”或“存根”。

但是如果我们想要实际断言SlackClientsend()方法是否被调用了呢?这时我们放弃了shouldIgnoreMissing(),转而使用其他的should*方法(示例12-25)。

示例 12-25. 使用 Mockery 模拟的shouldReceive()方法
public function test_notifier_notifies_admins(){ $slackMock = Mockery::mock(SlackClient::class); $slackMock->shouldReceive('send')->once(); $notifier = new Notifier($slackMock); $notifier->notifyAdmins('Test message');}

shouldReceive('send')->once()等同于说“断言$slackMocksend()方法将被调用一次且仅一次。” 因此,我们现在断言Notifier在调用notifyAdmins()时,会在SlackClient上的send()方法上做一次调用。

我们也可以使用诸如shouldReceive('send')->times(3)shouldReceive('send')->never()之类的方法。我们可以使用with()定义我们期望随send()调用传递的参数,并使用andReturn()定义返回值:

$slackMock->shouldReceive('send')->with('Hello, world!')->andReturn(true);

如果我们想要使用 IoC 容器来解析我们的Notifier实例会怎么样?如果Notifier有几个其他依赖项我们不需要模拟,这可能会很有用。

我们可以这样做!只需在容器上使用instance()方法,就像在示例12-26 中一样,告诉 Laravel 为请求它的任何类提供我们模拟的实例(在本例中将是Notifier)。

示例 12-26. 将 Mockery 实例绑定到容器
public function test_notifier_notifies_admins(){ $slackMock = Mockery::mock(SlackClient::class); $slackMock->shouldReceive('send')->once(); app()->instance(SlackClient::class, $slackMock); $notifier = app(Notifier::class); $notifier->notifyAdmins('Test message');}

还有一个便捷的快捷方式,可以创建并绑定一个 Mockery 实例到容器中(示例12-27):

示例 12-27. 更轻松地将 Mockery 实例绑定到容器
$this->mock(SlackClient::class, function ($mock) { $mock->shouldReceive('send')->once();});

使用 Mockery 还有很多事情可以做:你可以使用间谍,部分间谍,等等。深入探讨如何使用 Mockery 超出了本书的范围,但我鼓励你通过阅读Mockery 文档来更多地了解这个库及其工作原理。

伪造其他外观

使用 Mockery 还有一个聪明的事情:您可以在应用程序中的任何外观上使用 Mockery 方法(例如shouldReceive())。

假设我们有一个控制器方法,使用了一个不是我们已经覆盖过的可伪造系统的外观;我们想要测试该控制器方法,并断言某个外观调用确实被执行。

幸运的是,这很简单:我们可以在外观上运行我们 Mockery 风格的方法,正如你在示例12-28 中所见。

示例 12-28. 模拟外观
// PersonControllerpublic function index(){ return Cache::remember('people', function () { return Person::all(); });}// PeopleTestpublic function test_all_people_route_should_be_cached(){ $person = Person::factory()->create(); Cache::shouldReceive('remember') ->once() ->andReturn(collect([$person])); $this->get('people')->assertJsonFragment(['name' => $person->name]);}

如您所见,您可以在外观上使用像shouldReceive()这样的方法,就像在Mockery对象上一样。

您还可以将您的外观用作间谍,这意味着您可以在最后设置您的断言,并使用shouldHaveReceived()而不是shouldReceive()。示例12-29 说明了这一点。

示例 12-29. 外观间谍
public function test_package_should_be_cached_after_visit(){ Cache::spy(); $package = Package::factory()->create(); $this->get(route('packages.show', [$package->id])); Cache::shouldHaveReceived('put') ->once() ->with('packages.' . $package->id, $package->toArray());}

你还可以部分模拟外观,就像你在 示例12-30 中看到的那样。

示例 12-30. 部分模拟外观
// Full mockCustomFacade::shouldReceive('someMethod')->once();CustomFacade::someMethod();CustomFacade::anotherMethod(); // Fails// Partial mockCustomFacade::partialMock()->shouldReceive('someMethod')->once();CustomFacade::someMethod(); // Uses the mocked objectCustomFacade::anotherMethod(); // Uses the method on the actual Facade

在本章中,我们已经涵盖了很多内容,但我们快要完成了!我们还有 Laravel 测试工具的三个要点要讲解:Artisan、并行测试和浏览器测试。

测试 Artisan 命令的最佳方式是使用 $this->artisan(*$commandName*, *$parameters*) 来调用它们,然后测试它们的影响,就像在 示例12-31 中所示。

示例 12-31. 简单的 Artisan 测试
public function test_promote_console_command_promotes_user(){ $user = User::factory()->create(); $this->artisan('user:promote', ['userId' => $user->id]); $this->assertTrue($user->isPromoted());}

你可以对来自 Artisan 的响应代码进行断言,就像在 示例12-32 中看到的那样。

示例 12-32. 手动断言 Artisan 退出码
$code = $this->artisan('do:thing', ['--flagOfSomeSort' => true]);$this->assertEquals(0, $code); // 0 means "no errors were returned"

你还可以在 $this->artisan() 调用上链式调用三个方法:expectsQuestion()expectsOutput()assertExitCode()expects* 方法可以处理任何交互提示,包括 confirm()anticipate(),而 assertExitCode() 方法是 示例12-32 中所见的快捷方式。

看看 示例12-33 来了解它的工作原理。

示例 12-33. 基本的 Artisan “expects” 测试
// routes/console.phpArtisan::command('make:post {--expanded}', function () { $title = $this->ask('What is the post title?'); $this->comment('Creating at ' . Str::slug($title) . '.md'); $category = $this->choice('What category?', ['technology', 'construction'], 0); // Create post here $this->comment('Post created');});
// Test filepublic function test_make_post_console_commands_performs_as_expected(){ $this->artisan('make:post', ['--expanded' => true]) ->expectsQuestion('What is the post title?', 'My Best Post Now') ->expectsOutput('Creating at my-best-post-now.md') ->expectsQuestion('What category?', 'construction') ->expectsOutput('Post created') ->assertExitCode(0);}

正如你所见,expectsQuestion() 的第一个参数是我们期望从问题中看到的文本,第二个参数是我们的答案文本。expectsOutput() 只是测试返回的字符串是否正确。

默认情况下,Laravel 中的测试在单线程中运行。测试越多,且越复杂,你的测试套件运行时间就越长,这可能会显著影响团队运行测试套件的可能性。

如果你想加快测试套件的运行速度,你可以并行运行你的测试。你需要安装一个叫做 paratest 的依赖:

composer require brianium/paratest --dev

安装了 paratest 后,你可以使用 --parallel 标志并行运行你的测试,就像在 示例12-34 中看到的那样。

示例 12-34. 并行运行测试
# Use as many processes as your CPU can offerphp artisan test --parallel# Specify the desired number of processesphp artisan test --parallel --processes=3

我们已经到了浏览器测试!这使得你可以实际与页面的 DOM 进行交互:在浏览器测试中,你可以点击按钮、填写并提交表单,甚至与 JavaScript 交互。

选择工具

对于非单页面应用的浏览器测试,我建议你使用 Dusk。如果你正在处理单页面应用或一些 JavaScript 重的应用程序,它们可能更适合使用前端测试套件,这超出了本书的范围。

使用 Dusk 进行测试

Dusk 是 Laravel 的一个工具(可安装为 Composer 包),它可以轻松地将嵌入式 Google Chrome 实例(称为 ChromeDriver)指向你的应用程序进行交互。Dusk 的 API 简单易用,你可以轻松编写手动与其交互的代码。看一看:

$this->browse(function ($browser) { $browser->visit('/register') ->type('email', 'test@example.com') ->type('password', 'secret') ->press('Sign Up') ->assertPathIs('/dashboard');});

使用 Dusk,实际上有一个浏览器启动您的整个应用程序并与之交互。这意味着您可以与您的 JavaScript 进行复杂的交互,并获取失败状态的截图——但这也意味着一切都会慢一些,并且比 Laravel 的基本应用程序测试套件更容易出现故障。

就个人而言,我发现 Dusk 最有用作回归测试套件,并且它比像 Selenium 这样的工具更有效。我不是用它来进行任何类型的测试驱动开发,而是用它来断言用户体验在应用程序继续开发过程中没有“退化”。可以将其视为在界面构建完成后编写有关用户界面的测试。

Dusk 文档 非常丰富,因此我在这里不会深入讨论,但我想向您展示如何使用 Dusk 的基础知识。

安装 Dusk

要安装 Dusk,请运行以下两个命令:

composer require --dev laravel/duskphp artisan dusk:install

然后编辑您的 .env 文件,将 APP_URL 变量设置为与您本地浏览器查看站点相同的 URL;例如 http://mysite.test

要运行您的 Dusk 测试,只需运行 php artisan dusk。您可以传递从 PHPUnit 中习惯的所有相同参数(例如,php artisan dusk --filter=my_best_test)。

编写 Dusk 测试

要生成新的 Dusk 测试,请使用以下命令:

php artisan dusk:make RatingTest

此测试将放置在 tests/Browser/RatingTest.php 中。

您可以通过创建一个名为 .env.dusk.local 的新文件来自定义 Dusk 的环境变量(如果您在不同的环境中工作,如“staging”,则可以替换 .local)。

要编写您的 Dusk 测试,请想象您正在指导一个或多个 Web 浏览器访问您的应用程序并执行某些操作。这就是语法的样子,正如您可以在 示例12-35 中看到的那样。

示例 12-35. 一个简单的 Dusk 测试
public function testBasicExample(){ $user = User::factory()->create(); $this->browse(function ($browser) use ($user) { $browser->visit('login') ->type('email', $user->email) ->type('password', 'secret') ->press('Login') ->assertPathIs('/home'); });}

$this->browse() 创建一个浏览器,您将其传递给一个闭包;然后,在闭包内,您指示浏览器执行哪些操作。

需要注意的是——与 Laravel 的其他应用程序测试工具不同,这些工具模仿您的表单行为——Dusk 实际上正在启动浏览器,并向浏览器发送事件以输入这些文字,然后发送事件以按下该按钮。这是一个真实的浏览器,Dusk 完全驱动它。

您还可以通过向闭包添加参数“请求”更多浏览器,这样可以测试多个用户如何与网站交互(例如,使用聊天系统)。请参阅文档中的 示例12-36。

示例 12-36. 多个 Dusk 浏览器
$this->browse(function ($first, $second) { $first->loginAs(User::find(1)) ->visit('home') ->waitForText('Message'); $second->loginAs(User::find(2)) ->visit('home') ->waitForText('Message') ->type('message', 'Hey Taylor') ->press('Send'); $first->waitForText('Hey Taylor') ->assertSee('Jeffrey Way');});

这里有大量的操作和断言可用,我们在此不会详细介绍(请查看文档),但让我们看看 Dusk 提供的其他一些工具。

身份验证和数据库

正如您在示例 12-36 中所见,身份验证的语法与 Laravel 应用测试的其余部分有所不同:$browser->loginAs(*$user*)

不要与 Dusk 一起使用RefreshDatabase特性!改用DatabaseMigrations特性;RefreshDatabase使用的事务在请求间不会持久化。

与页面的交互

如果您曾经编写过 jQuery,则使用 Dusk 与页面进行交互会非常自然。查看示例 12-37 以了解使用 Dusk 选择项目的常见模式。

示例 12-37。使用 Dusk 选择项目
<-- Template --><div class="search"><input><button id="search-button"></button></div><button dusk="expand-nav"></button>
// Dusk tests// Option 1: jQuery-style syntax$browser->click('.search button');$browser->click('#search-button');// Option 2: dusk="selector-here" syntax; recommended$browser->click('@expand-nav');

正如您可以看到的那样,将dusk属性添加到您的页面元素中允许您以一种不会在以后显示或布局更改时改变的方式直接引用它们;当任何方法要求选择器时,请传递@符号,然后是您的dusk属性的内容。

让我们来看看您可以在$browser上调用的一些方法。

要处理文本和属性值,请使用以下方法:

value(*$selector, $value = null*)

如果只传递一个参数,则返回任何文本输入的值;如果传递第二个参数,则设置输入的值。

text(*$selector*)

获取非填充项(如<div><span>)的文本内容。

attribute(*$selector, $attributeName*)

返回与匹配*$selector*的元素上的特定属性的值。

用于处理表单和文件的方法包括以下内容:

type(*$selector, $valueToType*)

类似于value(),但实际上输入字符而不是直接设置值。

使用诸如type()之类的方法来定位输入时,Dusk 将首先尝试匹配一个 Dusk 或 CSS 选择器,然后将查找具有提供的名称的输入,最后将尝试查找具有提供的名称的<textarea>

select(*$selector, $optionValue*)

在可由*$selector*选择的下拉选择中选择值为*$optionValue*的选项。

check(*$selector*)uncheck(*$selector*)

检查或取消检查由*$selector*选择的复选框。

radio(*$selector, $optionValue*)

在可由*$selector*选择的单选组中选择值为*$optionValue*的选项。

attach(*$selector, $filePath*)

将文件附加到由*$selector*选择的文件输入中的*$filePath*

用于键盘和鼠标输入的方法有:

clickLink(*$selector*)

跟随文本链接到其目标。

click(*$selector*)mouseover(*$selector*)

触发鼠标在*$selector*上的点击或悬停事件。

drag(*$selectorToDrag, $selectorToDragTo*)

将一个项目拖动到另一个项目。

dragLeft()dragRight()dragUp()dragDown()

给定选择器的第一个参数和像素数的第二个参数,向给定方向拖动所选项目的这么多像素。

keys(*$selector, $instructions*)

根据*$instructions*中的指示在*$selector*的上下文中发送键按下事件。您甚至可以将修饰符与键入组合:

$browser->keys('selector', 'this is ', ['{shift}', 'great']);

这将键入“this is GREAT。”正如您所见,将数组添加到要键入的项目列表中允许您将修饰符(用{}包装)与键入组合。您可以在Facebook WebDriver source中看到所有可能的修饰符列表。

如果您只想将您的键序列发送到页面(例如触发键盘快捷键),则可以将您的应用或页面的顶级作为选择器。例如,如果它是一个 Vue 应用程序,并且顶级是 ID 为app<div>

$browser->keys('#app', ['{command}', '/']);

等待中

因为 Dusk 与 JavaScript 交互,并且正在操作实际的浏览器,所以需要解决时间和超时以及“等待”的概念。Dusk 提供了几种方法,可以确保您的测试正确处理时间问题。其中一些方法对于与页面中故意缓慢或延迟的元素交互非常有用,但有些方法也仅仅是为了解决组件初始化时间。可用的方法包括以下内容:

pause(*$milliseconds*)

暂停 Dusk 测试的执行,以毫秒为单位。这是最简单的“等待”选项;它会使您发送给浏览器的未来命令在操作之前等待该时间量。

您可以在断言链中间使用此方法和其他等待方法,如下所示:

$browser->click('chat') ->pause(500) ->assertSee('How can we help?');

waitFor(*$selector, $maxSeconds = null*)waitUntilMissing(*$selector*, *$maxSeconds* *= null*)

等待页面上存在给定元素(waitFor())或消失(waitUntilMissing()),或在可选的第二个参数的秒数后超时:

$browser->waitFor('@chat', 5);$browser->waitUntilMissing('@loading', 5);

whenAvailable(*$selector, $callback*)

类似于waitFor(),但接受闭包作为第二个参数,该闭包将定义在指定元素可用时要执行的操作:

$browser->whenAvailable('@chat', function ($chat) { $chat->assertSee('How can we help you?');});

waitForText(*$text, $maxSeconds = null*)

等待文本显示在页面上,或在可选的第二个参数的秒数后超时:

$browser->waitForText('Your purchase has been completed.', 5);

waitForLink(*$linkText, $maxSeconds = null*)

等待具有给定链接文本的链接存在,或在可选的第二个参数的秒数后超时:

$browser->waitForLink('Clear these results', 2);

waitForLocation(*$path*)

等待直到页面 URL 与提供的路径匹配:

$browser->waitForLocation('auth/login');

waitForRoute(*$routeName*)

等待直到页面 URL 与提供的路由的 URL 匹配:

$browser->waitForRoute('packages.show', [$package->id]);

waitForReload()

等待页面重新加载。

waitUntil(*$expression*)

等待直到提供的 JavaScript 表达式评估为 true:

$browser->waitUntil('App.packages.length > 0', 7);

其他断言

正如我提到的,您可以使用 Dusk 对应用程序进行大量断言。以下是我最常用的一些——您可以在Dusk 文档中看到完整列表:

  • assertTitleContains(*$text*)

  • assertQueryStringHas(*$keyName*)

  • assertHasCookie(*$cookieName*)

  • assertSourceHas(*$htmlSourceCode*)

  • assertChecked(*$selector*)

  • assertSelectHasOption(*$selectorForSelect, $optionValue*)

  • assertVisible(*$selector*)

  • assertFocused()

  • assertVue(*$dataLocation, $dataValue, $selector*)

其他组织结构

到目前为止,我们所覆盖的内容使得我们能够测试页面上的各个元素。但是,我们经常会使用 Dusk 来测试更复杂的应用程序和单页面应用程序,这意味着我们需要围绕我们的断言建立组织结构。

我们遇到的第一个组织结构是 dusk 属性(例如,<div dusk="abc">,创建一个名为 @abc 的选择器,我们稍后可以引用它)和我们可以用来包装代码特定部分的闭包(例如,使用 when Available())。

Dusk 提供了另外两个组织工具:页面和组件。让我们从页面开始。

页面

页面是一个你将生成的类,包含两个功能部分:首先是一个 URL 和断言,用来定义应用程序中应该附加到这个 Dusk 页面的页面;其次是像我们在内联中使用的快捷方式(由我们 HTML 中的 dusk="abc" 属性生成的 @abc 选择器),但只针对这个页面,而无需编辑我们的 HTML。

让我们想象一下我们的应用程序有一个“创建包裹”页面。我们可以按照以下步骤生成一个 Dusk 页面:

php artisan dusk:page CreatePackage

查看 示例12-38 以查看我们生成的类会是什么样子。

示例 12-38. 生成的 Dusk 页面
<?phpnamespace Tests\Browser\Pages;use Laravel\Dusk\Browser;class CreatePackage extends Page{ /** * Get the URL for the page * * @return string */ public function url() { return '/'; } /** * Assert that the browser is on the page * * @param Browser $browser * @return void */ public function assert(Browser $browser) { $browser->assertPathIs($this->url()); } /** * Get the element shortcuts for the page * * @return array */ public function elements() { return [ '@element' => '#selector', ]; }}

url() 方法定义了 Dusk 应该期望此页面存在的位置,assert() 让您可以运行额外的断言来验证您是否在正确的页面上,而 elements() 则为 @dusk 风格的选择器提供了快捷方式。

让我们快速修改一下我们的“创建包裹”页面,使其看起来像 示例12-39。

示例 12-39. 一个简单的“创建包裹”Dusk 页面
class CreatePackage extends Page{ public function url() { return '/packages/create'; } public function assert(Browser $browser) { $browser->assertTitleContains('Create Package'); $browser->assertPathIs($this->url()); } public function elements() { return [ '@title' => 'input[name=title]', '@instructions' => 'textarea[name=instructions]', ]; }}

现在我们有了一个可用的页面,我们可以导航到它并访问其定义的元素:

// In a test$browser->visit(new Tests\Browser\Pages\CreatePackage) ->type('@title', 'My package title');

页面的一个常见用途是定义您希望在测试中执行的常见操作;可以将其视为 Dusk 的宏。您可以在页面上定义一个方法,然后从您的代码中调用它,就像在 示例12-40 中所看到的。

示例 12-40. 定义并使用自定义页面方法
class CreatePackage extends Page{ // ... url(), assert(), elements() public function fillBasicFields(Browser $browser, $packageTitle = 'Best package') { $browser->type('@title', $packageTitle) ->type('@instructions', 'Do this stuff and then that stuff'); }}
$browser->visit(new CreatePackage) ->fillBasicFields('Greatest Package Ever') ->press('Create Package') ->assertSee('Greatest Package Ever');

组件

如果你希望得到与 Dusk 页面提供的相同功能,但不限于特定的 URL,你可能想要使用 Dusk 组件。这些类与页面非常相似,但不是绑定到 URL,而是绑定到选择器。

NovaPackages.com 中,我们有一个用于评分包和显示评分的小 Vue 组件。让我们为它创建一个 Dusk 组件:

php artisan dusk:component RatingWidget

查看 示例12-41 以查看将生成什么。

示例 12-41. 生成的 Dusk 组件的默认源
<?phpnamespace Tests\Browser\Components;use Laravel\Dusk\Browser;use Laravel\Dusk\Component as BaseComponent;class RatingWidget extends BaseComponent{ /** * Get the root selector for the component * * @return string */ public function selector() { return '#selector'; } /** * Assert that the browser page contains the component * * @param Browser $browser * @return void */ public function assert(Browser $browser) { $browser->assertVisible($this->selector()); } /** * Get the element shortcuts for the component * * @return array */ public function elements() { return [ '@element' => '#selector', ]; }}

如你所见,这与 Dusk 页面基本相同,但我们将工作封装到 HTML 元素而不是 URL 中。其他方面基本相同。请看示例12-42,看看我们的评分小部件在 Dusk 组件形式中的例子。

示例 12-42. 一个用于评分小部件的 Dusk 组件
class RatingWidget extends BaseComponent{ public function selector() { return '.rating-widget'; } public function assert(Browser $browser) { $browser->assertVisible($this->selector()); } public function elements() { return [ '@5-star' => '.five-star-rating', '@4-star' => '.four-star-rating', '@3-star' => '.three-star-rating', '@2-star' => '.two-star-rating', '@1-star' => '.one-star-rating', '@average' => '.average-rating', '@mine' => '.current-user-rating', ]; } public function ratePackage(Browser $browser, $rating) { $browser->click("@{$rating}-star") ->assertSeeIn('@mine', $rating); }}

使用组件的方式与使用页面的方式完全相同,就像你在示例12-43 中看到的那样。

示例 12-43. 使用 Dusk 组件
$browser->visit('/packages/tightenco/nova-stock-picker') ->within(new RatingWidget, function ($browser) { $browser->ratePackage(2); $browser->assertSeeIn('@average', 2); });

这是 Dusk 可以做的一个很好的简要概述。还有更多内容——更多断言、更多边缘案例、更多陷阱、更多示例——详见Dusk 文档,如果你计划使用 Dusk,我建议你仔细阅读一下。

Pest 是 Laravel 的第三方测试框架。它是建立在 PHPUnit 之上的一层,提供定制的控制台输出、简单的并行测试和代码覆盖率、架构测试等功能。

Pest 还提供了不同的测试语法,受 Ruby 的 RSpec 启发。你可以使用 Pest 并享受其所有好处,而无需切换到其独特的测试语法,但如果你确实想试试看,可以参考示例12-44 来看看语法是如何的。

示例 12-44. Pest 语法示例
it('has a welcome page', function () { $response = $this->get('/'); expect($response->status())->toBe(200);});

欲了解更多关于 Pest 的信息,请访问pestphp.com

Laravel 可以与任何现代 PHP 测试框架一起使用,但它对 PHPUnit 进行了优化(特别是如果你的测试扩展了 Laravel 的TestCase)。Laravel 的应用程序测试框架使得通过应用程序发送伪造的 HTTP 和控制台请求并检查结果变得简单。

Laravel 中的测试可以轻松而强大地与数据库、缓存、会话、文件系统、邮件和许多其他系统进行交互和断言。其中很多系统都有内置的伪造功能,使测试变得更加简单。你可以使用 Dusk 测试 DOM 和类似浏览器的交互。

Laravel 为了模拟、存根、监听器、虚拟对象或其他任何内容,引入了 Mockery,但 Laravel 的测试哲学是尽可能使用真实的协作对象。除非必要,否则不要伪造。

Laravel 开发者面临的最常见任务之一是创建 API,通常是 JSON 和 REST 或类似 REST 的,允许第三方与 Laravel 应用程序的数据进行交互。

Laravel 让与 JSON 工作变得非常容易,并且它的资源控制器已经围绕 REST 动词和模式进行了结构化。在本章中,您将学习一些基本的 API 编写概念,Laravel 提供的编写 API 的工具,以及在编写您的第一个 Laravel API 时需要考虑的一些外部工具和组织系统。

表述性状态转移(REST)是一种用于构建 API 的架构风格。技术上来说,REST 或者是一个广义定义,几乎可以适用于整个互联网,或者是一个如此具体的东西,以至于没有人真正使用它,所以不要让自己被定义或与书呆子争论所困扰。在 Laravel 的世界中,当我们谈论 RESTful 或类似 REST 的 API 时,通常是指具有以下几个共同特征的 API:

  • 它们围绕可由 URI 唯一表示的“资源”进行组织,比如 /cats 表示所有猫,/cats/15 表示 ID 为 15 的单个猫等。

  • 主要使用 HTTP 动词(GET /cats/15DELETE /cats/15)与资源进行交互。

  • 它们是无状态的,这意味着请求之间没有持久的会话身份验证;每个请求必须唯一验证自己。

  • 它们是可缓存且一致的,这意味着每个请求(除了少数特定于经过身份验证的用户的请求)无论请求者是谁,都应该返回相同的结果。

  • 它们返回 JSON。

最常见的 API 模式是为每个 Eloquent 模型创建一个唯一的 URL 结构,将其公开为 API 资源,并允许用户使用特定的动词与该资源进行交互并获取 JSON 返回。示例13-1 展示了一些可能的示例。

示例 13-1. 常见的 REST API 端点结构
GET /api/cats[ { id: 1, name: 'Fluffy' }, { id: 2, name: 'Killer' }]GET /api/cats/2{ id: 2, name: 'Killer'}POST /api/cats with body:{ name: 'Mr Bigglesworth'}(creates new cat)PATCH /api/cats/3 with body:{ name: 'Mr. Bigglesworth'}(updates cat)DELETE /api/cats/2(deletes cat)

这让你了解到我们可能与 API 交互的基本集合。让我们深入了解如何通过 Laravel 实现它们。

Laravel 的 API 资源控制器类似于普通资源控制器(参见“资源控制器”),但修改为与 RESTful API 路由对齐。例如,它们排除了 create()edit() 方法,这两者在 API 中是不相关的。让我们从这里开始。首先,我们将为我们的资源创建一个新的控制器,并将其路由到 /api/dogs

php artisan make:controller Api/DogController --api

示例13-2 展示了我们的 API 资源控制器的样子。

示例 13-2. 生成的 API 资源控制器
<?phpnamespace App\Http\Controllers\Api;use Illuminate\Http\Request;use App\Http\Controllers\Controller;class DogController extends Controller{ /** * Display a listing of the resource. */ public function index() { // } /** * Store a newly created resource in storage. */ public function store(Request $request) { // } /** * Display the specified resource. */ public function show(string $id) { // } /** * Update the specified resource in storage. */ public function update(Request $request, string $id) { // } /** * Remove the specified resource from storage. */ public function destroy(string $id) { // }}

文档块几乎讲述了整个故事。index() 列出所有狗,show() 列出单个狗,store() 存储新狗,update() 更新狗,destroy() 删除狗。

让我们快速制作一个模型和一个迁移,以便我们可以处理它:

php artisan make:model Dog --migrationphp artisan migrate

太棒了!现在我们可以填充我们的控制器方法了。

如果您希望我们在这里编写的代码实际上起作用,您将希望在迁移中添加一个名为namestring()列,另一个名为breed,并将这些列添加到 Eloquent 模型的fillable属性,或者只是将该模型的guarded属性设置为空数组([])。稍后的示例还将需要weightcolor的列,以及bonesfriends的关系。

我们可以利用 Eloquent 的一个很棒的特性:如果您输出一个 Eloquent 结果集合,它会自动将自己转换为 JSON(使用__toString()魔术方法,如果您感兴趣的话)。这意味着,如果您从路由返回一个结果集合,您实际上将返回 JSON。因此,正如示例13-3 所示,这将是您写过的一些最简单的代码。

示例 13-3. Dog实体的示例 API 资源控制器
...class DogController extends Controller{ public function index() { return Dog::all(); } public function store(Request $request) { return Dog::create($request->only(['name', 'breed'])); } public function show(string $id) { return Dog::findOrFail($id); } public function update(Request $request, string $id) { $dog = Dog::findOrFail($id); $dog->update($request->only(['name', 'breed'])); return $dog; } public function destroy(string $id) { Dog::findOrFail($id)->delete(); }}

Artisan 的make:model命令还有一个--api标志,您可以传递以生成与上述相同的 API 特定控制器:

php artisan make:model Dog --api

如果您想要一次性生成迁移、seeder、factory、policy、资源控制器以及存储和更新表单请求,并在一条命令中使用--all标志:

php artisan make:model Dog --all

示例13-4 展示了我们如何在路由文件中链接它。正如您所见,我们可以使用Route::apiResource()自动将所有这些默认方法映射到相应的路由和 HTTP 动词。

示例 13-4. 绑定资源控制器的路由
// routes/api.phpRoute::namespace('App\Http\Controllers\Api')->group(function () { Route::apiResource('dogs', DogController::class);});

就是这样!您的第一个 Laravel RESTful API。当然,您需要更多的细微差别:分页、排序、认证和更好定义的响应头。但这是其他一切的基础。

REST API 通常使用标头读取和发送非内容信息。例如,对 GitHub 的任何 API 请求都将返回详细说明当前用户的速率限制状态的标头:

X-RateLimit-Limit: 5000X-RateLimit-Remaining: 4987X-RateLimit-Reset: 1350085394

同样,许多 API 允许开发人员使用请求头自定义其请求。例如,GitHub 的 API 使用Accept头很容易定义要使用的 API 版本:

Accept: application/vnd.github.v3+json

如果您将v3更改为v2,GitHub 将将您的请求传递到其 API 的第 2 版本。

让我们快速学习如何在 Laravel 中同时做这两件事。

在 Laravel 中发送响应标头

我们在第十章已经详细讨论了这个主题,但这里是一个快速的复习。一旦您有了一个响应对象,您可以使用header(*$headerName*, *$headerValue*)添加一个标头,就像在示例13-5 中所见。

示例 13-5. 在 Laravel 中添加响应头
Route::get('dogs', function () { return response(Dog::all()) ->header('X-Greatness-Index', 12);});

很简单易行。

在 Laravel 中读取请求标头

如果您有一个传入请求,读取任何给定标头也很简单。示例 13-6说明了这一点。

示例 13-6。在 Laravel 中读取请求标头
Route::get('dogs', function (Request $request) { var_dump($request->header('Accept'));});

现在您可以读取传入的请求标头并在 API 响应中设置标头了,让我们看看如何自定义您的 API。

分页是大多数 API 需要考虑特殊说明的第一个地方。Eloquent 提供了一个分页系统,直接连接到任何页面请求的查询参数。我们在第六章中已经简要介绍了分页组件,但这里是一个快速的复习。

任何 Eloquent 调用都提供了一个paginate()方法,您可以在其中传递希望每页返回的项目数。然后,Eloquent 会检查页面查询参数的 URL,并且如果设置了,将其视为用户在分页列表中的位置(多少页)的指示器。

要使您的 API 路由准备好自动化的 Laravel 分页,请在调用 Eloquent 查询的路由中使用paginate()而不是all()get();类似于示例 13-7

示例 13-7。一个分页的 API 路由
Route::get('dogs', function () { return Dog::paginate(20);});

我们已经定义了 Eloquent 应该从数据库中获取 20 个结果。根据page查询参数设置的内容,Laravel 将准确知道为我们拉取哪 20 个结果:

GET /dogs - Return results 1-20GET /dogs?page=1 - Return results 1-20GET /dogs?page=2 - Return results 21-40

注意,paginate()方法也适用于查询构建器调用,如示例 13-8所示。

示例 13-8。在查询构建器调用中使用paginate()方法
Route::get('dogs', function () { return DB::table('dogs')->paginate(20);});

不过,这里有一些有趣的地方:当您将其转换为 JSON 时,它不仅会返回 20 个结果。相反,它将构建一个响应对象,自动向最终用户传递一些有用的与分页相关的详细信息,并且从我们的调用中显示可能的响应,缩减为仅三条记录以节省空间。

示例 13-9。来自分页数据库调用的示例输出
{ "current_page": 1, "data": [ { 'name': 'Fido' }, { 'name': 'Pickles' }, { 'name': 'Spot' } ] "first_page_url": "http://myapp.com/api/dogs?page=1", "from": 1, "last_page": 2, "last_page_url": "http://myapp.com/api/dogs?page=2", "links": [ { "url": null, "label": "&laquo; Previous", "active": false }, { "url": "http://myapp.com/api/dogs?page=1", "label": "1", "active": true }, { "url": null, "label": "Next &raquo;", "active": false } ], "next_page_url": "http://myapp.com/api/dogs?page=2", "path": "http://myapp.com/api/dogs", "per_page": 20, "prev_page_url": null, "to": 2, "total": 4}

尽管 Laravel 中有关于分页的约定和一些内置工具,但没有关于排序的内容,因此您必须自己解决。我将在这里快速给出一个代码示例,并且我将类似于 JSON API 规范(在下面的侧边栏中描述)样式化查询参数。

您的 API 结果排序

首先,让我们设置排序结果的能力。我们从示例 13-10开始,只能按单列和单方向排序。

示例 13-10。最简单的 API 排序
// Handles /dogs?sort=nameRoute::get('dogs', function (Request $request) { // Get the sort query parameter (or fall back to default sort "name") $sortColumn = $request->input('sort', 'name'); return Dog::orderBy($sortColumn)->paginate(20);});

我们在示例 13-11中添加了反转的能力(例如?sort=-weight)。

示例 13-11。单列 API 排序,带有方向控制
// Handles /dogs?sort=name and /dogs?sort=-nameRoute::get('dogs', function (Request $request) { // Get the sort query parameter (or fall back to default sort "name") $sortColumn = $request->input('sort', 'name'); // Set the sort direction based on whether the key starts with - // using Laravel's starts_with() helper function $sortDirection = str_starts_with($sortColumn, '-') ? 'desc' : 'asc'; $sortColumn = ltrim($sortColumn, '-'); return Dog::orderBy($sortColumn, $sortDirection) ->paginate(20);});

最后,在示例13-12 中,我们也为多列(例如,?sort=name,-weight)执行相同操作。

示例 13-12. JSON API 风格的排序
// Handles ?sort=name,-weightRoute::get('dogs', function (Request $request) { // Grab the query parameter and turn it into an array exploded by , $sorts = explode(',', $request->input('sort', '')); // Create a query $query = Dog::query(); // Add the sorts one by one foreach ($sorts as $sortColumn) { $sortDirection = str_starts_with($sortColumn, '-') ? 'desc' : 'asc'; $sortColumn = ltrim($sortColumn, '-'); $query->orderBy($sortColumn, $sortDirection); } // Return return $query->paginate(20);});

正如您所看到的,这并不是最简单的过程,您可能希望围绕重复的过程构建一些辅助工具,但我们正在逐步构建 API 的可定制性,使用逻辑和简单的功能。

过滤您的 API 结果

在构建 API 时,另一个常见任务是仅过滤出特定数据子集。例如,客户端可能会要求列出吉娃娃犬的列表。

JSON API 在这里没有为我们提供任何优秀的语法建议,除了我们应该使用filter查询参数。让我们沿着排序语法的思路,将所有内容放入单一键中——也许是?filter=breed:chihuahua。您可以在示例13-13 中看到如何做到这一点。

示例 13-13. API 结果的单个过滤器
Route::get('dogs', function () { $query = Dog::query(); $query->when(request()->filled('filter'), function ($query) { [$criteria, $value] = explode(':', request('filter')); return $query->where($criteria, $value); }); return $query->paginate(20);});

注意,在示例13-13 中,我们使用request()辅助函数而不是注入$request实例。两者功能相同,但有时在闭包内工作时,request()辅助函数可能更方便,这样您就不必手动传递变量。

而且,仅仅是为了好玩,在示例13-14 中,我们允许多个过滤器,例如?filter=breed:chihuahua,color:brown

示例 13-14. API 结果的多个过滤器
Route::get('dogs', function (Request $request) { $query = Dog::query(); $query->when(request()->filled('filter'), function ($query) { $filters = explode(',', request('filter')); foreach ($filters as $filter) { [$criteria, $value] = explode(':', $filter); $query->where($criteria, $value); } return $query; }); return $query->paginate(20);});

我们已经介绍了如何对结果集进行排序和过滤。但现在,我们依赖于 Eloquent 的 JSON 序列化,这意味着我们会返回每个模型的每个字段。

当您序列化一个数组时,Eloquent 提供了一些便捷工具来定义应显示哪些字段。您可以在第五章中阅读更多内容,但其主要思想是,如果您在 Eloquent 类上设置了$hidden数组属性,则该数组中列出的任何字段都不会显示在序列化的模型输出中。您还可以设置一个$visible数组,定义允许显示的字段。或者您还可以覆盖或模仿模型上的toArray()函数,以制定自定义输出格式。

另一个常见模式是为每种数据类型创建一个转换器。转换器很有帮助,因为它们让您拥有更多控制权,将与 API 特定逻辑隔离开来,使模型本身更一致,即使模型及其关系在未来发生变化。

有一个非常棒但复杂的软件包,Fractal,它设置了一系列方便的结构和类来转换您的数据。

在过去,当我们在 Laravel 中开发 API 时,我们遇到的第一个挑战之一是如何转换我们的数据。最简单的 API 可以将 Eloquent 对象作为 JSON 返回,但是大多数 API 很快就会超出这种结构的需求。我们应该如何将我们的 Eloquent 结果转换为正确的格式?如果我们想要嵌入其他资源或只在需要时这样做,或者添加计算字段或隐藏某些字段不在 API 中显示,但在其他 JSON 输出中显示呢?API 特定的转换器是解决方案。

现在我们可以访问一个名为Eloquent API 资源的功能,它们是定义如何将给定类的 Eloquent 对象(或 Eloquent 对象集合)转换为 API 结果的结构。例如,您的Dog Eloquent 模型现在有一个Dog资源,其责任是将每个Dog实例转换为相应的Dog形状的 API 响应对象。

创建一个资源类

让我们通过这个Dog示例来看一下如何转换我们的 API 输出。首先,使用 Artisan 命令make:resource来创建您的第一个资源:

php artisan make:resource Dog

这将在app/Http/Resources/Dog.php中创建一个新的类,其中包含一个方法:toArray()。您可以在 Example 13-15 中看到文件的样子。

Example 13-15. 生成的 API 资源
<?phpnamespace App\Http\Resources;use Illuminate\Http\Request;use Illuminate\Http\Resources\Json\JsonResource;class Dog extends JsonResource{ /** * Transform the resource into an array. * * @return array<string, mixed> */ public function toArray(Request $request): array { return parent::toArray($request); }}

我们在这里使用的toArray()方法可以访问两个重要的数据片段。首先,它可以访问 Illuminate 的Request对象,因此我们可以根据查询参数、头信息和其他重要信息来自定义我们的响应。其次,它可以通过在$this上调用其属性和方法来访问整个 Eloquent 对象,正如您在 Example 13-16 中所见。

Example 13-16. Dog模型的简单 API 资源
class Dog extends JsonResource{ public function toArray(Request $request): array { return [ 'id' => $this->id, 'name' => $this->name, 'breed' => $this->breed, ]; }}

要使用这个新资源,您需要更新返回单个Dog的任何 API 端点,以包装您的新资源响应,就像在 Example 13-17 中看到的那样。

Example 13-17. 使用简单的Dog资源
use App\Dog;use App\Http\Resources\Dog as DogResource;Route::get('dogs/{dogId}', function ($dogId) { return new DogResource(Dog::find($dogId));});

资源集合

现在,让我们谈谈当您从给定 API 端点返回多个实体时会发生什么。这可以通过 API 资源的collection()方法来实现,正如您在 Example 13-18 中所见。

Example 13-18. 使用默认的 API 资源集合方法
use App\Dog;use App\Http\Resources\Dog as DogResource;Route::get('dogs', function () { return DogResource::collection(Dog::all());});

此方法遍历传递给它的每个条目,使用DogResource API 资源进行转换,然后返回集合。

对于许多 API 来说,这可能已经足够了,但是如果您需要自定义结构或向您的集合响应添加元数据,您可能需要创建一个自定义的 API 资源集合。

为了做到这一点,让我们再次使用make:resource Artisan 命令。这次我们将其命名为DogCollection,这表明这是一个 API 资源集合,而不仅仅是一个 API 资源:

php artisan make:resource DogCollection

这将生成一个非常类似于 API 资源文件的新文件,位于app/Http/Resources/DogCollection.php,再次包含一个方法:toArray()。您可以在示例13-19 中查看文件的外观。

示例 13-19. 生成的 API 资源集合
<?phpnamespace App\Http\Resources;use Illuminate\Http\Resources\Json\ResourceCollection;class DogCollection extends ResourceCollection{ /** * Transform the resource collection into an array. * * @return array<int|string, mixed> */ public function toArray(Request $request): array { return parent::toArray($request); }}

就像使用 API 资源一样,我们可以访问请求和底层数据。但与 API 资源不同的是,我们处理的是一组项目而不是单个项目,因此我们将访问(已转换的)集合作为$this->collection。请参阅示例13-20 了解示例。

示例 13-20. 用于Dog模型的简单 API 资源集合
class DogCollection extends ResourceCollection{ public function toArray(Request $request): array { return [ 'data' => $this->collection, 'links' => [ 'self' => route('dogs.index'), ], ]; }}

嵌套关系

任何 API 的更复杂方面之一是关系如何嵌套。使用 API 资源的最简单方法是将一个键添加到返回的数组中,该键设置为 API 资源集合,就像示例13-21 中一样。

示例 13-21. 简单包含的 API 关系
public function toArray(Request $request): array{ return [ 'name' => $this->name, 'breed' => $this->breed, 'friends' => Dog::collection($this->friends), ];}
警告

如果您尝试在示例13-21 中的代码并收到 502 错误,则是因为您尚未首先加载父资源上的“friends”关系。继续阅读以了解如何解决此问题,但在处理此资源时,以下是如何使用with()方法急加载该关系:

return new DogResource(Dog::with('friends')->find($dogId));

您可能还希望这是一个条件属性;您可以选择仅在请求中请求它或仅在已经预加载到传递给 Eloquent 对象上时才嵌套它。请参阅示例13-22。

示例 13-22. 有条件地加载 API 关系
public function toArray(Request $request): array{ return [ 'name' => $this->name, 'breed' => $this->breed, // Only load this relationship if it's been eager loaded 'bones' => BoneResource::collection($this->whenLoaded('bones')), // Or only load this relationship if the URL asks for it 'bones' => $this->when( $request->get('include') == 'bones', BoneResource::collection($this->bones) ), ];}

使用分页与 API 资源

只需像将一组 Eloquent 模型传递给资源一样,您也可以传递一个分页器实例。请参阅示例13-23。

示例 13-23. 将分页器实例传递给 API 资源集合
Route::get('dogs', function () { return new DogCollection(Dog::paginate(20));});

如果您传递一个分页器实例,转换后的结果将具有包含分页信息(first页,last页,prev页和next页)和有关整个集合的元信息的附加链接。

您可以查看示例13-24 以查看此信息的外观。在此示例中,我通过调用Dog::paginate(2)将每页项数设置为 2,以便更容易地查看链接的工作方式。

示例 13-24. 带有分页链接的样本分页资源响应
{ "data": [ { "name": "Pickles", "breed": "Chorkie" }, { "name": "Gandalf", "breed": "Golden Retriever Mix" } ], "links": { "self": "http://gooddogbrant.com/api/dogs", "first": "http://gooddogbrant.com/api/dogs?page=1", "last": "http://gooddogbrant.com/api/dogs?page=3", "prev": null, "next": null }, "meta": { "current_page": 1, "data": [ { "name": "Pickles", "breed": "Chorkie", }, { "name": "Gandalf", "breed": "Golden Retriever Mix", } ], "first_page_url": "http://gooddogbrent.com/api/dogs?page=1", "from": 1, "last_page": 3, "last_page_url": "http://gooddogbrent.com/api/dogs?page=3", "links": [ { "url": null, "label": "&laquo; Previous", "active": false }, { "url": "http://gooddogbrent.com/api/dogs?page=1", "label": "1", "active": true }, { "url": "http://gooddogbrent.com/api/dogs?page=2", "label": "Next &raquo;", "active": false } ], "next_page_url": null, "path": "http://gooddogbrent.com/api/dogs", "per_page": 3, "to": 3, "total": 9 }}

有条件地应用属性

您还可以指定响应中的某些属性仅在满足特定测试时应用,如示例13-25 所示。

示例 13-25. 有条件地应用属性
public function toArray(Request $request): array{ return [ 'name' => $this->name, 'breed' => $this->breed, 'rating' => $this->when(Auth::user()->canSeeRatings(), 12), ];}

API 资源的更多自定义

data 属性包装的默认形状可能不是你喜欢的方式,或者你可能发现自己需要为响应添加或自定义元数据。查看 资源文档 以获取有关如何自定义 API 响应的每个方面的详细信息。

Laravel 提供了两个主要工具来认证 API 请求:Sanctum(推荐使用)和 Passport(功能强大但非常复杂,通常过于复杂)。

使用 Sanctum 进行 API 身份验证

Sanctum 是 Laravel 的一个 API 身份验证系统,专为两个任务而建:为你的高级用户生成简单的令牌,以便与你的 API 交互,并允许 SPA 和移动应用程序依附于你现有的身份验证系统。它不像 OAuth 2.0 那样可配置,但非常接近,并且在设置和配置方面成本要低得多。

使用 Sanctum 有几种方式。你可以允许高级用户在管理面板直接为你的 API 生成令牌,这与许多面向开发者的 SaaS 服务类似。你也可以允许用户访问一个特殊的登录页面直接获取令牌,这对于将移动应用程序认证到你的 API 是有用的。此外,你还可以与你的 SPA 集成,使用 Sanctum 的特殊功能之一,直接挂接到 Laravel 基于 cookie 的身份验证会话中,完全不需要管理令牌。

让我们看看如何安装 Sanctum,然后在每个上下文中如何使用它。

安装 Sanctum

Sanctum 已预安装在新的 Laravel 项目中。如果你的项目没有安装它,你需要手动安装并发布其配置文件。

composer require laravel/sanctumphp artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"php artisan migrate

对于任何你想保护的使用 Sanctum 的路由,附加 auth:sanctum 中间件:

Route::get('clips', function () { return view('clips.index', ['clips' => Clip::all()]);})->middleware('auth:sanctum');

手动发放 Sanctum 令牌

如果你想在你的应用程序中构建工具来为用户提供认证 API 的令牌,这里是你需要采取的步骤。

首先确保你的 User 模型使用了 HasApiTokens 特性(在新项目上,它已经有了):

use Laravel\Sanctum\HasApiTokens;class User extends Authenticatable{ use HasApiTokens, HasFactory, Notifiable;}

接下来,构建一个用户界面,允许用户生成一个令牌。你可以在他们的设置页面上放置一个按钮,上面写着“生成新令牌”,弹出一个模态框询问该令牌的昵称,然后将结果发布到这个表单:

Route::post('tokens/create', function () { $token = auth()->user()->createToken(request()->token_name); return view('tokens.created', ['token' => $token->plainTextToken]);});

你也可以通过引用 user 对象的 tokens 属性列出用户拥有的所有令牌:

Route::get('tokens', function () { return view('tokens.index', ['tokens' => auth()->user()->tokens]);});

Sanctum 令牌能力

基于令牌的 API 身份验证的一种常见安全模式是,只允许用户生成具有特定特权的令牌,以减少如果令牌被 compromise 的潜在损害。

如果你想为此构建一个系统,你可以定义(基于业务逻辑或用户偏好)创建时每个令牌拥有的“能力”。将一个字符串数组传递给 createToken() 方法,每个字符串代表该令牌拥有的一个能力。

$token = $user->createToken( request()->token_name, ['list-clips', 'add-delete-clips']);

然后,您的代码可以直接检查已验证用户的令牌(如 Example13-26 中所示),或通过中间件(如 Example13-27 中所示)。

Example 13-26. 根据令牌能力手动检查用户访问权限
if (request()->user()->tokenCan('list-clips')) { // ...}
Example 13-27. 使用中间件根据令牌范围限制访问
// routes/api.phpRoute::get('clips', function () { // Access token has both the "list-clips" and "add-delete-clips" abilities})->middleware(['auth:sanctum','abilities:list-clips,add-delete-clips']);// orRoute::get('clips', function () { // Access token has at least one of the listed abilities})->middleware(['auth:sanctum','ability:list-clips,add-delete-clips'])
注意

如果您希望使用 Sanctum 的中间件检查功能,则需要将以下两行添加到App\Http\KernelmiddlewareAliases属性中。

'abilities' => \Laravel\Sanctum\Http\Middleware\ CheckAbilities::class,'ability' => \Laravel\Sanctum\Http\Middleware\ CheckForAnyAbility::class,

SPA 认证

如果您计划使用 Sanctum 与 SPA 进行身份验证,则首先需要采取一些步骤来设置您的 Laravel 应用程序和您的 SPA。

Laravel 应用准备工作

首先,在app/Http/Kernel.php中的api中间件组取消注释EnsureFrontendRequestsAreStateful类。

'api' => [ \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, // Other API middleware here],

其次,在 Sanctum 配置中更新“stateful”域的列表。这些是您的 SPA 可以发出请求的所有域。您可以直接在config/sanctum.php中修改它们,或者将逗号分隔的域列表添加到您的.env文件中的SANCTUM_STATEFUL_DOMAINS键。

SPA 应用准备工作

在允许用户登录您的应用之前,您的 SPA 应请求 Laravel 设置一个 CSRF cookie,大多数 JavaScript HTTP 客户端(如 Axios)将在以后的每个请求中传递它。

axios.get('/sanctum/csrf-cookie').then(response => { // Handle login});

您可以登录到您的 Laravel 登录路由,无论是您自己创建的路由还是由类似 Fortify 的现有工具提供的路由。未来的请求将通过 Laravel 为您设置的会话 cookie 进行验证。

移动应用认证

这是允许您的移动应用用户对基于 Sanctum 的应用进行认证的工作流程:在您的移动应用中请求用户的电子邮件(或用户名)和他们的密码。将这些信息与设备的名称一起发送(从设备的操作系统中读取设备名称;例如,“Matt's iPhone”),发送到您在后端自己创建的路由,该路由将验证他们的登录,并(假设登录有效)创建并返回一个令牌,正如您可以从 Example13-28 直接看到的文档中获取的内容。

Example 13-28. 用于接受基于 Sanctum 的应用的移动应用登录的路由
Route::post('sanctum/token', function (Request $request) { $request->validate([ 'email' => 'required|email', 'password' => 'required', 'device_name' => 'required', ]); $user = User::where('email', $request->email)->first(); if (! $user || ! Hash::check($request->password, $user->password)) { throw ValidationException::withMessages([ 'email' => ['The provided credentials are incorrect.'], ]); } return $user->createToken($request->device_name)->plainTextToken;});

未来对 API 的请求应在Authorization标头中传递Bearer类型的令牌。

进一步的配置和调试

如果您在安装 Sanctum 方面遇到任何问题或想要自定义 Sanctum 的任何功能,请查阅Sanctum 文档获取更多信息。

使用 Laravel Passport 进行 API 身份验证

Passport(通过 Composer 引入的第一方包,必须安装)可轻松在您的应用程序中设置一个功能齐全的 OAuth 2.0 服务器,包括管理客户端和令牌的 API 和 UI 组件。

OAuth 2.0 简介

OAuth 是 RESTful API 中最常用的认证系统。不幸的是,这是一个过于复杂的主题,我们无法在此深入讨论。有关更多信息,请参阅 Matt Frost 撰写的关于 OAuth 和 PHP 的优秀书籍 Integrating Web Services with OAuth and PHP (php[architect])。

OAuth 最简单的概念是:由于 API 是无状态的,我们不能依赖于正常的基于会话的身份验证方式,这种方式在普通的基于浏览器的查看会话中使用,用户登录后,其验证状态保存在会话中以供后续查看使用。相反,API 客户端需要向认证端点发出单个调用,并执行某种握手来证明自己的身份。然后,它将获得一个令牌,必须在以后的每个请求中(通常通过 Authorization 标头)发送以证明其身份。

OAuth 有几种不同的授权类型,“授权”基本上意味着有几种不同的场景和交互类型可以定义认证握手。不同的项目和不同类型的最终消费者将需要不同的授权。

Passport 提供了将基本的 OAuth 2.0 认证服务器添加到您的 Laravel 应用程序中所需的一切,具有更简单和强大的 API 和界面。

安装 Passport

Passport 是一个独立的包,因此您的第一步是安装它。我将在这里总结步骤,但您可以在Passport 文档中获取更详细的安装说明。

首先,使用 Composer 导入它:

composer require laravel/passport

Passport 导入了一系列迁移,因此使用 php artisan migrate 运行这些迁移以创建 OAuth 客户端、作用域和令牌所需的表。

接下来,使用 php artisan passport:install 运行安装程序。这将为 OAuth 服务器创建加密密钥(storage/oauth-private.keystorage/oauth-public.key),并在数据库中插入我们的个人和密码授权类型令牌的 OAuth 客户端(稍后将介绍)。

您需要将 Laravel\Passport\HasApiTokens trait 导入到您的 User 模型中;这将为每个 User 添加与 OAuth 客户端和令牌相关的关系,以及一些与令牌相关的辅助方法。

最后,在 config/auth.php 中添加一个名为 api 的新认证守卫;将提供者设置为 users,驱动程序设置为 passport

现在,您已经拥有了一个完全功能的 OAuth 2.0 服务器!您可以使用 php artisan passport:client 创建新的客户端,并且您可以使用 /oauth 路由前缀下的 API 来管理您的客户端和令牌。

要在您的 Passport 认证系统后面保护路由,请将 auth:api 中间件添加到路由或路由组中,如示例13-29 所示。

示例 13-29. 使用 Passport 认证中间件保护 API 路由
// routes/api.phpRoute::get('/user', function (Request $request) { return $request->user();})->middleware('auth:api');

要对这些受保护的路由进行身份验证,您的客户端应用程序将需要通过Authorization标头中的Bearer令牌传递令牌(我们将很快介绍如何获取)。示例 13-30 展示了如果您正在使用 Laravel 包含的 HTTP 客户端进行请求,会是什么样子。

示例 13-30. 使用Bearer令牌进行样本 API 请求
use Illuminate\Support\Facades\Http;$response = Http::withHeaders(['Accept' => 'application/json']) ->withToken($accessToken) ->get('http://tweeter.test/api/user');

现在,让我们更详细地看看它是如何工作的。

Passport 的 API

Passport 在您的应用程序中通过/oauth路由前缀公开了一个 API。该 API 提供两个主要功能:首先,通过 OAuth 2.0 授权流(/oauth/authorize/oauth/token)授权用户,其次,允许用户管理其客户端和令牌(其余路由)。

这是一个重要的区别,特别是如果您对 OAuth 不熟悉。每个 OAuth 服务器都需要公开允许消费者使用您的服务器进行身份验证的能力;这就是该服务的全部意义。但是 Passport 还公开了用于管理 OAuth 服务器客户端和令牌状态的 API。这意味着您可以轻松构建一个前端,让用户在您的 OAuth 应用程序中管理其信息。Passport 实际上附带了基于 Vue 的管理组件,您可以直接使用或作为灵感。

我们将介绍 API 路由,让您可以管理客户端和令牌,以及 Passport 提供的 Vue 组件,使其变得简单易用,但首先让我们深入了解用户可以使用 Passport 保护的 API 进行身份验证的各种方式。

Passport 可用的授权类型

Passport 使您可以以四种不同的方式对用户进行身份验证。其中两种是传统的 OAuth 2.0 授权(密码授权和授权码授权),另外两种是 Passport 独有的便利方法(个人令牌和同步器令牌)。

密码授权

密码授权虽然比授权码授权方式更少见,但要简单得多。如果您希望用户能够直接使用其用户名和密码在您的 API 上进行身份验证,例如,如果您的公司为自己的 API 消耗具有移动应用程序,您可以使用密码授权。

使用密码授权类型,获取令牌只需一步:将用户的凭据发送到/oauth/token路由,就像示例 13-31 中那样。

示例 13-31. 使用密码授权类型进行请求
// routes/web.php in the *consuming application*Route::get('tweeter/password-grant-auth', function () { // Make call to "Tweeter," our Passport-powered OAuth server $response = Http::post('http://tweeter.test/oauth/token', [ 'grant_type' => 'password', 'client_id' => config('tweeter.id'), 'client_secret' => config('tweeter.secret'), 'username' => 'matt@mattstauffer.co', 'password' => 'my-tweeter-password', 'scope' => '', ]); $thisUsersTokens = $response->json(); // Do stuff with the tokens});

此路由将返回一个access_token,一个refresh_token和两个元数据:token_typeexpires_in(本章后面将讨论)。您现在可以保存这些令牌以用于 API 进行身份验证(访问令牌)和以后请求更多令牌(刷新令牌)。

请注意,我们将用于密码授权类型的 ID 和密钥将是我们 Passport 应用程序中oauth_clients数据库表中的那些在其名称与我们 Passport 授权客户端名称匹配的行中的 ID 和密钥。当您运行passport:install时,您还将在此表中看到两个默认生成的客户端条目:“Laravel 个人访问客户端”和“Laravel 密码授权客户端”。

授权码授权

最常见的 OAuth 2.0 授权工作流程也是 Passport 支持的最复杂的工作流程。让我们想象我们正在开发一个类似 Twitter 但用于声音片段的应用程序;我们将其称为 Tweeter。我们再想象另一个网站,一个名为 SpaceBook 的科幻迷社交网络。SpaceBook 的开发人员希望让人们将他们的 Tweeter 数据嵌入到他们的 SpaceBook 新闻源中。我们将在我们的 Tweeter 应用程序中安装 Passport,以便其他应用程序 - 例如 SpaceBook - 可以允许他们的用户使用他们的 Tweeter 信息进行身份验证。

授权码授权类型中,每个消费网站 - 例如这个例子中的 SpaceBook - 需要在我们的 Passport 启用的应用程序中创建一个客户端。在大多数情况下,其他站点的管理员将在 Tweeter 拥有用户帐户,我们将为他们构建工具来在那里创建客户端。但是首先,我们可以为 SpaceBook 的管理员手动创建一个客户端:

php artisan passport:clientWhich user ID should the client be assigned to?: > 1 What should we name the client?: > SpaceBook Where should we redirect the request after authorization? [http://tweeter.test/auth/callback]: > http://spacebook.test/tweeter/callback New client created successfully. Client ID: 4 Client secret: 5rzqKpeCjIgz3MXpi3tjQ37HBnLLykrgWgmc18uH

要回答第一个问题,您需要知道每个客户端都需要分配给您应用程序中的一个用户。假设用户#1 正在编写 SpaceBook;他们将是我们创建的这个客户端的“所有者”。

一旦我们运行了这个命令,我们就有了 SpaceBook 客户端的 ID 和密钥。在这一点上,SpaceBook 可以使用这个 ID 和密钥来构建工具,允许一个个体 SpaceBook 用户(也是 Tweeter 用户)从 Tweeter 获取授权令牌,以便当 SpaceBook 希望代表该用户进行 API 调用到 Tweeter 时使用。示例13-32 说明了这一点。 (这和后面的示例假设 SpaceBook 也是一个 Laravel 应用程序;它们还假设 SpaceBook 的开发人员创建了一个在config/tweeter.php中返回我们刚刚创建的 ID 和密钥的文件。)

示例 13-32. 消费者应用程序将用户重定向到我们的 OAuth 服务器
// In SpaceBook's routes/web.php:Route::get('tweeter/redirect', function () { $query = http_build_query([ 'client_id' => config('tweeter.id'), 'redirect_uri' => url('tweeter/callback'), 'response_type' => 'code', 'scope' => '', ]); // Builds a string like: // client_id={$client_id}&redirect_uri={$redirect_uri}&response_type=code return redirect('http://tweeter.test/oauth/authorize?' . $query);});

当用户访问 SpaceBook 中的该路由时,他们现在将被重定向到我们 Tweeter 应用中的/oauth/authorize Passport 路由。此时他们将看到一个确认页面 - 您可以通过运行此命令使用默认的 Passport 确认页面:

php artisan vendor:publish --tag=passport-views

这将发布视图到resources/views/vendor/passport/authorize.blade.php,您的用户将看到图 13-1 中显示的页面。

Laravel-启动指南-全- - 绝不原创的飞龙 - 博客园 (5)

图 13-1. OAuth 授权码批准页面

一旦用户选择接受或拒绝授权,Passport 将将用户重定向回提供的redirect_uri。在示例13-32 中,我们设置了redirect_uriurl('tweeter/callback'),因此用户将被重定向回http://spacebook.test/tweeter/callback

批准请求将包含一个代码,我们的消费者应用程序回调路由现在可以使用它从我们的启用 Passport 的应用程序 Tweeter 获取令牌。 拒绝请求将包含一个错误。 SpaceBook 的回调路由可能类似于示例13-33。

示例 13-33。示例消费应用程序中的授权回调路由
// In SpaceBook's routes/web.php:Route::get('tweeter/callback', function (Request $request) { if ($request->has('error')) { // Handle error condition } $response = Http::post('http://tweeter.test/oauth/token', [ 'grant_type' => 'authorization_code', 'client_id' => config('tweeter.id'), 'client_secret' => config('tweeter.secret'), 'redirect_uri' => url('tweeter/callback'), 'code' => $request->code, ]); $thisUsersTokens = $response->json(); // Do stuff with the tokens});

SpaceBook 开发者在这里做的是使用 Laravel HTTP 客户端构建 HTTP 请求,到 Tweeter 的/oauth/token Passport 路由。 然后,他们发送一个POST请求,其中包含用户批准访问时收到的授权码,Tweeter 将返回一个包含几个键的 JSON 响应:

access_token

SpaceBook 将要保存的该用户令牌。 此令牌是用户将来用于认证到 Tweeter 的请求时使用的。 (使用Authorization标头)。

refresh_token

如果您决定将您的令牌设置为过期,则 SpaceBook 将需要的令牌。 默认情况下,Passport 的访问令牌有效期为一年。

expires_in

直到access_token过期的秒数(需要刷新)。

token_type

您获取的令牌类型将是Bearer;这意味着您在未来的所有请求中传递一个带有名称为Authorization和值为Bearer *YOURTOKENHERE*的标头。

现在您已经拥有执行基本授权代码流所需的所有工具。 我们将稍后介绍如何为客户和令牌构建管理员面板,但首先,让我们快速查看其他授权类型。

个人访问令牌

授权码授予适用于用户的应用程序,密码授予适用于您自己的应用程序,但是如果您的用户想要为自己创建令牌以测试您的 API 或在开发其应用程序时使用什么? 这就是个人令牌的用途。

要创建个人令牌,您需要在数据库中拥有个人访问客户端。 运行php artisan passport:install将已经添加一个,但是如果出于任何原因需要生成一个新的个人访问客户端,您可以运行php artisan passport:client --personal

`php` `artisan` `passport:client` `--personal` What should we name the personal access client? [My Application Personal Access Client]: > `My` `Application` `Personal` `Access` `Client`Personal access client created successfully.

个人访问令牌并不是“授权”类型;这里没有 OAuth 规定的流程。 相反,它们是 Passport 添加的便捷方法,可以轻松在系统中注册一个单一客户端,该客户端仅用于便捷地为开发者用户创建便利令牌。

例如,也许您有一个正在开发名为 RaceBook(马拉松选手专用的社交网络)的竞争对手 SpaceBook 的用户,他们希望在开始编码之前先玩一玩 Tweeter API,以弄清它的工作原理。这个开发者能够使用授权码流程创建令牌吗?还没有——他们甚至还没有写任何代码呢!这就是个人访问令牌的用途。

您可以通过 JSON API 创建个人访问令牌,我们稍后会介绍,但您也可以直接在代码中为您的用户创建一个:

// Creating a token without scopes$token = $user->createToken('Token Name')->accessToken;// Creating a token with scopes$token = $user->createToken('My Token', ['place-orders'])->accessToken;

您的用户可以像使用授权码授予流程创建的令牌一样使用这些令牌。我们将在“护照范围”中详细讨论作用域。

Laravel 会话认证的令牌(同步令牌)

还有一种方法让您的用户获取访问 API 的令牌,这是 Passport 添加的另一种便利方法,而普通的 OAuth 服务器不提供。这种方法是当您的用户已经通过常规方式登录到您的 Laravel 应用程序,并且您希望您应用程序的 JavaScript 能够访问 API 时使用的。重新使用授权码或密码授予流程重新认证用户会很麻烦,因此 Laravel 提供了一个辅助方法。

如果您将Laravel\Passport\Http\Middleware\CreateFreshApiToken中间件添加到您的web中间件组(在app/Http/Kernel.php中),Laravel 发送给您的经过身份验证的用户的每个响应都会附带一个名为laravel_token的 cookie。这个 cookie 是一个包含有关 CSRF 令牌编码信息的 JSON Web Token(JWT)。现在,如果您在 JavaScript 请求中使用X-CSRF-TOKEN标头发送正常的 CSRF 令牌,并且在任何您做的 API 请求中也发送X-Requested-With标头,API 将会比较您的 CSRF 令牌与此 cookie,并像处理任何其他令牌一样对您的用户进行身份验证。

Laravel 捆绑的默认 JavaScript 引导设置为您设置了这个标头,但如果您使用不同的框架,您需要手动设置它。示例13-36 展示了如何在 jQuery 中设置它。

示例 13-36. 设置 jQuery 通过所有 Ajax 请求传递 Laravel 的 CSRF 令牌和X-Requested-With标头
$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': "{{ csrf_token() }}", 'X-Requested-With': 'XMLHttpRequest' }});

如果您将CreateFreshApiToken中间件添加到您的web中间件组,并且在每个 JavaScript 请求中传递这些标头,那么您的 JavaScript 请求将能够访问您的 Passport 保护的 API 路由,而不必担心授权码或密码授予的任何复杂性。

护照作用域

如果您熟悉 OAuth,您可能已经注意到我们还没有详细讨论作用域。到目前为止,我们所涵盖的所有内容都可以通过作用域进行定制——但在进一步讨论作用域之前,让我们先快速了解一下什么是作用域。

在 OAuth 中,作用域 是定义的一组权限,不是“可以做所有事情”。例如,如果你曾经获取过 GitHub API 令牌,你可能会注意到一些应用只想访问你的姓名和电子邮件地址,一些应用想要访问你所有的仓库,还有一些应用想要访问你的 Gists。这些都是“作用域”,它允许用户和消费者应用定义消费者应用需要执行其工作的访问权限。

如 示例 13-37 所示,你可以在 AuthServiceProviderboot() 方法中定义应用的作用域。

示例 13-37. 定义 Passport 作用域
// AuthServiceProvideruse Laravel\Passport\Passport;... public function boot(): void { ... Passport::tokensCan([ 'list-clips' => 'List sound clips', 'add-delete-clips' => 'Add new and delete old sound clips', 'admin-account' => 'Administer account details', ]); }

一旦你定义了作用域,消费者应用可以定义它请求访问的作用域。只需在初始重定向中的 scope 字段添加一个空格分隔的令牌列表,如 示例 13-38 所示。

示例 13-38. 请求授权以访问特定作用域
// In SpaceBook's routes/web.php:Route::get('tweeter/redirect', function () { $query = http_build_query([ 'client_id' => config('tweeter.id'), 'redirect_uri' => url('tweeter/callback'), 'response_type' => 'code', 'scope' => 'list-clips add-delete-clips', ]); return redirect('http://tweeter.test/oauth/authorize?' . $query);});

当用户尝试授权此应用时,它将展示请求的作用域列表。这样,用户就会知道“SpaceBook 请求查看你的电子邮件地址”还是“SpaceBook 请求访问以你身份发布、删除你的帖子和发送消息给你的朋友”。

你 你可以使用中间件或在 User 实例上检查作用域。示例 13-39 展示了如何在 User 上进行检查。

示例 13-39. 检查用户验证的令牌是否可以执行指定的操作
Route::get('/events', function () { if (auth()->user()->tokenCan('add-delete-clips')) { // }});

你也可以使用两个中间件,scopescopes。在你的应用中使用它们,只需将它们添加到 app/Http/Kernel.php 文件中的 $middlewareAliases

'scopes' => \Laravel\Passport\Http\Middleware\CheckScopes::class,'scope' => \Laravel\Passport\Http\Middleware\CheckForAnyScope::class,

你现在可以使用中间件,如 示例 13-40 所示。scopes 需要用户令牌上包含所有定义的作用域才能访问路由,而 scope 只需要用户令牌上包含至少一个定义的作用域。

示例 13-40. 使用中间件基于令牌作用域限制访问
// routes/api.phpRoute::get('clips', function () { // Access token has both the "list-clips" and "add-delete-clips" scopes})->middleware('scopes:list-clips,add-delete-clips');// orRoute::get('clips', function () { // Access token has at least one of the listed scopes})->middleware('scope:list-clips,add-delete-clips')

如果你没有定义任何作用域,应用将像不存在一样工作。然而,一旦你使用了作用域,你的消费者应用必须明确定义它们请求的作用域。此规则的一个例外是,如果你使用的是密码授权类型,你的消费者应用可以请求 * 作用域,这会使令牌获得对所有内容的访问权限。

部署 Passport

第一次部署你的 Passport 支持的应用时,Passport API 在你为应用生成密钥之前不会起作用。这可以通过在生产服务器上运行 php artisan passport:keys 来完成,这将生成 Passport 用于生成令牌的加密密钥。

Laravel 为普通 HTML 视图提供可自定义的错误消息页面,但您还可以为带有 JSON 内容类型的调用自定义默认的 404 回退响应。为此,请将 Route::fallback() 调用添加到您的 API 中,如示例 13-41 所示。

示例 13-41. 定义回退路由
// routes/api.phpRoute::fallback(function () { return response()->json(['message' => 'Route Not Found'], 404);})->name('api.fallback.404');

触发回退路由

如果您想要自定义 Laravel 捕获“未找到”异常时返回的路由,可以使用 respondWithRoute() 方法更新异常处理程序,如示例 13-42 所示。

示例 13-42. 当捕获“未找到”异常时调用回退路由
// App\Exceptions\Handleruse Illuminate\Support\Facades\Route;use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;use Illuminate\Http\Request;public function register(): void{ $this->renderable(function (NotFoundHttpException $e, Request $request) { if ($request->isJson()) { return Route::respondWithRoute('api.fallback.404'); } });}

幸运的是,在 Laravel 中测试 API 实际上比测试几乎任何其他东西都简单。

我们在第十二章中详细介绍了这一点,但有一系列针对 JSON 进行断言的方法。结合全栈应用程序测试的简单性,您可以快速轻松地编写 API 测试。查看示例 13-43 中的常见 API 测试模式。

示例 13-43. 常见的 API 测试模式
...class DogsApiTest extends TestCase{ use WithoutMiddleware, RefreshDatabase; public function test_it_gets_all_dogs() { $dog1 = Dog::factory()->create(); $dog2 = Dog::factory()->create(); $response = $this->getJson('api/dogs'); $response->assertJsonFragment(['name' => $dog1->name]); $response->assertJsonFragment(['name' => $dog2->name]); }}

请注意,我们使用 WithoutMiddleware 来避免担心身份验证问题。如果需要,您可以单独进行身份验证测试(有关身份验证的更多信息,请参见第九章)。

在这个测试中,我们向数据库中插入了两只Dog,然后访问 API 路由以列出所有Dog,确保两者都出现在输出中。

您可以在这里简单轻松地覆盖所有 API 路由,包括修改POSTPATCH等操作。

测试 Passport

您可以使用 Passport 门面上的 actingAs() 方法来测试您的作用域。查看示例 13-44 以查看 Passport 中测试作用域的常见模式。

示例 13-44. 测试作用域访问
public function test_it_lists_all_clips_for_those_with_list_clips_scope(){ Passport::actingAs( User::factory()->create(), ['list-clips'] ); $response = $this->getJson('api/clips'); $response->assertStatus(200);}

Laravel 专注于构建 API,并简化了与 JSON 和 RESTful API 的工作。有一些约定,如分页,但关于 API 的具体排序、身份验证或其他内容的定义大部分由您决定。

Laravel 提供了身份验证和测试工具,易于操作和读取头信息,并处理 JSON,甚至在直接从路由返回时自动将所有 Eloquent 结果编码为 JSON。

Laravel Passport 是一个单独的包,使得在 Laravel 应用中创建和管理 OAuth 服务器变得简单。

我们在 第五章 中讨论了如何在关系数据库中存储数据,但可以在本地和远程存储中存储更多数据。本章将涵盖文件系统和内存存储、文件上传和操作、非关系数据存储、会话、缓存、日志记录、Cookie 和全文搜索。

Laravel 通过 Storage 门面和一些辅助函数提供一系列文件操作工具。

Laravel 的文件系统访问工具可以连接到本地文件系统以及 S3、Rackspace 和 FTP。S3 和 Rackspace 文件驱动程序由 Flysystem 提供,并且可以简单地添加额外的 Flysystem 提供者,如 Dropbox 或 WebDAV,到您的 Laravel 应用程序中。

配置文件访问

Laravel 文件管理器的定义位于 config/filesystems.php 中。每个连接称为“磁盘”,示例 14-1 列出了开箱即用的磁盘。

示例 14-1 默认可用的存储磁盘
...'disks' => [ 'local' => [ 'driver' => 'local', 'root' => storage_path('app'), 'throw' => false, ], 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), 'url' => env('APP_URL').'/storage', 'visibility' => 'public', 'throw' => false, ], 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION'), 'bucket' => env('AWS_BUCKET'), 'url' => env('AWS_URL'), 'endpoint' => env('AWS_ENDPOINT'), 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 'throw' => false, ],],

在 示例 14-1 中使用的 storage_path() 辅助函数链接到 Laravel 配置的存储目录,即 storage/。将任何内容传递给它,都将添加到目录名称的末尾,因此 storage_path('public') 将返回字符串 storage/public

local 磁盘连接到您的本地存储系统,并假定它将与存储路径的 app 目录进行交互,即 storage/app

public 磁盘也是一个本地磁盘(虽然您可以根据需要更改它),用于应用程序提供的文件。它默认为 storage/app/public 目录,如果您希望使用此目录向公众提供文件,则需要在 public/ 目录中添加符号链接(symlink)。幸运的是,有一个 Artisan 命令将 public/storage 映射为从 storage/app/public 读取文件的服务:

php artisan storage:link

s3 磁盘显示了 Laravel 如何连接到基于云的文件存储系统。如果您曾连接到 S3 或任何其他云存储提供者,这将很熟悉;传递您的密钥和秘密以及定义您正在使用的“文件夹”的一些信息,S3 中是区域和存储桶。

使用 Storage 门面

config/filesystem.php 中,您可以设置默认磁盘,这将在未指定磁盘时使用任何时候调用 Storage 门面。要指定磁盘,请在门面上调用 disk('*diskname*')

Storage::disk('s3')->get('file.jpg');

所有文件系统都提供以下方法:

get('*file.jpg*')

检索 *file.jpg* 文件。

json('*file.json*', $flags)

检索 *file.json* 文件并解码其 JSON 内容。

put('*file.jpg*', *$contentsOrStream*)

将给定的文件内容放到 *file.jpg*

putFile('*myDir*', *$file*)

将提供文件的内容(以 Illuminate\Http\FileIlluminate\Http\UploadedFile 实例的形式)放置到 *myDir* 目录中,但 Laravel 管理整个流程和文件命名

exists('*file.jpg*')

返回一个布尔值,指示 *file.jpg* 是否存在

getVisibility('*myPath*')

获取给定路径的可见性(“public”或“private”)

setVisibility('*myPath*')

设置给定路径的可见性(“public”或“private”)

copy('*file.jpg*', '*newfile.jpg*')

*file.jpg* 复制到 *newfile.jpg*

move('*file.jpg*', '*newfile.jpg*')

*file.jpg* 移动到 *newfile.jpg*

prepend('*my.log*', '*log text*')

*my.log* 的开头添加 log text 内容

append('*my.log*', '*log text*')

*my.log* 的末尾添加 log text 内容

delete('*file.jpg*')

删除 *file.jpg*

size('*file.jpg*')

返回 *file.jpg* 的字节大小

lastModified('*file.jpg*')

返回 *file.jpg* 上次修改的 Unix 时间戳

files('*myDir*')

返回目录 *myDir* 中的文件名数组

allFiles('*myDir*')

返回目录 *myDir* 及其所有子目录中的文件名数组

directories('*myDir*')

返回目录 *myDir* 中的目录名数组

allDirectories('*myDir*')

返回目录 *myDir* 及其所有子目录中的目录名数组

makeDirectory('*myDir*')

创建一个新目录

deleteDirectory('*myDir*')

删除 *myDir*

readStream('*my.log*')

获取用于读取 *my.log* 的资源

writeStream('*my.log*', $resource)

使用流写入新文件(*my.log*

如果您希望注入一个实例而不是使用 File 门面,可以类型提示或注入 Illuminate\Filesystem\Filesystem,您将拥有相同的所有方法可用。

添加额外的 Flysystem 提供者

如果您想添加额外的 Flysystem 提供者,您需要“扩展” Laravel 的本机存储系统。在某个服务提供者中——可以是 AppServiceProviderboot() 方法,但为每个新绑定创建一个唯一的服务提供者会更合适——使用 Storage 门面添加新的存储系统,如 示例14-2 中所示。

示例 14-2. 添加额外的 Flysystem 提供者
// Some service providerpublic function boot(): void{ Storage::extend('dropbox', function ($app, $config) { $client = new DropboxClient( $config['accessToken'], $config['clientIdentifier'] ); return new Filesystem(new DropboxAdapter($client)); });}

Storage 门面的更常见用法之一是接受来自应用程序用户的文件上传。让我们看一下在 示例14-3 中的常见工作流程。

示例 14-3. 常见用户上传工作流程
...class DogController{ public function updatePicture(Request $request, Dog $dog) { Storage::put( "dogs/{$dog->id}", file_get_contents($request->file('picture')->getRealPath()) ); }}

我们将put()到名为dogs/id的文件中,并且我们从上传的文件中获取我们的内容。每个上传的文件都是SplFileInfo类的后代,它提供了一个getRealPath()方法,返回文件位置的路径。因此,我们获取用户上传文件的临时上传路径,用file_get_contents()读取它,并传递给Storage::put()

由于我们在这里可以使用此文件,我们可以在存储之前对文件进行任何操作——如果是图像,则使用图像处理包进行调整大小,验证并拒绝不符合我们标准的文件,或者其他任何操作。

如果我们想要将同一文件上传到 S3,并且我们的凭据存储在config/filesystems.php中,我们可以简单地调整示例14-3 以调用Storage::disk('s3')->put();现在我们将上传到 S3。查看示例14-4 以查看更复杂的上传示例。

示例 14-4. 使用 Intervention 的文件上传的更复杂示例
...class DogController{ public function updatePicture(Request $request, Dog $dog) { $original = $request->file('picture'); // Resize image to max width 150 $image = Image::make($original)->resize(150, null, function ($constraint) { $constraint->aspectRatio(); })->encode('jpg', 75); Storage::put( "dogs/thumbs/{$dog->id}", $image->getEncoded() ); }

我在示例14-4 中使用了一个称为Intervention的图像库,仅作为示例;您可以使用任何您想要的库。重要的是,在存储之前,您有自由对文件进行任意操作。

您还可以使用文件本身存储已上传的文件。在示例7-18 中了解更多信息。

就像Storage简化了接受用户上传的任务一样,它也简化了将文件返回给用户的任务。查看示例14-5 以获取最简单的示例。

示例 14-5. 简单文件下载
public function downloadMyFile(){ return Storage::download('my-file.pdf');}

会话存储是我们在 Web 应用程序中用于在页面请求之间存储状态的主要工具。Laravel 的会话管理器支持使用文件、Cookie、数据库、Memcached 或 Redis、DynamoDB 或内存数组作为会话驱动程序(在页面请求后过期,仅适用于测试)。

您可以在config/session.php中配置所有会话设置和驱动程序。您可以选择是否加密会话数据,选择使用哪个驱动程序(file是默认值),并指定更多特定于连接的详细信息,如会话存储的长度以及要使用哪些文件或数据库表。查看会话文档以了解您需要为所选择的驱动程序准备的特定依赖关系和设置。

会话工具的一般 API 允许您基于单个键保存和检索数据:例如session()->put('*user_id*')session()->get('*user_id*')。确保避免将任何内容保存到flash会话键中,因为 Laravel 在内部使用它进行闪存(仅在下一个页面请求中可用)会话存储。

访问会话

访问会话的最常见方法是使用Session门面:

Session::get('user_id');

但是你也可以在任何给定的 Illuminate Request 对象上使用 session() 方法,就像在 示例 14-6 中所示的那样。

示例 14-6. 在 Request 对象上使用 session() 方法
Route::get('dashboard', function (Request $request) { $request->session()->get('user_id');});

或者你可以像在 示例 14-7 中那样,注入 Illuminate\Session\Store 的实例。

示例 14-7. 注入会话支持类
Route::get('dashboard', function (Illuminate\Session\Store $session) { return $session->get('user_id');});

最后,你可以使用全局 session() 辅助函数。使用无参数获取会话实例,使用单个字符串参数从会话中“获取”,或者使用数组“存入”会话,如 示例 14-8 中所示。

示例 14-8. 使用全局的 session() 辅助函数
// Get$value = session()->get('key');$value = session('key');// Putsession()->put('key', 'value');session(['key', 'value']);

如果你是 Laravel 新手并不确定该使用哪个方法,我建议使用全局辅助函数。

会话实例上可用的方法

最常见的两个方法是 get()put(),但让我们来看看每个可用方法及其参数:

session()->get(*$key*, *$fallbackValue*)

get() 方法从会话中获取提供的键的值。如果该键没有对应的值,它将返回备用值(如果没有提供备用值,则返回 null)。备用值可以是一个简单的值或一个闭包,正如下面的示例所示:

$points = session()->get('points');$points = session()->get('points', 0);$points = session()->get('points', function () { return (new PointGetterService)->getPoints();});

session()->put(*$key*, *$value*)

put() 将提供的值存储在会话中的提供键下:

session()->put('points', 45);$points = session()->get('points');

session()->push(*$key*, *$value*)

如果你的会话值是数组,你可以使用 push() 方法向数组中添加一个值:

session()->put('friends', ['Saúl', 'Quang', 'Mechteld']);session()->push('friends', 'Javier');

session()->has(*$key*)

has() 检查是否在提供的键上设置了一个值:

if (session()->has('points')) { // Do something}

你也可以传递一个键的数组,只有所有键存在时它才返回 true

如果设置了会话值但该值为 nullsession()``->``has() 将返回 false

session()->exists(*$key*)

exists() 检查是否在提供的键上设置了一个值,类似于 has(),但不同的是,即使设置的值为 null,它也将返回 true

if (session()->exists('points')) { // returns true even if 'points' is set to null}

session()->all()

all() 返回会话中的所有内容的数组,包括框架设置的值。你可能会在键如 _token(CSRF 令牌)、_previous(上一页,用于 back() 重定向)和 flash(闪存存储)下看到值。

session()->only()

only() 返回会话中仅指定值的数组。

session()->forget(*$key*)session()->flush()

forget() 删除先前设置的会话值。flush() 删除每个会话值,即使是框架设置的值也会被删除。

session()->put('a', 'awesome');session()->put('b', 'bodacious');session()->forget('a');// a is no longer set; b is still setsession()->flush();// Session is now empty

session()->pull(*$key*, *$fallbackValue*)

pull()get() 相同,不同之处在于后者在从会话中获取值后将其删除。

session()->regenerate()

并不常见,但如果你需要重新生成会话 ID,可以使用 regenerate() 方法。

闪存会话存储

还有三种我们尚未介绍的方法,它们都与 闪存会话存储 有关。

会话存储的一种非常常见的模式是设置一个值,你希望它仅在下一个页面加载时可用。例如,你可能想存储像“成功更新帖子。”这样的消息。你可以手动获取该消息,然后在下一个页面加载时清除它,但如果你经常使用这种模式,会变得浪费。引入闪存会话存储:预期仅在单个页面请求期间存在的键。

Laravel 会为你处理这些工作,你只需使用flash()而不是put()。以下是这里的有用方法:

session()->flash(*$key*, *$value*)

flash()设置会话键为提供的值,仅用于下一个页面请求。

session()->reflash(), session()->keep(*$key*)

如果你需要上一页的闪存会话数据保留一个更多的请求,你可以使用reflash()来恢复所有数据到下一个请求或keep(*$key*)来只恢复一个单一的闪存值到下一个请求。keep()也可以接受一个键的数组来刷新。

缓存的结构与会话非常相似。你提供一个键,Laravel 为你存储它。最大的区别在于缓存中的数据是应用程序级别的,而会话中的数据是用户级别的。这意味着缓存更常用于存储来自数据库查询、API 调用或其他可以稍微“过时”的缓慢查询的结果。

缓存配置设置可以在config/cache.php中找到。就像会话一样,你可以为任何驱动程序设置特定的配置详细信息,并选择哪一个将成为默认值。Laravel 默认使用file缓存驱动程序,但你也可以使用 Memcached 或 Redis、APC、DynamoDB 或数据库,或编写自己的缓存驱动程序。查看缓存文档了解你选择使用的驱动程序需要准备的特定依赖和设置。

访问缓存

就像会话一样,有几种访问缓存的方法。你可以使用外观:

$users = Cache::get('users');

或者你可以从容器中获取一个实例,如示例14-9。

示例 14-9. 注入缓存实例
Route::get('users', function (Illuminate\Contracts\Cache\Repository $cache) { return $cache->get('users');});

你还可以使用全局cache()助手,如示例14-10。

示例 14-10. 使用全局cache()助手
// Get from cache$users = cache('key', 'default value');$users = cache()->get('key', 'default value');// Put for $seconds duration$users = cache(['key' => 'value'], $seconds);$users = cache()->put('key', 'value', $seconds);

如果你是 Laravel 的新手,不确定该使用哪个,我建议使用全局助手。

缓存实例上可用的方法

让我们看看可以在Cache实例上调用的方法:

cache()->get(*$key*, *$fallbackValue*),

cache()->pull(*$key*, *$fallbackValue*)

get()使得轻松检索任何给定键的值。pull()get()相同,只是在检索后移除缓存的值。

cache()->put(*$key*, *$value*, *$secondsOrExpiration*)

put() 为指定的键设置值,并在给定秒数后过期。如果你愿意设置一个到期日期/时间而不是秒数,你可以将 Carbon 对象作为第三个参数传递:

cache()->put('key', 'value', now()->addDay());

cache()->add(*$key*, *$value*)

add() 类似于 put(),但如果值已存在,add() 不会设置它。此外,该方法返回一个布尔值,指示值是否实际被添加:

$someDate = now();cache()->add('someDate', $someDate); // returns true$someOtherDate = now()->addHour();cache()->add('someDate', $someOtherDate); // returns false

cache()->forever(*$key*, *$value*)

forever() 会将一个值永久保存在缓存中,对应特定的键;它和 put() 相同,除了这些值永远不会过期(直到用 forget() 移除它们)。

cache()->has(*$key*)

has() 返回一个布尔值,指示提供的键是否存在值。

cache()->remember(*$key*, *$seconds*, *$closure*),

cache()->rememberForever(*$key*, *$closure*)

remember() 提供了一个单一的方法来处理非常常见的流程:查看是否存在某个键的缓存值,如果不存在,则以某种方式获取该值,保存到缓存中,并返回它。

remember() 允许你提供一个键来查找,应该保存的秒数以及一个闭包来定义如何查找它,以防该键没有设置值。rememberForever() 相同,只是它不需要你设置应保存的秒数。看下面的例子,了解 remember() 的常见用户场景:

// Either returns the value cached at "users" or gets "User::all()",// caches it at "users", and returns it$users = cache()->remember('users', 7200, function () { return User::all();});

cache()->increment(*$key*, *$amount*), cache()->decrement(*$key*, *$amount*)

increment()decrement() 允许你在缓存中增加和减少整数值。如果给定键没有值,它将被视为 0,如果你向增加或减少传递第二个参数,它将按该数量增加或减少,而不是按 1

cache()->forget(*$key*), cache()->flush()

forget() 的工作方式与 Sessionforget() 方法相同:传递一个键,它将清除该键的值。flush() 将清空整个缓存。

你可能期望 cookie 能像会话和缓存一样工作。对于这三者,我们都提供了一个外观和全局助手,而我们对它们的心理模型也是相似的:你可以以同样的方式获取或设置它们的值。

但由于 cookie 本质上与请求和响应相关联,你需要以不同的方式与 cookie 交互。让我们简要看看使 cookie 不同的地方。

Laravel 中的 Cookies

在 Laravel 中,cookie 可以存在三个地方。它们可以通过请求进入,这意味着用户在访问页面时拥有 cookie。你可以使用 Cookie 外观或从请求对象中读取它。

它们还可以与响应一起发送,这意味着响应将指示用户的浏览器保存 cookie 以备将来访问。在返回响应对象之前,你可以通过将 cookie 添加到响应对象中来实现这一点。

最后,一个 cookie 可以被排队。如果您使用 Cookie 门面设置一个 cookie,您必须将它放入“CookieJar”队列中,并且它将由 AddQueuedCookiesToResponse 中间件从响应对象中移除并添加。

访问 cookie 工具

您可以在三个位置获取和设置 cookie:Cookie 门面、cookie() 全局辅助函数以及请求和响应对象。

cookie 门面

Cookie 门面提供了最全面的选项,不仅可以读取和创建 cookie,还可以将它们排队以添加到响应中。它提供以下方法:

Cookie::get(*$key*)

要获取请求中带有的 cookie 值,只需运行 Cookie::get('*cookie-name*')。这是最简单的选择。

Cookie::has(*$key*)

您可以使用 Cookie::has('*cookie-name*') 检查请求中是否带有 cookie,该方法返回一个布尔值。

Cookie::make(*...params*)

如果您想要在任何地方制作一个 cookie 而不将其排队,可以使用 Cookie::make()。这样做的最可能用途是制作一个 cookie,然后手动将其附加到响应对象,我们稍后会讨论这一点。

下面是 make() 方法的参数顺序:

  • $name 是 cookie 的名称。

  • $value 是 cookie 的内容。

  • $minutes 指定 cookie 应该存活多少分钟。

  • $path 是 cookie 应该有效的路径。

  • $domain 列出 cookie 应该工作的域。

  • $secure 表示 cookie 是否只能通过安全的(HTTPS)连接传输。

  • $httpOnly 表示 cookie 是否仅通过 HTTP 协议访问。

  • $raw 表示是否应无需 URL 编码地发送 cookie。

  • $sameSite 表示 cookie 是否可供跨站点请求使用;选项有 laxstrictnull

Cookie::make()

返回一个 Symfony\Component\HttpFoundation\Cookie 的实例。

Cookie 门面实例使用的 CookieJar 从会话配置中读取其默认值。因此,如果您在 config/session.php 中更改会话 cookie 的任何配置值,那么您使用 Cookie 门面创建的所有 cookie 都将应用相同的默认值。

Cookie::queue(*Cookie || params*)

如果你使用 Cookie::make(),仍然需要将 cookie 附加到响应中,我们稍后会讨论这个问题。Cookie::queue()Cookie::make() 的语法相同,但是它会将创建的 cookie 加入队列,由中间件自动附加到响应中。

如果您愿意,您也可以将您自己创建的 cookie 直接传递给 Cookie::queue()

这是在 Laravel 中向响应添加 cookie 的最简单方法:

Cookie::queue('dismissed-popup', true, 15);

Cookies 只能作为响应的一部分返回。因此,如果您使用 Cookie 门面添加了 cookie,然后响应未正确返回——例如,如果使用 PHP 的 exit() 或其他停止执行脚本的方法——则不会设置 cookie。

cookie() 全局辅助函数

如果调用 cookie() 时不带参数,cookie() 全局辅助函数将返回一个 CookieJar 实例。然而,Cookie 门面上存在的两个最方便的方法——has()get()——仅存在于门面上,而不是 CookieJar 上。因此,在这种情况下,我认为全局辅助函数实际上不如其他选项有用。

cookie() 全局辅助函数最有用的任务是创建一个 cookie。如果将参数传递给 cookie(),它们将直接传递给 Cookie::make() 的等效函数,因此这是创建 cookie 的最快方法:

$cookie = cookie('dismissed-popup', true, 15);

您还可以在应用程序的任何地方注入一个 Illuminate\Cookie\CookieJar 实例,但您将面临此处讨论的相同限制。

请求和响应对象中的 Cookies

由于 cookie 作为请求的一部分进入,并作为响应的一部分设置,这些 Illuminate 对象实际上是它们实际存在的位置。Cookie 门面的 get()has()queue() 方法只是与 RequestResponse 对象交互的代理。

因此,与 cookie 交互的最简单方法是从请求中获取 cookie 并将其设置到响应中。

从请求对象中读取 Cookies

一旦您有了 Request 对象的副本——如果您不知道如何获取它,只需尝试 app('request')——您可以使用 Request 对象的 cookie() 方法读取其 cookie,如 示例14-11 中所示。

示例 14-11. 从 Request 对象中读取 cookie
Route::get('dashboard', function (Illuminate\Http\Request $request) { $userDismissedPopup = $request->cookie('dismissed-popup', false);});

如您在本例中所见,cookie() 方法有两个参数:cookie 的名称和可选的回退值。

在响应对象上设置 Cookies

一旦您的 Response 对象准备就绪,您可以像 示例14-12 中那样,在其上使用 cookie() 方法向响应添加 cookie。

示例 14-12. 在 Response 对象上设置 cookie
Route::get('dashboard', function () { $cookie = cookie('saw-dashboard', true); return Response::view('dashboard') ->cookie($cookie);});

如果您是 Laravel 的新手,并且不确定使用哪种选项,我建议您在 RequestResponse 对象上设置 cookie。这需要更多工作,但如果未来的开发人员不理解 CookieJar 队列,会导致更少的意外。

到目前为止,在本书中我们已经看到了一些关于日志的简短示例,当我们讨论其他概念如容器和门面时,让我们简要看看除了 Log::info('Message') 之外的日志选项。

日志的目的是增加可发现性,或者说增加您理解应用程序当前状态的能力。

日志是您的代码为了理解应用程序执行过程中发生的事情而生成的短消息,有时会以人类可读的形式嵌入一些数据。每个日志必须以特定的级别捕获,这可以从emergency(发生了非常严重的事情)到debug(几乎没有意义的事情发生)不等。

没有任何修改,您的应用程序将会将任何日志语句写入到位于storage/logs/laravel.log的文件中,并且每个日志语句看起来都会有点像这样:

[2018-09-22 21:34:38] local.ERROR: Something went wrong.

您可以看到我们在一行上有日期、时间、环境、错误级别和消息。但是,默认情况下,Laravel 还会记录任何未捕获的异常,这种情况下您将在一行中看到整个堆栈跟踪。

在接下来的部分中,我们将介绍如何记录、为何记录以及如何在其他地方(例如 Slack)记录。

何时以及为何使用日志

日志最常见的用例是作为一种准一次性记录的记录,记录了您后来可能关心的事情,但您明确不需要程序化访问的事物。日志更多地用于了解应用程序中正在发生的情况,而不是创建您的应用程序可以消费的结构化数据。

例如,如果您希望编写代码以记录每次用户登录并对其进行有趣的处理,那么这是一个logins数据库表的使用案例。但是,如果您对这些登录有一种随意的兴趣,但又不确定您是否在编程上关心或需要这些信息,您可以只是在其上放置一个debuginfo级别的日志并忘记它。

当您需要在发生问题时或在某个特定时间点查看某些东西的值,或者其他情况下时,日志也很常见。在代码中放置一个日志语句,从日志中获取您需要的数据,然后要么将其保留在代码中以备后用,要么再次删除它。

写入日志

在 Laravel 中编写日志条目的最简单方法是使用Log门面,并使用该门面上与您希望记录的严重级别匹配的方法。这些级别与RFC 5424中定义的相同:

Log::emergency($message);Log::alert($message);Log::critical($message);Log::error($message);Log::warning($message);Log::notice($message);Log::info($message);Log::debug($message);

可选的,您还可以传递第二个参数,这是一个连接数据的数组:

Log::error('Failed to upload user image.', ['user' => $user]);

不同的日志目标可能会以不同的方式捕获此附加信息,但是在默认的本地日志中看起来像这样(尽管它将只是日志中的一行):

[2018-09-27 20:53:31] local.ERROR: Failed to upload user image. { "user":"[object] (App\\User: { \"id\":1, \"name\":\"Matt\", \"email\":\"matt@tighten.co\", \"email_verified_at\":null, \"api_token\":\"long-token-here\", \"created_at\":\"2018-09-22 21:39:55\", \"updated_at\":\"2018-09-22 21:40:08\" })"}

日志通道

与 Laravel 的许多其他方面(文件存储、数据库、邮件等)一样,您可以配置日志以使用一个或多个预定义的日志类型,这些类型在配置文件中定义。使用每种类型涉及向特定的日志驱动程序传递各种配置详细信息。

这些日志类型被称为频道,并且您将有stacksingledailyslackstderrsyslogerrorlog等选项。每个频道连接到一个驱动程序;可用的驱动程序包括stacksingledailyslacksyslogerrorlogmonologcustom

我们将在这里介绍最常见的频道:singledailyslackstack。要了解更多有关驱动程序和可用频道的完整列表,请查看日志文档

单一频道

single频道将每个日志条目写入单个文件,您将在path键中定义它。您可以在示例 14-13 中查看其默认配置:

示例 14-13. single频道的默认配置
'single' => [ 'driver' => 'single', 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'),],

这意味着它只会记录debug级别或更高级别的事件,并将它们全部写入单个文件storage/logs/laravel.log

日志频道

daily频道每天生成一个新文件。你可以在示例 14-14 中查看其默认配置。

示例 14-14. daily频道的默认配置
'daily' => [ 'driver' => 'daily', 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'days' => 14,],

它类似于single,但现在我们可以设置在清理之前要保留多少天的日志,并且日期将附加到我们指定的文件名中。例如,前述配置将生成名为storage/logs/laravel-.log的文件。

Slack 频道

slack频道使得将你的日志(或更可能的是特定的日志)发送到 Slack 变得简单。

它还说明您不仅限于 Laravel 默认提供的处理程序。我们将在下一节中介绍这一点,但这不是自定义 Slack 实现;这只是 Laravel 构建一个连接到 Monolog Slack 处理程序的日志驱动程序,如果您可以使用任何 Monolog 处理程序,那么您有很多选项可用。

默认配置显示在示例 14-15 中。

示例 14-15. slack频道的默认配置
'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), 'username' => 'Laravel Log', 'emoji' => ':boom:', 'level' => env('LOG_LEVEL', 'critical'),],

stack 频道

stack频道是应用程序默认启用的频道。其默认配置显示在示例 14-16 中。

示例 14-16. stack频道的默认配置
'stack' => [ 'driver' => 'stack', 'channels' => ['single'], 'ignore_exceptions' => false,],

stack频道允许你将所有日志发送到多个频道(列在channels数组中)。因此,虽然这是默认在你的 Laravel 应用中配置的频道,因为它的channels数组默认设置为single,实际上你的应用只是使用了single日志频道。

但是如果您希望所有info级别及以上的内容都进入日常文件,而critical及更高级别的日志消息进入 Slack,使用stack驱动程序非常简单,正如示例 14-17 所示。

示例 14-17. 自定义stack驱动器
'channels' => [ 'stack' => [ 'driver' => 'stack', 'channels' => ['daily', 'slack'], ], 'daily' => [ 'driver' => 'daily', 'path' => storage_path('logs/laravel.log'), 'level' => 'info', 'days' => 14, ], 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), 'username' => 'Laravel Log', 'emoji' => ':boom:', 'level' => 'critical', ],]

写入特定日志频道

有时您可能希望在调用Log门面时精确控制哪些日志消息应放在哪里。您可以通过指定频道来实现这一点:

Log::channel('slack')->info("This message will go to Slack.");

如果您想要自定义如何将每个日志发送到每个频道,或实现自定义 Monolog 处理程序,请查看 logging docs 以了解更多。

Laravel Scout 是一个独立的包,您可以将其引入您的 Laravel 应用程序中,以为您的 Eloquent 模型添加全文搜索功能。Scout 可以轻松地索引和搜索您的 Eloquent 模型内容;它配备了用于 Algolia、Meilisearch 和数据库(MySQL/PostgreSQL)的驱动程序,但也有其他提供者的社区包。我假设您正在使用 Algolia。

安装 Scout

首先,在任何 Laravel 应用程序中引入包:

composer require laravel/scout

接下来,您将需要设置您的 Scout 配置。运行此命令:

php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"

并将您的 Algolia 凭证粘贴到 config/scout.php 中。

最后,安装 Algolia SDK:

composer require algolia/algoliasearch-client-php

标记您的模型以进行索引

在您的模型中(我们将使用 Review,例如书评),导入 Laravel\Scout\Searchable 特性。

您可以使用 toSearchableArray() 方法定义哪些属性可搜索(默认镜像 toArray()),并使用 searchableAs() 方法定义模型索引的名称(默认为表名)。

Scout 订阅您标记模型上的创建/删除/更新事件。当您创建、更新或删除任何行时,Scout 将同步这些更改到 Algolia。它将根据您的配置使用队列,将这些更改同步或异步进行队列处理。

搜索您的索引

Scout 的语法很简单。例如,要查找任何包含 Llew 一词的 Review

Review::search('Llew')->get();

您也可以像使用常规的 Eloquent 调用那样修改您的查询:

// Get all records from the Review that match the term "Llew",// limited to 20 per page and reading the page query parameter,// just like Eloquent paginationReview::search('Llew')->paginate(20);// Get all records from the Review that match the term "Llew"// and have the account_id field set to 2Review::search('Llew')->where('account_id', 2)->get();

这些搜索返回什么?一组从数据库中重新生成的 Eloquent 模型。ID 存储在 Algolia 中,Algolia 返回匹配的 ID 列表;然后 Scout 从数据库中提取这些记录,并将它们作为 Eloquent 对象返回。

您无法完全访问 SQL WHERE 命令的复杂性,但它提供了比较检查的基本框架,如此处代码示例中所示。

队列和 Scout

此时,您的应用程序将在修改任何数据库记录时向 Algolia 发送 HTTP 请求。这可能会迅速减慢您的应用程序,这就是为什么 Scout 使得将其所有操作推送到队列中变得容易。

config/scout.php 中,将 queue 设置为 true,使这些更新可以异步索引。现在,您的全文索引在“最终一致性”下运行;您的数据库记录将立即接收更新,并且搜索索引的更新将被排队并根据队列工作程序的速度快速更新。

执行无索引操作

如果您需要执行一组操作并避免触发响应的索引,请在您的模型上使用 withoutSyncingToSearch() 方法包装这些操作。

Review::withoutSyncingToSearch(function () { // Make a bunch of reviews, e.g. Review::factory()->count(10)->create();});

有条件地对模型进行索引

有时,您可能只想在满足某些条件时索引记录。您可以在模型类上使用shouldBeSearchable()方法来实现这一点:

public function shouldBeSearchable(){ return $this->isApproved();}

通过代码手动触发索引

如果您想手动触发对模型的索引,可以在应用程序中使用代码或通过命令行完成。

要从代码中手动触发索引,请在任何 Eloquent 查询的末尾添加searchable(),它将索引该查询中找到的所有记录:

Review::all()->searchable();

你还可以选择将查询范围限制为只索引你想要的记录。但是,Scout 足够智能,可以插入新记录并更新旧记录,因此你可能选择重新索引模型数据库表的整个内容。

您还可以在关系方法上运行searchable()

$user->reviews()->searchable();

如果要取消索引任何符合相同查询链条件的记录,只需使用unsearchable()即可:

Review::where('sucky', true)->unsearchable();

通过 CLI 手动触发索引

您还可以使用 Artisan 命令触发索引:

php artisan scout:import "App\Review"

这将分块处理所有Review模型并对其进行索引。

Laravel 的 HTTP 客户端并不完全是存储机制,但它是检索机制,说实话,我不确定它在这本书中还适合什么其他位置。让我们开始吧!

HTTP 客户端使您的 Laravel 应用程序可以通过简单而清晰的界面调用POSTGET等方式与外部 Web 服务和 API 进行通信。

如果您曾经使用过 Guzzle,您会理解它可以做什么,您还可能理解为什么简单的界面值得一提:Guzzle 功能强大,但也非常复杂,多年来变得越来越复杂。

使用 HTTP Facade

大多数时候,如果您正在使用 HTTP 客户端,您将依赖于其外观,直接在外观上调用get()post()等方法。查看示例14-18 以获取示例。

示例 14-18. HTTP Facade 的基本用法示例
use Illuminate\Support\Facades\Http;$response = Http::get('http://my-api.com/posts');$response = Http::post('http://my-api.com/posts/2/comments', [ 'title' => 'I loved this post!',]);

从 HTTP Facade 调用返回的$responseIlluminate\Http\Client\Response的一个实例,它提供了一套方法来检查响应。您可以查看文档获取完整列表,但也可以在示例14-19 中看到一些常见方法。

示例 14-19. HTTP Client Response 对象上常用的方法
$response = Http::get('http://my-api.com/posts');$response->body(); // string$response->json(); // array$response->json('key', 'default') // string$response->successful(); // bool

正如你从示例14-18 中看到的,你可以在POST请求中发送数据,但还有许多其他方法可以在请求中发送数据。

再次,这里是一些常见示例,您可以在文档中看到更多:

$response = Http::withHeaders([ 'X-Custom-Header' => 'header value here'])->post(/* ... */);$response = Http::withToken($authToken)->post(/* ... */);$response = Http::accept('application/json')->get('http://my-api.com/users');

处理错误和超时以及检查状态

默认情况下,HTTP 客户端在请求失败时将等待 30 秒,并且不会重试。但您可以自定义客户端响应意外情况的许多方面。

要定义超时时间,请链接timeout()并传递应等待的秒数:

$response = Http::timeout(120)->get(/* ... */);

如果您期望尝试失败,您可以定义客户端应该重试每个请求的次数,使用retry()链式方法:

$response = Http::retry($retries, $millisecondsBetweenRetries)->post(/* ... */);

响应对象上的其他一些方法允许我们检查请求是否成功以及我们收到了什么 HTTP 状态码;以下是其中一些:

$response->successful(); // 200 or 300$response->failed(); // 400 or 500 errors$response->clientError(); // 400 errors$response->serverError(); // 500 errors// A few of the specific checks we can run for given status codes$response->ok(); // 200 OK$response->movedPermanently(); // 301 Moved Permanently$response->unauthorized(); // 401 Unauthorized$response->serverError(); // 500 Internal Server Error

您还可以定义一个回调函数,在发生错误时运行:

$response->onError(function (Response $response) { // handle error});

测试大多数这些功能就像在您的测试中使用它们一样简单;无需模拟或存根。默认配置已经可以工作了—​例如,查看phpunit.xml,查看您的会话驱动程序和缓存驱动程序已设置为适合测试的值。

但是,在您尝试测试它们之前,有一些方便的方法和一些需要注意的地方。

文件存储

测试文件上传可能有点麻烦,但是按照这些步骤进行操作,一切将变得清晰。

上传虚假文件

首先,让我们看看如何手动创建一个Illuminate\Http\UploadedFile对象,以便在我们的应用程序测试中使用(示例14-20)。

示例 14-20. 创建用于测试的假UploadedFile对象
public function test_file_should_be_stored(){ Storage::fake('public'); $file = UploadedFile::fake()->image('avatar.jpg'); $response = $this->postJson('/avatar', [ 'avatar' => $file, ]); // Assert the file was stored Storage::disk('public')->assertExists("avatars/{$file->hashName()}"); // Assert a file does not exist Storage::disk('public')->assertMissing('missing.jpg');}

我们已经创建了一个新的UploadedFile实例,引用我们的测试文件,现在我们可以使用它来测试我们的路由。

返回虚假文件

如果您的路由期望真实文件存在,有时使其可测试的最佳方法是使该真实文件实际存在。假设每个用户都必须有个人资料图片。

首先,让我们为用户设置模型工厂,使用 Faker 复制图片,如在示例14-21 中所见。

示例 14-21. 使用 Faker 返回虚假文件
public function definition (){ return [ 'picture' => fake()->file( base_path('tests/stubs/images'), // Source directory storage_path('app'), // Target directory false, // Return just filename, not full path ), 'name' => fake()->name(), ];};

Faker 的file()方法从源目录中选择一个随机文件,将其复制到目标目录,然后返回文件名。因此,我们刚刚从tests/stubs/images目录中选择了一个随机文件,将其复制到storage/app目录,并将其文件名设置为我们的User上的picture属性。此时,我们可以在期望User具有图片的路由测试中使用User,如示例14-22 中所示。

示例 14-22. 断言图像的 URL 已回显
public function test_user_profile_picture_echoes_correctly(){ $user = User::factory()->create(); $response = $this->get(route('users.show', $user->id)); $response->assertSee($user->picture);}

当然,在许多情况下,您可以只在那里生成一个随机字符串,甚至不复制文件。但是,如果您的路由检查文件是否存在或对文件运行任何操作,则这是您的最佳选择。

会话

如果您需要断言会话中已设置了某些内容,可以在每个测试中使用 Laravel 提供的一些方便方法。所有这些方法都在Illuminate\Testing\TestResponse对象的测试中可用:

assertSessionHas(*$key*, *$value = null*)

断言会话对特定键有值,并且如果传递了第二个参数,则该键具有特定值:

public function test_some_thing(){ // Do stuff that ends up with a $response object... $response->assertSessionHas('key', 'value');}

assertSessionHasAll(*array $bindings*)

如果传递了一个键/值对的数组,断言所有键都等于所有值。如果一个或多个数组条目只是一个值(具有 PHP 的默认数值键),则仅检查该值是否存在于会话中:

$check = [ 'has', 'hasWithThisValue' => 'thisValue',];$response->assertSessionHasAll($check);

assertSessionMissing(*$key*)

断言会话对于特定键没有值。

assertSessionHasErrors(*$bindings = []*, *$format = null*)

断言会话具有一个errors值。这是 Laravel 用于从验证失败中返回错误的关键。

如果数组只包含键,它将检查这些键是否设置了错误:

$response = $this->post('test-route', ['failing' => 'data']);$response->assertSessionHasErrors(['name', 'email']);

你还可以传递这些键的值,并且可选地传递一个$format,以验证这些错误消息是否按预期返回:

$response = $this->post('test-route', ['failing' => 'data']);$response->assertSessionHasErrors([ 'email' => '<strong>The email field is required.</strong>',], '<strong>:message</strong>');

缓存

对于使用缓存的功能进行测试并没有什么特别的地方 —— 只需要去做:

Cache::put('key', 'value', 900);$this->assertEquals('value', Cache::get('key'));

Laravel 默认在您的测试环境中使用array缓存驱动程序,它只是将您的缓存值存储在内存中。

Cookies

如果您需要在应用程序测试中测试路由之前设置 cookie 怎么办?您可以使用withCookies()方法在请求中设置 cookies。要了解更多,请查看第十二章。

如果你的测试中的 cookies 不起作用,除非你将它们排除在 Laravel 的 cookie 加密中间件之外。您可以通过教EncryptCookies中间件暂时禁用这些 cookies 来实现这一点:

use Illuminate\Cookie\Middleware\EncryptCookies;...$this->app->resolving( EncryptCookies::class, function ($object) { $object->disableFor('cookie-name'); });// ...run test

这意味着您可以设置一个 cookie,并使用类似示例14-23 来检查它。

示例 14-23. 对 cookies 运行单元测试
public function test_cookie(){ $this->app->resolving(EncryptCookies::class, function ($object) { $object->disableFor('my-cookie'); }); $response = $this->call( 'get', 'route-echoing-my-cookie-value', [], ['my-cookie' => 'baz'] ); $response->assertSee('baz');}

如果您想测试响应是否设置了 cookie,可以使用assertCookie()来检查该 cookie:

$response = $this->get('cookie-setting-route');$response->assertCookie('cookie-name');

或者您可以使用assertPlainCookie()来测试 cookie 并断言它未加密。

日志

测试某个特定日志是否已写入的最简单方法是针对Log外观进行断言(详细了解请参阅“模拟其他外观”)。示例14-24 展示了这个工作原理。

示例 14-24. 对Log外观进行断言
// Test filepublic function test_new_accounts_generate_log_entries(){ Log::shouldReceive('info') ->once() ->with('New account created!'); // Create a new account $this->post(route('accounts.store'), ['email' => 'matt@mattstauffer.com']);}// AccountControllerpublic function store(){ // Create account Log::info('New account created!');}

也有一个名为Log Fake的包,扩展了此处展示的外观测试可以做的事情,并允许您针对日志编写更多定制的断言。

Scout

如果您需要测试使用 Scout 数据的代码,您可能不希望您的测试触发索引操作或从 Scout 读取数据。只需向您的 phpunit.xml 添加一个环境变量来禁用 Scout 与 Algolia 的连接:

<env name="SCOUT_DRIVER" value="null"/>

HTTP 客户端

使用 Laravel 的 HTTP 客户端的一个不可思议的好处是,它使您能够在测试中以最小的配置来伪造响应。

最简单的选项是运行Http::fake(),它将为您每次调用返回一个空的成功响应。

不过,您还可以自定义您希望从 HTTP 客户端调用返回的具体响应,就像您在示例14-25 中看到的那样。

示例 14-25. 通过 URL 自定义对 HTTP 客户端的响应
Http::fake([ // Return a JSON response for a particular API 'my-api.com/*' => Http::response(['key' => 'value'], 200, $headersArray), // Return a string response for all other endpoints '*' => Http::response('This is a fake API response', 200, $headersArray),]);

如果需要定义针对特定端点(或符合特定端点模式)的请求遵循特定顺序,可以如 示例14-26 所示进行定义。

示例 14-26. 定义针对特定端点的响应序列
Http::fake([ // Return a sequence of responses for consecutive calls to this API 'my-api.com/*' => Http::sequence() ->push('Initial string response', 200) ->push(['secondary' => 'response'], 200) ->pushStatus(404),]);

您还可以对应用程序发送到特定端点的数据进行断言,如 示例14-27 所示。

示例 14-27. 对应用程序发出的调用进行断言
Http::fake();Http::assertSent(function (Request $request) { return $request->hasHeader('X-Custom-Header', 'certain-value') && $request->url() == 'http://my-api.com/users/2/comments' && $request['name'] == 'New User';});

Laravel 提供了简单的接口来执行许多常见的存储操作:文件系统访问、会话、Cookie、缓存和搜索。无论您使用哪个提供者,每个 API 都是相同的,这是 Laravel 通过允许多个“驱动程序”提供相同公共接口实现的。这使得根据环境或应用程序需求的变化简单切换提供者成为可能。

通过电子邮件、Slack、SMS 或其他通知系统向应用的用户发送通知是一个常见但令人惊讶地复杂的需求。Laravel 的邮件和通知功能提供了一致的 API,抽象了不必太关注任何特定提供者的需要。就像在第十四章中一样,你只需编写一次代码,并在配置级别选择要用于发送电子邮件或通知的提供程序。

Laravel 的邮件功能是建立在Symfony Mailer之上的便捷层。默认情况下,Laravel 提供了 SMTP、Mailgun、Postmark、Amazon SES 和 Sendmail 驱动程序。

对于所有的云服务,你将在 config**/services.php 中设置你的身份验证信息。然而,如果你查看一下,你会看到已经有了一些键——在 config/mail.php 中——允许你使用像 MAIL_MAILERMAILGUN_SECRET 这样的变量,在 .env 中自定义应用程序的邮件功能。

基本的“Mailable”邮件使用

在现代 Laravel 应用中,你发送的每一封邮件都是一个特定的 PHP 类的实例,用来表示每封邮件,称为mailable

要创建一个 mailable,可以使用 make:mail Artisan 命令:

php artisan make:mail AssignmentCreated

示例15-1 展示了该类的样子。

示例 15-1 自动生成的 mailable PHP 类
<?phpnamespace App\Mail;use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Mail\Mailable;use Illuminate\Mail\Mailables\Content;use Illuminate\Mail\Mailables\Envelope;use Illuminate\Queue\SerializesModels;class AssignmentCreated extends Mailable{ use Queueable, SerializesModels; /** * Create a new message instance. */ public function __construct() { // } /** * Get the message envelope. */ public function envelope(): Envelope { return new Envelope( subject: 'Assignment Created', ); } /** * Get the message content definition. */ public function content(): Content { return new Content( view: 'view.name', ); } /** * Get the attachments for the message. * * @return array<int, \Illuminate\Mail\Mailables\Attachment> */ public function attachments(): array { return []; }}

你可能会注意到 mailable 和 jobs 之间有一些相似之处;这个类甚至导入了 Queueable trait 用于排队你的邮件和 SerializesModels trait,因此你传递给构造函数的任何 Eloquent 模型都将被正确序列化。

那么,这是如何工作的呢?类的构造函数是你传递任何数据的地方,你在 mailable 类上设置为公共的任何属性都将在模板中可用。

envelope() 方法中,你将设置关于邮件的配置详情——发件人、主题、元数据。

content() 方法中,你将定义内容——包括你使用的视图以及任何 Markdown 内容和文本参数。

如果要附加文件到邮件中,你将使用 attachments() 方法。

查看 示例15-2 以了解我们如何更新为我们的任务示例自动生成的 mailable。

示例 15-2 一个示例 mailable
<?phpnamespace App\Mail;use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Mail\Mailable;use Illuminate\Mail\Mailables\Address;use Illuminate\Mail\Mailables\Content;use Illuminate\Mail\Mailables\Envelope;use Illuminate\Queue\SerializesModels;class AssignmentCreated extends Mailable{ use Queueable, SerializesModels; public function __construct(public $trainer, public $trainee) {} public function envelope(): Envelope { return new Envelope( subject: 'New assignment from ' . $this->trainer->name, from: new Address($this->trainer->email, $this->trainer->name), ); } public function content(): Content { return new Content( view: 'emails.assignment-created' ); } public function attachments(): array { return []; }}

一旦创建了 mailable 类,就可以开始发送邮件了。首先,创建 mailable 类的一个实例,并传入适当的数据;然后,通过链式调用 Mail::to($user)->send($mailable) 来发送邮件。你还可以在内联调用链的一部分中自定义邮件的一些其他细节,例如 CC 和 BCC。查看 示例15-3 以查看一些示例。

示例 15-3 如何发送 mailables
$mail = new AssignmentCreated($trainer, $trainee);// SimpleMail::to($user)->send($mail);// With CC/BCC/etc.Mail::to($user1)) ->cc($user2) ->bcc($user3) ->send($mail);// With string email address and collectionsMail::to('me@app.com') ->bcc(User::all()) ->send($mail)

电子邮件模板

邮件模板与任何其他模板都一样。它们可以扩展其他模板,使用部分,解析变量,包含条件或循环指令,以及执行任何您可以在普通 Blade 视图中执行的操作。

查看示例15-4,查看示例15-2 的可能的 emails.assignment-created 模板。

示例 15-4. 示例 assignment-created 电子邮件模板
<!-- resources/views/emails/assignment-created.blade.php --><p>Hey {{ $trainee->name }}!</p><p>You have received a new training assignment from <b>{{ $trainer->name }}</b>.Check out your <a href="{{ route('training-dashboard') }}">trainingdashboard</a> now!</p>

在示例15-2 中,$trainer$trainee 都是您的可邮件化对象上的公共属性,这使它们可供模板使用。如果其中一个是私有的,它将不可用。

如果您想明确定义传递给模板的变量,可以在您的可邮件化内容的 with 参数中使用,如示例15-5。

示例 15-5. 自定义模板变量
use Illuminate\Mail\Mailables\Content;public function content(): Content{ return new Content( view: 'emails.assignment-created', with: ['assignment' => $this->event->name], );}

到目前为止,我们已经使用了 new Content() 实例上的 view 参数。这需要我们引用的模板返回 HTML。如果您想传递纯文本版本,请使用 text 参数来定义您的纯文本视图:

public function content(): Content{ return new Content( html: 'emails.assignment-created', text: 'emails.assignment-created-text', );}

envelope() 方法中可用的方法

我们已经看过如何使用 envelope() 方法自定义主题和“发件人”地址。请注意,我们自定义它们的方式是通过向 Envelope 类的构造函数传递不同的命名参数:

public function envelope(): Envelope{ return new Envelope( subject: 'New assignment from ' . $this->trainer->name, from: new Address($this->trainer->email, $this->trainer->name), );}

这不是一个详尽的列表,但这里是一些可以通过 envelope() 方法传递到 Envelope 类以自定义电子邮件的参数的简短列表。任何可以接受 Address 的参数也可以接受字符串电子邮件地址或包含 Address 对象和/或字符串混合的数组。

from: *地址*

设置“发件人”姓名和地址 — 代表作者

subject: *字符串*

设置电子邮件主题

cc: *地址*

设置抄送

bcc: *地址*

设置密送

replyTo: *地址*

设置“回复地址”

tags: *数组*

设置标签,如果适用于您的电子邮件发件人

metadata: *数组*

设置元数据,如果适用于您的电子邮件发件人

最后,如果您想要对底层 Symfony 消息进行任何手动修改,可以在 using 参数中执行,如示例15-6 所示。

示例 15-6. 修改底层 SymfonyMessage 对象
public function envelope(): Envelope{ return new Envelope( subject: 'Howdy!', view: 'emails.howdy', using: [ function (Email $message) { $message->setReplyTo('noreply@email.com'); }, ], );}

附加文件和内联图片

要将文件附加到您的邮件中,请从 attachments() 方法返回一个数组(其中每个条目都是 Attachment),如示例15-7 所示。

示例 15-7. 附加文件或数据到可邮件化对象
use Illuminate\Mail\Mailables\Attachment;// Attach a file using the local filenamepublic function attachments(): array{ return [ Attachment::fromPath('/absolute/path/to/file'), ];}// Attach a file using storage diskspublic function attachments(): array{ return [ // Attach from default disk Attachment::fromStorage('/path/to/file'), // Attach from custom disk Attachment::fromStorageDisk('s3', '/path/to/file'), ];}// Attach a file passing the raw datapublic function attachments(): array{ return [ Attachment::fromData(fn () => file_get_contents($this->pdf), 'whitepaper.pdf') ->withMime('application/pdf'), ];}

可附加的邮件对象

如果您有一个可以作为电子邮件附件表示的 PHP 类,或者如果您想要构建一个围绕附加到电子邮件的对象的逻辑的 PHP 类,您可以尝试 Laravel 的可附加对象。

这些对象中的每一个只需要是一个实现Illuminate\Contracts\Mail\Attachable接口的 PHP 类,该接口需要一个toMailAttachment()方法,该方法返回一个Illuminate\Mail\Attachment实例。

一个常见的例子是,如果你想要使你的 Eloquent 模型中的一个可附加的,我们的例子中,我们一直在给我们的客户发送来自他们的教练的新作业的电子邮件,所以让我们尝试使Assignment可附加。查看示例 15-8。

示例 15-8. 使 Eloquent 模型可附加
<?phpnamespace App\Models;use Illuminate\Contracts\Mail\Attachable;use Illuminate\Database\Eloquent\Model;use Illuminate\Mail\Attachment;class Assignment extends Model implements Attachable{ /** * Get the attachable representation of the model. */ public function toMailAttachment(): Attachment { return Attachment::fromPath($this->pdf_path); }}

如果一个类实现了Attachable接口,你可以将该类的任何实例用作从attachments()方法返回的数组中的条目:

public function attachments(): array{ return [$this->assignment];}

内联图像

如果你想要将图像内联附加到你的电子邮件中,Laravel 也提供了相应的功能,正如你在示例 15-9 中所见。

示例 15-9. 在电子邮件中内联图像
<!-- emails/image.blade.php -->Here is an image:<img src="{{ $message->embed(storage_path('embed.jpg')) }}">Or, the same image embedding the data:<img src="{{ $message->embedData( file_get_contents(storage_path('embed.jpg')), 'embed.jpg') }}">

Markdown 可发送邮件

Markdown 可发送邮件允许你在 Markdown 中编写电子邮件内容,之后它将被转换为具有 Laravel 内置响应式 HTML 模板的完整 HTML(和纯文本)电子邮件。你还可以调整这些模板,以创建一个简单的、适合开发人员和非开发人员创建内容的自定义电子邮件模板。

首先,使用markdown标志运行make:mail Artisan 命令:

php artisan make:mail AssignmentCreated --markdown=emails.assignment-created

你可以在示例 15-10 中看到生成的邮件文件的示例。

示例 15-10. 生成的 Markdown 可发送邮件
class AssignmentCreated extends Mailable{ // ... public function content(): Content { return new Content( markdown: 'emails.assignment-created', ); }}

如你所见,这几乎与 Laravel 中的普通可发送邮件文件完全相同。主要区别在于,你将你的模板传递给markdown参数而不是view参数。还要注意,你引用的模板应该代表一个 Markdown 模板,而不是普通的 Blade 模板。

什么是Markdown 模板?与普通的 Blade 电子邮件模板不同,Blade 模板预期—​通过包含和继承等方式—​生成完整的 HTML 电子邮件,而 Markdown 模板只需将 Markdown 内容传递给几个预定义的组件。

这些组件看起来像<x-mail::component-name-here>,因此,你的 Markdown 邮件的主体应该传递给名为<x-mail::message>的组件。查看示例 15-11 来查看一个简单 Markdown 邮件模板的示例。

示例 15-11. 简单分配的 Markdown 邮件
{{-- resources/views/emails/assignment-created.blade.php --}}<x-mail::message># Hey {{ $trainee->name }}!You have received a new training assignment from **{{ $trainer->name }}**<x-mail::button :url="route('training-dashboard')">View Your Assignment</x-mail::button>Thanks,<br>{{ config('app.name') }}</x-mail::message>

正如你在示例 15-11 中所见,有一个父级mail::message组件,你可以向其传递电子邮件的正文,但你还可以添加其他更小的组件到你的电子邮件中。我们在这里使用了mail::button组件,它需要传递内容(“查看您的作业”),同时需要传递url属性。

有三种可用的组件类型:

按钮

生成一个居中的按钮链接。按钮组件需要一个url属性,并允许一个可选的color属性,你可以传递primarysuccesserror

面板

使用稍亮于消息其余部分的背景来渲染提供的文本。

表格

将通过 Markdown 表格语法传递的内容转换为表格。

这些 Markdown 组件内置在 Laravel 框架的核心中,但如果您需要自定义它们的工作方式,可以发布它们的文件并进行编辑:

php artisan vendor:publish --tag=laravel-mail

您可以在Laravel 文档中了解有关自定义这些文件及其主题的更多信息。

将可邮件渲染到浏览器

在应用程序中开发电子邮件时,能够预览它们的渲染效果是很有帮助的。您可以依赖像 Mailtrap 这样的工具来进行预览,这是一个很有用的工具,但直接在浏览器中渲染邮件并立即看到您所做的更改也是很有帮助的。

查看示例 15-12 以查看您可以添加到应用程序中以渲染给定可邮件的示例路由。

示例 15-12. 将可邮件渲染到路由
Route::get('preview-assignment-created-mailable', function () { $trainer = Trainer::first(); $trainee = Trainee::first(); return new \App\Mail\AssignmentCreated($trainer, $trainee);});

Laravel 也提供了一种快速在浏览器中预览通知的方法:

Route::get('preview-notification', function () { $trainer = Trainer::first(); $trainee = Trainee::first(); return (new App\Notifications\AssignmentCreated($trainer, $trainee)) ->toMail($trainee);});

队列

发送电子邮件是一项耗时的任务,可能导致应用程序变慢,因此通常将其移到后台队列中是很常见的。事实上,Laravel 提供了一组内置工具,使得可以更轻松地将消息加入队列,而不必为每封电子邮件编写队列作业:

queue()

要将邮件对象加入队列而不是立即发送,只需将可邮件对象传递给Mail::queue()而不是Mail::send()

 Mail::to($user)->queue(new AssignmentCreated($trainer, $trainee));

later()

Mail::later()Mail::queue()相同,但允许您添加延迟—​可以是几分钟,也可以通过传递DateTimeCarbon的实例来指定具体时间—​指定何时从队列中提取并发送电子邮件:

 $when = now()->addMinutes(30); Mail::to($user)->later($when, new AssignmentCreated($trainer, $trainee));

这些方法的工作需要正确配置您的队列。查看第十六章了解有关队列工作原理及如何在应用程序中运行它们的更多信息。

对于queue()later(),如果您希望指定邮件添加到哪个队列或队列连接,请在可邮件对象上使用onConnection()onQueue()方法:

$message = (new AssignmentCreated($trainer, $trainee)) ->onConnection('sqs') ->onQueue('emails');Mail::to($user)->queue($message);

如果您希望指定某个可邮件始终应该加入队列,可以让该可邮件实现Illuminate\Contracts\Queue\ShouldQueue接口。

本地开发

这对于在生产环境中发送邮件是很好的。但是如何进行测试呢?有两个主要工具值得考虑:Laravel 的log驱动程序和用于测试的虚拟收件箱,比如 Mailtrap。

log 驱动程序

Laravel 提供了一个log驱动程序,会将您尝试发送的每封电子邮件记录到本地的laravel.log文件中(默认位于storage/logs中)。

要使用此功能,请编辑.env文件,并将MAIL_MAILER设置为log。现在打开或尾随storage/logs/laravel.log并从您的应用程序发送电子邮件。您将看到类似于以下内容:

Message-ID: <04ee2e97289c68f0c9191f4b04fc0de1@localhost>Date: Tue, 17 May 2016 02:52:46 +0000Subject: Welcome to our app!From: Matt Stauffer <matt@mattstauffer.com>To: freja@jensen.noMIME-Version: 1.0Content-Type: text/html; charset=utf-8Content-Transfer-Encoding: quoted-printableWelcome to our app!

您可以选择指定将记录的邮件发送到与其余日志不同的日志通道。要么修改config/mail.php,要么在您的.env文件中将MAIL_LOG_CHANNEL变量设置为任何现有日志通道的名称。

虚拟收件箱

如果您想看看您的测试电子邮件在真实收件箱中的外观,您可以使用几种服务之一,这些服务允许您将您的电子邮件发送到它们,并在一个完整的虚假收件箱中显示您的电子邮件。

最常见的两种此类服务是 Mailtrap,这是一个无需设置的付费 SaaS,允许您与同事和客户分享收件箱,以及 Mailpit,这是一个可以通过 Docker 在本地运行的服务。

Mailtrap

Mailtrap是一个在开发环境中捕获和检查电子邮件的服务。您通过 SMTP 将您的邮件发送到 Mailtrap 服务器,但 Mailtrap 不会将这些邮件发送给预期的接收者,而是捕获它们并为您提供一个基于 Web 的电子邮件客户端以进行检查,无论目标电子邮件地址是什么。

要设置 Mailtrap,请注册免费帐户并访问您的演示基础仪表板。从 SMTP 列复制您的用户名和密码。

然后编辑您的应用程序的.env文件,并在mail部分设置以下值:

MAIL_MAILER=smtpMAIL_HOST=mailtrap.ioMAIL_PORT=2525MAIL_USERNAME=your_username_from_mailtrap_hereMAIL_PASSWORD=your_password_from_mailtrap_hereMAIL_ENCRYPTION=null

现在,您从应用程序发送的任何电子邮件都将显示在 Mailtrap 收件箱中。

Mailpit

如果您喜欢 Mailtrap 的想法,但希望在本地(免费)运行应用程序,您可以使用Mailpit,这是 Mailtrap 的一种替代方案,您可以在本地 Docker 容器中运行。

大多数从 Web 应用程序发送的邮件的目的是通知用户特定操作已发生或需要发生。随着用户的沟通偏好变得越来越多样化,我们通过 Slack、短信和其他方式收集越来越多——以及更为不同的——通信包。

为支持这些偏好,Laravel 引入了一个名为notifications的概念。就像可邮寄物一样,通知是一个表示您可能希望发送给用户的单个通信的 PHP 类。现在,让我们假设我们正在通知我们的身体训练应用程序用户,他们有新的训练可用。

每个类代表发送通知给您的用户所需的所有信息,可以使用一个或多个通知渠道。单个通知可以通过电子邮件发送,通过 Vonage 发送短信,发送 WebSocket ping,向数据库添加记录,向 Slack 频道发送消息等等。

所以,让我们创建我们的通知:

php artisan make:notification WorkoutAvailable

示例15-13 展示了这给我们带来的东西。

示例 15-13. 自动生成的通知类
<?phpnamespace App\Notifications;use Illuminate\Bus\Queueable;use Illuminate\Notifications\Notification;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Notifications\Messages\MailMessage;class WorkoutAvailable extends Notification{ use Queueable; /** * Create a new notification instance. */ public function __construct() { // } /** * Get the notification's delivery channels. * * @return array<int, string> */ public function via(object $notifiable): array { return ['mail']; } /** * Get the mail representation of the notification. */ public function toMail(object $notifiable): MailMessage { return (new MailMessage) ->line('The introduction to the notification.') ->action('Notification Action', url('/')) ->line('Thank you for using our application!'); } /** * Get the array representation of the notification. * * @return array<string, mixed> */ public function toArray(object $notifiable): array { return [ // ]; }}

我们可以从这里学到一些东西。首先,我们将向构造函数传递相关数据。其次,有一个via()方法,允许我们为给定用户定义要使用的通知渠道($notifiable代表您系统中希望通知的任何实体;对于大多数应用程序,它将是一个用户,但并非总是如此)。第三,每个通知渠道都有单独的方法,允许我们明确定义如何通过该渠道发送其中一个通知。

尽管最常见的通知目标将是用户,但您可能希望通知其他内容。这可能仅仅是因为您的应用程序具有多个用户类型,因此您可能希望通知培训师和学员。但您也可能希望通知一个群组、一个公司或一个服务器。

所以,让我们修改这个类,适用于我们的WorkoutAvailable示例。看一下示例15-14。

示例 15-14. 我们的WorkoutAvailable通知类
...class WorkoutAvailable extends Notification{ use Queueable; public function __construct(public $workout) {} public function via(object $notifiable): array { // This method doesn't exist on the User... we're going to make it up return $notifiable->preferredNotificationChannels(); } public function toMail(object $notifiable): MailMessage { return (new MailMessage) ->line('You have a new workout available!') ->action('Check it out now', route('workout.show', [$this->workout])) ->line('Thank you for training with us!'); } public function toArray(object $notifiable): array { return []; }}

为您的通知定义via()方法

正如您在示例15-14 中所看到的,我们需要负责决定每个通知和每个通知对象要使用哪些通知渠道。

您可以将所有内容都发送为邮件或只发送为短信(示例15-15)。

示例 15-15. 最简单的via()方法
public function via(object $notifiable): array{ return 'vonage';}

您还可以让每个用户选择一种首选方法,并将其保存在用户本身上(示例15-16)。

示例 15-16. 根据用户自定义via()方法
public function via(object $notifiable): array{ return $notifiable->preferred_notification_channel;}

或者,正如我们在示例15-14 中设想的那样,您可以在每个通知对象上创建一个方法,以进行一些复杂的通知逻辑。例如,您可以在工作时间通过某些渠道通知用户,而在晚上通过其他渠道通知用户。重要的是via()是一个 PHP 类方法,因此您可以在那里执行任何复杂的逻辑。

发送通知

有两种方法可以发送通知:使用Notification门面,或将Notifiable特性添加到一个 Eloquent 类(通常是您的User类)。

使用通知门面发送通知

Notification门面是两种方法中较为笨拙的一种,因为您必须同时传递通知对象和通知内容。然而,它非常有用,因为您可以选择同时传递多个通知对象,就像示例15-17 中所示。

示例 15-17. 使用Notification门面发送通知
use App\Notifications\WorkoutAvailable;...Notification::send($users, new WorkoutAvailable($workout));

使用通知特性发送通知

任何导入Laravel\Notifications\Notifiable特性的模型(默认情况下是App\User类)都有一个notify()方法,可以传递一个通知,看起来像示例15-18。

示例 15-18. 使用Notifiable特性发送通知
use App\Notifications\WorkoutAvailable;...$user->notify(new WorkoutAvailable($workout));

将通知排队

大多数通知驱动程序需要使用 HTTP 请求发送通知,这可能会减慢用户体验。为了处理这个问题,您可能希望将通知加入队列。所有通知默认导入 Queueable 特性,因此您只需在通知中添加 implements ShouldQueue,Laravel 将立即将其移到队列中。

与任何其他队列功能一样,您需要确保正确配置队列设置并运行队列工作程序。

如果您想延迟发送通知,可以在通知上运行 delay() 方法:

$delayUntil = now()->addMinutes(15);$user->notify((new WorkoutAvailable($workout))->delay($delayUntil));

开箱即用的通知类型

Laravel 默认提供了用于电子邮件、数据库、广播、Vonage SMS 和 Slack 的通知驱动程序。我会简要介绍每一个,但建议参考 通知文档 以获取更详细的介绍。

创建自己的通知驱动程序也很容易,已经有数十个人这样做了;您可以在 Laravel 通知通道网站 找到它们。

电子邮件通知

让我们看看我们先前示例中的电子邮件,示例15-14,是如何构建的:

public function toMail(object $notifiable): MailMessage{ return (new MailMessage) ->line('You have a new workout available!') ->action('Check it out now', route('workouts.show', [$this->workout])) ->line('Thank you for training with us!');}

结果显示在 图15-1 中。电子邮件通知系统将您应用程序的名称放在电子邮件的标题中;您可以在 config/app.phpname 键中自定义该应用程序名称。

此电子邮件会自动发送到可通知对象的 email 属性,但您可以通过向可通知类添加名为 routeNotificationForMail() 的方法并返回您希望发送电子邮件通知的电子邮件地址来自定义此行为。

电子邮件的主题是通过解析通知类名称并将其转换为单词来设置的。因此,我们的 WorkoutAvailable 通知将具有默认主题“Workout Available”。您还可以通过在 toMail() 方法中的 MailMessage 上链接 subject() 方法来自定义此主题。

如果您想修改模板,请发布它们并随心所欲地编辑:

php artisan vendor:publish --tag=laravel-notifications

Laravel-启动指南-全- - 绝不原创的飞龙 - 博客园 (6)

图15-1. 使用默认通知模板发送的电子邮件

数据库通知

您可以使用 database 通知渠道将通知发送到数据库表。首先,使用 php artisan notifications:table 创建您的表。接下来,在通知上创建一个 toDatabase() 方法并在其中返回一个数据数组。这些数据将被编码为 JSON 并存储在数据库表的 data 列中。

Notifiable 特性将 notifications 关系添加到其导入的任何模型中,使您可以轻松访问通知表中的记录。因此,如果您使用数据库通知,您可以执行类似 示例15-20 的操作:

示例15-20. 遍历用户的数据库通知
User::first()->notifications->each(function ($notification) { // Do something});

database通知渠道还有一个通知是否“已读”的概念。您可以仅作用于“未读”通知,如示例15-21 所示:

示例 15-21. 遍历用户的未读数据库通知
User::first()->unreadNotifications->each(function ($notification) { // Do something});

您可以将一个或所有通知标记为已读,正如示例15-22 所示。

示例 15-22. 将数据库通知标记为已读
// IndividualUser::first()->unreadNotifications->each(function ($notification) { if ($condition) { $notification->markAsRead(); }});// AllUser::first()->unreadNotifications->markAsRead();

广播通知

broadcast通道使用 Laravel 的事件广播功能发送通知,该功能由 WebSockets 提供支持(我们将在“通过 WebSockets 广播事件和 Laravel Echo”中了解更多信息)。

在您的通知上创建一个toBroadcast()方法并返回一个数据数组。如果您的应用程序已正确配置为事件广播,则该数据将在名为*notifiable.id*的私有通道上广播。*id*将是可通知对象的 ID,而*notifiable*将是可通知对象的完全限定类名,斜杠替换为句点—例如,ID 为1App\User的私有通道将是App.User.1

短信通知

短信通知通过Vonage发送,因此如果您想发送短信通知,请注册 Vonage 帐户,并按照通知文档中的说明操作。与其他渠道一样,您将设置toVonage()方法并在那里自定义短信内容。

在 Laravel 中,短信通知渠道是一个官方包。如果您想使用 Vonage 短信通知,只需使用 Composer 要求此包:

composer require laravel/vonage-notification-channel \ guzzlehttp/guzzle

Slack 通知

slack通知渠道允许您自定义通知的外观,甚至附加文件。与其他渠道一样,您将设置toSlack()方法并在那里自定义消息。

Laravel 的 Slack 通知渠道是一个官方包。如果您想使用 Slack 通知,只需使用 Composer 要求此包:

composer require laravel/slack-notification-channel

其他通知

想要通过其他渠道发送通知,而不是默认提供的渠道?有一个强大的社区努力提供各种通知渠道的选择;请查看Laravel 通知渠道网站上提供的内容。

让我们看看如何测试邮件和通知。

邮件

我们可以针对我们的邮件编写两个方面的断言:邮件的内容和属性,以及实际触发发送的事实。让我们从针对邮件内容的断言开始。

针对邮件进行断言

首先,我们可以对envelope()类型的数据运行断言,就像您在示例15-23 中所看到的那样。

示例 15-23. 针对可邮寄信封数据的断言
$mailable = new AssignmentCreated($trainer, trainee);$mailable->assertFrom('noreply@mytrainingapp.com');$mailable->assertTo('user@gmail.com');$mailable->assertHasCc('trainer@mytrainingapp.com');$mailable->assertHasBcc('records@mytrainingapp.com');$mailable->assertHasReplyTo('trainer@mytrainingap.com');$mailable->assertHasSubject('New assignment from Faith Elizabeth');$mailable->assertHasTag('assignments');$mailable->assertHasMetadata('clientId', 4);

接下来,我们可以对消息内容进行断言,正如您在 示例 15-24 中所看到的。

示例 15-24. 对可邮寄对象的内容进行断言
$mailable->assertSeeInHtml($trainee->name);$mailable->assertSeeInHtml('You have received a new training assignment');$mailable->assertSeeInOrderInHtml(['Hey', 'You have received']);$mailable->assertSeeInText($trainee->name);$mailable->assertSeeInOrderInText(['Hey', 'You have received']);

我们可以对附件进行断言,正如您在 示例 15-25 中所看到的。

示例 15-25. 对可邮寄对象的附件进行断言
$mailable->assertHasAttachment('/pdfs/assignment-24.pdf');$mailable->assertHasAttachment(Attachment::fromPath('/pdfs/assignment-24.pdf'));$mailable->assertHasAttachedData($pdfData, 'assignment-24.pdf', [ 'mime' => 'application/pdf',]);$mailable->assertHasAttachmentFromStorage( '/pdfs/assignment-24.pdf', 'assignment-24.pdf', ['mime' => 'application/pdf']);$mailable->assertHasAttachmentFromStorageDisk( 's3', '/pdfs/assignment-24.pdf', 'assignment-24.pdf', ['mime' => 'application/pdf']);

断言邮件是否已发送

要测试邮件是否发送(或未发送),我们首先会运行 Mail::fake() 来捕获邮件动作以便检查。然后,我们可以运行各种断言,正如您在 示例 15-26 中所看到的。

示例 15-26. 对邮件是否已发送进行断言
Mail::fake();// Call the code that sends the email// Assert that no mailables were sentMail::assertNothingSent();// Assert that a mailable was sentMail::assertSent(AssignmentCreated::class);// Assert a mailable was sent a certain number of timesMail::assertSent(AssignmentCreated::class, 4);// Assert a mailable was not sentMail::assertNotSent(AssignmentCreated::class);// Assertions for queued emailsMail::assertQueued(AssignmentCreated::class);Mail::assertNotQueued(AssignmentCreated::class);Mail::assertNothingQueued();

Laravel 还允许我们将闭包作为这些断言的第二个参数,检查电子邮件以确保它们符合我们的预期。看一看 示例 15-27。

示例 15-27. 在断言中检查邮件的属性
Mail::assertSent( AssignmentCreated::class, function (AssignmentCreated $mail) use ($trainer, $trainee) { return $mail->hasTo($trainee->email) && $mail->hasSubject('New assignment from ' . $trainer->name); });

您还可以使用 hasCc()hasBcc()hasReplyTo()hasFrom()

通知

Laravel 提供了一组内置的断言用于测试您的通知。示例 15-28 示范了这一点。

示例 15-28. 断言通知已发送
public function test_new_signups_triggers_admin_notification(){ Notification::fake(); Notification::assertSentTo($user, NewUsersSignedup::class, function ($notification, $channels) { return $notification->user->email == 'user-who-signed-up@gmail.com' && $channels == ['mail']; }); // Assert that the email was sent to a given user Notification::assertSentTo( [$user], NewUsersSignedup::class ); // You can also use assertNotSentTo() Notification::assertNotSentTo( [$userDidntSignUp], NewUsersSignedup::class );}

Laravel 的邮件和通知功能提供了简单一致的接口,可以连接各种消息系统。Laravel 的邮件系统使用可邮寄对象(mailables),这些是代表电子邮件的 PHP 类,提供了对不同邮件驱动程序一致的语法。通知系统使得构建一个可以通过多种不同媒介传送的单一通知变得简单,从电子邮件到短信再到实体明信片。

到目前为止,我们已经涵盖了一些支持 Web 应用程序的最常见结构:数据库、邮件、文件系统等等。这些在大多数应用程序和框架中都很常见。

Laravel 还提供了一些不太常见的架构模式和应用程序结构的工具。在本章中,我们将介绍 Laravel 实现队列、排队作业、事件和 WebSocket 事件发布的工具。我们还将介绍 Laravel 的调度器,它使手动编辑的 cron 调度成为过去时。

要理解什么是队列,只需想象一下在银行排队的概念。即使有多条线—​队列—​每次也只有一个人从每条队列中被服务,并且每个人最终都会到达前面并被服务。在某些银行中,这是严格的先进先出策略,但在其他银行中,并不能完全保证某个时刻不会有人插队。基本上,有人可以被加入队列,被过早移出队列,或者成功“处理”然后被移除。有时候,有人可能会到达队列的前面,但无法正确地得到服务,于是又返回队列一段时间,然后再次被处理。

编程中的队列非常相似。你的应用程序向队列添加一个“作业”,这是一段代码,告诉应用程序如何执行特定的行为。然后,另一个单独的应用程序结构,通常是“队列工作者”,负责逐个从队列中取出作业并执行适当的行为。队列工作者可以删除作业,延迟返回到队列,或标记为成功处理。

Laravel 提供了使用 Redis、beanstalkd、Amazon Simple Queue Service (SQS) 或数据库表格来轻松服务队列的工具。你也可以选择 sync 驱动程序,在你的应用程序中直接运行作业而不实际排队,或者选择 null 驱动程序让作业被丢弃;这两者通常用于本地开发或测试环境。

为什么要使用队列?

队列使得轻松地将昂贵或慢速的过程从任何同步调用中移除变得可能。最常见的例子是发送邮件—​这样做可能很慢,而你不希望用户在等待邮件发送响应他们的操作时被阻塞。相反,你可以触发一个“发送邮件”队列作业,让用户继续他们的日常工作。有时候,你可能不关心节省用户的时间,但你可能有像 cron 作业或 webhook 这样需要大量工作量的进程;与其让所有工作一次性运行(可能导致超时),你可以选择逐个将其作业加入队列,让队列工作者逐个处理。

另外,如果您有一些处理繁重的工作,超出了服务器的处理能力,您可以启动多个队列工作程序,以比您的正常应用服务器更快的速度处理您的队列。

基本队列配置

就像许多其他 Laravel 功能一样,它们抽象了多个提供程序,队列也有自己的专用配置文件(config/queue.php),允许您设置多个驱动程序,并定义默认驱动程序。这也是您将存储 SQS、Redis 或beanstalkd身份验证信息的地方。

Laravel Forge 是由 Laravel 创建者 Taylor Otwell 提供的托管管理服务,使得通过 Redis 服务队列变得轻松。您创建的每个服务器都会自动配置 Redis,因此,如果您访问任何站点的 Forge 控制台,只需转到队列选项卡并点击“启动工作者”,您就可以准备好使用 Redis 作为队列驱动程序;您可以保留所有默认设置,无需进行其他工作。

排队作业

记得我们的银行类比吗?银行队列中的每个人(行)在编程术语中称为作业。根据环境的不同,排队的作业可以采用多种形式,如数据数组或简单字符串。在 Laravel 中,每个作业都是一个包含作业名称、数据有效载荷、到目前为止已尝试处理此作业的次数以及一些其他简单元数据的信息集合。

但是在与 Laravel 的交互中,您无需担心任何这些。Laravel 提供了一个称为Job的结构,旨在封装单个任务—​您的应用程序可以被命令执行的行为—​并允许将其添加到队列中并从中提取。还有简单的助手函数,使排队 Artisan 命令和邮件变得容易。

让我们从一个示例开始,每当用户在您的 SaaS 应用程序中更改他们的计划时,您希望重新运行一些关于整体利润的计算。

创建一个作业

像往常一样,都有一个 Artisan 命令:

php artisan make:job CrunchReports

查看 示例 16-1 ,看看您将得到什么。

示例 16-1. Laravel 中作业的默认模板
<?phpnamespace App\Jobs;use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldBeUnique;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Bus\Dispatchable;use Illuminate\Queue\InteractsWithQueue;use Illuminate\Queue\SerializesModels;class CrunchReports implements ShouldQueue{ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; /** * Create a new job instance. */ public function __construct() { // } /** * Execute the job. */ public function handle(): void { // }}

正如您所看到的,这个模板导入了DispatchableInteractsWithQueueQueueableSerializesModels特性,并实现了ShouldQueue接口。

我们还从这个模板中获得了两种方法:构造函数,您将使用它来附加数据到作业中,以及handle()方法,这是作业逻辑应驻留的地方(也是您将用于注入依赖项的方法签名)。

特征和接口提供了向类添加到队列并与之交互的能力。Dispatchable为其提供了调度自身的方法;InteractsWithQueue允许每个作业在处理时控制其与队列的关系,包括删除或重新排队自身;Queueable允许您指定 Laravel 如何将此作业推送到队列;而SerializesModels使作业能够序列化和反序列化 Eloquent 模型。

SerializesModels特征使作业能够序列化(转换为可以存储在数据库或队列系统等数据存储中的更平坦格式)注入的模型,以便您的作业的handle()方法可以访问它们。然而,由于可靠地序列化整个 Eloquent 对象太困难,该特征确保在将作业推送到队列时,仅序列化附加的 Eloquent 对象的主键。当作业反序列化并处理时,该特征会通过它们的主键从数据库中重新获取这些 Eloquent 模型。这意味着当您的作业运行时,它将从数据库中获取这个模型的最新实例,而不是您排队作业时的状态。

让我们填写我们示例类的方法,就像示例16-2 那样。

示例 16-2. 一个示例作业
...use App\ReportGenerator;class CrunchReports implements ShouldQueue{ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; protected $user; public function __construct($user) { $this->user = $user; } public function handle(ReportGenerator $generator): void { $generator->generateReportsForUser($this->user); Log::info('Generated reports.'); }}

我们期望在创建作业时注入User实例,并且在处理时,我们使用了ReportGenerator类的类型提示(我们可能编写了该类)。Laravel 将读取类型提示并自动注入该依赖项。

将作业推送到队列

有多种方法可以调度作业,包括每个控制器都可以使用的某些方法和全局的dispatch()辅助程序。但更简单和首选的方法是在作业本身上调用dispatch()方法,这就是我们本章剩余部分要做的事情。

要调度您的作业,只需创建其实例,然后调用其dispatch()方法,并直接传入任何必要的数据。请查看示例16-3 了解示例。

示例 16-3. 调度作业
$user = auth()->user();$daysToCrunch = 7;\App\Jobs\CrunchReports::dispatch($user, $daysToCrunch);

有三个设置可以控制确切如何调度作业:连接、队列和延迟。

自定义连接

如果您同时存在多个队列连接,可以在dispatch()方法后链式调用onConnection()来自定义连接:

DoThingJob::dispatch()->onConnection('redis');

自定义队列

在队列服务器中,您可以指定将作业推送到哪个命名队列。例如,您可以根据其重要性区分队列,将一个命名为low,另一个命名为high

您可以使用onQueue()方法自定义要将作业推送到哪个队列:

DoThingJob::dispatch()->onQueue('high');

自定义延迟

您可以使用 delay() 方法自定义队列工作者在处理作业之前应等待的时间,该方法接受一个整数(表示延迟作业的秒数)或 *DateTime* / *Carbon* 实例:

// Delays five minutes before releasing the job to queue workers$delay = now()->addMinutes(5);DoThingJob::dispatch()->delay($delay);

请注意,Amazon SQS 不允许超过 15 分钟的延迟。

作业链

如果您需要一系列作业按顺序运行,可以将它们“链”在一起。每个作业将等待上一个作业完成后运行,如果一个作业失败,其后的作业将不会运行。

$user = auth()->user();$daysToCrunch = 7;Bus::chain([ new CrunchReports($user, $daysToCrunch), new SendReport($user),])->dispatch();

当链式作业之一失败时,您可以使用catch()方法执行:

$user = auth()->user();$daysToCrunch = 7;Bus::chain([ new CrunchReports($user, $daysToCrunch), new NotifyNewReportsDone($user)])->catch(function (Throwable $e) { new ReportsNotCrunchedNotification($user)})->dispatch($user);

作业批处理

作业批处理使得能够同时将一组作业推送到队列中,检查批处理的状态,并在批处理完成后采取行动成为可能。

此功能需要一个数据库表来跟踪作业;正如您可能期望的那样,有一个 Artisan 命令来创建它:

php artisan queue:batches-tablephp artisan migrate

要将作业标记为可批处理,包括 Illuminate\Bus\Batchable 特性。此特性将在您的作业中添加一个 batch() 方法,允许您检索有关当前作业批次的信息。

查看 示例16-4 了解其工作原理。在这个示例中,您可以看到可批处理作业中最重要的一步是确保如果其批处理已取消,则不执行任何操作。

示例 16-4. Laravel 中的可批处理作业
...class SampleBatchableJob implements ShouldQueue{ use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public function handle(): void { // Don't run if this batch is canceled if ($this->batch()->cancelled()) { return; } // Otherwise, run like normal // ... }}

调度可批处理作业

Bus 门面提供了一个 batch() 方法,允许您调度一批作业。您还可以使用 then()(成功时)、catch()(失败时)或 finally()(成功或失败时)方法定义批处理成功或失败后要执行的操作。

您可以查看 示例16-5 中如何调用它们。

示例 16-5. 调度可批处理作业
use App\Jobs\CrunchReports;use Illuminate\Support\Facades\Bus;$user = auth()->user();$admin = User::admin()->first();$supervisor = User::supervisor()->first();$daysToCrunch = 7;Bus::batch([ new CrunchReports::dispatch($user, $daysToCrunch), new CrunchReports::dispatch($admin, $daysToCrunch), new CrunchReports::dispatch($supervisor, $daysToCrunch)])->then(function (Batch $batch) { // Run when the batch is completed successfully})->catch(function (Batch $batch, Throwable $e) { // Run when any job fails})->finally(function (Batch $batch) { // Run when the batch is complete})->dispatch();

从作业中添加作业到批次

如果您的批处理作业负责向批处理中添加作业——例如,如果您最初调度了几个作业调度器类型的作业——它们可以在由batch()返回的Batch对象上使用add()方法:

public function handle(): void{ if ($this->batch()->cancelled()) { return; } $this->batch()->add([ new \App\Jobs\ImportContacts, new \App\Jobs\ImportContacts, new \App\Jobs\ImportContacts, ]);}

取消批处理

如果作业有理由取消其批处理,则可以:

public function handle(): void{ if (/* This batch should be canceled for whatever reason */) { return $this->batch()->cancel(); } // ...}

批处理失败

默认情况下,如果批处理中的单个作业失败,批处理将标记为“已取消”。如果您想定义不同的行为,可以在调度批处理时链式调用 allowFailures()

$batch = Bus::batch([ // ...])->allowFailures()->dispatch();

清理批次表

批次表不会自我修剪,因此您需要计划您的应用程序定期“修剪”该表:

$schedule->command('queue:prune-batches')->daily();

运行队列工作者

那么队列工作者是什么,它是如何工作的?在 Laravel 中,它是一个运行到手动停止之前(直到手动停止)的 Artisan 命令,负责从队列中拉取作业并运行它们:

php artisan queue:work

此命令启动一个守护进程来“监听”您的队列;每当队列中有作业时,它将拉取第一个作业,处理它,然后删除它,并继续下一个。如果在任何时候队列中没有作业,它会“休眠”一段可配置的时间,然后再次检查是否有更多作业。

您可以定义作业在队列监听器停止之前允许运行的秒数(--timeout),当没有作业时监听器应“休眠”的秒数(--sleep),每个作业允许的重试次数(--tries),工作器应该监听的连接(queue:work后的第一个参数),以及它应该监听的队列(--queue=):

php artisan queue:work redis --timeout=60 --sleep=15 --tries=3 --queue=high,medium

您还可以使用php artisan queue:work处理单个作业。

处理错误

那么,当处理中的作业出现问题时会发生什么?

处理异常情况

如果抛出异常,队列监听器将释放该作业回到队列中。作业将被重新释放以再次处理,直到成功完成或达到队列监听器允许的最大尝试次数为止。

限制重试次数

最大重试次数由传递给queue:listenqueue:work Artisan 命令的--tries开关定义。

如果您没有设置--tries,或者将其设置为0,队列监听器将允许无限重试。这意味着如果有任何情况下一个工作可能永远无法完成——例如,如果它依赖于一个已经被删除的推文——您的应用程序将因为无限重试而逐渐停滞。

文档和 Laravel Forge 都将3作为建议的最大重试次数的起点。因此,在困惑时,从这里开始并进行调整:

php artisan queue:work --tries=3

如果您想要随时检查作业已尝试的次数,请在作业本身使用attempts()方法,例如示例16-6。

示例 16-6. 检查作业已尝试的次数
public function handle(): void{ ... if ($this->attempts() > 3) { // }}

您还可以通过在作业类本身定义$tries属性来指定给定作业可以重试的最大次数。当指定时,此值将优先于使用--tries开关设置的值:

public $tries = 3;

您可以在作业类中设置$maxExceptions属性,以指定作业可以抛出异常(因此可以重试)多少次,然后应该被视为失败:

// Can attempt this job 10 times.public $tries = 10;// If the job fails 3 times because an exception was thrown,// stop attempting the job and fail it.public $maxExceptions = 3;

您还可以指定作业何时超时,指示框架在指定时间范围内尝试任意次数的作业。您可以在作业上指定retryUntil()方法,并从中返回一个DateTime/Carbon实例:

public function retryUntil(){ return now()->addSeconds(30);}

基于作业的重试延迟

我们可以通过在作业上设置$retryAfter属性来指定在重新尝试失败的作业之前等待多长时间,等效于等待的分钟数。对于更复杂的计算,我们可以定义一个retryAfter方法,该方法也应返回等待的分钟数:

public $retryAfter = 10;public function retryAfter() {...}

作业中间件

我们可以通过中间件运行作业,就像我们通过中间件运行 HTTP 请求一样。这是提取保护或验证作业或它们运行条件的逻辑的好机会:

<?phpnamespace App\Jobs\Middleware;use Illuminate\Http\Response;class MyMiddleware{ public function handle($job, $next): Response { if ($something) { $next($job); } else { $job->release(5); } }}

要为作业分配一个中间件,指定作业类中的middleware()方法:

...use App\Jobs\Middleware\MyMiddleware;...public function middleware(){ return [new MyMiddleware];}

你还可以在调度作业时使用through方法指定一个中间件:

DoThingJob::dispatch()->through([new MyMiddleware]);

作业的速率限制中间件

Laravel 默认带有一个作业速率限制中间件。要使用它,在服务提供者的boot()方法中使用RateLimiter::for()定义速率限制器,如示例16-7 所示。

示例 16-7. 一个示例作业的速率限制中间件
// In a service providerpublic function boot(): void{ RateLimiter::for('imageConversions', function (object $job) { return $job->user->paidForPriorityConversions() ? Limit::none() : Limit::perHour(1)->by($job->user->id); });}

作业速率限制中间件的语法与路由速率限制中间件相同(“速率限制”)。

处理失败的作业

一旦一个作业超过了允许的重试次数,它被视为“失败”作业。在做任何其他事情之前,即使你只想限制作业的尝试次数,你也需要创建一个“失败的作业”数据库表。

有一个 Artisan 命令来创建迁移(然后你会想要迁移):

php artisan queue:failed-tablephp artisan migrate

任何超过允许的最大尝试次数的作业都会被倾倒在那里。但是,你可以对失败的作业做很多事情。

首先,你可以在作业本身定义一个failed()方法,在作业失败时运行(参见示例16-8)。

示例 16-8. 定义一个作业失败时运行的方法
...class CrunchReports implements ShouldQueue{ ... public function failed() { // Do whatever you want, like notify an admin }}

接下来,你可以注册一个全局的失败作业处理程序。在应用程序的启动过程中的任何位置,如果你不知道该放在哪里,只需将代码放在AppServiceProviderboot()方法中,像示例16-9 一样定义一个监听器。

示例 16-9. 注册一个全局处理程序来处理失败的作业
// Some service provideruse Illuminate\Support\Facades\Queue;use Illuminate\Queue\Events\JobFailed;// ... public function boot(): void { Queue::failing(function (JobFailed $event) { // $event->connectionName // $event->job // $event->exception }); }

还有一套用于与失败作业表交互的 Artisan 工具。

queue:failed显示你的失败作业列表:

php artisan queue:failed

列表看起来像这样:

+----+------------+---------+----------------------+---------------------+| ID | Connection | Queue | Class | Failed At |+----+------------+---------+----------------------+---------------------+| 9 | database | default | App\Jobs\AlwaysFails | 2018-08-26 03:42:55 |+----+------------+---------+----------------------+---------------------+

然后,你可以获取任何单个失败作业的 ID,并使用queue:retry重试它:

php artisan queue:retry 9

如果你宁愿重试所有作业,而不是传递 ID,请传递all

php artisan queue:retry all

你可以使用queue:forget删除一个单独的失败作业:

php artisan queue:forget 5

你可以删除所有超过一定时间的失败作业(默认为 24 小时,但也可以使用--hours=48传递自定义小时数):

php artisan queue:prune-failed

你可以使用queue:flush删除所有失败的作业:

php artisan queue:flush

控制队列

有时,在作业的处理过程中,你可能希望添加条件,可能会将作业释放以后重新启动,或永久删除作业。

要将作业释放回队列,使用release()方法,如示例16-10 所示。

示例 16-10. 将作业释放回队列
public function handle(){ ... if (condition) { $this->release($numberOfSecondsToDelayBeforeRetrying); }}

如果你想在处理过程中删除一个作业,你可以随时使用return,如示例16-11 所示;这是向队列发送的信号,表明作业已适当处理,不应返回到队列。

示例 16-11. 删除作业
public function handle(): void{ // ... if ($jobShouldBeDeleted) { return; }}

支持其他功能的队列

队列的主要用途是将作业推送到其中,但您也可以使用 Mail::queue 功能排队邮件。您可以在“队列”中了解更多信息。您还可以排队 Artisan 命令,我们在第八章中介绍过。

Laravel Horizon,就像我们涵盖过的其他一些工具(如 Scout、Passport 等),是 Laravel 提供的一个工具,不随核心捆绑。

Horizon 提供了有关您的 Redis 队列作业状态的见解。您可以看到哪些作业失败了,有多少作业在排队,以及它们的工作速度,甚至可以在任何队列超载或失败时收到通知。Horizon 仪表板显示在图16-1 中。

安装和运行 Horizon 相对简单,文档也很详细,因此如果您有兴趣,请查看Horizon 文档了解如何安装、配置和部署它。

请注意,您需要在您的 .envconfig/queue.php 配置文件中将队列连接设置为 redis,以便运行 Horizon。

Laravel-启动指南-全- - 绝不原创的飞龙 - 博客园 (7)

图 16-1. Horizon 仪表板

对于作业,调用代码通知应用程序应该做些什么CrunchReportsNotifyAdminOfNewSignup

对于事件,调用代码通知应用程序发生了什么UserSubscribedUserSignedUpContactWasAdded事件是通知发生了某事的方式。

框架本身可能会“触发”其中一些事件。例如,当保存、创建或删除 Eloquent 模型时,会触发事件。但是,应用程序的代码也可以手动触发某些事件。

触发事件本身不会做任何事情。但是,您可以绑定事件监听器,它们的唯一目的是监听特定事件的广播并响应。任何事件可以有从零到多个事件监听器。

Laravel 的事件结构类似于观察者或“发布/订阅”模式。许多事件被发送到应用程序中;有些可能从不被监听,而其他一些可能有十几个监听器。这些事件不知道也不关心。

触发事件

有三种方法可以触发一个事件。您可以使用 Event 门面,注入 Dispatcher,或者使用 event() 全局辅助函数,如示例16-12 所示。

示例 16-12. 触发事件的三种方法
Event::fire(new UserSubscribed($user, $plan));// or$dispatcher = app(Illuminate\Contracts\Events\Dispatcher::class);$dispatcher->fire(new UserSubscribed($user, $plan));// orevent(new UserSubscribed($user, $plan));

如果不确定,我建议使用全局辅助函数。

要创建要触发的事件,请使用 make:event Artisan 命令:

php artisan make:event UserSubscribed

这将生成一个类似于示例16-13 的文件。

示例 16-13. Laravel 事件的默认模板
<?phpnamespace App\Events;use Illuminate\Broadcasting\Channel;use Illuminate\Broadcasting\InteractsWithSockets;use Illuminate\Broadcasting\PresenceChannel;use Illuminate\Broadcasting\PrivateChannel;use Illuminate\Contracts\Broadcasting\ShouldBroadcast;use Illuminate\Foundation\Events\Dispatchable;use Illuminate\Queue\SerializesModels;class UserSubscribed{ use Dispatchable, InteractsWithSockets, SerializesModels; /** * Create a new event instance. */ public function __construct() { // } /** * Get the channels the event should broadcast on. * * @return array<int, \Illuminate\Broadcasting\Channel> */ public function broadcastOn(): array { return [ new PrivateChannel('channel-name'), ]; }}

让我们来看看这里得到了什么。SerializesModels 就像作业一样工作;它允许您接受 Eloquent 模型作为参数。InteractsWithSocketsShouldBroadcastbroadcastOn() 方法为使用 WebSockets 广播事件提供了支持功能,稍后我们将详细介绍。

或许奇怪的是这里没有 handle()fire() 方法。但请记住,此对象的存在并不是为了确定特定的动作,而只是为了封装一些数据。第一个数据片段是其名称;UserSubscribed 告诉我们发生了特定事件(用户订阅)。其余数据是我们传递到构造函数并与此实体关联的任何数据。

示例16-14 显示了我们可能希望在 UserSubscribed 事件中执行的操作。

示例 16-14. 向事件中注入数据
...class UserSubscribed{ use InteractsWithSockets, SerializesModels; public $user; public $plan; public function __construct($user, $plan) { $this->user = $user; $this->plan = $plan; }}

现在我们有了一个恰当表示发生事件的对象:$event->user 订阅了 $event->plan 计划。记住,触发此事件就像 event(new UserSubscribed($user, $plan)) 这样简单。

监听事件

我们已经有一个事件及其触发能力。现在让我们看看如何监听它。

首先,我们将创建一个事件监听器。假设我们希望每当新用户订阅时都向应用程序所有者发送电子邮件:

php artisan make:listener EmailOwnerAboutSubscription --event=UserSubscribed

这给我们提供了 示例16-15 文件。

示例 16-15. Laravel 事件监听器的默认模板
<?phpnamespace App\Listeners;use App\Events\UserSubscribed;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Queue\InteractsWithQueue;class EmailOwnerAboutSubscription{ /** * Create the event listener. */ public function __construct() { // } /** * Handle the event. */ public function handle(UserSubscribed $event): void { // }}

这就是发生动作的地方——handle() 方法所在的地方。此方法期望接收一个 UserSubscribed 类型的事件,并对其做出响应。

所以,让我们让它发送一封电子邮件(示例16-16)。

示例 16-16. 一个示例事件监听器
...use App\Mail\UserSubscribed as UserSubscribedMessage;class EmailOwnerAboutSubscription{ public function handle(UserSubscribed $event): void { Log::info('Emailed owner about new user: ' . $event->user->email); Mail::to(config('app.owner-email')) ->send(new UserSubscribedMessage($event->user, $event->plan); }}

现在,最后一项任务:我们需要设置此监听器以侦听 UserSubscribed 事件。我们将在 EventServiceProvider 类的 $listen 属性中执行此操作(参见 示例16-17)。

示例 16-17. 将监听器绑定到 EventServiceProvider 中的事件
class EventServiceProvider extends ServiceProvider{ protected $listen = [ \App\Events\UserSubscribed::class => [ \App\Listeners\EmailOwnerAboutSubscription::class, ], ];

正如您所看到的,每个数组条目的键是事件类的类名,值是监听器类名的数组。我们可以在 UserSubscribed 键下添加尽可能多的类名,它们都将监听并响应每个 UserSubscribed 事件。

自动事件发现

您还可以指示 Laravel 自动连接事件及其匹配的监听器,而无需在 EventServiceProvider 中手动绑定它们。此功能称为自动事件发现,默认情况下禁用,但可以通过在 EventServiceProvider 中设置 shouldDiscoverEvents() 方法返回 true 来启用:

/** * Determine if events and listeners should be automatically discovered. */public function shouldDiscoverEvents(): bool{ return true;}

如果启用此功能,Laravel 将根据监听器中的类型提示将事件映射到它们的匹配监听器。这将导致每个请求都要进行匹配,这会给您的应用程序引入一小段延迟,但像许多慢速功能一样,您可以使用 php artisan event:cache 缓存这些查找,并使用 php artisan event:clear 清除缓存。

事件订阅者

还有一种结构可以用来定义事件和其监听器之间的关系。Laravel 中有一个称为 事件订阅者 的概念,它是一个包含一组方法的类,这些方法充当独立事件的监听器,并包含应该处理哪个事件的映射。在这种情况下,展示比解释更容易理解,请查看 示例16-18。请注意,事件订阅者不是特别常用的工具。

示例 16-18. 一个样例事件订阅者
<?phpnamespace App\Listeners;class UserEventSubscriber{ public function onUserSubscription($event) { // Handles the UserSubscribed event } public function onUserCancellation($event) { // Handles the UserCanceled event } public function subscribe($events) { $events->listen( \App\Events\UserSubscribed::class, 'App\Listeners\UserEventSubscriber@onUserSubscription' ); $events->listen( \App\Events\UserCanceled::class, 'App\Listeners\UserEventSubscriber@onUserCancellation' ); }}

订阅者需要定义一个 subscribe() 方法,该方法传递了一个事件分发器的实例。我们将使用它将事件与它们的监听器配对,但在这种情况下,这些监听器是这个类的方法,而不是整个类。

作为提醒,每当你看到像 @ 这样的内联内容时,它意味着类名在 @ 的左边,方法名在右边。因此,在 示例16-18 中,我们定义了该订阅者的 onUserSubscription() 方法将监听任何 UserSubscribed 事件。

我们还需要做最后一件事情:在 App\Providers\EventServiceProvider 中,我们需要将我们的订阅者类名添加到 $subscribe 属性中,如 示例16-19 所示。

示例 16-19. 注册事件订阅者
...class EventServiceProvider extends ServiceProvider{ ... protected $subscribe = [ \App\Listeners\UserEventSubscriber::class ];}

WebSocket(通常称为 WebSockets)是一种协议,由 Pusher(一个托管的 WebSocket SaaS 服务)推广,它简化了在 web 设备之间提供几乎实时通信。与依赖通过 HTTP 请求传递信息不同,WebSockets 库打开了客户端和服务器之间的直接连接。WebSockets 背后的工具如 Gmail 和 Facebook 中的聊天框,您无需等待页面重新加载或 Ajax 请求接收或发送数据;相反,数据实时发送和接收。

WebSockets 最适合使用小块数据以发布/订阅结构传递,就像 Laravel 的事件一样。Laravel 内置了一套工具集,可以轻松定义一个或多个事件应广播到 WebSocket 服务器;例如,很容易定义一个 MessageWasReceived 事件,该事件在您的应用程序接收到消息时即时发布到特定用户或一组用户的通知框中。

配置与设置

查看 config/broadcasting.php 可以找到事件广播的配置设置。Laravel 支持三种驱动程序用于广播:Pusher,一个付费的 SaaS 提供商;Redis,用于本地运行的 WebSocket 服务器;以及 log,用于本地开发和调试。

为了使事件广播快速进行,Laravel 将广播事件的指令推送到队列上。这意味着您需要运行一个队列工作程序(或者在本地开发时使用 sync 队列驱动程序)。请参阅 “运行队列工作程序” 以了解如何运行队列工作程序。

Laravel 建议在队列工作程序寻找新作业之前默认延迟三秒钟。然而,通过事件广播,您可能会注意到某些事件需要一两秒才能广播。为了加快速度,请将队列设置更新为在寻找新作业之前等待一秒钟。

广播事件

要广播事件,您需要将该事件标记为广播事件,并使其实现 Illuminate\Contracts\Broadcasting\ShouldBroadcast 接口。该接口要求您添加 broadcastOn() 方法,该方法将返回一个字符串或 Channel 对象数组,每个表示一个 WebSocket 通道。

示例16-20 展示了我们的 UserSubscribed 事件,修改为在两个通道上进行广播:一个用于用户(确认用户的订阅),另一个用于管理员(通知他们有新的订阅)。

示例 16-20. 在多个通道上进行广播的事件
...use Illuminate\Contracts\Broadcasting\ShouldBroadcast;class UserSubscribed implements ShouldBroadcast{ use Dispatchable, InteractsWithSockets, SerializesModels; public $user; public $plan; public function __construct($user, $plan) { $this->user = $user; $this->plan = $plan; } public function broadcastOn(): array { // String syntax return [ 'users.' . $this->user->id, 'admins' ]; // Channel object syntax return [ new Channel('users.' . $this->user->id), new Channel('admins'), // If it were a private channel: new PrivateChannel('admins'), // If it were a presence channel: new PresenceChannel('admins'), ]; }}

默认情况下,事件的任何公共属性将被序列化为 JSON 并作为广播事件的数据发送。这意味着我们的广播 UserSubscribed 事件的数据可能类似于 示例16-21。

示例 16-21. 示例广播事件数据
{ 'user': { 'id': 5, 'name': 'Fred McFeely', ... }, 'plan': 'silver'}

您可以通过从事件的 broadcastWith() 方法返回数据数组来覆盖此行为,如 示例16-22 所示。

示例 16-22. 自定义广播事件数据
public function broadcastWith(){ return [ 'userId' => $this->user->id, 'plan' => $this->plan ];}

您可以通过在事件类上设置 $broadcastQueue 属性来自定义将事件推送到哪个队列:

public $broadcastQueue = 'websockets-for-faster-processing';

您可能会选择这样做,以防止其他队列项目减慢事件广播的速度;如果排队中的长时间运行作业使事件无法及时发送,则实时 WebSocket 将不会很有趣。

您还可以通过使其实现 ShouldBroadcastNow 合同(如 示例16-23 所示)来强制使某个事件完全跳过队列(使用“sync”队列驱动程序,由当前 PHP 线程处理)。

示例 16-23. 强制事件跳过广播队列
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;class UserSubscribed implements ShouldBroadcastNow{ //}

最后,您可以选择通过为其添加 broadcastWhen() 方法来自定义是否应完全广播给定事件,如 示例16-24 所示:

示例 16-24. 有条件地确定是否应广播事件
public function broadcastWhen(){ // Notify me only when users sign up from the White House return Str::contains($this->user->email, 'whitehouse.gov');}

接收消息

截至本书出版时,Laravel 开发者最常用的解决方案是 Pusher。某些规模以上的计划需要付费,但有一个慷慨的免费计划。Pusher 极大地简化了设置简单 WebSocket 服务器的过程,其 JavaScript SDK 几乎不需要你的任何工作即可处理所有的认证和频道管理。SDK 可用于 iOS、Android 和许多其他平台、语言和框架。

如果你想要托管自己的与 Pusher 兼容的 WebSockets 服务器,有两个很好的选择。首先,你可以尝试一个基于 Laravel 的工具,名为 Laravel WebSockets。你可以将这个包安装到你当前的 Laravel 应用程序中(与你正在广播的相同应用程序),或者安装到一个单独的微服务中。

第二,如果你使用 Docker(包括 Sail),你可以安装 Soketi,这是一个由 TypeScript 开发的免费 Pusher 替代品。

如果你选择与 Pusher 不同的服务器一起工作,你将按照本书中与 Pusher 工作的所有指导进行操作,但是你的配置设置将有所不同。

即使你最终选择使用 Echo,了解如何监听 Laravel 的广播事件也是有帮助的。但由于这里的大部分代码在使用 Echo 时是不必要的,我建议先阅读本节,然后再阅读 “Laravel Echo (JavaScript 部分)”,你可以决定你更喜欢哪种方式,然后从那里开始编写你的代码。

要开始使用,引入 Pusher 的库,从你的 Pusher 帐户获取 API 密钥,并使用类似于 示例16-25 中的代码订阅任何频道上的任何事件是很有帮助的。

示例 16-25. Pusher 的基本使用
...<script src="https://js.pusher.com/4.3/pusher.min.js"></script><script>// Enable Pusher logging - don't include this in productionPusher.logToConsole = true;// Globally, perhaps; just a sample of how to get data invar App = { 'userId': {{ auth()->id() }}, 'pusherKey': '{{ config('broadcasting.connections.pusher.key') }}'};// Locallyvar pusher = new Pusher(App.pusherKey, { cluster: '{{ config('broadcasting.connections.pusher.options.cluster') }}', encrypted: {{ config('broadcasting.connections.pusher.options.encrypted') }}});var pusherChannel = pusher.subscribe('users.' + App.userId);pusherChannel.bind('App\\Events\\UserSubscribed', (data) => { console.log(data.user, data.plan);});</script>

由于 \ 是 JavaScript 中的控制字符,你需要写 \\ 来表示字符串中的反斜杠,这就是为什么在 示例16-25 中每个命名空间段之间有两个反斜杠的原因。

要从 Laravel 发布到 Pusher,从你的 Pusher 帐户仪表板获取你的 Pusher 密钥、密钥、集群和应用程序 ID,然后在你的 .env 文件中分别设置它们为 PUSHER_KEYPUSHER_SECRETPUSHER_APP_CLUSTERPUSHER_APP_ID 键。

如果你提供你的应用程序,访问一个页面,并在其中嵌入来自 示例16-25 的 JavaScript,在一个窗口中推送广播事件或从终端中进行操作,有一个队列监听器正在运行或者正在使用 sync 驱动,并且所有的认证信息都设置正确,那么你应该能够在几乎实时地在你的 JavaScript 窗口的控制台中看到事件日志弹出。

有了这种能力,你现在可以轻松地在用户在你的应用中时随时更新他们的数据状态。你可以通知用户其他用户的操作,刚刚完成的长时间运行的过程,或者你的应用对外部动作(如传入的电子邮件或 Webhook)的响应。可能性无限。

如果你想要使用 Pusher 或 Redis 进行广播,你需要引入以下依赖:

  • Pusher:pusher/pusher-php-server "~3.0"

  • Redis:predis/predis

高级广播工具

Laravel 还有一些工具,可以在事件广播中执行更复杂的交互。这些工具是框架功能和 JavaScript 库的组合,称为Laravel Echo

当你在 JavaScript 前端使用 Laravel Echo 时,这些框架功能表现最佳(我们将在“Laravel Echo (the JavaScript Side)”中介绍),但你仍然可以在不使用 JavaScript 组件的情况下享受 Echo 的一些好处。Echo 可以与 Pusher 和 Redis 一起使用,但我将在示例中使用 Pusher。

排除当前用户的广播事件

每个与 Pusher 的连接都分配了一个唯一的“socket ID”,用于标识该套接字连接。很容易定义任何给定套接字(用户)应该被排除在接收特定广播事件之外。

此功能使得可以定义某些事件不应广播给触发它们的用户。假设团队中的每个用户在其他用户创建任务时都会收到通知;你是否希望在刚创建的任务中收到通知?不,这就是我们有toOthers()方法的原因。

要实现这一点,需要按照两个步骤进行。首先,当 WebSocket 连接初始化时,你需要设置你的 JavaScript 发送到/broadcasting/socket的特定POST。这会将你的socket_id附加到你的 Laravel 会话中。Echo 会为你完成这一步骤,但你也可以手动完成——查看Echo 源码了解其工作原理。

接下来,你需要更新每个 JavaScript 发出的请求,以包含包含socket_idX-Socket-ID头部。示例16-26 展示了如何在 Axios 或 jQuery 中实现这一点。请注意,你的事件必须使用Illuminate\Broadcasting\InteractsWithSockets特性以调用toOthers()方法。

示例 16-26. 使用 Axios 或 jQuery 发送每个 Ajax 请求时发送 socket ID
// Run this right after you initialize Echo// With Axioswindow.axios.defaults.headers.common['X-Socket-Id'] = Echo.socketId();// With jQuery$.ajaxSetup({ headers: { 'X-Socket-Id': Echo.socketId() }});

处理完这些后,你可以使用broadcast()全局助手而不是event()全局助手,并在其后链式调用toOthers(),从而排除任何事件被广播给触发它的用户:

broadcast(new UserSubscribed($user, $plan))->toOthers();

广播服务提供程序

Echo 提供的所有其他功能都需要你的 JavaScript 与服务器进行身份验证。查看App\Providers\BroadcastServiceProvider,在那里你将定义如何授权用户访问你的私有和存在通道。

您可以采取的两个主要操作是定义将在广播授权路由上使用的中间件,以及为您的通道定义授权设置。

如果您要使用这些功能,需要取消注释 config/app.php 中的 App\Providers\BroadcastServiceProvider::class 行。

如果您要使用这些功能而不使用 Laravel Echo,您将需要手动处理在身份验证请求中发送 CSRF 令牌,或者通过将它们添加到 VerifyCsrfToken 中间件的 $except 属性中,排除 /broadcasting/auth/broadcasting/socket 的 CSRF 保护。

绑定 WebSocket 通道的授权定义

私有和存在的 WebSocket 通道需要能够 ping 您的应用程序,以了解当前用户是否对该通道授权。您将使用 Broadcast::channel() 方法在 routes/channels.php 文件中定义此授权的规则。

WebSockets 中有三种类型的通道:公共、私有和存在:

公共通道

可由任何用户订阅,无论是否经过身份验证。

私有通道

要求最终用户的 JavaScript 对应用程序进行身份验证,以证明用户既经过身份验证又被授权加入此通道。

存在通道

一种私有通道类型,但不是用于消息传递,而是仅跟踪加入和离开通道的用户,并使此信息可供应用程序的前端使用。

Broadcast::channel() 接受两个参数:首先是表示要匹配的通道的字符串,其次是定义如何为匹配该字符串的任何通道授权用户的闭包。闭包将以当前用户的 Eloquent 模型作为其第一个参数,并将任何匹配的 variableNameHere 段作为附加参数传递。例如,具有字符串 teams.*teamId* 的通道授权定义,当与通道 teams.5 匹配时,将闭包 $user 作为第一个参数,并将 5 作为第二个参数传递。

如果您正在定义私有通道的规则,则您的 Broadcast::channel() 闭包需要返回一个布尔值:此用户是否对此通道授权?如果您正在为存在通道定义规则,则您的闭包应返回一个数据数组,您希望这些数据在任何要显示在通道中的用户中可用。示例 16-27 演示了如何定义这两种通道的规则。

示例 16-27. 为私有和存在的 WebSocket 通道定义授权规则
...// routes/channels.php// Define how to authenticate a private channelBroadcast::channel('teams.{teamId}', function ($user, $teamId) { return (int) $user->team_id === (int) $teamId;});// Define how to authenticate a presence channel; return any data// you want the app to have about the user in the channelBroadcast::channel('rooms.{roomId}', function ($user, $roomId) { if ($user->rooms->contains($roomId)) { return [ 'name' => $user->name ]; }});

您可能想知道如何将此信息从您的 Laravel 应用程序传递到您的 JavaScript 前端。Pusher 的 JavaScript 库将向您的应用程序发送 POST 请求;默认情况下,它将命中 /pusher/auth,但您可以自定义它(Echo 会自动为您自定义它)以命中 Laravel 的认证路由 /broadcasting/auth

var pusher = new Pusher(App.pusherKey, { authEndpoint: '/broadcasting/auth'});

示例16-28 展示了如何调整 示例16-25 来处理私有和存在频道,不使用 Echo 的前端组件。

示例 16-28. 使用 Pusher 处理私有和存在频道的基本用法
...<script src="https://js.pusher.com/4.3/pusher.min.js"></script><script> // Enable Pusher logging - don't include this in production Pusher.logToConsole = true; // Globally, perhaps; just a sample of how to get data in var App = { 'userId': {{ auth()->id() }}, 'pusherKey': '{{ config('broadcasting.connections.pusher.key') }}' }; // Locally var pusher = new Pusher(App.pusherKey, { cluster: '{{ config('broadcasting.connections.pusher.options.cluster') }}', encrypted: {{ config('broadcasting.connections.pusher.options.encrypted') }}, authEndpoint: '/broadcasting/auth' }); // Private channel var privateChannel = pusher.subscribe('private-teams.1'); privateChannel.bind('App\\Events\\UserSubscribed', (data) => { console.log(data.user, data.plan); }); // Presence channel var presenceChannel = pusher.subscribe('presence-rooms.5'); console.log(presenceChannel.members);</script>

现在我们可以根据用户是否通过给定频道的授权规则发送 WebSocket 消息。我们还可以跟踪哪些用户在站点的特定组或部分活跃,并向每个用户显示有关同一组中其他用户的相关信息。

Laravel Echo(JavaScript 部分)

Laravel Echo 包含两个部分:我们刚刚介绍过的高级框架功能,以及利用这些功能并大幅减少所需编写的样板代码的 JavaScript 包。Echo JavaScript 包能够轻松处理认证、授权以及订阅私有和存在频道。Echo 可以与 Pusher SDK(适用于 Pusher 或自定义 Pusher 兼容服务器)或 socket.io 一起使用。

将 Echo 引入到您的项目中

要在项目的 JavaScript 中使用 Echo,请使用 npm install --save 将其添加到 package.json(确保还引入适当的 Pusher 或 socket.io SDK):

npm install pusher-js laravel-echo --save

假设您有一个基本的 Vite 文件来编译您的 app.js,就像在 Laravel 的默认安装设置中一样。

Laravel 默认的 resources/js/app.js 结构提供了一个很好的示例,展示了如何最佳初始化您的 Echo 安装。看看 示例16-29 来了解它在该文件和 resources/js/bootstrap.js 之间的工作方式。

示例 16-29. 在 app.js 和 bootstrap.js 中初始化 Echo
// app.jsrequire('./bootstrap');// ... lots of Vue stuff ...// Add your Echo bindings here
// bootstrap.jsimport Echo from "laravel-echo";window.Echo = new Echo({ broadcaster: 'pusher', key: process.env.MIX_PUSHER_APP_KEY, cluster: process.env.MIX_PUSHER_APP_CLUSTER});

为了 CSRF 保护,您还需要将 csrf-token <meta> 标签添加到 HTML 模板中:

<meta name="csrf-token" content="{{ csrf_token() }}">

当然,还要记得在 HTML 模板中链接到编译后的 app.js

<script src="{{ asset('js/app.js') }}"></script>

现在我们准备开始了。

如果您正在使用 Laravel WebSockets 服务器(使用之前讨论的包,见 “接收消息”),则 示例16-29 中的配置细节会有些不同。有关更多信息,请参阅 Laravel WebSockets 包文档

使用 Echo 进行基本事件广播

这与我们已经使用 Pusher 的方式没有什么不同,但 示例16-30 是一个简单的代码示例,展示了如何使用 Echo 监听公共频道获取基本事件信息。

示例 16-30. 使用 Echo 监听公共频道
var currentTeamId = 5; // Likely set elsewhereEcho.channel(`teams.${currentTeamId}`) .listen('UserSubscribed', (data) => { console.log(data); });

Echo 提供了几种订阅不同类型频道的方法;channel()将你订阅到一个公共频道。请注意,当你使用 Echo 监听事件时,你可以忽略完整的事件命名空间,只需监听这个事件的唯一类名即可。

现在我们可以访问传递给事件的公共数据,以data对象表示。我们也可以链式使用listen()处理程序,就像在示例16-31 中那样。

示例 16-31. 在 Echo 中链式事件监听器
Echo.channel(`teams.${currentTeamId}`) .listen('UserSubscribed', (data) => { console.log(data); }) .listen('UserCanceled', (data) => { console.log(data); });

如果你尝试了这些代码示例,却没有看到浏览器中有任何变化,确保运行npm run dev(如果你在本地运行)或者npm run build(用于构建一次)来编译你的代码。而且,如果还没有的话,确保确实在你的模板中包含app.js

私有频道和基本认证

Echo 还有一个订阅私有频道的方法:private()。它的工作方式与channel()相同,但要求你在routes/channel.php中设置频道授权定义,就像我们之前讨论的那样。此外,与 SDK 不同的是,你不需要在频道名前加上private-

示例16-32 展示了如何监听名为private-teams.5的私有频道。

示例 16-32. 使用 Echo 监听私有频道
var currentTeamId = 5; // Likely set elsewhereEcho.private(`teams.${currentTeamId}`) .listen('UserSubscribed', (data) => { console.log(data); });

存在频道

Echo 使加入和监听存在频道的事件变得更简单。这次你会想要使用join()方法来绑定到频道,就像在示例16-33 中那样。

示例 16-33. 加入存在频道
var currentTeamId = 5; // Likely set elsewhereEcho.join(`teams.${currentTeamId}`) .here((members) => { console.log(members); });

join()订阅存在频道,而here()允许你定义用户加入时以及其他用户加入或离开时的行为。

你可以把存在频道想象成聊天室中的“在线用户”侧边栏。当你首次加入存在频道时,你的here()回调将被调用,并提供那时所有成员的列表。任何成员加入或离开时,该回调将再次被调用,带上更新后的列表。这里没有消息传递,但你可以播放声音、更新页面上的成员列表,或者根据这些动作做任何其他响应。

还有特定事件的特定方法,你可以单独使用或者链式使用(参见示例16-34)。

示例 16-34. 监听特定存在事件
var currentTeamId = 5; // Likely set elsewhereEcho.join('teams.' + currentTeamId) .here((members) => { // Runs when you join console.table(members); }) .joining((joiningMember, members) => { // Runs when another member joins console.table(joiningMember); }) .leaving((leavingMember, members) => { // Runs when another member leaves console.table(leavingMember); });

排除当前用户

我们在本章中已经讨论过这个问题,但是如果你想排除当前用户,可以使用broadcast()全局助手,而不是event()全局助手,然后在广播调用后链式使用toOthers()方法。但是使用 Echo 时,这个 JavaScript 部分已经为你处理好了,它会正常工作。

正如你所见,Echo JavaScript 库并不会做任何你自己不能做的事情——但它使许多常见任务变得更简单,并为常见 WebSocket 任务提供了更干净、更表达性强的语法。

使用 Echo 订阅通知

Laravel 的通知默认带有广播驱动程序,将通知作为广播事件推送出去。你可以使用 Echo.notification() 订阅这些通知,如 Example16-35 中所示。

示例 16-35. 使用 Echo 订阅通知
Echo.private(`App.User.${userId}`) .notification((notification) => { console.log(notification.type); });

客户端事件

如果你想在不让消息甚至触及你的 Laravel 应用程序的情况下,即快速地在用户之间发送性能卓越的消息,例如发送“正在输入…”通知,你可以使用 Echo 的 whisper() 方法,如示例 Example16-36 所示。

示例 16-36. 使用 Echo 的 whisper() 方法绕过 Laravel 服务器
Echo.private('room') .whisper('typing', { name: this.user.name });

然后使用 listenForWhisper() 来监听,如 Example16-37 中所示。

示例 16-37. 使用 Echo 监听 whisper 事件
Echo.private('room') .listenForWhisper('typing', (e) => { console.log(e.name); });

如果你以前写过 cron 任务,你很可能已经希望有一个更好的工具。语法不仅繁琐且令人沮丧,难以记住,而且它是你的应用程序中无法存储在版本控制中的重要部分之一。

Laravel 的调度器使处理预定任务变得简单。你会在代码中编写你的预定任务,然后指向你的应用程序一个 cron 任务:每分钟运行 php artisan schedule:run。每次运行此 Artisan 命令时,Laravel 都会检查你的调度定义,以查看是否应该运行任何预定任务。

这是定义该命令的 cron 任务:

* * * * * cd /home/myapp.com && php artisan schedule:run >> /dev/null 2>&1

你可以安排许多任务类型,并使用许多时间框架来安排它们。

app/Console/Kernel.php 中有一个名为 schedule() 的方法,你可以在其中定义你想要调度的任何任务。

可用的任务类型

首先,让我们看看最简单的选项:一个闭包,每分钟运行一次 (Example16-38)。每次 cron 任务执行 schedule:run 命令时,它都会调用此闭包。

示例 16-38. 安排一个闭包每分钟运行一次
// app/Console/Kernel.phppublic function schedule(Schedule $schedule): void{ $schedule->call(function () { CalculateTotals::dispatch(); })->everyMinute();}

你可以安排的另外两种任务类型是 Artisan 和 shell 命令。

你可以通过以与从命令行调用它们完全相同的语法来安排 Artisan 命令:

$schedule->command('scores:tally --reset-cache')->everyMinute();

你还可以运行任何你可以用 PHP 的 exec() 方法运行的 shell 命令:

$schedule->exec('/home/myapp.com/bin/build.sh')->everyMinute();

可用的时间框架

调度器的美妙之处不仅在于你可以用代码定义你的任务;同样你也可以用代码调度它们。Laravel 会跟踪时间的流逝,并评估是否到了运行任何给定任务的时间。对于 everyMinute() 来说很简单:运行任务的答案始终是简单的。但是 Laravel 也会为你保持其余的简单,即使是对于最复杂的请求也是如此。

让我们从一个简单的 Laravel 定义开始,通过看一个巨大的定义选项:

$schedule->call(function () { // Runs once a week on Sunday at 23:50})->weekly()->sundays()->at('23:50');

注意,我们可以链接不同的时间设置:定义频率、指定星期几和时间,当然还可以做更多。

表16-1 显示了调度作业时可用的潜在日期/时间修饰符列表。

表16-1. 用于调度的日期/时间修饰符

Command描述
->timezone('America/Detroit')设置调度任务的时区
->cron('* * * * * *')使用传统的 cron 表达式定义调度时间
->everyMinute()每分钟运行
->everyTwoMinutes()每 2 分钟运行
->everyThreeMinutes()每 3 分钟运行
->everyFourMinutes()每 4 分钟运行
->everyFiveMinutes()每 5 分钟运行
->everyTenMinutes()每 10 分钟运行
->everyFifteenMinutes()每 15 分钟运行
->everyThirtyMinutes()每 30 分钟运行
->hourly()每小时运行
->hourlyAt(14)每小时 14 分钟运行
->everyTwoHours()每 2 小时运行
->everyThreeHours()每 3 小时运行
->everyFourHours()每 4 小时运行
->everySixHours()每 6 小时运行
->daily()每天午夜运行
->dailyAt('14:00')每天 14:00 运行
->twiceDaily(1, 14)每天 1:00 和 14:00 运行
->twiceDailyAt(1, 14, 6)每天 1:06 和 14:06 运行(第三个参数是开始分钟数)
->weekly()每周运行(每周星期日午夜)
->weeklyOn(5, '10:00')每周五的 10:00 运行
->monthly()每月运行(每月 1 日午夜)
->monthlyOn(15, '23:00')每月 15 日的 23:00 运行
->quarterly()每季度运行(每年 1 月、4 月、7 月和 10 月的午夜)
->yearly()每年运行(每年 1 月 1 日午夜)
->yearlyOn(6)每年运行(每年 6 月 1 日午夜)
->when(closure)仅在闭包返回true时运行任务
->skip(closure)仅在闭包返回false时运行任务
->between('8:00', '12:00')仅在指定时间范围内运行任务
->unlessBetween('8:00', '12:00')除了指定时间范围外的任何时间都可以运行任务
->weekdays()限制为工作日
->sundays()限制为星期日
->mondays()限制为星期一
->tuesdays()限制为星期二
->wednesdays()限制为星期三
->thursdays()限制为星期四
->fridays()限制为星期五
->saturdays()限制为星期六
->days([1,2])限制为星期日和星期一
->environments(*staging*)限制为仅在演示环境运行

大多数修饰符可以链式使用,但当然,任何不能合理链式使用的组合都不能链式使用。

示例16-39 展示了几种你可以考虑的组合。

示例 16-39. 一些样本调度事件
// Both run weekly on Sunday at 23:50$schedule->command('do:thing')->weeklyOn(0, '23:50');$schedule->command('do:thing')->weekly()->sundays()->at('23:50');// Run once per hour, weekdays, 8am-5pm$schedule->command('do:thing')->weekdays()->hourly()->when(function () { return date('H') >= 8 && date('H') <= 17;});// Run once per hour, weekdays, 8am-5pm using the "between" method$schedule->command('do:thing')->weekdays()->hourly()->between('8:00', '17:00');// Run every 30 minutes except when directed not to by the SkipDetector$schedule->command('do:thing')->everyThirtyMinutes()->skip(function () { return app('SkipDetector')->shouldSkip();});

为计划命令定义时区

你可以使用timezone()方法在特定调度命令上定义时区:

$schedule->command('do:it')->weeklyOn(0, '23:50')->timezone('America/Chicago');

你也可以设置一个默认的时区(与应用程序时区分开),所有计划任务的时间都可以通过在 App\Console\Kernel 中定义 scheduleTimezone() 方法来定义:

protected function scheduleTimezone(){ return 'America/Chicago';}

阻塞和重叠

如果你想避免任务重叠—例如,如果一个任务每分钟运行一次,但有时可能需要超过一分钟才能运行完毕—可以用withoutOverlapping()方法结束调度链。这个方法会在前一个任务实例仍在运行时跳过该任务:

$schedule->command('do:thing')->everyMinute()->withoutOverlapping();

处理任务输出

有时,计划任务的输出很重要,无论是用于日志记录、通知,还是仅仅确保任务运行。

如果你想将任务的返回输出写入文件(有可能覆盖文件中已有内容),可以使用sendOutputTo()

$schedule->command('do:thing')->daily()->sendOutputTo($filePath);

如果你想将输出追加到一个文件中,可以使用appendOutputTo()

$schedule->command('do:thing')->daily()->appendOutputTo($filePath);

如果你想将输出邮件发送给指定收件人,请先写入文件,然后添加emailOutputTo()

$schedule->command('do:thing') ->daily() ->sendOutputTo($filePath) ->emailOutputTo('me@myapp.com');

确保 Laravel 的基本电子邮件配置中正确配置了你的电子邮件设置。

sendOutputTo()appendOutputTo()emailOutputTo()方法仅适用于command()调度任务。很遗憾,你不能将它们用于闭包。

你可能还想将一些输出发送到 Webhook 来验证任务是否正确运行。有几种服务提供这种类型的运行时间监控,最显著的是Laravel Envoyer,一个零停机时间部署服务,还提供 cron 运行时间监控,以及Dead Man’s Snitch,一个专门用于监控 cron 作业运行时间的工具。

这些服务不希望有人给它们发送电子邮件,而是希望接收一个 HTTP "ping",所以 Laravel 通过pingBefore()thenPing()简化了这一过程:

$schedule->command('do:thing') ->daily() ->pingBefore($beforeUrl) ->thenPing($afterUrl);

如果你想使用 ping 功能,需要使用 Composer 导入 Guzzle:

composer require guzzlehttp/guzzle

任务钩子

谈到在任务之前之后运行某些东西,有一些钩子可以做到,比如before()after()

$schedule->command('do_thing') ->daily() ->before(function () { // Prepare }) ->after(function () { // Cleanup });

在本地开发中运行调度程序

由于调度程序依赖于 cron,在服务器上设置比在本地机器上设置更简单。如果你想在本地运行调度程序,请运行schedule:work Artisan 命令,这将每分钟调用调度程序,就像 cron 作业一样:

php artisan schedule:work

测试排队作业(或队列中的任何其他内容)很容易。在 phpunit.xml 中(这是您测试的配置文件),QUEUE_DRIVER 环境变量默认设置为 sync。这意味着您的测试将同步运行作业或其他排队任务,直接在您的代码中,而无需依赖任何类型的队列系统。您可以像测试任何其他代码一样测试它们。

但是,您也可以针对特定的作业进行断言,如 示例16-40 中所示。

示例 16-40. 使用闭包验证分派的作业是否符合给定条件
use Illuminate\Support\Facades\Bus;...public function test_changing_subscriptions_triggers_crunch_job(){ // ... Bus::fake(); Bus::assertDispatched(CrunchReports::class, function ($job) { return $job->subscriptions->contains(5); }); // Also can use assertNotDispatched()}

还有 assertPushedWithChain()assertPushedWithoutChain() 方法。

Bus::fake();Bus::assertPushedWithChain( CrunchReports::class, [ChainedJob::class], function ($job) { return $job->subscriptions->contains(5); }); // Also can use assertPushedWithoutChain() Bus::assertPushedWithChain(CrunchReports::class, function ($job) { return $job->subscriptions->contains(5); });

要测试事件是否触发,您有两个选项。首先,您可以只测试您期望发生的行为,而不必关注事件本身。

其次,您可以针对触发的事件运行测试,如 示例16-41 中所示。

示例 16-41. 使用闭包验证触发的事件是否符合给定条件
use Illuminate\Support\Facades\Event;...public function test_usersubscribed_event_fires(){ Event::fake(); // ... Event::assertDispatched(UserSubscribed::class, function ($e) { return $e->user->email = 'user-who-subscribed@mail.com'; }); // Also can use assertNotDispatched()}

另一个常见的场景是,您正在测试意外触发事件的代码,并且希望在测试期间禁用事件监听器。您可以使用 withoutEvents() 方法禁用事件系统,如 示例16-42 中所示。

示例 16-42. 在测试期间禁用事件监听器
public function test_something_subscription_related(){ $this->withoutEvents(); // ...}

队列允许您将应用程序代码的块从用户交互的同步流中分离出来,转换为由“队列工作者”处理的命令列表。这使得您的用户可以在后台异步处理较慢的进程时恢复与您的应用程序的交互。

作业是结构良好的类,旨在封装应用程序行为的块,以便将其推送到队列中。

Laravel 的事件系统遵循发布/订阅或观察者模式,允许您从应用程序的一部分发送事件的通知,并在其他地方绑定监听器来定义对这些事件的响应行为。使用 WebSockets,事件也可以广播到前端客户端。

Laravel 的调度程序简化了任务的调度。将每分钟的 cron 任务指向 php artisan schedule:run,然后使用调度程序安排您的任务,即使是最复杂的时间要求,Laravel 也会为您处理所有的时间安排。

我们已经在整本书中涵盖了许多全局函数:这些是一些小辅助函数,使执行常见任务变得更加容易,例如用于作业的dispatch(),用于事件的event(),以及用于依赖解析的app()。我们还在第五章中讨论了 Laravel 的集合,或称为增强数组的数组。

本章将涵盖一些常见且强大的辅助函数,以及使用集合进行编程的基础知识。本节中的许多“辅助函数”曾经是全局函数,现在是门面的调用;array_first(),全局函数,已被Arr::first(),授权调用所取代。因此,虽然这些在技术上并非全部是全局函数,因为它们不再是全局函数,但它们在我们的工具箱中仍然占据同样的位置。

您可以在辅助文档中找到 Laravel 提供的所有辅助函数的完整列表,但我们将在此处介绍一些最有用的函数。

数组

PHP 的本地数组操作函数为我们提供了很多能力,但有时我们希望进行的标准操作需要笨拙的循环和逻辑检查。Laravel 的数组辅助函数使一些常见的数组操作变得更加简单:

Arr::first(*$array, $callback, $default = null*)

返回通过回调闭包定义的测试的第一个数组值。您可以选择将默认值设置为第三个参数。以下是一个例子:

$people = [ [ 'email' => 'm@me.com', 'name' => 'Malcolm Me' ], [ 'email' => 'j@jo.com', 'name' => 'James Jo' ],]; $value = Arr::first($people, function ($person, $key) { return $person['email'] == 'j@jo.com'; });

Arr::get(*$array, $key, $default = null*)

使从数组中获取值变得简单,具有两个附加好处:如果请求不存在的键,它不会抛出错误(并且您可以使用第三个参数提供默认值),并且您可以使用点表示法遍历嵌套数组。例如:

$array = ['owner' => ['address' => ['line1' => '123 Main St.']]];$line1 = Arr::get($array, 'owner.address.line1', 'No address');$line2 = Arr::get($array, 'owner.address.line2');

Arr::has(*$array, $keys*)

使用点表示法来遍历嵌套数组,轻松检查数组是否具有特定值设置。$keys参数可以是单个条目或条目数组,将检查数组中的每个条目是否存在:

$array = ['owner' => ['address' => ['line1' => '123 Main St.']]];if (Arr::has($array, 'owner.address.line2')) { // Do stuff}

Arr::hasAny(*$array, $keys*)

使用点表示法轻松检查数组是否具有指定键之一。$keys参数可以是单个键或键数组,将检查数组中是否存在任何键:

$array = ['owner' => ['address' => ['line1' => '123 Main St.']]];if (Arr::hasAny($array, ['owner.address', 'default.address'])) { // Do stuff}

Arr::pluck(*$array, $value, $key = null*)

返回与提供的键对应的值数组:

$array = [ ['owner' => ['id' => 4, 'name' => 'Tricia']], ['owner' => ['id' => 7, 'name' => 'Kimberly']],];$array = Arr::pluck($array, 'owner.name');// Returns ['Tricia', 'Kimberly'];

如果您希望返回的数组由源数组的另一个值键入,则可以将该值的点表示法引用作为第三个参数:

$array = Arr::pluck($array, 'owner.name', 'owner.id');// Returns [4 => 'Tricia', 7 => 'Kimberly'];

Arr::random(*$array, $num = null*)

从提供的数组中返回一个随机项。如果提供了$num参数,则会随机选择那么多个结果的数组:

$array = [ ['owner' => ['id' => 4, 'name' => 'Tricia']], ['owner' => ['id' => 7, 'name' => 'Kimberly']],];$randomOwner = Arr::random($array);

Arr::join(*$array, $glue, $finalGlue = ''*)

$array中的项目连接成一个字符串,在它们之间添加$glue。如果提供了$finalGlue,它将添加到数组的最后一个元素之前,而不是$glue

$array = ['Malcolm', 'James', 'Tricia', 'Kimberly'];Arr::join($array, ', ');// Malcolm, James, Tricia, KimberlyArr::join($array, ', ', ', and');// Malcolm, James, Tricia, and Kimberly

字符串

就像使用数组一样,使用原生 PHP 函数可以进行一些字符串操作和检查,但这可能很麻烦。Laravel 的帮助函数使一些常见的字符串操作变得更快更简单:

e(*$string*)

htmlentities()的别名;准备(通常是用户提供的)字符串,以便在 HTML 页面上安全地回显。例如:

e('<script>do something nefarious</script>');// Returns &lt;script&gt;do something nefarious&lt;/script&gt;

str(*$string*)

用于转换可字符串化对象;是Str::of(*$string*)的别名:

str('http') === Str::of('http');// true

Str::startsWith(*$haystack, $needle*)Str::endsWith(*$haystack, $needle*)Str::contains(*$haystack, $needle, $ignoreCase*)

返回一个布尔值,指示提供的$haystack字符串是否以提供的$needle字符串开头、结尾或包含:

if (Str::startsWith($url, 'https')) { // Do something}if (Str::endsWith($abstract, '...')) { // Do something}if (Str::contains($description, '1337 h4x0r')) { // Run away}

Str::limit(*$value, $limit = 100, $end = '...'*)

将字符串限制为提供的字符数。如果字符串的长度小于限制,则只返回字符串;如果大于,则修剪为提供的字符数,然后附加...或提供的$end字符串。例如:

$abstract = Str::limit($loremIpsum, 30);// Returns "Lorem ipsum dolor sit amet, co..."$abstract = Str::limit($loremIpsum, 30, "&hellip;");// Returns "Lorem ipsum dolor sit amet, co&hellip;"

Str::words(*$value, $words = 100, $end = '...'*)

将字符串限制为提供的单词数。如果字符串的长度小于单词数,只返回字符串;如果大于,修剪为提供的单词数,然后附加...或提供的$end字符串。例如:

$abstract = Str::words($loremIpsum, 3);// Returns "Lorem ipsum dolor..."$abstract = Str::words($loremIpsum, 5, " &hellip;");// Returns "Lorem ipsum dolor sit amet, &hellip;"

Str::before(*$subject, $search*)Str::after(*$subject, $search*)Str::beforeLast(*$subject, $search*)Str::afterLast(*$subject, $search*)

返回一个字符串的子字符串,在另一个字符串之前或之后,或最后一个实例之后。例如:

Str::before('Nice to meet you!', 'meet you');// Returns "Nice to "Str::after('Nice to meet you!', 'Nice');// Returns " to meet you!"Str::beforeLast('App\Notifications\WelcomeNotification', '\\');// Returns "App\Notifications"Str::afterLast('App\Notifications\WelcomeNotification', '\\');// Returns "WelcomeNotification"

Str::is(*$pattern, $value*)

返回一个布尔值,指示给定字符串是否与给定模式匹配。该模式可以是正则表达式模式,或者你可以使用星号表示通配符位置:

Str::is('*.dev', 'myapp.dev'); // trueStr::is('*.dev', 'myapp.dev.co.uk'); // falseStr::is('*dev*', 'myapp.dev'); // trueStr::is('*myapp*', 'www.myapp.dev'); // trueStr::is('my*app', 'myfantasticapp'); // trueStr::is('my*app', 'myapp'); // true

如果你想知道可以传递给Str::is()的正则表达式模式是什么,请查看这里的方法定义(空间有限,已简化)来了解它的工作原理:

public function is($pattern, $value){ if ($pattern == $value) return true; $pattern = preg_quote($pattern, '#'); $pattern = Str::replace('\*', '.*', $pattern); if (preg_match('#^'.$pattern.'\z#u', $value) === 1) { return true; } return false;}

Str::isUuid(*$value*)

确定该值是否为有效的 UUID:

Str::isUuid('33f6115c-1c98-49f3-9158-a4a4376dfbe1'); // Returns trueStr::isUuid('laravel-up-and-running'); // Returns false

Str::random(*$length = n*)

返回指定长度的大小写混合字母数字随机字符串:

$hash = Str::random(64);// Sample: J40uNWAvY60wE4BPEWxu7BZFQEmxEHmGiLmQncj0ThMGJK7O5Kfgptyb9ul wspmh

Str::slug(*$title, $separator = '-', $language = 'en'*)

从字符串中创建一个 URL 友好的 slug,通常用于为名称或标题创建 URL 段:

Str::slug('How to Win Friends and Influence People');// Returns 'how-to-win-friends-and-influence-people'

Str::plural(*$value, $count = n*)

将字符串转换为其复数形式。该函数目前仅支持英语语言:

Str::plural('book');// Returns booksStr::plural('person');// Returns peopleStr::plural('person', 1);// Returns person

__(*$key, $replace = [], $locale = null*)

使用你的本地化文件翻译给定的翻译字符串或翻译键:

echo __('Welcome to your dashboard');echo __('messages.welcome');

应用程序路径

当处理文件系统时,通常很繁琐为获取和保存文件创建链接到某些目录。这些助手函数让你快速访问到你的应用程序中一些最重要目录的完全限定路径。

请注意,这些函数可以不带参数调用,但如果传递参数,它将附加到正常目录字符串的末尾并作为整体返回:

app_path(*$append = ''*)

返回你的应用程序中app目录的路径:

app_path();// Returns /home/forge/myapp.com/app

base_path(*$path = ''*)

返回你的应用程序根目录的路径:

base_path();// Returns /home/forge/myapp.com

config_path(*$path = ''*)

返回你的应用程序中配置文件的路径:

config_path();// Returns /home/forge/myapp.com/config

database_path(*$path = ''*)

返回你的应用程序中数据库文件的路径:

database_path();// Returns /home/forge/myapp.com/database

storage_path(*$path = ''*)

返回你的应用程序中storage目录的路径:

storage_path();// Returns /home/forge/myapp.com/storage

lang_path(*$path = ''*)

返回你的应用程序中lang目录的路径:

lang_path();// Returns /home/forge/myapp.com/resources/lang

URLs

一些前端文件路径是一致的,但有时输入起来很烦人—例如,资源文件的路径—有方便的快捷方式对它们很有帮助,我们将在这里介绍。但是,一些路径实际上可能会变化,因此确保所有链接和资源正常工作的一些助手函数至关重要:

action(*$action*, *$parameters = []*, *$absolute = true*)

假设控制器方法只映射到一个 URL,根据控制器和方法名对(用@分隔)或使用元组表示法,返回正确的 URL:

<a href="{{ action('PersonController@index') }}">See all People</a>// Or, using tuple notation:<a href="{{ action( [App\Http\Controllers\PersonController::class, 'index'] ) }}"> See all People</a>// Returns <a href="http://myapp.com/people">See all People</a>

如果控制器方法需要参数,你可以将它们作为第二个参数传递(如果需要多个参数,则作为数组传递)。如果你想要清晰地标记它们,你可以给它们加上键,但重要的是它们按正确的顺序排列:

<a href="{{ action( 'PersonController@show', ['id => 3] ) }}">See Person #3</a>// or<a href="{{ action( 'PersonController@show', [3] ) }}">See Person #3</a// Returns <a href="http://myapp.com/people/3">See Person #3</a>

如果将第三个参数传递为false,你的链接将生成为相对路径(/people/3)而不是绝对路径(http://myapp.com/people/3)。

route(*$name*, *$parameters = []*, *$absolute = true*)

如果路由有名称,返回该路由的 URL:

// routes/web.phpRoute::get('people', [PersonController::class, 'index']) ->name('people.index');// A view somewhere<a href="{{ route('people.index') }}">See all People</a>// Returns <a href="http://myapp.com/people">See all People</a>

如果路由定义需要参数,你可以将它们作为第二个参数传递(如果需要多个参数,则作为数组传递)。同样,如果你想要清晰地标记它们,你可以给它们加上键,但重要的是它们按正确的顺序排列:

<a href="{{ route('people.show', ['id' => 3]) }}">See Person #3</a>// or<a href="{{ route('people.show', [3]) }}">See Person #3</a>// Returns <a href="http://myapp.com/people/3">See Person #3</a>

如果将第三个参数传递为false,你的链接将生成为相对路径而不是绝对路径。

url(*$string*)secure_url(*$string*)

给定任何路径字符串,转换为完全限定的 URL。(secure_url()url()相同,但强制使用 HTTPS):

url('people/3');// Returns http://myapp.com/people/3

如果没有传递参数,这将返回一个Illuminate``\Routing``\UrlGenerator的实例,这样就可以进行方法链式调用:

url()->current();// Returns http://myapp.com/abcurl()->full();// Returns http://myapp.com/abc?order=reverseurl()->previous();// Returns http://myapp.com/login// And many more methods available on the UrlGenerator...

杂项

有一些其他全局助手我建议你熟悉一下。当然,你应该查看完整列表,但这里提到的几个绝对值得一看:

abort(*$code, $message, $headers*), abort_unless(*$boolean, $code, $message, $headers*), abort_if(*$boolean, $code, $message, $headers*)

抛出 HTTP 异常。abort()抛出定义的异常,abort_unless()在第一个参数为false时抛出异常,abort_if()在第一个参数为true时抛出异常:

public function controllerMethod(Request $request){ abort(403, 'You shall not pass'); abort_unless(request()->filled('magicToken'), 403); abort_if(request()->user()->isBanned, 403);}

auth()

返回 Laravel 认证器的实例。像Auth门面一样,您可以使用它来获取当前用户,检查登录状态等:

$user = auth()->user();$userId = auth()->id();if (auth()->check()) { // Do something}

back()

生成一个“重定向回上一页”的响应,将用户发送到先前的位置:

Route::get('post', function () { // ... if ($condition) { return back(); }});

collect(*$array*)

接受一个数组并返回相同数据,转换为集合:

$collection = collect(['Rachel', 'Hototo']);

我们稍后会讨论集合。

config(*$key*)

返回任何点表示的配置项的值:

$defaultDbConnection = config('database.default');

csrf_field(), csrf_token()

返回一个完整的 HTML 隐藏输入字段(csrf_field())或仅适当的令牌值(csrf_token()),用于向您的表单提交添加 CSRF 验证:

<form> {{ csrf_field() }}</form>// or<form> <input type="hidden" name="_token" value="{{ csrf_token() }}"></form>

dump(*$variable*), dd(*$variable*...)

对所有提供的参数运行类似于var_dump()的输出;dd()还会运行exit()来退出应用程序(这用于调试):

// ...dump($var1, $var2); // Check the output...// ...dd($var1, $var2, $state); // Why is this not working???

env(*$key*, *$default = null*)

返回给定键的环境变量:

$key = env('API_KEY', '');

请记住,在配置文件之外绝不要使用env()

dispatch(*$job*)

分派一个作业:

dispatch(new EmailAdminAboutNewUser($user));

event(*$event*)

触发一个事件:

event(new ContactAdded($contact));

old(*$key = null*, *$default = null*)

返回此表单键的上次用户表单提交的旧值(如果存在):

<input name="name" value="{{ old('value', 'Your name here') }}"

redirect(*$path*)

返回到给定路径的重定向响应:

Route::get('post', function () { // ... return redirect('home');});

没有参数时,生成Illuminate\Routing\Redirector类的实例。

response(*$content*, *$status = 200*, *$headers*)

如果传入参数,则返回预先构建的Response实例。如果没有参数,则返回Response工厂的实例:

return response('OK', 200, ['X-Header-Greatness' => 'Super great']);return response()->json(['status' => 'success']);

tap(*$value*, *$callback = null*)

调用闭包(第二个参数),传递第一个参数给闭包,然后返回第一个参数(而不是闭包的输出):

return tap(Contact::first(), function ($contact) { $contact->name = 'Aheahe'; $contact->save();});

view(*$viewPath*)

返回视图实例:

Route::get('home', function () { return view('home'); // Gets /resources/views/home.blade.php});

fake()

返回一个 Faker 的实例:

@for($i = 0; $i <= 4; $i++) <td>Purchased by {{ fake()->unique()->name() }}</td>@endfor

集合是 Laravel 提供的最强大但最不被赞赏的工具之一。我们在“Eloquent Collections”中稍作介绍,但这里快速回顾一下。

集合本质上是具有超能力的数组。您通常需要将数组传递给的数组遍历方法(array_walk()array_map()array_reduce()等)都可以作为每个集合上一致、清晰、可链式调用的方法使用。您可以尝试使用函数式编程,通过 map、reduce 和 filter 实现更清晰的代码。

我们将在这里讨论 Laravel 集合和集合管道编程的基础知识,但要深入了解,请查看 Adam Wathan 的书《Refactoring to Collections》(Gumroad)。

基础知识

Laravel 中的集合并不是一个新概念。许多语言默认支持数组的集合式编程,但在 PHP 中我们就没那么幸运了。

使用 PHP 的 array*() 函数,我们可以将 示例17-1 中显示的混乱怪物转换为稍微不那么混乱的怪物,如 示例17-2 所示。

示例 17-1. 一个常见但丑陋的 foreach 循环
$users = [...];$admins = [];foreach ($users as $user) { if ($user['status'] == 'admin') { $user['name'] = $user['first'] . ' ' . $user['last']; $admins[] = $user; }}return $admins;
示例 17-2. 使用原生 PHP 函数重构 foreach 循环
$users = [...];return array_map(function ($user) { $user['name'] = $user['first'] . ' ' . $user['last']; return $user;}, array_filter($users, function ($user) { return $user['status'] == 'admin';}));

在这里,我们去掉了一个临时变量($admins),并将一个令人困惑的 foreach 循环转换为两个明确的操作:map 和 filter。

问题在于,PHP 的数组操作函数令人困惑且糟糕。只需看看这个例子;array_map() 先接受闭包,再接受数组,但 array_filter() 先接受数组,再接受闭包。此外,如果我们增加了任何复杂性,就会有函数嵌套函数嵌套函数的情况。真是一团糟。

Laravel 的集合将 PHP 的数组操作方法的强大之处与简洁流畅的语法结合起来,并添加了许多甚至在 PHP 的数组操作工具箱中不存在的方法。使用 collect() 辅助方法将数组转换为 Laravel 集合后,我们可以像 示例17-3 中展示的那样操作。

示例 17-3. 使用 Laravel 集合重构 foreach 循环
$users = collect([...]);return $users->filter(function ($user) { return $user['status'] == 'admin';})->map(function ($user) { $user['name'] = $user['first'] . ' ' . $user['last']; return $user;});

这并不是最极端的例子。还有很多其他例子,减少代码行数和增加简单性会更加强有力。但这种情况非常常见

看看原始示例及其混乱的情况。除非你完全理解整个代码样本,否则不会完全清楚任何给定部分的用途。

集合提供的最大好处是,相比其他任何方法,它将操作数组的行为分解为简单、明确和可理解的任务。现在你可以像这样做:

$users = [...]$countAdmins = collect($users)->filter(function ($user) { return $user['status'] == 'admin';})->count();

或者像这样:

$users = [...];$greenTeamPoints = collect($users)->filter(function ($user) { return $user['team'] == 'green';})->sum('points');

在本章的其余部分中,我们将看到许多例子都是基于我们在这里开始想象的虚构的 $users 集合。$users 数组中的每个条目都代表一个单独的人类;它们可能都可以通过数组访问。每个用户具体的属性可能会根据示例有所不同。但是无论何时看到这个 $users 变量,都知道这是我们正在处理的内容。

几个集合操作

比我们迄今为止涵盖的还要多得多。我建议您查看Laravel 集合文档以了解更多可用的方法,但为了帮助您入门,这里只列出了一些核心方法:

all(), toArray()

如果你想将集合转换为数组,可以使用all()toArray()toArray()不仅将集合转换为数组,还会将其下面的任何 Eloquent 对象扁平化为数组。all()仅将集合转换为数组;集合中包含的任何 Eloquent 对象将保持为 Eloquent 对象。以下是一些示例:

$users = User::all();$users->toArray();/* Returns [ ['id' => '1', 'name' => 'Agouhanna'], ... ]*/$users->all();/* Returns [ Eloquent object { id : 1, name: 'Agouhanna' }, ... ]*/

filter(), reject()

当你想通过检查每个项目是否符合闭包来获取原始集合的一个子集时,你将使用filter()(如果闭包返回true则保留项目)或reject()(如果闭包返回false则保留项目):

$users = collect([...]);$admins = $users->filter(function ($user) { return $user->isAdmin;});$paidUsers = $user->reject(function ($user) { return $user->isTrial;});

where()

where()使得提供原始集合的一个子集变得容易,其中给定键等于给定值。你可以用where()做的任何事情也可以用filter(),但它是一个常见情景的快捷方式:

$users = collect([...]);$admins = $users->where('role', 'admin');

whereNull(), whereNotNull()

whereNull()使得提供原始集合的一个子集变得容易,其中给定键等于nullwhereNotNull()则是其反义:

$users = collect([...]);$active = $users->whereNull('deleted_at');$deleted = $users->whereNotNull('deleted_at');

first(), last()

如果你只想从集合中获取单个项目,可以使用first()来获取列表的开头或last()来获取列表的末尾。

如果你调用first()last()而没有参数,它们只会分别给出集合中的第一个或最后一个项目。但如果你传递一个闭包,它们将会给出集合中首个或最后一个在该闭包中返回true的项目。

有时你会这样做是因为你想要实际的第一个或最后一个项目。但有时这是获取一个项目的最简单方法,即使你只期望有一个项目:

$users = collect([...]);$owner = $users->first(function ($user) { return $user->isOwner;});$firstUser = $users->first();$lastUser = $users->last();

你还可以向每种方法传递第二个参数,即默认值,如果闭包没有提供任何结果,将作为后备提供。

each()

如果你想对集合的每个项目执行某些操作,但不包括修改项目或集合本身,则可以使用each()

$users = collect([...]);$users->each(function ($user) { EmailUserAThing::dispatch($user);});

map()

如果你想遍历集合中的所有项目,对它们进行更改,并返回带有所有更改的新集合,你应该使用map()

$users = collect([...]);$users = $users->map(function ($user) { return [ 'name' => $user['first'] . ' ' . $user['last'], 'email' => $user['email'], ];});

reduce()

如果你想从集合中获取单个结果,如计数或字符串,你可能想使用reduce()。该方法通过采用初始值(称为carry)并允许集合中的每个项目以某种方式改变该值来工作。你可以为carry定义一个初始值,并接受当前carry状态和每个项目作为参数的闭包:

$users = collect([...]);$points = $users->reduce(function ($carry, $user) { return $carry + $user['points'];}, 0); // Start with a carry of 0

pluck()

如果你想要仅获取集合中每个项目下给定键的值,可以使用pluck()

$users = collect([...]);$emails = $users->pluck('email')->toArray();

chunk(), take()

chunk()使得将集合分割成预定义大小的组变得容易,take()则仅获取指定数量的项目:

$users = collect([...]);$rowsOfUsers = $users->chunk(3); // Separates into groups of 3$topThree = $users->take(3); // Pulls the first 3

takeUntil(), takeWhile()

takeUntil() 返回集合中直到回调函数返回 true 的所有项目。takeWhile() 返回集合中直到回调函数返回 false 的所有项目。如果传递给 takeUntil() 的回调函数从未返回 true,或者传递给 takeWhile() 的回调函数从未返回 false,则返回整个集合:

$items = collect([1, 2, 3, 4, 5, 6, 7, 8, 9]);$subset = $items->takeUntil(function ($item) { return $item >= 5;})->toArray();// [1, 2, 3, 4]$subset = $items->takeWhile(function ($item) { return $item < 4;})->toArray();// [1, 2, 3]

groupBy()

如果你想要根据它们的某个属性值将所有项目分组到你的集合中,你可以使用 groupBy()

$users = collect([...]);$usersByRole = $users->groupBy('role');/* Returns: [ 'member' => [...], 'admin' => [...], ]*/

你也可以传递一个闭包,闭包返回的内容将用于分组记录:

$heroes = collect([...]);$heroesByAbilityType = $heroes->groupBy(function ($hero) { if ($hero->canFly() && $hero->isInvulnerable()) { return 'Kryptonian'; } if ($hero->bitByARadioactiveSpider()) { return 'Spidermanesque'; } if ($hero->color === 'green' && $hero->likesSmashing()) { return 'Hulk-like'; } return 'Generic';});

reverse()shuffle()

reverse() 将你的集合中项目的顺序反转,而 shuffle() 将它们随机化:

$numbers = collect([1, 2, 3]);$numbers->reverse()->toArray(); // [3, 2, 1]$numbers->shuffle()->toArray(); // [2, 3, 1]

skip()

skip() 返回一个新的集合,其中不包括指定数量的项目:

$numbers = collect([1, 2, 3, 4, 5]);$numbers->skip(3)->values(); // [4, 5]

skipUntil()

skipUntil() 跳过项目直到回调函数返回 true。你也可以传递一个值给 skipUntil,它将跳过所有值,直到找到给定的值。如果值从未找到或回调从未返回 true,则返回一个空集合:

$numbers = collect([1, 2, 3, 4, 5]);$numbers->skipUntil(function ($item) { return $item > 3;})->values();// [4, 5]$numbers->skipUntil(3)->values();// [3, 4, 5]

skipWhile()

skipWhile() 跳过项目直到回调函数返回 true。如果回调从未返回 false,则返回一个空集合:

$numbers = collect([1, 2, 3, 4, 5]);$numbers->skipWhile(function ($item) { return $item <= 3;})->toArray();// [4, 5]

sort()sortBy()sortByDesc()

如果你的项目是简单的字符串或整数,你可以使用 sort() 来对它们进行排序:

$sortedNumbers = collect([1, 7, 6])->sort()->toArray(); // [1, 6, 7]

如果它们更复杂,你可以将一个字符串(表示属性)或闭包传递给 sortBy()sortByDesc() 来定义你的排序行为:

$users = collect([...]);// Sort an array of users by their 'email' property$users->sort('email');// Sort an array of users by their 'email' property$users->sortBy(function ($user, $key) { return $user['email'];});

countBy()

countBy 计算集合中每个值的每次出现:

$collection = collect([10, 10, 20, 20, 20, 30]);$collection->countBy()->all();// [10 => 2, 20 => 3, 30 => 1]

结果集合中的每个键都是原始值之一;它的配对值是该值在原始集合中出现的次数。

countBy 方法还接受一个回调函数,用于自定义用于计算集合中每个项目的值:

$collection = collect(['laravel.com', 'tighten.co']);$collection->countBy(function ($address) { return Str::after($address, '.');})->all();// all: ["com" => 1, "co" => 1]

count()isEmpty()isNotEmpty()

你可以使用 count()isEmpty()isNotEmpty() 来查看集合中有多少项目:

$numbers = collect([1, 2, 3]);$numbers->count(); // 3$numbers->isEmpty(); // false$numbers->isNotEmpty() // true

avg()sum()

如果你正在处理一组数字的集合,avg()sum() 就像它们的方法名所说的那样工作,不需要任何参数:

collect([1, 2, 3])->sum(); // 6collect([1, 2, 3])->avg(); // 2

但如果你正在处理数组,你可以将每个数组中要提取的属性的键传递给操作:

$users = collect([...]);$sumPoints = $users->sum('points');$avgPoints = $users->avg('points');

join

join() 将集合值连接为单个输出字符串,使用提供的字符串连接每个值,类似于 PHP 的 join() 方法。你也可以(可选地)自定义最终的连接操作符:

$collection = collect(['a', 'b', 'c', 'd', 'e']);$collection->join(', ', ', and ');// 'a, b, c, d, and e'

你是否已经爱上了集合,并想在你的非 Laravel 项目中使用它们?

只需使用 composer require illuminate/collections 命令,你就可以在你的代码中准备好 Illuminate\S⁠u⁠p⁠p⁠o⁠r⁠t⁠\​C⁠o⁠l⁠l⁠e⁠c⁠t⁠i⁠o⁠n 类,以及 collect() 辅助函数。

Laravel 提供了一套全局助手函数,简化各种任务。它们使得操作和检查数组和字符串变得更加容易,方便生成路径和 URL,并提供简单访问一些持久和关键功能。

Laravel 的集合(collections)是强大的工具,为 PHP 带来了集合管道的可能性。

随着 Laravel 的发展,Laravel 团队构建了一套工具来支持和简化 Laravel 开发人员的生活和工作流程。许多新工作已直接进入核心,但还有很多包和 SaaS 提供并不是核心的一部分,但仍然是 Laravel 经验的重要组成部分。

我们已经涵盖了其中相当多的内容,对于那些内容,我将提供指向书中更多信息的指针。对于我们尚未涵盖的工具,我将简要描述每个工具,并提供相关网站的链接。

我们已经浏览过这些内容,但这里是它们的简要提醒以及您可以找到相关资源的链接。

Valet

Valet 是一个本地开发服务器(适用于 Mac,并有适用于 Windows 和 Linux 的分支),使得将所有项目快速轻松地提供给浏览器变得非常简单。您将通过 Composer 在本地开发机器上全局安装 Valet。

只需几个命令,您就可以在 *.test* 域上为您机器上的每个 Laravel 应用程序提供 Nginx、MySQL、Redis 等服务。

Valet 是在 “Laravel Valet” 中介绍的。

Homestead

Homestead 是在一个简单的 Vagrant 设置之上的配置层,使得从一个 Laravel 友好的 Vagrant 设置中服务多个 Laravel 应用程序变得简单。

Homestead 是在 “Laravel Homestead” 中简要介绍的。

Herd

Herd 是一个原生的 macOS 应用程序,将 Valet 及其依赖项打包成一个单独的应用程序,您可以在其中安装,无需处理 Docker、Homebrew 或任何其他依赖管理器。

Herd 是在 “Laravel Herd” 中介绍的。

Laravel 安装程序

Laravel 安装程序是一个全局安装在您的本地开发机器上(通过 Composer 安装),它可以轻松快速地设置一个新的 Laravel 项目。

安装程序是在 “使用 Laravel 安装程序工具安装 Laravel” 中介绍的。

Dusk

Dusk 是一个用于测试整个应用程序(包括 JavaScript 等)的前端测试框架。它是一个强大的包,您可以通过 Composer 将其引入您的应用程序,并通过 ChromeDriver 驱动实际的浏览器。

Dusk 是在 “使用 Dusk 进行测试” 中介绍的。

Passport

Passport 是一个强大而简单易用的 OAuth 2.0 服务器,用于为客户端身份验证您的 API。您将在每个应用程序中安装它作为一个 Composer 包,并且只需很少的工作,就可以为您的用户提供完整的 OAuth 2.0 流程。

Passport 是在 “使用 Laravel Passport 进行 API 认证” 中介绍的。

Sail

Sail 是由 Docker 驱动的 Laravel 默认的本地开发环境。

Sail 是在 “Laravel Sail” 中介绍的。

Sanctum

Sanctum 是一个用于支持移动应用程序、SPA 和简单基于令牌的 API 的身份验证系统。它是复杂的 OAuth 的一个简化但仍然功能强大的替代方案。

Sanctum 被介绍在“API Authentication with Sanctum”中。

Fortify

Fortify 是一个无头身份验证系统。它为 Laravel 需要的所有身份验证功能提供了路由和控制器,从登录和注册到密码重置等等,可供任何你选择的前端消费。

Fortify 被介绍在“Fortify”中。

Breeze

Breeze 是所有 Laravel 必需身份验证功能的最小路由和控制器集合,并与每个功能配套的前端模板。Breeze 可以通过 Blade、Vue、React 或 Inertia 提供。

Breeze 被介绍在“Laravel Breeze”中。

Jetstream

Jetstream 是一个强大的应用程序启动套件,提供了 Breeze 提供的所有身份验证功能,以及电子邮件验证、双因素身份验证、会话管理、API 身份验证和团队管理功能。与 Breeze 不同,Jetstream 仅提供两种前端工具选择:Livewire 和 Inertia/Vue。

Jetstream 被介绍在“Laravel Jetstream”中。

Horizon

Horizon 是一个可以通过 Composer 安装到每个应用程序中的队列监控包。它为监控 Redis 队列作业的健康状况、性能、失败和历史提供了完整的用户界面。

Horizon 简要介绍了“Laravel Horizon”。

Echo

Echo 是一个 JavaScript 库(随着 Laravel 通知系统的一系列改进而引入),使得通过 WebSockets 订阅来自你的 Laravel 应用的事件和频道变得简单。

Echo 被介绍在“Laravel Echo (the JavaScript Side)”中。

本书未涉及的一些工具,因为它们超出了本书的范围。其中一些只用于特殊情况(例如用于支付的 Cashier,用于社交登录的 Socialite 等),但有些我每天都在使用(尤其是 Forge)。

下面是一个简要介绍,从你在工作中最可能遇到的工具开始。请注意,这个列表并不详尽!

Forge

Forge 是一款付费的 SaaS 工具,用于在 DigitalOcean、Linode、AWS 等主机上创建和管理虚拟服务器。它为你提供了一切运行所需的工具,从队列和队列工作者到 Let’s Encrypt SSL 证书。此外,它还可以设置简单的 Shell 脚本,以在你将新代码推送到 GitHub 或 Bitbucket 时自动部署你的站点。

Forge 在快速轻松地启动站点方面非常有用,但它并不是如此简单,你无法在长期或更大规模上也运行你的应用程序。你可以扩展你的服务器大小,添加负载均衡器,并在 Forge 内管理服务器之间的私有网络。

Vapor

Vapor 是一个付费的 SaaS 工具,用于将 Laravel 应用程序部署到 AWS Lambda,使用“无服务器”托管模式。它管理缓存、队列、数据库、资源构建、域指向、自动缩放、内容传递网络、环境管理以及大多数您将需要为 Laravel 应用程序进行无服务器部署的任何其他事项。

Envoyer

Envoyer 是一个付费的 SaaS 工具,被称为“零停机 PHP 部署”。与 Forge 不同,Envoyer 不会启动或管理您的服务器。它的主要任务是监听触发器,通常是在推送新代码时,但您也可以手动触发部署或通过 Webhook 触发。

Envoyer 在以下三个方面远远优于 Forge 的推送部署工具和大多数其他推送部署解决方案:

  • 它具有强大的工具集,可以构建简单但强大的多阶段部署流程。

  • 它使用类似 Capistrano 的零停机部署方式部署您的应用程序;每个新部署都构建到自己的文件夹中,只有在构建过程成功完成后,才将该部署文件夹的符号链接到您的实际 Web 根目录。因此,在 Composer 安装或 NPM 构建时,服务器不会出现中断。

  • 由于这种基于文件夹的系统,可以轻松快速地回滚到以前的版本;Envoyer 只需将符号链接更新回以前的部署文件夹,即可立即提供旧版本的服务。

您还可以设置定期健康检查(对您的服务器进行 ping 测试,如果 ping 测试未返回 200 HTTP 响应,则向您报告错误),期望您的 cron 作业将定期向 Envoyer 发送 ping,并基于聊天的方式通知任何重要事件。

Envoyer 比 Forge 更像一个专业工具。我不认识很多 Laravel 开发者不使用 Forge,但那些愿意支付使用 Envoyer 的人更可能拥有如果不能立即回滚问题提交或者网站流量(或者重要的流量)足够大而会有很多问题的网站。如果您的网站属于这一类别,Envoyer 会让您感到像魔法一样。

Cashier

Cashier 是一个免费的包,提供了一个简单的界面,用来管理 Stripe 的订阅计费服务。Cashier 处理订阅用户的大部分基本功能,包括更改他们的计划、提供访问发票、处理来自计费服务的 Webhook 回调、管理取消宽限期等等。

如果你想让用户使用 Stripe 进行订阅注册,Cashier 会让你的生活变得轻松得多。

Socialite

Socialite 是一个免费的包,使得向您的应用程序添加社交登录变得非常简单(例如通过 GitHub 或 Facebook)。

Nova

Nova是一个付费包,用于构建管理面板。如果想象一下您平均复杂的 Laravel 应用程序,可能会有几个部分:公共面向客户的网站或视图、用于对核心数据或客户列表进行更改的管理部分,甚至可能还有一个 API。

Nova 极大地简化了使用 Vue 和 Laravel API 构建站点管理面板部分的过程。它可以轻松生成所有资源的 CRUD(创建、读取、更新、删除)页面,以及针对数据的更复杂的自定义视图、每个资源上的自定义操作和关系,甚至用于向相同的一般管理空间添加非 CRUD 工具的自定义工具。

Spark

Spark是一个付费包,用于生成接受付款的 SaaS,并且简化用户、团队和订阅的管理。它提供了 Stripe 和 Paddle 集成、发票、基于座位或团队的计费以及完整的独立计费门户,使您不必受限于 Spark 的默认技术栈。

Envoy

Envoy是一个本地任务运行程序,可以轻松定义将在远程服务器上运行的常见任务,将这些任务的定义提交到版本控制,并简单而可预测地运行它们。

查看示例18-1 以了解常见 Envoy 任务的样子。

示例18-1. 一个常见的 Envoy 任务
@servers(['web-1' => '192.168.1.1', 'web-2' => '192.168.1.2'])@task('deploy', ['on' => ['web-1', 'web-2']]) cd mysite.com git pull origin {{ $branch }} php artisan migrate php artisan route:cache@endtask

要运行示例18-1,请在本地终端中运行以下命令:

envoy run deploy --branch=master

望远镜

望远镜是一个免费的调试工具,可以作为 Laravel 应用程序的包安装。它生成一个仪表板,您可以深入了解作业的当前状态、队列工作者、HTTP 请求、数据库查询等等。

Octane

Octane是一个免费工具,使您能够使用异步、并发的 PHP Web 服务器为您的 Laravel 应用程序提供服务,旨在提供速度和性能。在撰写本书时,有三种这样的工具:Swoole、Open Swoole 和 RoadRunner。使用 Octane,这些工具将一次性加载您的应用程序到内存中,然后以最高效的方式为每个请求提供服务,利用语言和系统级工具来实现并发。

Pennant

Pennant是 Laravel 本地实现的“特性标志”模式,允许您轻松定义每个请求是否应在您的应用程序中看到某个特性—通常是因为请求的用户及其访问权限。Pennant 允许您一次性定义应用哪些指标用于确定是否应该为给定特性服务请求,提供了与 Laravel 访问控制列表层非常相似的语法。

Folio

Folio 是一个 Laravel 包,允许您基于文件夹中模板文件的排列来构建应用程序的路由。类似于 Next 和 Nuxt,Folio 允许您创建单独的模板(例如 /index.blade.php 显示在 mysite.com//about.blade.php 显示在 mysite.com/about/users/index.blade.php 显示在 mysite.com/users),或者定义应与 URL 中占位符匹配的模板(例如 /users/[id].blade.php 显示在 mysite.com/users/14)。

Volt

Volt 使用单文件功能组件扩展 Livewire 的功能。Volt 还提供了一个指令 @volt,允许您将模板的一部分分配给 Livewire 组件定义管理,同时模板的其余部分仍然是普通的 Blade。

Pint

Pint 是一个代码风格工具,旨在在你的应用程序中强制执行 Laravel 的默认代码风格。它基于 PHP-CS-Fixer 构建,并提供了一些工具改进,同时还提供了预配置的 Laravel 特定代码规则集。

我已经提到了很多资源,但这里是一些人们经常转向学习 Laravel 的资源的非详尽列表:

有许多博客(我在 mattstauffer.com 有一个,Tighten 在 tighten.com 也有一个,还有许多其他非常有用的),许多优秀的 Twitter 用户,许多出色的包作者,以及太多我尊重但无法在这里列出的 Laravel 开发者。这是一个富有多样性和慷慨的社区,充满了乐于分享一切他们学到的开发者;难的不是找到好内容,而是找到时间去消化这一切。

作为 Laravel 开发者的旅程中可能遇到的每一个人或资源我无法一一列举,但如果你从这里列出的资源和人士开始,你将在使用 Laravel 方面迈出良好的第一步。

访问器

在 Eloquent 模型上定义的一种方法,用于自定义如何返回给定属性。访问器使得可以定义从模型获取给定属性将返回与数据库中存储的值不同(或者更可能是格式不同)的值。

ActiveRecord

一种常见的数据库 ORM 模式,也是 Laravel 的 Eloquent 使用的模式。在 ActiveRecord 中,同一个模型类定义了如何检索和持久化数据库记录以及如何表示它们。此外,每个数据库记录由应用程序中的单个实体表示,而每个应用程序中的实体都映射到单个数据库记录。

API

技术上称为应用程序编程接口,但最常用来指一系列端点(及其使用方法说明),可以用于通过 HTTP 调用来读取和修改系统外部数据。有时,API 这个术语也用来描述任何给定包、库或类公开给其消费者的接口或便利。

应用程序测试

应用测试通常被称为验收或功能测试,它们测试应用程序的整体行为,通常在外部边界,通过使用诸如 DOM 爬虫之类的工具,这正是 Laravel 应用测试套件提供的功能。

参数(Artisan)

参数是可以传递给 Artisan 控制台命令的参数。参数不以--开头,也不跟着=, 而是只接受一个单一值。

Artisan

这个工具使得可以从命令行与 Laravel 应用程序进行交互。

断言

在测试中,断言是测试的核心:你断言某些东西应该等于(或小于或大于)其他某些东西,或者应该具有给定的数量,或者任何其他你喜欢的条件。断言是可以通过或失败的事物。

认证

正确地确认自己作为应用程序的成员/用户的身份是认证的行为。认证并不定义你可以做什么,而只是你是谁(或者不是谁)。

授权

假设你已经通过身份验证(或者未通过),授权定义了根据你的特定身份标识,你能够允许做什么。授权涉及访问和控制。

自动装配

当一个依赖注入容器将一个可解析类的实例注入而开发人员并没有显式地告诉它如何解析该类时,这被称为自动装配。对于没有自动装配的容器,你甚至不能注入一个没有依赖关系的纯 PHP 对象,直到你明确地将它绑定到容器中。通过自动装配,你只需要在容器中明确绑定某些东西,如果它的依赖关系对容器来说太复杂或者太模糊,容器无法自行解决。

beanstalkd

Beanstalk 是一个工作队列。它简单且擅长运行多个异步任务—这使得它成为 Laravel 队列的常用驱动程序。beanstalkd 是它的守护进程。

Blade

Laravel 的模板引擎。

Carbon

一个使得与日期工作更加轻松和表达力更强的 PHP 包。

Cashier

Laravel 的一个包,使得与 Stripe 或 Braintree 的计费特别在订阅上下文中更加简单、一致、强大。

闭包

闭包是 PHP 版本的匿名函数。闭包是一个可以像对象一样传递、分配给变量、作为参数传递给其他函数和方法,甚至可以序列化的函数。

CodeIgniter

一个旧的 PHP 框架,Laravel 的灵感来源。

集合

一个开发模式的名称,也是 Laravel 实现它的工具。像增强版数组一样,集合提供了 map、reduce、filter 等许多强大的操作,PHP 的原生数组没有这些功能。

命令

自定义 Artisan 控制台任务的名称。

Composer

PHP 的依赖管理器。类似于 RubyGems 或 NPM。

容器

在 Laravel 中,“容器”是指负责依赖注入的应用程序容器。通过app()访问,也负责解析对控制器、事件、作业和命令的调用,容器是将每个 Laravel 应用粘合在一起的关键。

契约

接口的另一个名称。

控制器

一个负责将用户请求路由到应用程序服务和数据,并向用户返回有用响应形式的类。

CSRF(跨站请求伪造)

一种恶意攻击,外部站点通过劫持用户的浏览器(通常使用 JavaScript)在用户仍然登录到您的站点时对您的应用程序发出请求。通过在站点上的每个表单中添加令牌(以及在POST端检查该令牌),可以防止这种攻击。

依赖注入

一种开发模式,依赖项从外部注入—通常通过构造函数—而不是在类中实例化。

指令

Blade 语法选项,如@if@unless等。

点符号表示法

使用.向下导航继承树,引用跳转到新层级。如果你有一个数组['owner' => ['address' => ['line1' => '123 Main St.']]],你有三层嵌套。使用点表示法,你可以将“123 Main St.”表示为"owner.address.line1"

Dusk

Laravel 的前端测试包,可以通过启动 ChromeDriver 运行测试,测试 JavaScript(主要是 Vue)和 DOM 交互。

预加载

通过在第一个查询中添加第二个智能查询来避免 N+1 问题,以获取一组相关项目。通常,您有一个第一个查询来获取一组 A 的集合。但是每个 A 都有许多 B,因此每次从 A 获取 B 时,您需要一个新的查询。急切加载意味着执行两个查询:首先获取所有 A,然后获取所有这些 A 相关的 所有 B,在单个查询中完成。两个查询,搞定。

Echo

Laravel 产品,使 WebSocket 身份验证和数据同步变得简单。

Eloquent

Laravel 的 ActiveRecord ORM。您将用这个工具定义和查询诸如 User 模型之类的内容。

环境变量

.env 文件中定义的变量,预期不包含在版本控制中。这意味着它们在环境之间不同步,同时也保持安全。

Envoy

Laravel 包,用于编写在远程服务器上运行常见任务的脚本。Envoy 提供了定义任务和服务器的语法,以及用于运行任务的命令行实用程序。

Envoyer

Laravel 的 SaaS 产品,用于零停机时间部署、多服务器部署以及服务器和 cron 健康检查。

事件

Laravel 的工具,用于实现发布/订阅或观察者模式。每个事件表示发生了某个事件:事件的名称描述了发生了什么(例如,UserSubscribed),载荷允许附加相关信息。设计为“触发”然后“监听”(或者按照发布/订阅的概念发布和订阅,如果你更喜欢这种方式)。

外观

Laravel 中用于简化访问复杂工具的工具。外观为 Laravel 核心服务提供静态访问。由于每个外观都由容器中的类支持,因此您可以将对 Cache::put(); 的调用替换为对 $cache = app('cache'); $cache->put(); 的两行调用。

Faker

一个 PHP 包,使生成随机数据变得简单。您可以请求不同类别的数据,如名称、地址和时间戳。

标志

一个参数,可以是开或关(布尔值)。

流畅

可以一个接一个地链接的方法称为流畅方法。为了提供流畅的语法,每个方法必须返回实例,准备好再次链接。这允许类似于 People::where('age', '>' , 14)->orderBy('name')->get() 这样的操作。

Flysystem

Laravel 用于促进其本地和云文件访问的包。

Forge

Laravel 产品,可以轻松在 DigitalOcean 和 AWS 等主要云提供商上快速创建和管理虚拟服务器。

Fortify

无头后端身份验证系统,提供 Laravel 所有重要身份验证系统的路由和控制器。

FQCN(完全限定类名)

任何给定类、特征或接口的完全命名空间名称。Controller 是类名;Illuminate\Routing\Controller 是完全限定类名。

辅助函数

全局可访问的 PHP 函数(或在 Laravel 中有时是全局可访问的外观调用),可以使其他功能更加简单。

Homestead

Laravel 的工具,封装了 Vagrant,并简化了在本地 Laravel 开发中启动 Forge-并行虚拟服务器的过程。

Horizon

Laravel 包,提供更细致管理队列的工具,比 Laravel 默认的更加深入,并提供队列工作者及其作业的当前和历史操作状态的洞察。

HTTP 客户端

Laravel 内置的 HTTP 客户端,提供向其他 Web 应用程序发出出站请求的能力。

Illuminate

Laravel 组件的所有顶级命名空间。

集成测试

集成测试测试单个单元如何协同工作并传递消息。

IoC(控制反转)

将如何创建接口的具体实例的“控制”权交给包的高级代码而不是低级代码的概念。没有 IoC,每个控制器和类可能都会决定要创建哪个 Mailer 的实例。IoC 使得低级代码——这些控制器和类——只需请求一个 Mailer,一些高级配置代码就会定义每个应用程序一次提供哪个实例来满足请求。

job

意图封装单一任务的类。作业旨在能够推送到队列并异步运行。

JSON(JavaScript 对象表示法)

数据表示的语法。

JWT(JSON Web Token)

包含确定用户身份验证状态和访问权限所需所有信息的 JSON 对象。此 JSON 对象经过数字签名,这就是其可信的原因,使用 HMAC 或 RSA。通常在标头中传递。

mailable

一种旨在将发送邮件的功能封装到一个“可发送”类中的架构模式。

Markdown

一种设计用于格式化纯文本并输出到多种格式的格式化语言。通常用于格式化可能由脚本处理或以其原始形式被人类阅读的文本,例如 Git 的 README。

大量赋值

一次性传递多个参数来创建或更新 Eloquent 模型,使用一个键控数组。

Memcached

旨在提供简单但快速数据存储的内存中数据存储。Memcached 仅支持基本的键/值存储。

中间件

围绕应用程序的一系列包装器,过滤和装饰其输入和输出。

迁移

存储在代码中并从中运行的对数据库状态的操作。

Mockery

Laravel 自带的一个库,可以在测试中轻松模拟 PHP 类。

模型

用于表示系统中给定数据库表的类。在像 Laravel 的 Eloquent 这样的 ActiveRecord ORM 中,此类用于表示系统中的单个记录,并与数据库表交互。

模型工厂

用于定义应用程序如何在需要时生成模型实例的工具。通常与像 Faker 这样的假数据生成器配对使用。

多租户

一个应用程序为多个客户提供服务,每个客户都有自己的客户。多租户通常意味着应用程序的每个客户都可以获得自己的主题和域名,以便通过您的其他客户的潜在服务来区分其向客户提供的服务。

修改器

Eloquent 中的工具,允许您在保存到数据库之前操纵要保存到模型属性的数据。

Nginx

一种类似 Apache 的 Web 服务器。

通知

Laravel 框架工具,允许通过多种通知渠道(例如电子邮件、Slack、短信)向一个或多个接收者发送单条消息。

Nova

一个用于为您的 Laravel 应用程序构建管理面板的付费 Laravel 包。

NPM(Node 包管理器)

一个用于 Node 包的中央基于 Web 的存储库,在npmjs.org;还是一种工具,用于根据package.json的规范将项目的前端依赖项安装到node_modules目录中的本地计算机上。

OAuth

API 的最常见认证框架。OAuth 具有多个授权类型,每种类型描述了消费者如何在初始认证握手后检索、使用和刷新标识其身份的“令牌”的不同流程。

选项(Artisan)

类似于参数,选项是可以传递给 Artisan 命令的参数。它们以--开头,可以用作标志(--force)或提供数据(--userId=5)。

ORM(对象关系映射器)

一个设计模式,其核心是使用编程语言中的对象来表示关系数据库中的数据及其关系。

护照

一个可以轻松将 OAuth 身份验证服务器添加到您的 Laravel 应用程序中的 Laravel 包。

PHPSpec

一个 PHP 测试框架。

PHPUnit

一个 PHP 测试框架。最常见的,与大多数 Laravel 的自定义测试代码连接在一起。

多态的

在数据库术语中,能够与具有类似特征的多个数据库表进行交互。多态关系允许将多个模型的实体以相同的方式附加。

预处理器

一个构建工具,接收语言的特殊形式(对于 CSS,一种特殊形式是 LESS)并生成具有正常语言(CSS)的代码。预处理器内置工具和特性,这些特性不在核心语言中。

主键

大多数数据库表都有一个旨在代表每行的单个列。这称为主键,通常命名为id

队列

一个可以添加作业的堆栈。通常与队列工作者相关联,队列工作者逐个从队列中拉取作业,处理它们,然后丢弃它们。

React

一个 JavaScript 框架。由 Facebook 创建和维护。

实时门面

与门面类似,但不需要单独的类。实时门面可用于通过在其命名空间前加上Facades\来使任何类的方法可调用为静态方法。

Redis

像 Memcached 一样,比大多数关系数据库更简单但功能强大和快速的数据存储。Redis 支持非常有限的数据结构和数据类型,但在速度和可伸缩性方面弥补了这一不足。

REST(表述性状态转移)

当今 API 的最常见格式。通常建议与 API 的每个交互都单独进行身份验证,并且应该是“无状态”的;通常还建议使用 HTTP 动词来基本区分请求。

路由

用户可能访问 Web 应用程序的方式或方式的定义。路由是一种模式定义;它可以是像 /users/5,或者 /users,或者 /users/*id* 这样的东西。

S3(简单存储服务)

Amazon 的“对象存储”服务,它使得使用 AWS 强大的计算能力来存储和提供文件变得容易。

SaaS(软件即服务)

通过支付费用使用的基于 Web 的应用程序。

Sanctum

针对单页面应用程序、移动应用程序和简单基于令牌的 API 的 API 令牌认证系统。

作用域

在 Eloquent 中,用于定义如何一致而简单地缩小查询的工具。

Scout

用于在 Eloquent 模型上进行全文搜索的 Laravel 包。

序列化

将更复杂的数据(通常是 Eloquent 模型)转换为更简单的形式(在 Laravel 中通常是数组或 JSON)的过程。

服务提供商

Laravel 中注册和启动类和容器绑定的结构。

Socialite

一个使得在 Laravel 应用程序中添加社交认证(例如通过 Facebook 登录)变得简单的 Laravel 包。

软删除

将数据库行标记为“已删除”,而不实际删除它;通常与默认隐藏所有“已删除”行的 ORM 配对使用。

Spark

一个使得轻松创建新的基于订阅的 SaaS 应用程序变得容易的 Laravel 工具。

Symfony

一个专注于构建优秀组件并使其对他人可访问的 PHP 框架。Symfony 的 HTTPFoundation 是 Laravel 和其他现代 PHP 框架的核心。

Telescope

一个为 Laravel 应用程序添加调试助手的 Laravel 包。

Tinker

Laravel 的 REPL 或读-评估-打印循环。这是一个工具,允许您在命令行中以应用程序的完整上下文中执行复杂的 PHP 操作。

TL;DR

太长了;没读。 “摘要。”

类型提示

在方法签名中以类或接口名作为变量名的前缀。告诉 PHP(以及 Laravel 和其他开发者),该参数中允许传递的唯一允许的是具有给定类或接口的对象。

单元测试

单元测试针对较小的、相对孤立的单元——通常是一个类或方法。

Vagrant

一个命令行工具,可以使用预定义的镜像在本地计算机上轻松构建虚拟机。

Valet

一个 Laravel 包(适用于 macOS 用户,但也有 Linux 和 Windows 的分支),可以轻松地从您选择的开发文件夹中为您的应用程序提供服务,而无需担心 Vagrant 或虚拟机。

验证

确保用户输入与预期模式匹配。

视图

一个单独的文件,从后端系统或框架获取数据并将其转换为 HTML。

视图组合器

每当加载给定视图时,它会提供一组特定的数据。

Vue

一个 JavaScript 框架。Laravel 首选。由尤雨溪编写。

Laravel-启动指南-全- - 绝不原创的飞龙 - 博客园 (2024)

References

Top Articles
Latest Posts
Article information

Author: Aracelis Kilback

Last Updated:

Views: 6058

Rating: 4.3 / 5 (64 voted)

Reviews: 87% of readers found this page helpful

Author information

Name: Aracelis Kilback

Birthday: 1994-11-22

Address: Apt. 895 30151 Green Plain, Lake Mariela, RI 98141

Phone: +5992291857476

Job: Legal Officer

Hobby: LARPing, role-playing games, Slacklining, Reading, Inline skating, Brazilian jiu-jitsu, Dance

Introduction: My name is Aracelis Kilback, I am a nice, gentle, agreeable, joyous, attractive, combative, gifted person who loves writing and wants to share my knowledge and understanding with you.