资讯动态

PHP+MySQL全栈房屋租赁管理系统:从表设计到权限控制与可视化报表

发布时间:2026/9/14 3:46:19 来源:尧图企业网站定制
简介面向个人房东的现代化全栈房屋租赁管理系统基于PHP与MySQL开发集房源发布、在线预约、租客筛选、合同管理、租金支付、维修申报及多角色权限控制等功能于一体适合需要快速开展线上租赁业务或学习全栈项目开发的个人与初级开发者。资源共52个文件以31个PHP业务逻辑与页面文件为核心辅以SQL数据库脚本、CSS样式、若干JPG/PNG图片及WebP素材并附带README与说明文档整体包体仅3.41MB结构精简便于部署与二次开发。已有56人学习浏览。通过该压缩包可获取完整的前后端源码、数据库初始化脚本、房东/租客/管理员多角色页面与处理引擎同时涵盖搜索房源、预约看房、在线签约、租金支付、聊天及维修申报等真实业务场景适合用于课程设计、毕业设计或实际小规模租赁平台搭建参考。1. 为什么这套 PHP 与 MySQL 全栈房屋租赁管理系统还需要自己搭当房源超过 20 套租客咨询还散落在微信、电话和中介平台时Excel 台账就彻底跟不上节奏了。市面上的租房管理 SaaS 按房源数收费合同和流水都在别人服务器上想要做一张归属自己的经营分析报表还得导出再加工。基于 PHP 与 MySQL 的全栈房屋租赁管理系统核心是把房源发布、在线预约、租客筛选、合同管理、租金支付、维修申报、多角色权限控制和数据可视化分析整合成一个可以部署在普通云主机的 PHP 项目。个人房东、中小型中介团队以及想拿真实业务练手“全栈项目”的 PHP 工程师都可以照着这套思路落地。下面直接给出我平时搭这类系统最常用的表结构、PHP 组织方式和排错手段。2. 全栈架构与 MySQL 表设计先把租赁业务的“理”立住2.1 为什么 PHP MySQL 仍然是这类系统的合理选型一说“现代化全栈”很多人第一反应是 Java Spring Boot 或 Go Vue。但对个人房东或日 UV 只有几百的内部业务系统PHP MySQL 的长期维护成本是最低的。PHP 部署简单Nginx PHP-FPM 一跑就能用MySQL 的关系模型又非常适合租赁这种强结构化业务房源、租客、合同、账单之间的外键关系清晰出问题也容易排查。PHP 的 PDO 扩展能统一做预处理避免 SQL 注入。如果你刚接触这套技术栈按照 mysql 安装配置教程装好 MySQL 5.7 或 8.0再确认 PHP 的 pdo_mysql 扩展打开开发环境半天内就能转起来。我建议直接用 PHP 8.0构造器、枚举、match 表达式都能让代码比 PHP 5 时代干净不少。需要注意“全栈项目”不等于前后端一定分离。对这种管理后台用 PHP 模板直接渲染页面比 SPA 更实用既没有跨域成本也不用来回调状态。等以后要开放给外部租客小程序再把业务逻辑封装成接口前端对接即可。这里的“全栈”更多指一个人从数据库、PHP 业务层、前端页面和部署脚本全部打通。真正的难点不在语言选型而在数据表怎么设计才不返工。2.2 核心数据表房源、租客、合同、账单、维修工单的关系模型第一版我建议至少建 7 张表见下表。预约表和租客筛选条件可以后续再加但核心业务字段最好一次规划可扩展。表名用途关键字段house房源id, landlord_id, title, price, address, statususer用户id, username, password_hash, real_name, phonerole角色id, code, namepermission权限点id, module, actioncontract合同id, contract_no, house_id, tenant_id, start_date, end_date, rentpayment租金流水id, contract_id, bill_month, amount, due_date, pay_time, statusmaintenance维修工单id, house_id, tenant_id, assignee_id, status, fee关键的建表语句-- 房源表状态用 tinyint方便扩展 CREATE TABLE house ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 主键, landlord_id INT UNSIGNED NOT NULL COMMENT 房东用户ID, title VARCHAR(100) NOT NULL COMMENT 房源标题, price INT UNSIGNED NOT NULL COMMENT 月租金单位元, area INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 建筑面积, address VARCHAR(255) NOT NULL COMMENT 详细地址, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待出租 1已出租 2已下架, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_landlord (landlord_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT房源表; -- 合同表租客来源于 user 表 CREATE TABLE contract ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, contract_no VARCHAR(32) NOT NULL COMMENT 合同编号, house_id INT UNSIGNED NOT NULL, tenant_id INT UNSIGNED NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, rent INT UNSIGNED NOT NULL COMMENT 月租金快照, status TINYINT NOT NULL DEFAULT 1 COMMENT 1生效 2到期 3解除, UNIQUE KEY uk_house (house_id, status), KEY idx_tenant (tenant_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT合同表;代码里的uk_house (house_id, status)只保证同一房源最多有一条生效合同能挡住并发重复签约。但注意 MySQL 8.0 才支持函数索引老版本不要在这里加太多约束业务层事务判断仍然要做。所有金额用 INT 存元避免浮点误差如果以后要计到分一律改存分PHP 输出时用number_format转成元。状态字段不要用字符串tinyint加注释最省心。2.3 多角色权限控制不引入框架也能做干净的 RBAC权限模型用经典的 RBAC用户、角色、权限中间加 user_role 和 role_permission 两张关联表。系统至少要有 admin、landlord房东、tenant租客、maintainer维修工四种角色。获取当前用户权限的 SQL 如下SELECT p.module, p.action FROM user_role ur JOIN role_permission rp ON ur.role_id rp.role_id JOIN permission p ON rp.permission_id p.id WHERE ur.user_id ?实际项目里我不会在每次请求都查一遍权限表而是登录后把权限列表加载到一个全局数组PHP 代码中用函数判断$permMap []; $stmt $pdo-prepare($sql); $stmt-execute([$_SESSION[user_id]]); while ($row $stmt-fetch(PDO::FETCH_ASSOC)) { $permMap[$row[module]][$row[action]] true; } function can(string $module, string $action): bool { global $permMap; return isset($permMap[$module][$action]); }用global在小项目里可接受上线前可以把它挪进一个 Request 类。角色关联表比直接给 user 加 role_id 字段灵活得多因为一个用户可能既是房东又是租客比如租了自己管理的某套房子。权限点示例包括house:publish、contract:sign、payment:receive、maintenance:assign等。这样给新角色分配权限只需要往 role_permission 里插记录不用改代码。3. 房源发布、在线预约与租客筛选把看房流程变成一条流水线3.1 房源发布上传图片和写入核心字段房源发布最容易出问题的是图片上传。下面是一段可用的处理代码// publish_house.php require auth.php; if (!can(house, publish)) { http_response_code(403); exit(无权限发布房源); } $title trim($_POST[title] ?? ); $price (int)($_POST[price] ?? 0); $address trim($_POST[address] ?? ); $area (int)($_POST[area] ?? 0); if (mb_strlen($title) 5 || $price 0 || $address || $area 0) { exit(标题至少5个字面积和价格必须大于0); } $imagePaths []; $allowed [image/jpeg, image/png]; $uploadDir __DIR__ . /../uploads/houses/ . date(Ymd); if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } foreach (($_FILES[images][tmp_name] ?? []) as $i $tmpName) { if (!in_array($_FILES[images][type][$i], $allowed)) { continue; } $ext $_FILES[images][type][$i] image/jpeg ? jpg : png; $filename uniqid() . . . $ext; move_uploaded_file($tmpName, $uploadDir . / . $filename); $imagePaths[] uploads/houses/ . date(Ymd) . / . $filename; } $stmt $pdo-prepare( INSERT INTO house (landlord_id, title, price, area, address, status) VALUES (?, ?, ?, ?, ?, 0) ); $stmt-execute([$_SESSION[user_id], $title, $price, $area, $address]); $houseId (int)$pdo-lastInsertId(); if ($imagePaths) { $insert $pdo-prepare( INSERT INTO house_image (house_id, path, sort_order) VALUES (?, ?, ?) ); foreach ($imagePaths as $k $path) { $insert-execute([$houseId, $path, $k]); } } header(Location: house_detail.php?id . $houseId);代码里$_FILES[images]是数组形式上传对应表单中的input typefile nameimages[] multiple。图片按日期目录存放避免单目录文件过多uniqid()生成文件名即可因为目录已经按天隔离。上传目录权限设 755并且绝不允许执行 PHP 脚本这一点第 6 章还会再强调。房源删除时还要连数据库记录和磁盘文件一起清掉否则时间久了会产生大量垃圾文件。3.2 在线预约用唯一索引和事务防止重复预约租客在线预约看房最容易出现同一房源同一时间被重复预约的情况。数据库层面先把唯一索引建好CREATE TABLE appointment ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, house_id INT UNSIGNED NOT NULL, tenant_id INT UNSIGNED NOT NULL, visit_time DATETIME NOT NULL COMMENT 约定看房时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待确认 1已确认 2已取消 3已完成, remark VARCHAR(255) DEFAULT , created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_house_time (house_id, visit_time), KEY idx_tenant (tenant_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT看房预约表;写入预约的 PHP 事务如下try { $pdo-beginTransaction(); $check $pdo-prepare( SELECT id FROM appointment WHERE house_id ? AND visit_time ? FOR UPDATE ); $check-execute([$houseId, $visitTime]); if ($check-fetch()) { throw new RuntimeException(该时间段已被预约); } $insert $pdo-prepare( INSERT INTO appointment (house_id, tenant_id, visit_time) VALUES (?, ?, ?) ); $insert-execute([$houseId, $_SESSION[user_id], $visitTime]); $pdo-commit(); } catch (Throwable $e) { $pdo-rollBack(); // 记录日志并提示用户更换时间 }FOR UPDATE是悲观锁配合唯一索引能挡住并发写入。如果担心锁冲突影响性能也可以去掉显式查询直接INSERT捕获Duplicate entry错误。事务保留是因为后续可能还要顺手发站内信、通知房东这些操作应该和预约写库一起成功或失败。另外visit_time最好统一按服务器时区Asia/Shanghai存储前端传过来的时间戳先做时区转换否则容易出现约了上午结果存成晚上的问题。3.3 租客筛选把勾选条件安全地拼进 WHERE租客找房时通常要筛价格区间、面积、状态这属于典型的多条件动态 SQL。安全的做法是用 PDO 参数绑定数组// search_houses.php $conds []; $params []; $priceMin (int)($_GET[price_min] ?? 0); $priceMax (int)($_GET[price_max] ?? 0); $areaMin (int)($_GET[area] ?? 0); $status (int)($_GET[status] ?? 0); if ($priceMin 0) { $conds[] price ?; $params[] $priceMin; } if ($priceMax 0) { $conds[] price ?; $params[] $priceMax; } if ($areaMin 0) { $conds[] area ?; $params[] $areaMin; } if ($status 0) { $conds[] status ?; $params[] $status - 1; } $where $conds ? WHERE . implode( AND , $conds) : ; $sql SELECT * FROM house $where ORDER BY created_at DESC LIMIT 20; $stmt $pdo-prepare($sql); $stmt-execute($params); $houses $stmt-fetchAll(PDO::FETCH_ASSOC);前端参数和数据库条件的对应关系如下前端参数类型数据库条件说明price_minintprice ?最低租金price_maxintprice ?最高租金areaintarea ?最低面积statusintstatus ? - 11 在售2 已租所有$_GET参数先转 int从源头杜绝字符型注入。如果以后有按小区名模糊搜索用LIKE时也要绑定参数并对%和_转义。这里的分页直接写死 20 条正式系统再补?limit20offset0参数但注意 offset 也要强制 int。4. 合同管理、租金支付与维修申报三条状态流贯穿租务闭环4.1 合同管理签订合同时把房源状态一起切换合同签订是租赁系统里最需要严谨的操作。核心逻辑是在一个事务里同时完成“插入合同”和“修改房源状态为已出租”并将当前租客 ID 回写到房源表。// contract_sign.php $pdo-beginTransaction(); $lock $pdo-prepare( SELECT * FROM house WHERE id ? AND status 0 FOR UPDATE ); $lock-execute([$houseId]); $house $lock-fetch(PDO::FETCH_ASSOC); if (!$house) { $pdo-rollBack(); exit(房源已出租); } $contractNo HT . date(Ymd) . strtoupper(substr(uniqid(), -6)); $insert $pdo-prepare( INSERT INTO contract (contract_no, house_id, tenant_id, start_date, end_date, rent, status) VALUES (?,?,?,?,?,?,1) ); $insert-execute([ $contractNo, $houseId, $tenantId, $startDate, $endDate, $house[price] ]); $update $pdo-prepare( UPDATE house SET status 1, current_tenant_id ? WHERE id ? ); $update-execute([$tenantId, $houseId]); $pdo-commit();current_tenant_id这个字段要在 house 表里加上方便页面直接显示谁在住不用每次都 join contract。合同号HT 日期 uniqid 后 6 位基本不会重复。合同到期前 15 天要提醒通常写一个每天执行的 PHP CLI 脚本扫描end_date在窗口期内的合同给房东发站内信。第一版合同文件不一定要生成 PDF可以做成 HTML 模板让房东打印签字后扫描上传后期要自动化再引入 PDF 库。4.2 租金支付账单生成与逾期状态机租金支付模块最怕人工记账漏收。我习惯写一个独立的 CLI 脚本每天跑一次自动为生效合同生成当月账单// cron/generate_bills.php $today date(Y-m-d); $stmt $pdo-query( SELECT c.id, c.tenant_id, c.rent, c.start_date, c.end_date FROM contract c WHERE c.status 1 AND c.start_date $today ); $insert $pdo-prepare( INSERT IGNORE INTO payment (contract_id, bill_month, amount, due_date, status) VALUES (?,?,?,?,0) ); foreach ($stmt as $c) { $month date(Y-m, strtotime($c[start_date])); $end date(Y-m, strtotime($c[end_date])); while ($month $end) { // 每月10号前交租 $due date(Y-m-d, strtotime($month . -10)); $insert-execute([$c[contract_id], $month, $c[rent], $due]); $month date(Y-m, strtotime($month . 1 month)); } }这里要求 payment 表有唯一索引(contract_id, bill_month)否则重复执行脚本会产生重复账单。INSERT IGNORE加唯一索引让脚本天然幂等。逾期转换也靠定时任务UPDATE payment SET status 2 WHERE status 0 AND due_date CURDATE();支付状态定义如下表status含义可执行操作0未支付租客可标记“线下已转账”1已支付仅房东可确认收款2逾期房东可发起催收提醒实际支付金额和时间写入pay_time字段状态置为 1 的操作只有 landlord 和 admin 角色能做。第一版不要急着接支付宝微信支付先支持“租客标记转账房东手动确认”这条闭环够用且没有对公账户也能跑。4.3 维修申报租客上报、管理员派单、维修工回填维修工单是典型的状态机状态流转为待分配 - 进行中 - 已完成。每个状态变更对应不同角色if ($_POST[action] assign) { if (!can(maintenance, assign)) { exit(无权限派单); } $sql UPDATE maintenance SET status 1, assignee_id ? WHERE id ? AND status 0; $stmt $pdo-prepare($sql); $stmt-execute([(int)$_POST[assignee_id], (int)$_POST[id]]); } elseif ($_POST[action] complete) { if (!can(maintenance, complete)) { exit(无权限完工); } $sql UPDATE maintenance SET status 2, completed_at NOW() WHERE id ? AND assignee_id ?; $stmt $pdo-prepare($sql); $stmt-execute([(int)$_POST[id], $_SESSION[user_id]]); }assignee_id记录当前处理人维修工提交完成时WHERE 里必须带assignee_id否则任意登录用户都能把工单改成已完成。租客提交维修申报时系统应该根据当前登录用户的生效合同自动带出房源 ID而不是让租客手填这样既能防止越权报修也能让房东知道是哪套房子出了问题。维修费用建议在“已完成”状态里再填写并且记录提交者方便月底对账。5. 多角色权限控制与数据可视化分析后台的“仪表盘”不只是图表5.1 权限控制中间件让每个入口都过同一道门小项目里最直接的做法是写一个auth.php每个控制器开头必须 require 它// auth.php session_start(); if (empty($_SESSION[user_id])) { header(Location: login.php); exit; } // $permMap 的加载方式见 2.3 节 function can(string $module, string $action): bool { global $permMap; return isset($permMap[$module][$action]); }然后在各控制器开头写require_once __DIR__ . /../auth.php; if (!can(house, publish)) { http_response_code(403); exit(没有发布房源权限); }权限点不要写死在代码里尽量做成可配置。我一般会在 admin 后台放一个权限初始化脚本把系统所有module:action批量插入 permission 表新角色只要在 role_permission 里勾选即可。也可以把路由表集中在一个入口文件定义每个 URI 需要的权限但小系统直接在文件头判断更直观、调试也轻松。下面是一张常用的权限点示例表开发时按这个粒度划分足够模块动作允许角色housepublishlandlord, adminappointmentview_alllandlord, admincontractsignlandlord, adminpaymentconfirmlandlord, adminmaintenanceassignadminstatsviewlandlord, admin这里的核心不是代码写法而是“所有入口都要经过同一道门”。租客接口里偶然漏一个判断就叫越权。建议在开发阶段把can()的失败场景统一输出 403 页面而不是让代码继续往下执行否则很容易在调试时跳过权限判断。5.2 可视化报表接口PHP 输出 ECharts 要的 JSON数据可视化的起点不是前端图表库而是后端能否稳定输出聚合数据。租赁系统最有价值的两张图是月度租金收入曲线和房源空置率。接口代码// api/stats.php header(Content-Type: application/json; charsetutf-8); require ../auth.php; if (!can(stats, view)) { http_response_code(403); exit(json_encode([error forbidden])); } $year isset($_GET[year]) ? (int)$_GET[year] : date(Y); $sql SELECT DATE_FORMAT(pay_time, %Y-%m) AS month, SUM(amount) AS total FROM payment WHERE pay_time BETWEEN ? AND ? AND status 1 GROUP BY month ORDER BY month; $stmt $pdo-prepare($sql); $stmt-execute([$year . -01-01, $year . -12-31]); $income $stmt-fetchAll(PDO::FETCH_ASSOC); echo json_encode([ months array_column($income, month), income array_column($income, total) ]);这里统计的是实际入账时间pay_time不是账单到期日due_date否则 1 月欠的钱拖到 2 月交曲线会失真。空置率第一版可以用“每月最后一天状态为已出租的房源数 / 总房源数”后面对精度要求高了再按天折算。接口必须加权限控制否则租客登录后直接访问接口就能看到平台全部收入属于重大事故。企业级数据可视化大屏听起来复杂但租赁平台真正需要的往往只有月度现金流、空置率、维修工单完成率这几张图。先把接口做正确比堆砌 3D 地图重要得多。5.3 前端图表用 ECharts 把接口数据画成大屏前端用原生 fetch 调用接口不需要后端渲染模板。示例// stats.html 中的脚本 fetch(/api/stats.php?year new Date().getFullYear()) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(incomeChart)); chart.setOption({ tooltip: { trigger: axis }, grid: { left: 3%, right: 4%, bottom: 3%, containLabel: true }, xAxis: { type: category, data: data.months }, yAxis: { type: value }, series: [ { name: 租金收入, type: bar, data: data.income, itemStyle: { color: #4C9AFF } } ] }); });开发阶段直接在 HTML 里引echarts.min.js不用按需引入。数据大屏要自动刷新加一行setInterval(() fetchAndUpdate(), 60000)即可。接口响应头要加Cache-Control: no-store否则浏览器可能缓存住上一个月的报表数据。多角色权限在这里同样要生效房东登录只能看自己名下房源的收入admin 能看全部接口层用$_SESSION[user_id]做数据过滤。6. 租赁系统上线前用 EXPLAIN 和目录权限拦住低级事故6.1 给高频 SQL 做一次 EXPLAIN很多人上线前只测功能不测数据量翻倍后的性能。租房系统最常见的慢 SQL 是预约表按时间查、合同表按租客查。先用EXPLAIN看执行计划EXPLAIN SELECT * FROM appointment WHERE house_id 123 AND visit_time 2025-06-01 10:00:00;重点看type列如果出现ALL说明走了全表扫描必须检查索引是否生效。SHOW PROCESSLIST;可以实时看到当前有没有慢查询在堆积。对于超过 500 毫秒的 SQL先确认是否少了联合索引。6.2 上传目录必须和 PHP 执行目录隔离房源图片上传目录往往是最容易出安全问题的位置。配置层面做两步# PHP 只允许访问应用目录和临时目录 php_admin_value[open_basedir] /var/www/leasemanager:/tmp # 上传目录移除 PHP 执行权 chmod 755 uploads/houses 文件里的php_flag engine off在部分 PHP-FPM 环境下不生效最好在 Nginx 配置里把uploads目录的 location 指向纯静态资源。备份用 cron 每天凌晨跑一次mysqldump保留最近 7 天0 3 * * * mysqldump -ulease -ppassword leasemanager | gzip /backup/leasemanager_$(date \%F).sql.gz最后检查一下日志目录的写入权限和session.save_path是否在 open_basedir 范围内。这两步做完系统上线后能少接到很多深夜报警电话。本文还有配套的精品资源点击获取

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

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

免费获取报价