使用Composer搭建PHP框架(一准备)
我见过太多人一上来就照着网上的教程写路由、写容器,结果写到一半发现类加载全乱了,依赖也没法管理,最后只能推倒重来。其实问题不是出在写框架这件事本身,而是出在准备工作没做到位。今天这篇就来把前期该做完的事全部做完——环境、工具、目录骨架、Composer的核心玩法,以及几个决定你后面顺不顺利的关键认知。这个系列面向的是对PHP基础语法已经熟悉、但对Composer和框架内部机制还比较陌生的开发者,看完这一篇,你会对“用Composer搭框架到底在做什么”有非常具体的概念。
1. 动手之前,先想清楚“框架的骨架”和“Composer的位置”
1.1 一个框架到底由哪些部分组成
框架这个词听起来很抽象,其实拆开来看就三件事:
- 怎么把用户的请求(URL)准确送到对应的处理函数里去——这是“路由”;
- 当处理函数需要数据库连接、缓存、配置、日志这些服务时,怎么方便地拿到——这是“依赖注入容器”;
- 处理完的结果怎么返回成HTML或JSON给用户——这是“响应与中间件”。
再加一个贯穿全局的底层能力:代码文件怎么被自动找到、自动加载,而不用在每个文件里手动写一堆require。这一条在PHP里尤其重要,因为PHP不像Java那样有统一的类路径机制,它完全靠你手动去include。
所以在准备阶段,你真正要搞清楚的核心问题不是“框架有多少功能”,而是:Composer在这个体系里到底站在哪?
1.2 Composer解决的是三个层面的问题
第一个层面是依赖管理。你要在框架里集成日志库、数据库抽象层、模板引擎,如果用老办法,就是去Github下载压缩包,手动解压、手动require,升级版本的时候再来一遍。Composer把这件事变成了一个命令:它读配置文件,算出依赖树,把所有包下载到统一目录,并且帮你生成自动加载规则。
第二个层面是自动加载。Composer的autoload机制是它最有价值的地方。你只要在composer.json里声明一个命名空间对应的目录,之后实例化任何类都不需要再关心它在哪个文件里,Composer会按PSR-4规则帮你找到那个文件。自定义框架的所有类都可以享受这个待遇。
第三个层面是版本约束。Composer要求你声明项目的PHP版本、扩展依赖、每个第三方包允许的版本范围。这些约束在准备阶段定好,后面怎么折腾都不会把环境搞崩。
一个容易产生的误解:Composer只用来“下载别人的代码”。其实它更大的作用是自己项目的自动加载标准,这才是搭框架时的主力。
1.3 为什么选择自建一套简单框架而不是直接用成熟框架
遇到这个问题我总会建议对方先想清楚目的。如果你是想快速上线一个业务项目,直接用成熟框架明显是对的。但如果你希望理解框架的原理,或者你所在的项目组有特殊约束,那从零搭一套简单框架就有不可替代的价值:
- 完全掌控每一行代码,出问题时你理解的是根因,而不是黑盒;
- 没有历史包袱,结构可以按自己的习惯组织;
- 扩展性能按照真实需求增长,而不是用到一个庞然大物的冰山一角。
这篇文章按“最小可用”的思路来准备:先建立起可运行的骨架,后续再逐步加中间件、加ORM集成。
1.4 学习路线规划:从骨架到完整功能
我建议把整个搭建过程切分成明确阶段,避免一步到位:
- 准备阶段(本文):环境、目录、Composer配置、自动加载验证;
- 前端控制器:把请求统一收口,处理URL分发;
- 路由与控制器:实现一个轻量路由器和基础控制器基类;
- 容器与依赖注入:解决服务如何绑定、如何解析的问题;
- 中间件与异常处理:实现请求链路中的横切逻辑;
- 集成常用组件:配置加载、日志、数据库操作等。
每个阶段都建立在上一阶段基础上,每个阶段结束时都能跑出一个可验证的结果。这种小步迭代的方式能最大程度减少返工。
2. 环境准备:PHP版本、扩展和Composer安装
2.1 选对PHP版本,别为小问题浪费一晚上
写框架不同于写业务,你会大量用到反射、匿名函数、联合类型这些语言特性,所以版本选择直接影响代码写法和运行效率。
目前我推荐直接用PHP 8.1以上版本。原因很简单:
- 8.0加入了构造器属性提升、联合类型、命名参数,写框架时能大量减少样板代码;
- 8.1加入了枚举、只读属性、final类常量,也让一些框架层面的设计更自然;
- 8.2到8.4主要提供性能优化和类型系统增强。
开发时建议开启严格类型声明。就是说,在你所有PHP文件的顶部写上declare(strict_types=1);,让类型约束真正生效,否则传参时PHP会做弱类型转换,很多隐蔽bug就是这么来的。
用php -v检查版本,如果环境里是旧版本,建议安装一个版本管理工具(如Linux下用发行版自带的PHP源切换,macOS下用相关包管理工具),不要手动编译,除非你特别清楚自己在做什么。
2.2 必须确认的PHP扩展清单
写路由和容器时,你至少需要以下扩展,缺一个都会在运行时报出莫名其妙的问题:
| 扩展 | 用途 | 缺失时的表现 |
|---|---|---|
| mbstring | 字符串处理,尤其是编码相关 | 部分字符串函数报错或行为异常 |
| curl | 第三方HTTP请求 | 框架的客户端功能不可用 |
| openssl | HTTPS处理、签名相关 | Composer下载或请求时报TLS错误 |
| json | JSON编码解码 | 配置和请求体解析失败 |
| fileinfo | 文件类型识别 | 上传文件场景不可用 |
用php -m查看已装扩展,用php --ri 扩展名查看具体信息。注意,不同系统的扩展安装命令不一样,建议优先用发行版包管理器自带方案。
2.3 Composer安装与镜像配置
Composer的安装方式很简单,这里只指出容易踩坑的地方。
在Linux或macOS上用官方安装脚本,会得到一个composer.phar文件,移动并建一个软链到/usr/local/bin/composer,就可以全局使用。Windows用户可以去官网下载安装程序,或者用包管理器。
安装完成后建议立即切换到国内可用的镜像源,如果你在对应地区的话。命令:
composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/这个步骤不是必需的,但它能让包的下载速度快一个量级,尤其在大项目里效果明显。
安全方面也值得注意:建议开启disable-tls为false保持默认,同时打开secure-http。只有在你明确测试环境需要时才调整这些选项。
2.4 本机调试环境的建议
写框架时你需要在终端里不断测试路由、调试输出,我强烈建议装一个PHP内置服务器能快速跑起来的方案:
php -S 0.0.0.0:8080 -t public这里用-t指定站点根目录,等于模拟了真实服务器访问时的入口位置。开发阶段先别急着配Nginx或Apache,内置服务器足够你在前几条里把路由跑通。等框架稳定后再把Web服务器软件装好,重写规则或者对应配置文件指向同一个public目录。
3. 设计工作目录结构:为后续每一个功能准备好“位置”
3.1 目录结构透露的是架构思路
我见过很多自建框架项目,所有代码堆在两个目录里,类名和文件名对不上,配置散落各处。这些问题都是准备阶段没有设计目录导致的。
目录结构不是随便拍的,它揭示了你在框架里对“职责”的划分方式。我的推荐结构如下:
project-root/ ├── app/ │ ├── Controllers/ │ ├── Models/ │ └── Views/ ├── config/ ├── public/ │ └── index.php ├── routes/ │ └── web.php ├── storage/ │ ├── logs/ │ └── cache/ ├── src/ │ ├── Container/ │ ├── Http/ │ ├── Routing/ │ └── Support/ └── vendor/解释一下核心意图:
public/是唯一Web服务器能访问到的目录,所有入口请求都通过index.php进来。这是安全底线,框架源码、配置文件、日志都不该被直接访问;app/放项目业务代码(控制器、模型、视图),这部分是使用方写业务时会碰到的区域;src/放框架本身的源码,命名空间以项目根命名,和app/分开是为了清晰区分“框架代码”和“业务代码”;config/存配置,routes/存路由定义,storage/存运行时产生的文件。它们各自独立,后面扩展时会非常舒服。
3.2 入口文件的作用:这里只负责“收口”和“引导”
public/index.php是整个应用的唯一入口,也是目录结构里最核心的文件。它的职责有两个:
- 定义所有常量,加载自动加载器;
- 从全局
$_SERVER里剥离出请求信息,创建应用实例,执行它并输出响应。
一个最小可用的骨架长这样:
<?php declare(strict_types=1); use MyFramework\Application; define('BASE_PATH', dirname(__DIR__)); require BASE_PATH . '/vendor/autoload.php'; $app = new Application(); $response = $app->handle( $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'] ); $response->send();在这个阶段,Application类还不存在,所以这个文件暂时不能运行,但它的存在明确了后续的开发目标和测试入口。这种“先定契约、后写实现”的方式,会在后面每一步都让你有清晰的方向。
3.3 配置文件的组织方式
配置文件准备阶段就可以先放一个示例,不一定马上就用。我的思路是:所有配置以PHP数组形式组织,这样不需要解析器、缓存也方便。
比如config/app.php存放基础配置:
<?php return [ 'name' => 'MyFramework', 'debug' => true, 'timezone' => 'Asia/Shanghai', ];config/database.php可以暂时写一个空的连接数组,等后面集成数据库操作时再补。准备阶段的关键是把“配置文件路径”和“读取方式”定下来,内容反而是次要的。
3.4 目录权限与安全性的初始设置
storage/logs和storage/cache在准备阶段就要设置好写权限。因为后续框架往里面写日志、写缓存文件,如果权限不对,会得到很难排查的Permission denied错误。
一般运行Web服务的用户对这两个目录拥有写权限就够了。如果是在本机开发,把目录权限设为755,并让属主是当前用户即可。不要给project-root整体777权限,那会给后面的真实部署留下风险。
4. Composer关键概念扫盲:autoload、dev依赖和lock文件
4.1 composer.json:从零配置一个项目
进入项目根目录,执行composer init会交互式生成一个composer.json。但我更喜欢手动写,因为交互式提示会漏掉一些关键字段。
一个给框架项目用最基础的composer.json长这样:
{ "name": "myframework/myframework", "type": "library", "description": "A lightweight PHP framework built with Composer.", "license": "MIT", "require": { "php": ">=8.1" }, "require-dev": { "phpunit/phpunit": "^10.0" }, "autoload": { "psr-4": { "MyFramework\\": "src/", "App\\": "app/" } }, "autoload-dev": { "psr-4": { "MyFramework\\Tests\\": "tests/" } }, "config": { "sort-packages": true } }里面最关键的就是autoload节点中的psr-4映射。"MyFramework\\": "src/"意味着:在代码里写new \MyFramework\Container\Container()时,Composer会去src/Container/Container.php找这个类。
"App\\": "app/"同理。这样整个项目的代码都纳入了统一自动加载体系,写控制器或业务类时完全不用手动require。
4.2 require与require-dev的区别,以及为什么框架项目要区分开
require是项目运行时需要的依赖,比如日志库、数据库驱动。require-dev是开发和测试时才需要的工具,比如PHPUnit、代码检查工具、调试辅助包。
写框架时这个区分很重要:
- 如果你的框架以后要发布给别人用,
require-dev不会随包一起安装到使用者的项目里; - 这能有效控制使用者的安装体量,也符合一个库的职责边界。
在开发阶段,执行:
composer install会同时装require和require-dev。执行:
composer install --no-dev则只装生产依赖。这套机制在后期打包发布框架时非常方便。
4.3 理解composer.lock:为什么框架项目也要提交它
composer.lock保存了当前所有依赖的具体版本号和哈希校验值。团队协作或部署时,只要执行composer install,所有人都会得到完全一致的依赖版本,避免“我这跑得好好的,你那儿就是报错”的经典画面。
对于库项目(比如一个将发布给他人使用的框架包),是否提交composer.lock存在争议。库的消费者会根据你的composer.json计算依赖;但在这个自建框架项目里,我建议保留并提交它。理由很务实:你的目标是保证自己环境和项目稳定可见,而不是作为一个被依赖的库去传播风险。
4.4 autoload的生成和重载:怎么让新类立即被识别
频繁增删类文件后,记得执行:
composer dump-autoload这个命令会重新扫描composer.json里的映射关系,并更新vendor/composer/autoload_psr4.php等文件。如果你加了新类但一直报Class not found,第一个要怀疑的就是自动加载缓存没有更新。
另外,我习惯在composer.json里配置脚本,把常用命令串起来:
"scripts": { "post-autoload-dump": [ "@php -r \"echo 'Autoload refreshed.' . PHP_EOL;\"" ] }这不是必须的,但用久了你会体会到,自动化这些小事能少很多无谓的输入。
5. 初始化Composer项目并验证自动加载
5.1 步骤一:创建目录与基础文件
在准备好以上理论后,动手创建目录。命令如下(以Linux/macOS为例):
mkdir -p project-root/{app,config,public,routes,storage/logs,storage/cache,src} cd project-root touch public/index.php routes/web.php config/app.php到这一步,project-root已经具备框架的基本骨架。注意storage下没有单独建logs和cache之外的东西,其他运行时文件后续按需再加。
5.2 步骤二:写入composer.json并安装PhpUnit
将上面展示的composer.json保存到项目根目录,然后执行:
composer installComposer会创建vendor/目录并安装PHPUnit至开发环境。如果网络不好,先确认镜像源配置已经生效。
安装完成后,检查vendor/bin/目录,应该能看到phpunit脚本。这是后续写测试时要用到的入口,也是验证环境是否正常的一个信号。
5.3 步骤三:写一个测试类验证PSR-4自动加载
为了让准备阶段有个明确的“验收点”,我建议创建一个极简类和一个测试用例。
先在src/Foundation/Application.php里写一个占位用的类:
<?php declare(strict_types=1); namespace MyFramework\Foundation; class Application { public function version(): string { return '0.1.0'; } }然后在tests/Foundation/ApplicationTest.php里写一个PHPUnit测试:
<?php declare(strict_types=1); namespace MyFramework\Tests\Foundation; use MyFramework\Foundation\Application; use PHPUnit\Framework\TestCase; class ApplicationTest extends TestCase { public function testVersionReturnsExpectedString(): void { $app = new Application(); $this->assertSame('0.1.0', $app->version()); } }执行:
vendor/bin/phpunit tests看到绿色通过提示,说明三件事同时得到了验证:
- PHP环境符合要求;
- Composer的PSR-4自动加载映射生效;
- PHPUnit在dev依赖下能正常执行。
5.4 开启内置服务器进行简单访问测试
如果你还想确认入口文件对HTTP请求的响应链路,可以先写一个极简的响应逻辑。比如暂时在public/index.php里手动返回一个静态字符串来观察效果:
<?php declare(strict_types=1); echo 'Hello from MyFramework.';然后在项目根目录执行:
php -S 0.0.0.0:8080 -t public浏览器访问http://127.0.0.1:8080/,能看到那行字符串,说明Web服务器层面没有问题。到下一篇我们再实现完整的应用处理流程,把这段占位代码替换掉。
6. 准备阶段最容易被忽视的5个细节
6.1 严格类型声明必须从一开始就养成习惯
自建框架的代码一般规模不大,但类型声明的价值会在维护期成倍放大。框架内部的每个方法都尽量写明参数类型和返回类型,外部调用时就能在入口处挡住大部分错误,而不是让错误在逻辑深处炸开。
举个例子,如果你的路由处理器是这样声明的:
public function dispatch(string $path): Response那么传入一个非法类型时,PHP会在还没有进入方法体之前就抛出类型错误,错误位置清晰明确。如果没有类型声明,你可能在几十行后才发现数据不对,到时候排查成本就高了。
6.2 命名空间前缀和目录必须严格对齐
PSR-4的映射规则是:命名空间前缀对应目录根。如果你把MyFramework\映射到src/,那么MyFramework\Container\Container就对应src/Container/Container.php。目录名、文件名、命名空间三段要保持完全一致。
路径大小写在Linux下是敏感的。为了避免这类问题,推荐所有目录和文件名统一使用大驼峰式命名,和PSR-4示例一致。
6.3 版本约束别乱写,分清波浪号和尖号
在composer.json里,^10.0表示允许10.x系列所有不越过11.0的版本更新;~10.0表示允许10.0.x系列内更新。从语义化版本角度讲,^用于依赖范围更宽松的场景,~用于补丁版本更新。框架项目建议从^开始,让依赖能够获取安全补丁。
6.4 不要在这时就加入一堆用不上的包
准备阶段最容易犯的错误是“见一个库就想集成”。日志、ORM、模板引擎都是后续按需加入的,这一阶段加入它们只会干扰你对基础结构的理解,也会让自动加载映射的验证变得复杂。先保证核心骨架上没有任何多余的依赖,再按功能需求逐步增加。
6.5 Git仓库从第一天就建好,并用.gitignore排除vendor等目录
框架开发是一个不断迭代的过程,没有版本控制做后盾,任何大的重构都会让人胆战心惊。建议在项目初始化后立刻:
git init并创建.gitignore文件,内容至少包含:
/vendor/ /storage/logs/*.log /storage/cache/*.php .env .phpunit.result.cachevendor/目录不应提交到版本库,因为它完全可以靠composer.lock和composer.json重建。日志和缓存文件同理。这个习惯越早养成,后面越省心。
7. 准备阶段完成后,怎么判断自己真的准备好了
准备阶段看似没写出多少业务代码,但它决定了整个框架的体验。我总结了一套简短的验收标准,你可以对照着检查:
- 执行
php -v能确认PHP 8.1及以上版本,并且php -m能看到mbstring、curl、openssl、json等扩展; - 执行
composer --version正常输出,镜像源已配置; - 项目目录结构符合预期,配置、路由、业务代码、框架代码划分清楚;
composer install能顺利执行,vendor/bin/phpunit存在;- 写一个带命名空间的测试类,运行
vendor/bin/phpunit tests全部通过; - 内置服务器能启动,浏览器可以访问到
public/index.php输出的内容; git init完成,.gitignore正确排除非提交文件。
如果以上七条全部满足,你的基础环境就已经达到可以动手写路由和容器的状态。这一阶段我个人的体会是:扎实准备带来的收益不会立刻体现在第一天的进度上,但当写容器、写中间件时,你能把注意力完全放在设计本身,而不是频频回头处理环境问题。
下一步就可以开始实现前端控制器和路由分发。我会从请求的封装说起,把一个HTTP请求从原始$_SERVER数据变成一个干净的对象,让后面的代码只跟对象打交道。保持这个小步迭代的节奏,框架会自然长出来。