CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
PHP Web 入门/课时/综合练习
RawBlame
课时目录12 节

PHP Web 入门

  1. 01PHP 是什么
  2. 02基础语法
  3. 03函数与命名空间
  4. 04面向对象
  5. 05Composer 依赖
  6. 06Web 请求处理
  7. 07PDO 与 MySQL
  8. 08REST API 风格
  9. 09PHP 8 新特性
  10. 10Docker 运行 PHP
  11. 11PHP vs Java 选型
  12. 12综合练习

综合练习

只读 tracks JSON API 迷你脚本。

40 分钟

综合练习

这一课你将能做到

  1. 从零搭出结构规范的迷你博客后端:PSR-4 自动加载 + 前端控制器 + 分层类
  2. 把全路线知识拼进一个项目:Composer、PDO 预处理、Session 登录、统一 JSON 响应
  3. 用 curl 完成 API 验收,明确下一步学框架(Laravel/Symfony)的衔接点

为什么要学

前 11 课的知识都是散装积木。这一课把它们拼成一个能跑的完整项目——项目结构是工程能力的照妖镜:入口是否唯一、SQL 是否安全、职责是否分层。用原生把框架要做的事手动做一遍,之后学 Laravel 就是「认旧朋友」。

先建立图像

mini-blog/
├── public/index.php        # 唯一入口(前端控制器)
├── src/
│   ├── Database.php        # PDO 连接(每请求单例)
│   ├── Auth.php            # Session 登录态 + CSRF
│   ├── PostRepository.php  # 数据访问层(只管 SQL)
│   └── PostController.php  # 控制器(校验→调仓储→响应)
├── composer.json
└── posts.sql

public 是「门面」:Web 根只指向它,src 里的类文件永远不会被 URL 直接访问——这是安全底线。

跟着做

步骤 1:初始化工程

composer.json 只需两块:"require": {"php": ">=8.2"} 与 "autoload": {"psr-4": {"App\\": "src/"}},改完执行 composer dump-autoload。

表 posts 五列:id 自增主键、title VARCHAR(80)、content TEXT、author VARCHAR(40)、created_at DATETIME,InnoDB 引擎 + utf8mb4 字符集。

步骤 2:Database 与 Auth

src/Database.php:静态属性缓存连接的请求级单例,配置沿用 PDO 课三件套——ERRMODE_EXCEPTION、FETCH_ASSOC、EMULATE_PREPARES=false。

src/Auth.php——登录态与 CSRF:

<?php
declare(strict_types=1);

namespace App;

final class Auth
{
    public static function boot(): void
    {
        session_set_cookie_params(['httponly' => true, 'samesite' => 'Lax']);
        session_start();
    }

    public static function login(string $user): void
    {
        $_SESSION['user'] = $user;
        $_SESSION['csrf'] = bin2hex(random_bytes(16));
    }

    public static function user(): ?string { return $_SESSION['user'] ?? null; }

    public static function csrf(): string { return $_SESSION['csrf'] ?? ''; }
}

步骤 3:数据访问层与控制器

src/PostRepository.php——只写 SQL,不掺路由与响应:

<?php
declare(strict_types=1);

namespace App;

final class PostRepository
{
    public function __construct(private \PDO $pdo) {}

    public function all(): array
    {
        return $this->pdo->query(
            'SELECT id, title, author, created_at FROM posts ORDER BY id DESC LIMIT 20'
        )->fetchAll();
    }

    public function create(string $title, string $content, string $author): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO posts (title, content, author) VALUES (?, ?, ?)'
        );
        $stmt->execute([$title, $content, $author]);
        return (int) $this->pdo->lastInsertId();
    }

    public function delete(int $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM posts WHERE id = ?');
        $stmt->execute([$id]);
        return $stmt->rowCount() > 0;
    }
}

src/PostController.php——校验、鉴权、响应:

<?php
declare(strict_types=1);

namespace App;

final class PostController
{
    public function __construct(private PostRepository $repo) {}

    private function json(int $code, array $data = []): never
    {
        http_response_code($code);
        header('Content-Type: application/json; charset=utf-8');
        if ($code !== 204) {
            echo json_encode(['code' => $code] + $data, JSON_UNESCAPED_UNICODE);
        }
        exit;
    }

    public function index(): void
    {
        $this->json(200, ['data' => $this->repo->all()]);
    }

    public function store(): void
    {
        if (Auth::user() === null) {
            $this->json(401, ['message' => '请先登录']);
        }
        $body    = json_decode(file_get_contents('php://input'), true) ?? [];
        $title   = trim((string) ($body['title'] ?? ''));
        $content = trim((string) ($body['content'] ?? ''));
        if ($title === '' || mb_strlen($title) > 80 || $content === '') {
            $this->json(422, ['message' => 'title 必填且 ≤80 字,content 必填']);
        }
        $this->json(201, ['data' => ['id' => $this->repo->create($title, $content, Auth::user())]]);
    }

    public function destroy(int $id): void
    {
        if (Auth::user() === null) {
            $this->json(401, ['message' => '请先登录']);
        }
        $this->repo->delete($id)
            ? $this->json(204)
            : $this->json(404, ['message' => '文章不存在']);
    }
}

步骤 4:前端控制器组装一切

public/index.php——路由 + 装配 + 兜底:

<?php
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use App\Auth;
use App\Database;
use App\PostController;
use App\PostRepository;

Auth::boot();
$method = $_SERVER['REQUEST_METHOD'];
$uri    = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$ctrl   = new PostController(new PostRepository(Database::pdo()));

try {
    if ($uri === '/api/login' && $method === 'POST') {
        $body = json_decode(file_get_contents('php://input'), true) ?? [];
        if (($body['user'] ?? '') === 'leebf') {
            Auth::login('leebf');
            echo json_encode(['code' => 200, 'csrf' => Auth::csrf()]);
        } else {
            http_response_code(401);
            echo '{"code":401,"message":"用户名错误"}';
        }
        exit;
    }
    if (preg_match('#^/api/posts/?$#', $uri)) {
        $method === 'GET'  && $ctrl->index();
        $method === 'POST' && $ctrl->store();
    }
    if (preg_match('#^/api/posts/(\d+)$#', $uri, $m) && $method === 'DELETE') {
        $ctrl->destroy((int) $m[1]);
    }
    http_response_code(405);
    header('Content-Type: application/json; charset=utf-8');
    echo '{"code":405,"message":"方法不支持"}';
} catch (\Throwable $e) {
    http_response_code(500);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['code' => 500, 'message' => '服务器内部错误'], JSON_UNESCAPED_UNICODE);
    error_log($e->getMessage());
}

步骤 5:curl 验收

php -S localhost:8000 -t public public/index.php

curl -c c.txt -X POST localhost:8000/api/login -d '{"user":"leebf"}'
curl -b c.txt -X POST localhost:8000/api/posts -H "Content-Type: application/json" \
     -d '{"title":"毕业作品","content":"全路线知识点串烧"}'   # 201
curl localhost:8000/api/posts            # 200
curl -b c.txt -X DELETE localhost:8000/api/posts/1

讲清楚要点

  1. 前端控制器是框架的雏形:所有请求进 public/index.php,统一加载、路由、异常兜底,横切逻辑只写一次。Laravel 的 public/index.php → Kernel → 路由 就是同一思想的完整实现。
  2. 分层依据是「变更频率」:Repository 只因 SQL 变而变,Controller 只因接口契约变而变。判断类放哪层,就问「它将来会因什么被修改」。
  3. 安全三道闸在此落地:SQL 注入(预处理全覆盖)、XSS(API 只出 JSON,渲染点转义)、越权(写操作查 Session + CSRF token)。
  4. 错误两级分权:业务错误(404/422)走正常分支;系统异常统一 catch、记日志、对外只给 500。

你可能会懵的地方

  • 为什么 Database 用静态单例而 Repository 要构造器注入? 连接请求内全局唯一是合理约束,静态属性省资源;Repository 显式声明依赖,测试时可换内存库替身。别学 Java 把单例跨请求复用——PHP 做不到也不该做。
  • 纯 API 场景还需要 CSRF token 吗? 用 JWT/Authorization 头时可省(浏览器不自动携带自定义头);本课是 Cookie + Session,浏览器同域自动带 Cookie,写操作必须校验 token,且用 hash_equals 防时序攻击。
  • 路由为什么要收口到唯一入口? 分散入口意味着鉴权、日志要在每个文件重复,且 src 类文件可能被直接访问。-t public 把流量收敛到 index.php,安全与维护同时受益。

错法对照

错法问题正解
把 src/ 放进 Web 根目录类文件/SQL 可被 URL 直接下载Web 根只指 public/,源码在外
每个文件自己 new PDO连接爆炸、配置散落Database 请求级单例统一发连接
500 响应里 echo 异常堆栈泄露路径、SQL、配置对外固定文案,详情进 error_log
路由无兜底分支未知路径裸奔白屏兜底 405 + try/catch 500

卡点急救

  1. Class "App\xxx" not found:先 composer dump-autoload;核对命名空间与 src 目录、文件名大小写是否与类名一致。
  2. curl 登录后仍 401:-c c.txt 存 Cookie 与 -b c.txt 带 Cookie 缺一不可;确认响应头有 Set-Cookie: PHPSESSID。
  3. php -S 访问 /api/posts 找不到:-t public public/index.php 两个参数都要给——前者定根目录,后者把未命中路径转给入口。

面试常问

  • 什么是前端控制器模式?解决了什么问题? 所有请求汇聚到唯一入口脚本,由统一路由与内核分发,横切逻辑(鉴权、日志、异常)只实现一次;Laravel/Symfony/Spring MVC 的 DispatcherServlet 均是同构实现。
  • 这个项目里你如何防 SQL 注入与 XSS? SQL 注入:全部输入经 PDO 预处理绑定,绝不拼接;XSS:API 只输出 JSON,HTML 渲染点统一 htmlspecialchars(ENT_QUOTES),配合 httponly + SameSite Cookie。
  • 为什么 Repository 走构造器注入而不是全局 PDO? 构造器签名让依赖显式化、可测试;全局变量把依赖藏进实现,无法隔离测试,也与框架 DI 习惯脱节。

自测三问

  1. 画出请求流:curl 命中 POST /api/posts 到返回 201 经过哪些类?
  2. 写操作为什么同时校验 Session 与 CSRF token?hash_equals 换成 === 有什么风险?
  3. 本项目迁到 Laravel 时,路由、容器、仓储、鉴权分别对应什么官方组件?

小结一句话

public/ 唯一入口 + 分层 src/ + Composer 自动加载 + 预处理 PDO + Session 鉴权,这个迷你项目是框架世界的完整预演——毕业即入学 Laravel。

延伸

  • PHP 官方手册:语言参考索引
  • 下一课:无——本路线完结,建议衔接 Laravel 框架路线或回到 Spring Boot 深化。
加载本节练习…
← 上一课PHP vs Java 选型