1. 为什么我最终定下这套前后端组合选型逻辑我最近把一个压了很久的内部管理系统从老式多页面模式整体重写了一遍。业务本身不算复杂但技术选型阶段来回纠结了好几次最后落定的组合是Vue 3 Vite 负责前端Laravel 提供 API用户认证直接走 Sanctum。整套 Vue Laravel 全栈开发方案跑通之后我发现大多数让新人卡壳的问题并不是某个函数不会写而是前后端之间的几条链路没有提前理顺跨域、认证、接口约定、部署回退这四个点只要前期想清楚后面写业务代码会顺畅很多。这篇文章把我的决策过程和完整配置写出来从目录搭建到上线部署都覆盖到适合正在做全栈项目、或者刚准备从“只会前端/只会后端”跨到全栈的开发者参考。1.1 传统多页面模板在项目变大后开始吃力早期我用 Laravel 做项目习惯直接在 Blade 模板里写页面再用路由跳转来切换。这种模式在页面数量少、交互简单的时候非常舒服但一旦项目里出现大量表格、弹窗、多步骤表单、实时校验这种高频交互问题就开始冒头前端状态散落在各个页面刷新之间改一个按钮的交互逻辑可能要动控制器、路由、模板三处代码后端起一个接口前端页面要完整刷新一次体验和开发效率都不理想。前后端分离解决的核心问题是让数据和呈现彻底解耦。后端只负责把资源以 API 形式暴露出去前端拿到 JSON 后自己决定怎么展示、怎么交互。这样一来Web 端、移动端、甚至未来的其他客户端都能复用同一套接口前端业务逻辑也可以独立迭代、独立打包不用每次调整交互都牵动整个 Laravel 项目。1.2 为什么前端是 Vue、后端是 Laravel前端框架里我最终选了 Vue 3主要原因是它够轻、上手平缓、模板语法贴近常规 HTML 思维。对内部管理系统这类偏表单和管理界面的项目来说Vue 的单文件组件结构比 React 的 JSX 更直观团队成员即便只懂一点 JavaScript 也能快速参与。配合 Vite 之后冷启动和热更新速度非常快开发体验比早期 Vue CLI 时代好了一大截。后端选 Laravel 则更看重它开箱即用的完整度路由、ORM、数据库迁移、表单验证、队列、权限策略这些日常开发高频用到的能力全都内置了。相比从零搭建一个 Express 或直接写原生 PHPLaravel 能帮我把大量重复劳动压缩掉。另外 Laravel 官方生态里就有 Sanctum专门解决前后端分离场景下的 API 认证问题省去了自己造轮子的麻烦。1.3 第一次做这套组合时容易踩的四个认知坑第一个坑是用写 Session 的思维去写 API 认证。Laravel 传统登录靠 Cookie Session前后端同域很好用但前后端分离后Session 方案会因为跨域、CSRF、第三方 Cookie 限制变得很别扭。第二个坑是把前端页面继续放在 Laravel 的 resources 里维护这等于没有真正分离只是换了个写法。第三个坑是不知道 Vite 开发服务器可以反代 API结果每个请求都在跟浏览器同源策略搏斗。第四个坑是构建上线之后发现刷新页面就 404SPA 的历史路由模式没有和后端或 Nginx 对齐。这几个坑其实都属于“链路问题”不是单点技术不会写。后文我会沿着这些问题把一套能跑通的全栈骨架完整拆给你看。2. 从零初始化目录组织与开发环境的关键配置2.1 创建项目的具体命令与版本选择我习惯把前后端拆成两个平行目录而不是把前端塞进 Laravel 的 resources/js 里。这样做的理由是前端和后端各自拥有独立的依赖、版本控制与部署产物团队里如果前后端分工明确这个结构会清晰很多。项目命名我统一用“项目名-api”和“项目名-web”方便一眼识别。后端创建命令composer create-project laravel/laravel example-api前端创建命令npm create vitelatest example-web -- --template vue版本方面我目前用的组合是 Laravel 11、Vue 3.4、Vite 5、Vue Router 4、Pinia。选 Vite 而不是继续用 Vue CLI是因为 Vite 基于原生 ES 模块开发模式下的冷启动和热更新都明显更快配置也更简洁。如果你是从旧项目迁移过来的只要 Node 版本不低于 18基本都能直接用。2.2 一个必须提前想清楚的路径约定前后端分离项目里最忌讳的是各写各的接口路径和前端请求路径各自随意。我在项目启动时就会约定所有业务接口统一挂在 /api 前缀下前端所有请求也都从 /api 出发。这样不管是开发代理还是生产反代规则都极其简单只需要把 /api 指向后端服务即可。开发阶段Vite 提供了非常方便的代理能力。在 example-web 根目录的 vite.config.js 里配置这样一段import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true } } } })这段配置的含义是当浏览器向 http://localhost:5173/api/xxx 发起请求时Vite 开发服务器会把请求转发到 http://127.0.0.1:8000/api/xxx。浏览器始终认为自己在请求同一个域名这样就不存在跨域问题CORS 配置在本地开发阶段基本可以不碰。很多人第一次做前后端分离时花大量时间调 CORS其实只要用上 Vite proxy本地联调会自然很多。2.3 CORS 配置的正确姿势不过 CORS 仍然需要配置因为本地能用代理不等于生产环境也能用代理。前端如果部署在独立域名或者将来要对接小程序、第三方服务后端就必须正确处理跨域请求。Laravel 从 8 开始就内置了统一的 CORS 配置文件 config/cors.php不需要再手动写 CORS 中间件。我的常用配置如下return [ paths [api/*, sanctum/csrf-cookie], allowed_methods [*], allowed_origins [http://localhost:5173], allowed_origins_patterns [], allowed_headers [*], exposed_headers [], max_age 0, supports_credentials false, ];这里有两个关键点。第一supports_credentials 和 allowed_origins 的关系一旦 supports_credentials 设为 trueallowed_origins 就不能写通配符 *浏览器会直接拒绝带凭据的跨域请求。第二生产环境下 allowed_origins 务必写成你的前端真实域名别图省事用 *。我排过的 CORS 问题里90% 是 allowed_origins 没匹配上导致浏览器在预检请求阶段就返回 403。2.4 环境变量规划前后端分离后环境变量如果不提前规划后面会出现“前端写死后端地址”这种脏做法。我的方案是在前端 .env 和 .env.production 里都定义一个变量VITE_API_BASE/api前端请求封装时统一使用这个变量作为 baseURL。本地开发走 Vite proxy生产环境走同域反代代码里不出现任何硬编码的域名。后端的 .env 则保持 APP_URL、DB、Redis 等独立配置前端密钥永远不要放进前端环境变量这是底线。3. 认证链路Sanctum 把 Token 塞进每次请求3.1 前后端分离下的认证方案取舍前面提过用 Session 做前后端分离的认证会非常别扭。分离后前后端可能不在同一个域名浏览器对第三方 Cookie 的限制越来越严跨域携带 Cookie 需要额外开启 withCredentials还要处理 CSRF Token 的传递链路长且容易被各种安全策略卡住。Token 认证的优势在于无状态、跨端友好。前端拿到 Token 后存在本地存储每次请求在 Authorization 请求头里带上后端验 Token 即可不需要维护服务端 Session 状态。JWT 确实也很流行但它需要自己处理刷新、注销、密钥轮换这些事对一个内部管理系统来说偏重。Laravel 官方提供的 Sanctum 做 API Token 认证则非常轻量项目里没有复杂多端权限需求时它是最省心的选择。3.2 安装 Sanctum 并写完第一套登录接口在 Laravel 11 及以上版本执行下面这条命令就能完成 Sanctum 的安装与初始化php artisan install:api这条命令会注册 API 路由文件。如果你用的是更早的 Laravel 版本则需要手动通过 Composer 安装 Sanctum 包、执行迁移、发布配置。接下来在 User 模型里引入 HasApiTokens?php namespace App\Models; 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; }然后写路由。routes/api.php 里我习惯这样组织Route::post(/login, [AuthController::class, login]); Route::middleware(auth:sanctum)-group(function () { Route::get(/user, [AuthController::class, user]); Route::post(/logout, [AuthController::class, logout]); });登录接口的核心逻辑大致如下public function login(Request $request) { $credentials $request-validate([ email required|email, password required, ]); if (!Auth::attempt($credentials)) { return response()-json([message 账号或密码错误], 401); } $token $request-user()-createToken(frontend)-plainTextToken; return response()-json([ token $token, user $request-user(), ]); }createToken 会在 personal_access_tokens 表里生成一条记录plainTextToken 是给前端实际使用的 Token 字符串。前端只需要保存这个字符串并在后续请求的请求头里带上它。整个认证闭环不需要维护 Session后端每次请求通过 auth:sanctum 中间件解析 Token 对应的用户。3.3 前端如何保管与携带 TokenToken 在前端存放位置我默认推荐 sessionStorage而不是 localStorage。区别在于sessionStorage 在关闭浏览器标签页后自动清空降低了 Token 被长期遗留的风险localStorage 则会在浏览器重启后依然存在适合需要“记住登录状态”的场景但安全风险更高。内部管理系统通常都希望用户每次打开重新验证身份所以我优先用 sessionStorage。在 axios 封装里统一注入 Token是最省事的做法。我通常会创建一个 http.js 作为全局请求实例import axios from axios const http axios.create({ baseURL: import.meta.env.VITE_API_BASE || /api, timeout: 15000 }) http.interceptors.request.use(config { const token sessionStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) http.interceptors.response.use( response response.data, error { if (error.response?.status 401) { sessionStorage.removeItem(token) window.location.href /login } return Promise.reject(error) } ) export default http这样做的好处是业务代码里不再重复写请求头处理。所有请求自动带 Token所有 401 响应自动跳回登录页代码量大幅度减少。3.4 注销与 Token 过期处理注销接口的核心操作是删掉当前 Token让它在服务端立即失效public function logout(Request $request) { $request-user()-currentAccessToken()-delete(); return response()-json([message 已退出]); }前端调用该接口后清空 sessionStorage 并跳转登录页。如果后端想限制 Token 有效期可以在 personal_access_tokens 表上增加过期时间字段并在中间件里判断但内部系统对时效性要求不高时先用比较简单的方式跑通再逐步收紧策略。有一点务必注意永远不要把 Token 放到 URL 参数里也不要在日志或第三方统计工具里输出 Token。4. 接口设计与数据交互的实战细节4.1 所有接口先约定统一返回结构前后端联调经常出现的一个尴尬场景是后端 A 接口返回 { success: true, data: ... }后端 B 接口返回 { code: 200, result: ... }前端每接一个新接口都要重新确认字段时间全耗在琐碎对接上。我现在的做法是在项目第一天就约定统一的返回结构{ code: 0, message: ok, data: {} }成功时 code 为 0message 为提示信息data 放业务数据业务失败或系统异常时code 改为非 0同时配合 HTTP 状态码标记错误类型。这样前端拦截器可以统一处理业务错误页面组件里只需要关心 data 字段的内容。后端侧我会封装一个公共的 JSON 返回辅助方法避免每个控制器各写一套。4.2 列表接口的分页、筛选与排序约定列表是管理系统最常见的页面形态。分页参数我统一约定为 page、per_page筛选和排序分别为 keyword、sort_by、sort_order。后端返回的数据结构固定为{ data: [], meta: { current_page: 1, last_page: 10, total: 98 } }前端可以针对这个结构写一个通用的分页组合式函数把当前页、总条数、加载方法封装在一起。否则每个页面都复制粘贴一套分页逻辑迟早会改出版本不一致的问题。接口文档在项目初期哪怕只有几行文字也要写清楚这些公共约定能省下后面大量的扯皮时间。4.3 文件上传FormData 和 Storage 的正确打开方式文件上传是前后端分离里一个很典型的“细节决定成败”的场景。最常见的错误写法是前端把文件读成 Base64 字符串塞进 JSON后端再解码存文件。这种方式会带来三个问题流量膨胀约三分之一、服务端内存压力大、处理大文件时很容易超时。正确做法是用 FormData 提交。前端代码大致是这样const fd new FormData() fd.append(file, fileInput.files[0]) fd.append(type, avatar) http.post(/upload, fd)后端处理时利用 Laravel 的 Storage$path $request-file(file)-store(uploads, public); return response()-json([ url Storage::url($path), ]);这里最容易忘的一步是执行php artisan storage:link不做这一步public 目录下没有软链接Storage::url 返回的地址会直接 404。此外文件上传涉及的体积限制往往不止一处php.ini 的 upload_max_filesize 和 post_max_size、Nginx 的 client_max_body_size三处都需要检查任何一个限制都会导致上传失败。4.4 表单校验错误在 Vue 里的优雅呈现Laravel 的表单校验失败会返回 422 状态码响应体形如{ message: The given data was invalid., errors: { email: [邮箱已存在], name: [姓名不能为空] } }前端在提交函数里做这样的处理把服务端错误映射到表单字段try { await http.post(/register, form.value) } catch (error) { if (error.response?.status 422) { const errors error.response.data.errors for (const key in errors) { serverErrors.value[key] errors[key][0] } } }模板里每个字段下方预留错误展示位置。这样做的好处是验证逻辑只维护后端一份前端不需要重复写一套同样文案的校验规则。提交按钮还要加上 loading 状态防止用户双击重复提交这在网络慢的时候尤其重要。5. 构建与部署前端打包后那些容易被忽略的坑5.1 两种常见的部署拓扑构建上线时前后端分离项目有两种主流部署方式它们各有取舍完全取决于你的项目规模和部署资源。第一种方式是“合并托管”前端 build 之后把 dist 目录里的文件放到 Laravel 的 public 目录由一个 Web 服务统一对外提供。优点是单一域名、单一入口静态资源和 API 走同一台服务器配置简单适合中小型内部系统。缺点是需要把两个项目耦合到同一部署流程里前端每次构建都要重新同步一次。第二种方式是“Nginx 双站点”前端静态文件由 Nginx 直接托管/api 路径反向代理到后端的 PHP 服务。优点是两个项目可以独立部署、独立扩容前端静态资源甚至可以直接放到 CDN缺点是配置复杂需要额外维护 Nginx 规则。两种方式的对比我整理成了下面这个表格维度合并托管Nginx 双站点部署复杂度低中高前后端耦合度高低静态资源独立扩容不支持支持适用场景内部系统、小团队独立前端团队、对外产品5.2 Nginx 下 history 路由刷新 404 的解法Vue Router 默认使用 history 模式URL 里不会有 # 号看起来更干净但有一个必须解决的副作用用户访问 /users/1 并刷新时服务器如果找不到对应的静态文件就会返回 404。这个问题的核心是 Nginx 不知道应该把请求转给 SPA 入口。合并托管的典型 Nginx 配置长这样server { listen 80; root /var/www/example/dist; location / { try_files $uri $uri/ /index.html; } location /api { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }location / 里的 try_files 会先检查是否存在真实文件如果不存在就回退到 index.html交给前端路由接管location /api 则把所有 API 请求转发给 Laravel。这个配置同时解决了 SPA 刷新 404 和 API 请求转发两个问题是我在实际项目里最常用的骨架。5.3 build 之后为什么接口全挂——代理失效的复盘有段时间我总在本地跑得好好的项目构建上线后接口全部请求失败。后来在浏览器 Network 面板里一看才发现请求根本没到 Laravel而是打到了前端静态服务器上。根因其实很简单Vite proxy 是开发服务器独有的能力构建产物里不会包含任何代理逻辑。前端代码里写的 baseURL 是 /api上线后浏览器会把 /api 请求发给当前域名如果当前 Nginx 没有把 /api 反向代理给后端自然就 404。这个坑的教训是上线的第一步不是在浏览器里看页面报错而是先打开 Network 面板确认请求真正去了哪里。另外还要留意 HTTPS 与 HTTP 混合内容问题如果前端域名是 HTTPS而后端 API 地址写死成 http://浏览器会直接拦截这类请求。解决办法是 axios 的 baseURL 使用相对路径或者保持前后端同协议。5.4 我在部署时吃过的一个亏再分享一个真实教训。某次给客户部署系统本地联调一切都正常上线后却发现只有管理员账号能登录普通用户登录总是提示“账号或密码错误”。排查了很久才发现问题不在密码而在 sessionStorage 里没有写入 Token——原因是那次部署的 Nginx 配置把 /api 请求错误地转发到了另一台旧的测试服务器上。数据库结构一模一样但旧库里的用户密码哈希来自一个完全不同的应用所以 Auth::attempt 永远失败。这类问题在前后端分离项目里极难通过代码逻辑发现因为你必须把“浏览器请求到底打到了哪台机器”这件事确认清楚。现在我部署后的固定动作有三步先看 Network 面板确认 /api 请求的域名和响应状态再打开后端日志确认请求是否真实到达最后用生产环境账号跑一遍完整登录流程。三步都没问题才算部署真正完成。到这里从选型、初始化、认证、接口联调到部署上线一条完整的前后端分离链路就算闭环了。这套骨架后来被我照搬到另一个数据统计项目里几乎没有改动基础设施代码只换业务模块前后端都在一天内跑了起来。我的固定习惯是接口字段约定先定好Sanctum 认证先打好Vite proxy 和 Nginx 反代提前配好然后再去写业务页面。最后再分享一个实用小技巧把项目的启动命令、环境变量说明、常见排错思路整理到一张速查表里放在两个仓库的根目录。三个月之后回来维护或者换一台新电脑重新拉代码照着这张表十分钟就能把环境跑起来。全栈项目做久了就会明白真正拖慢进度的往往不是业务代码本身而是环境失控和部署细节。把这些基础链路建稳后续开发效率会有肉眼可见的提升。