资讯动态

PHP实战:用MVC模式构建可维护的信息管理系统

发布时间:2026/10/14 1:12:55 来源:尧图企业网站定制
做信息管理系统这活儿说难不难说简单也不简单。很多人刚开始接触PHP的时候都写过“用户管理”“文章管理”这类东西最常见的就是一个list.php里又拼SQL又写HTML改个需求翻半天文件加个字段改三处地方。当项目从两三个页面膨胀到十几个模块的时候你不拆架构代码就会用各种诡异的方式反噬你。这篇文章我打算把“PHP中用MVC模式写一个信息管理系统综合应用”这件事完整讲透从一个能落地的骨架开始到 Model、Controller、View 各层到底该放什么再到一个模块从建表到页面的完整流程最后把我自己实战里踩过的几个坑一并交代。适合已经会 PHP 基本语法、知道怎么连 MySQL但没正经按分层思想做过项目的朋友如果你正准备用原生 PHP 搭一个内部管理系统这篇可以直接照着抄。1. 信息管理系统选型MVC究竟解决了什么实际问题1.1 没有分层的时候一个“简单模块”是怎么写崩的我见过太多的“起始代码”长这样一个add_user.php上面是if ($_POST[submit])的提交判断中间是mysql_query(INSERT INTO users ...)下面直接echo trtd.../td/tr循环输出表格。平时看着挺顺一旦出现这么几个需求问题就全冒出来了想给“新增用户”加一个手机号字段你要改表单页、改插入SQL、改列表页、改编辑页四处都可能漏前端想换个UI框架发现HTML散落在二十几个文件里每个文件还都有重复的头尾某个页面报了个 SQL 错误你要在满是HTML标签的代码里大海捞针找 SQL 拼接位置安全上更是重灾区直接拼变量进 SQL、输出不转义几天不出事是运气好。这些问题的本质不是“代码写得乱”而是职责没有分离。数据怎么取、业务怎么处理、页面怎么展示三件事全搅在一起耦合度高得吓人。MVC 就是把这三种职责强制拆开的一套思路。1.2 MVC在这类系统里的真实分工拿吃饭打比方MVC 就像一家餐厅的分工Model模型后厨。负责和数据库打交道查询数据、保存数据不关心你前端长什么样。Controller控制器服务员。接收顾客的点单请求把需求告诉后厨Model再把菜数据端出去交给顾客。View视图菜品摆盘。只负责怎么呈现不关心数据从哪来。在信息管理系统里常见模块基本都是对某几张表的增删改查用户、部门、文章、订单、日志……它们的共性非常强请求进来 → 查数据/写数据 → 渲染页面。MVC 恰好把这个流程切成了清晰的三个阶段每个阶段有专门的“负责人”谁出问题就找谁。1.3 什么时候不该硬上MVC话说回来也不是所有场景都得上 MVC。如果你只是写一个几十行代码、没有复用需求的小接口比如一个返回 JSON 的验证服务那强行拆三层反而更折腾。我的判断标准很简单当项目预计超过3个页面、或者你预感到以后会持续加功能的时候才值得花半小时把骨架搭起来。MVC 解决的是“变化”和“维护”的问题而不是所有问题的银弹。会判断“现阶段该不该用”比会用更重要。2. 从零搭骨架前端控制器、路由与目录解构2.1 目录结构一开始就分清“入口”和“业务”我习惯的骨架长这样不复杂但边界清晰project/ ├── index.php // 前端控制器唯一的入口 ├── config/ │ └── config.php // 数据库、时区、错误级别等配置 ├── app/ │ ├── controllers/ // 控制器 │ ├── models/ // 模型 │ ├── views/ // 视图模板 │ └── helpers/ │ ├── functions.php // 公共函数 │ └── Router.php // 路由解析 ├── core/ │ ├── Controller.php // 控制器基类 │ ├── Model.php // 模型基类 │ └── View.php // 视图渲染 └── public/ ├── css/ ├── js/ └── upload/ // 上传文件目录这里有个关键点css、js、图片这类静态资源单独放一个 public 目录和 PHP 业务代码分开。这样在配置 Web 服务器的时候可以让静态文件不走 PHP 解析更重要的是防止用户上传的恶意文件被放到可执行目录里这是很多系统被打的原因之一。2.2 前端控制器与 URL 重写MVC 的“入口单一”靠的是前端控制器模式所有请求都打到index.php再由路由决定交给哪个控制器处理。Apache 下需要一份.htaccess放在项目根目录RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^(.*)$ index.php [QSA,L]这两行!-f、!-d的意思是如果请求的是真实存在的文件或目录比如图片、CSS直接放行否则全部转给index.php。这样控制器方法的 URL 就可以写成index.php?cuseraadd这种形式。Nginx 对应配置是location / { try_files $uri $uri/ /index.php?$query_string; }2.3 路由解析只允许调用“计划内”的控制器index.php里做的事非常少就是定义常量、加载配置、启动路由?php // index.php define(BASE_PATH, __DIR__); spl_autoload_register(function ($class) { // 把命名空间映射到目录简化处理 $file BASE_PATH . / . str_replace(\\, /, $class) . .php; if (is_file($file)) { require $file; } }); $config require BASE_PATH . /config/config.php; date_default_timezone_set($config[timezone] ?? Asia/Shanghai); require BASE_PATH . /app/helpers/functions.php; $router new Router($config); $router-dispatch();Router 的核心是从参数里取出c控制器名和a方法名然后实例化调用。这里有一个必须强调的坑绝不能把 URL 里的参数直接当方法名调用否则别人可以调用你控制器里不该被外部访问的方法。我一般做一层白名单校验?php class Router { public function dispatch(): void { $controller $_GET[c] ?? user; $action $_GET[a] ?? index; // 白名单只允许调用以合法后缀命名的控制器和 action if (!preg_match(/^[a-zA-Z][a-zA-Z0-9_]*$/, $controller) || !preg_match(/^[a-zA-Z][a-zA-Z0-9_]*$/, $action)) { exit(请求不合法); } $class ucfirst($controller) . Controller; $file BASE_PATH . /app/controllers/ . $class . .php; if (!is_file($file)) { exit(控制器不存在); } require $file; $instance new $class(); if (!method_exists($instance, $action)) { exit(方法不存在); } $instance-$action(); } }写到这里你应该能感受到MVC 的入口部分其实不复杂但它是整个系统的基础设施路由解析的严谨程度直接决定系统的安全下限。3. Model层的底线不要把SQL拼在控制器里3.1 表结构设计一个能跑的库比花哨的字段重要信息管理系统的核心是数据所以 Model 层要先从表结构说。以最常见的“用户管理”为例我通常这样设计CREATE TABLE user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录名, password_hash VARCHAR(255) NOT NULL COMMENT 密码哈希, real_name VARCHAR(50) NOT NULL DEFAULT COMMENT 姓名, email VARCHAR(100) NOT NULL DEFAULT , status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0禁用, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个容易忽略的设计点密码字段必须存password_hash()的结果绝不能存明文所有涉及时间的地方我建议统一用DATETIME别混用时间戳不然前端格式化很容易出乱子updated_at这种字段虽然看着冗余复查数据问题是救命稻草。表设计好了Model 才有发挥空间。3.2 一个基于 PDO 的模型基类选 PDO 而不是旧的mysqli_*函数最大的原因是预处理语句的参数绑定能从根本上防住 SQL 注入。我自己封装的 Model 基类大致长这样?php class Model { protected PDO $pdo; protected string $table; public function __construct() { $config require BASE_PATH . /config/config.php; $dsn sprintf(mysql:host%s;port%s;dbname%s;charsetutf8mb4, $config[db_host], $config[db_port], $config[db_name]); $this-pdo new PDO($dsn, $config[db_user], $config[db_pass], [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, ]); } public function findAll(string $where , array $params [], string $order , string $limit ): array { $sql SELECT * FROM . $this-table; if ($where ! ) { $sql . WHERE . $where; } if ($order ! ) { $sql . ORDER BY . $order; } if ($limit ! ) { $sql . LIMIT . $limit; } $stmt $this-pdo-prepare($sql); $stmt-execute($params); return $stmt-fetchAll(); } public function findById(int $id): ?array { $stmt $this-pdo-prepare(SELECT * FROM . $this-table . WHERE id ?); $stmt-execute([$id]); $row $stmt-fetch(); return $row ?: null; } public function insert(array $data): int { $fields array_keys($data); $sql sprintf( INSERT INTO %s (%s) VALUES (%s), $this-table, implode(, , $fields), implode(, , array_fill(0, count($fields), ?)) ); $stmt $this-pdo-prepare($sql); $stmt-execute(array_values($data)); return (int)$this-pdo-lastInsertId(); } public function update(int $id, array $data): int { $sets implode(, , array_map(fn($f) $f ?, array_keys($data))); $sql UPDATE . $this-table . SET . $sets . WHERE id ?; $stmt $this-pdo-prepare($sql); $stmt-execute(array_merge(array_values($data), [$id])); return $stmt-rowCount(); } public function delete(int $id): int { $stmt $this-pdo-prepare(DELETE FROM . $this-table . WHERE id ?); $stmt-execute([$id]); return $stmt-rowCount(); } }看到这你可能觉得“这不就是重复造轮子吗”。对但我认为初学 MVC 的时候亲手封一遍这个基类非常值得——只有自己写过你才知道框架里那些方法做了什么、背后的参数绑定原理是什么。等你真的理解了再去用现成框架就能瞬间看懂它的设计逻辑。3.3 关联查询与分页最常见的两个暗坑分页的坑在于你既要查出当前页的数据还要查出总条数来算总页数。常见新手写法是先SELECT *再count($rows)数据量一大就卡死。正确姿势是先单独查总数再查一页数据并且把分页参数计算封装成公共方法。我在 Model 基类里会加一个paginate()参数是页码、每页条数、where 条件和绑定参数内部自动算 offset 和 total。关联查询的坑在于信息管理系统里表之间常有外键关系比如“用户属于部门”。我见过最崩溃的写法是两个表 JOIN 出来的字段全部SELECT *前端拿到一大堆用不上的列。我的建议是明确列出需要的字段宁可在 Model 里多写几个查询方法也不要把所有数据一股脑倒给页面。Model 层的职责是把数据整理成业务需要的样子而不是原始的数据库行。4. Controller的责任边界参数校验、业务编排与响应跳转4.1 控制器里到底该写什么很多初学 MVC 的人会把控制器当成“三不管地带”往里塞 SQL 或 HTML这不叫 MVC这叫换了个文件名。控制器的职责应该是接收并过滤请求参数调用 Model 完成业务操作根据结果决定跳转、返回错误还是渲染视图。它像一个调度中心自己不做重活但知道每件事该交给谁。所以如果一个控制器方法超过三四十行我基本会先怀疑是不是业务逻辑写多了该拆到 Model 或服务类里。4.2 参数接收永远默认输入是不可信的我写了一个公共方法从$_GET、$_POST里取参数统一做 trim 和默认值处理protected function input(string $key, $default null, string $type string) { $value $_POST[$key] ?? $_GET[$key] ?? $default; if (is_string($value)) { $value trim($value); } if ($type int) { return (int)$value; } return $value; }然后每个具体的业务方法再做针对性校验。比如“新增用户”时用户名必填、长度 3~20密码最少 8 位邮箱格式用filter_var验证。这些校验看似琐碎却是把脏数据挡在 Model 之外的第一道防线。Model 只负责数据持久化不负责判断这个数据合不合理这个边界要非常清楚。4.3 统一响应处理渲染与跳转控制器基类里我会提供两个最常用的方法view()渲染页面、redirect()跳转。跳转时顺便支持带一次性的 flash 提示信息比如“保存成功”“删除失败”这样用户操作后有明确反馈?php class Controller { protected function view(string $template, array $data []): void { $content $this-renderTemplate($template, $data); // 渲染外层布局传入子页面内容 echo $this-renderTemplate(layout, array_merge($data, [content $content])); } protected function redirect(string $url, string $flash ): void { if ($flash ! ) { $_SESSION[flash] $flash; } header(Location: . $url); exit; } protected function renderTemplate(string $template, array $data): string { extract($data); ob_start(); require BASE_PATH . /app/views/ . $template . .php; return ob_get_clean(); } }注意view()里的实现先把子页面模板渲染成字符串再塞进 layout 布局模板这样“页头页脚只写一次”的需求天然被满足。ob_start()的作用是捕获 HTML 输出而不是直接打印这中间的小细节值得自己动手试几次感受一下。5. View层模板化布局复用、转义输出与前端资源5.1 用原生 PHP 做模板还是引入模板引擎模板引擎Smarty、Twig 这类确实能提供继承、块、安全过滤等能力但在我看来对于一个信息管理系统——内部使用为主、页面重复度高、需求变化快——原生 PHP 模板完全够用而且学习成本为零。你唯一要守住的自律是模板文件里只写 HTML 和极简单的foreach、if绝不允许写复杂的业务逻辑。把“模板里不做业务”当成强制规范比用什么引擎更重要。5.2 布局模板与局部渲染有了上面的renderTemplate实现layout 就长这样!-- app/views/layout.php -- !DOCTYPE html html langzh-CN head meta charsetutf-8 title? $title ?? 信息管理系统 ?/title link relstylesheet href/public/css/app.css /head body div classnavbar…导航…/div div classcontainer ?php if (!empty($_SESSION[flash])): ? div classalert? htmlspecialchars($_SESSION[flash], ENT_QUOTES) ?/div ?php unset($_SESSION[flash]); ? ?php endif; ? ? $content ? /div script src/public/js/app.js/script /body /html子页面模板只需要关心自己那一块内容比如用户列表页就只写表格!-- app/views/user/index.php -- h2用户列表/h2 table classtable trthID/thth用户名/thth姓名/thth状态/thth操作/th/tr ?php foreach ($users as $u): ? tr td? (int)$u[id] ?/td td? htmlspecialchars($u[username], ENT_QUOTES) ?/td td? htmlspecialchars($u[real_name], ENT_QUOTES) ?/td td? $u[status] ? 启用 : 禁用 ?/td td a href/index.php?cuseraeditid? (int)$u[id] ?编辑/a a href/index.php?cuseradeleteid? (int)$u[id] ? onclickreturn confirm(确定删除)删除/a /td /tr ?php endforeach; ? /table5.3 XSS 与 CSRFView 层必须守的两条红线View 层最常见的漏洞是 XSS。所有从后台取出来展示的数据都应该做htmlspecialchars($value, ENT_QUOTES)转义从根上杜绝script注入。嫌每个地方写太长就封装一个全局函数e()模板里? e($u[username]) ?简洁得多。另一个容易被忽略的是 CSRF用户可能在登录状态下点到一个恶意链接就执行了删除操作。解决方案是给所有表单尤其是删除、修改操作加一个 token// 生成 token 存 session $_SESSION[csrf_token] bin2hex(random_bytes(16)); // 表单里输出 input typehidden namecsrf_token value? $_SESSION[csrf_token] ? // 控制器里验证 if (!hash_equals($_SESSION[csrf_token], $_POST[csrf_token] ?? )) { exit(CSRF验证失败); }我现在写任何管理系统的后端操作CSRF 校验都是标配加上之后基本能挡住这一类绝大多数攻击。记住安全不是某个功能模块而是写每一行代码时的默认动作。6. 跑通一个完整功能以“用户管理”模块为例6.1 从需求到响应流程前面铺了这么多现在走一遍完整流程。假设需求是管理员要能在后台新增、编辑、删除、搜索用户。设计表前面已给出 user 表建UserModel继承 Model 基类设置$table user建UserController写index()列表搜索、add()显示表单、store()处理新增、edit()/update()、delete()几个方法写对应的视图模板user/index.php列表、user/edit.php新增和编辑共用一份表单在导航栏加链接指向/index.php?cuseraindex。6.2 控制器的完整样子以index()和store()为例?php class UserController extends Controller { private UserModel $model; public function __construct() { $this-model new UserModel(); } public function index(): void { $keyword $this-input(keyword, , string); $where ; $params []; if ($keyword ! ) { $where username LIKE ? OR real_name LIKE ?; $params [%$keyword%, %$keyword%]; } $page max(1, $this-input(page, 1, int)); $data $this-model-paginate($page, 10, $where, $params); $this-view(user/index, [ users $data[items], total $data[total], page $page, pages $data[pages], keyword $keyword, ]); } public function store(): void { $username $this-input(username, , string); $password $this-input(password, , string); $realName $this-input(real_name, , string); $email $this-input(email, , string); // 简单校验 if ($username || mb_strlen($username) 3 || mb_strlen($username) 20) { $this-redirect(/index.php?cuseraadd, 用户名不合法); } if (strlen($password) 8) { $this-redirect(/index.php?cuseraadd, 密码至少8位); } if ($this-model-existsByUsername($username)) { $this-redirect(/index.php?cuseraadd, 用户名已存在); } $this-model-insert([ username $username, password_hash password_hash($password, PASSWORD_DEFAULT), real_name $realName, email $email, status 1, created_at date(Y-m-d H:i:s), updated_at date(Y-m-d H:i:s), ]); $this-redirect(/index.php?cuseraindex, 新增成功); } }注意这里有几个细节我希望你留意密码用password_hash()登录判断用password_verify()数据库里永远不出现明文密码控制器里的$_POST取值全部经由input()方法杜绝直接$_POST[xxx]裸用变量的情况。6.3 联调时的常见报错排查跑完这套代码新手最容易碰到的几个问题我先把排查思路列出来页面全部 404先确认 URL 重写是否生效把/index.php?cuseraindex直接输入浏览器看有没有反应有反应说明是重写配置问题。模板显示空白检查视图文件路径是否和控制器里view()的传参一致require一个不存在的文件通常会白屏或报 fatal error先把display_errors打开看报错。写入成功但页面没提示flash 提示依赖 session检查脚本开头是否执行了session_start()。联调这个步骤我强烈建议开着错误显示来调开发环境把display_errors设为On上线前再统一关掉并打开日志记录省得在生产环境裸眼看白屏。7. 我踩过的坑以及什么时候该投奔现成框架7.1 三个让我印象深刻的坑第一个坑是路由白名单形同虚设。早期我偷懒只做了正则校验没做方法限制结果有人请求index.php?cusera__construct虽然没造成破坏但这种“能用”和“安全”的差距就在一念之间。现在我的指导原则是控制器基类里所有公开方法都默认可被 URL 访问凡是内部工具方法一律protected再加显式白名单双保险。第二个坑是列表页把用户的原始 HTML 内容直接 echo。当时做一个公告管理编辑在后台填了带样式的文本我为了省事没转义就输出结果某天通知栏弹了个不和谐的 div。这个错误让我养成了条件反射任何来自数据库或用户输入的字符串输出前默认转义。第三个坑是批量赋值Mass Assignment导致的字段篡改。“编辑用户资料”的表单只允许改real_name和email但我图省事直接$this-model-update($id, $_POST)结果有人用浏览器开发者工具加了个status0字段把自己账号的启用状态改了。从那以后再也不敢整表单提交入库Model 的update和insert必须显式声明允许写入的字段白名单。7.2 自研 MVC 和现成框架怎么选写到这你可能想问既然现成框架都这么成熟了为什么还要自己撸一套我的观点很直接学习阶段自己写过一遍 MVC是理解框架最好的方式但项目上了规模之后老老实实迁到成熟框架才是正道。当你发现自己开始陆陆续续补“模板继承”“事件机制”“依赖注入容器”这些功能说明你已经在重复造轮子的路上走远了这时候不如直接用开源方案把精力和时间留给业务本身。个人经验是这种“拆骨架→写模块→加权限→优化性能”的过程经历一轮之后你再去看 Laravel 的文档、ThinkPHP 的源码理解速度会快非常多因为你已经知道这些设计到底在解决什么问题。7.3 最后一点体会我用 M-V-C 这三个字母概括这套系统的价值Model 管数据是一门“道”Controller 管流程是一门“术”View 管呈现是一门“艺”。很多新人把 MVC 当成一种“写法”来背其实它是一种思维习惯——你在接到任何一个功能需求时第一反应是“这个动作属于哪一层”而不是“这个按钮写在哪段代码里”。当你有了这个习惯后面写什么系统、用什么框架都不至于跑偏。真的动手做一遍比看十篇文章都管用。建议你拿着这套骨架先把用户管理跑通再把部门管理、登录权限这些常见模块一个个补上。补的过程里遇到的所有报错和困惑才是这篇文章真正想给你的东西。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑