Winter Boot owns
Routes, JSON contracts, authentication, RBAC/ABAC decisions, transactions.
Winter Boot has no view layer — no templates, no server-rendered pages — and that is deliberate. It is a framework for backend services of all kinds: REST APIs, background workers, scheduled jobs, daemon processes, and message consumers. The parts that face a browser expose JSON APIs; the UI itself lives in a separate frontend application built with React, Next.js, Vue, Svelte, or plain HTML with Tailwind/Bootstrap, talking to those APIs over HTTP. This guide shows the split and the minimal wiring on each side.
browser ── static files (CDN / Nginx) ──► your SPA (React, Next.js, …) │ └── /api/* ── reverse proxy ──► Winter Boot (JSON only)One origin serves both the static bundle and the API (the proxy forwards
/api/* to the Swoole port). Same-origin means no CORS configuration, and the
session cookie from the RBAC guide just works. The
framework ships no CORS module, so treat cross-origin browser calls as a
problem to avoid, not to solve.
Winter Boot owns
Routes, JSON contracts, authentication, RBAC/ABAC decisions, transactions.
The frontend owns
Pages, styling, form state, loading and error states, navigation.
The frontend depends on a contract, not on PHP. Fix these three shapes and both sides can evolve independently:
{ "username": "alice", "password": "s3cret" }{ "success": true, "data": { "username": "alice" } }{ "error": "Authentication required" }{ "error": "Forbidden" }Conventions that keep frontend code simple:
{ "success": true, "data": … }; every failure
carries { "error": "human-readable reason" } with the right status
(401 unknown session, 403 missing grant, 400 bad input).context-path: /api in application.yml or a
proxy rule) so the proxy can distinguish API traffic from static files.AuthService sessions and calls guarded routes unchanged.A tiny React app is enough to show the whole pattern: one fetch wrapper that sends the session cookie and translates statuses, one component with login and a guarded list, and a dev proxy so the browser stays same-origin. Switch between the three files — the same calls work identically from Next.js, Vue, or Svelte:
The only place that knows HTTP. Same-origin base URL, cookies included,
401/403 surfaced as typed errors the UI switches on.
const BASE = '/api';
export class AuthError extends Error {}export class ForbiddenError extends Error {}
async function request(path, options = {}) { const res = await fetch(BASE + path, { credentials: 'include', // send the SID session cookie headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) }, ...options, });
if (res.status === 401) throw new AuthError('Please log in again.'); if (res.status === 403) throw new ForbiddenError('You are not allowed to do that.');
const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.error ?? `Request failed (${res.status})`); return body.data ?? body;}
export const api = { login: (username, password) => request('/login', { method: 'POST', body: JSON.stringify({ username, password }) }), logout: () => request('/logout', { method: 'POST' }), pendingOrders: () => request('/orders/pending'),};Login form plus a guarded list. AuthError routes back to login,
ForbiddenError renders an inline message — never a retry loop.
import { useState } from 'react';import { api, AuthError, ForbiddenError } from './api.js';
export default function App() { const [user, setUser] = useState(null); const [orders, setOrders] = useState([]); const [notice, setNotice] = useState(''); const [form, setForm] = useState({ username: '', password: '' });
async function handleLogin(e) { e.preventDefault(); setNotice(''); try { const data = await api.login(form.username, form.password); setUser(data.username); await loadOrders(); } catch (err) { setNotice(err.message); } }
async function loadOrders() { try { setOrders(await api.pendingOrders()); } catch (err) { if (err instanceof AuthError) { setUser(null); // session expired: back to the login form } setNotice(err.message); } }
if (!user) { return ( <form onSubmit={handleLogin} className="mx-auto max-w-sm space-y-3 p-6"> <h1 className="text-xl font-bold">Log in</h1> {notice && <p className="text-red-600">{notice}</p>} <input className="w-full border p-2" placeholder="Username" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /> <input className="w-full border p-2" type="password" placeholder="Password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} /> <button className="w-full bg-blue-600 p-2 text-white" type="submit"> Log in </button> </form> ); }
return ( <main className="mx-auto max-w-2xl space-y-3 p-6"> <h1 className="text-xl font-bold">Pending orders</h1> {notice && <p className="text-red-600">{notice}</p>} <ul className="divide-y border"> {orders.map((o) => ( <li key={o.id} className="p-2"> #{o.id} — {o.amount} ({o.status}) </li> ))} </ul> </main> );}Dev-only proxy: the browser calls /api/* on the Vite origin, Vite
forwards to Swoole. No CORS headers needed anywhere.
import { defineConfig } from 'vite';
export default defineConfig({ server: { proxy: { '/api': { target: 'http://127.0.0.1:8080', changeOrigin: true }, }, },});Styling is ordinary frontend work: the classNames above are Tailwind, but a
Bootstrap stylesheet with class attributes behaves the same — the backend
never sees it.
npm run build) and serve the static files from Nginx or a
CDN — never from the Swoole process./api/* to the Winter Boot port (8080 in these guides) with
changeOrigin-equivalent behaviour, preserving cookies and paths./api path prefix, never
API keys, DB passwords, or signing keys — those live in backend
property sources as the guides show.SID), which fit
same-origin browser apps with zero frontend token code. If a mobile app or a
third party also calls the API, add token auth (e.g. an Authorization
header the interceptor reads via getFirstHeader()) alongside sessions —
don’t bend cookies to fit non-browsers./api/v1/orders) so an old bundle keeps working while the new
one rolls out.api.* call is async: skeleton rows
while pending, an empty-list message (not a blank page), and the notice
pattern above for failures.Forbidden,
never stack traces or SQL) — the frontend can only render what the API sends.