博客系统实战 #
一、项目概述 #
1.1 功能需求 #
text
┌─────────────────────────────────────────────────────┐
│ 博客系统功能 │
├─────────────────────────────────────────────────────┤
│ │
│ 前台功能: │
│ ├── 文章列表和详情 │
│ ├── 分类浏览 │
│ ├── 标签云 │
│ ├── 评论功能 │
│ └── 搜索功能 │
│ │
│ 后台功能: │
│ ├── 用户登录/登出 │
│ ├── 文章管理(CRUD) │
│ ├── 分类管理 │
│ ├── 标签管理 │
│ └── 评论审核 │
│ │
└─────────────────────────────────────────────────────┘
1.2 技术栈 #
| 技术 | 说明 |
|---|---|
| Symfony 6.4 | 框架 |
| Doctrine ORM | 数据库 |
| Twig | 模板引擎 |
| Bootstrap 5 | 前端框架 |
| MySQL | 数据库 |
二、项目初始化 #
2.1 创建项目 #
bash
symfony new blog --full
cd blog
2.2 安装依赖 #
bash
composer require maker
composer require security
composer require form validator
composer require doctrine
三、实体设计 #
3.1 User实体 #
php
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 100)]
private ?string $name = null;
#[ORM\Column(length: 180, unique: true)]
private ?string $email = null;
#[ORM\Column]
private array $roles = [];
#[ORM\Column]
private ?string $password = null;
#[ORM\OneToMany(mappedBy: 'author', targetEntity: Post::class)]
private Collection $posts;
#[ORM\OneToMany(mappedBy: 'author', targetEntity: Comment::class)]
private Collection $comments;
public function __construct()
{
$this->posts = new ArrayCollection();
$this->comments = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
public function getName(): ?string { return $this->name; }
public function setName(string $name): static { $this->name = $name; return $this; }
public function getEmail(): ?string { return $this->email; }
public function setEmail(string $email): static { $this->email = $email; return $this; }
public function getUserIdentifier(): string { return $this->email; }
public function getRoles(): array { $roles = $this->roles; $roles[] = 'ROLE_USER'; return array_unique($roles); }
public function setRoles(array $roles): static { $this->roles = $roles; return $this; }
public function getPassword(): string { return $this->password; }
public function setPassword(string $password): static { $this->password = $password; return $this; }
public function eraseCredentials(): void {}
}
3.2 Category实体 #
php
<?php
namespace App\Entity;
use App\Repository\CategoryRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 100)]
private ?string $name = null;
#[ORM\Column(length: 100)]
private ?string $slug = null;
#[ORM\OneToMany(mappedBy: 'category', targetEntity: Post::class)]
private Collection $posts;
public function __construct()
{
$this->posts = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
public function getName(): ?string { return $this->name; }
public function setName(string $name): static { $this->name = $name; return $this; }
public function getSlug(): ?string { return $this->slug; }
public function setSlug(string $slug): static { $this->slug = $slug; return $this; }
}
3.3 Post实体 #
php
<?php
namespace App\Entity;
use App\Repository\PostRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PostRepository::class)]
class Post
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\Column(length: 255)]
private ?string $slug = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $content = null;
#[ORM\Column(length: 500, nullable: true)]
private ?string $excerpt = null;
#[ORM\Column]
private bool $published = false;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\ManyToOne(inversedBy: 'posts')]
#[ORM\JoinColumn(nullable: false)]
private ?User $author = null;
#[ORM\ManyToOne(inversedBy: 'posts')]
#[ORM\JoinColumn(nullable: false)]
private ?Category $category = null;
#[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'posts')]
private Collection $tags;
#[ORM\OneToMany(mappedBy: 'post', targetEntity: Comment::class)]
private Collection $comments;
public function __construct()
{
$this->tags = new ArrayCollection();
$this->comments = new ArrayCollection();
$this->createdAt = new \DateTimeImmutable();
$this->updatedAt = new \DateTimeImmutable();
}
public function getId(): ?int { return $this->id; }
public function getTitle(): ?string { return $this->title; }
public function setTitle(string $title): static { $this->title = $title; return $this; }
public function getSlug(): ?string { return $this->slug; }
public function setSlug(string $slug): static { $this->slug = $slug; return $this; }
public function getContent(): ?string { return $this->content; }
public function setContent(string $content): static { $this->content = $content; return $this; }
public function isPublished(): bool { return $this->published; }
public function setPublished(bool $published): static { $this->published = $published; return $this; }
public function getAuthor(): ?User { return $this->author; }
public function setAuthor(?User $author): static { $this->author = $author; return $this; }
public function getCategory(): ?Category { return $this->category; }
public function setCategory(?Category $category): static { $this->category = $category; return $this; }
}
四、控制器实现 #
4.1 前台控制器 #
php
<?php
namespace App\Controller;
use App\Entity\Post;
use App\Repository\CategoryRepository;
use App\Repository\PostRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class BlogController extends AbstractController
{
#[Route('/', name: 'app_home')]
public function index(PostRepository $postRepository, CategoryRepository $categoryRepository): Response
{
$posts = $postRepository->findBy(['published' => true], ['createdAt' => 'DESC'], 10);
$categories = $categoryRepository->findAll();
return $this->render('blog/index.html.twig', [
'posts' => $posts,
'categories' => $categories,
]);
}
#[Route('/post/{slug}', name: 'app_post_show')]
public function show(Post $post): Response
{
if (!$post->isPublished()) {
throw $this->createNotFoundException('文章不存在');
}
return $this->render('blog/show.html.twig', [
'post' => $post,
]);
}
#[Route('/category/{slug}', name: 'app_category')]
public function category(Category $category, PostRepository $postRepository): Response
{
$posts = $postRepository->findBy([
'category' => $category,
'published' => true,
], ['createdAt' => 'DESC']);
return $this->render('blog/category.html.twig', [
'category' => $category,
'posts' => $posts,
]);
}
}
4.2 后台控制器 #
php
<?php
namespace App\Controller\Admin;
use App\Entity\Post;
use App\Form\PostType;
use App\Repository\PostRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin')]
#[IsGranted('ROLE_ADMIN')]
class AdminController extends AbstractController
{
#[Route('/', name: 'admin_dashboard')]
public function dashboard(): Response
{
return $this->render('admin/dashboard.html.twig');
}
#[Route('/posts', name: 'admin_posts')]
public function posts(PostRepository $postRepository): Response
{
$posts = $postRepository->findBy([], ['createdAt' => 'DESC']);
return $this->render('admin/posts.html.twig', [
'posts' => $posts,
]);
}
#[Route('/post/new', name: 'admin_post_new')]
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
$post = new Post();
$post->setAuthor($this->getUser());
$form = $this->createForm(PostType::class, $post);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($post);
$entityManager->flush();
return $this->redirectToRoute('admin_posts');
}
return $this->render('admin/post_form.html.twig', [
'form' => $form,
'post' => $post,
]);
}
#[Route('/post/{id}/edit', name: 'admin_post_edit')]
public function edit(Request $request, Post $post, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(PostType::class, $post);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$post->setUpdatedAt(new \DateTimeImmutable());
$entityManager->flush();
return $this->redirectToRoute('admin_posts');
}
return $this->render('admin/post_form.html.twig', [
'form' => $form,
'post' => $post,
]);
}
#[Route('/post/{id}/delete', name: 'admin_post_delete', methods: ['POST'])]
public function delete(Request $request, Post $post, EntityManagerInterface $entityManager): Response
{
if ($this->isCsrfTokenValid('delete' . $post->getId(), $request->request->get('_token'))) {
$entityManager->remove($post);
$entityManager->flush();
}
return $this->redirectToRoute('admin_posts');
}
}
五、表单实现 #
5.1 PostType表单 #
php
<?php
namespace App\Form;
use App\Entity\Category;
use App\Entity\Post;
use App\Entity\Tag;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PostType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => '标题',
])
->add('slug', TextType::class, [
'label' => 'URL别名',
'required' => false,
])
->add('content', TextareaType::class, [
'label' => '内容',
'attr' => ['rows' => 10],
])
->add('excerpt', TextareaType::class, [
'label' => '摘要',
'required' => false,
'attr' => ['rows' => 3],
])
->add('published', CheckboxType::class, [
'label' => '发布',
'required' => false,
])
->add('category', EntityType::class, [
'class' => Category::class,
'choice_label' => 'name',
'label' => '分类',
])
->add('tags', EntityType::class, [
'class' => Tag::class,
'choice_label' => 'name',
'multiple' => true,
'expanded' => true,
'label' => '标签',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Post::class,
]);
}
}
六、模板实现 #
6.1 基础模板 #
twig
{# templates/base.html.twig #}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}博客{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
{% block stylesheets %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{{ path('app_home') }}">博客</a>
<div class="navbar-nav ms-auto">
{% if app.user %}
<a class="nav-link" href="{{ path('admin_dashboard') }}">后台</a>
<a class="nav-link" href="{{ path('app_logout') }}">退出</a>
{% else %}
<a class="nav-link" href="{{ path('app_login') }}">登录</a>
{% endif %}
</div>
</div>
</nav>
<main class="container my-4">
{% block body %}{% endblock %}
</main>
<footer class="bg-dark text-light py-4">
<div class="container text-center">
<p>© {{ "now"|date("Y") }} 博客系统</p>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block javascripts %}{% endblock %}
</body>
</html>
6.2 首页模板 #
twig
{# templates/blog/index.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}首页 - 博客{% endblock %}
{% block body %}
<div class="row">
<div class="col-md-8">
<h1 class="mb-4">最新文章</h1>
{% for post in posts %}
<article class="card mb-4">
<div class="card-body">
<h2 class="card-title">
<a href="{{ path('app_post_show', {slug: post.slug}) }}">
{{ post.title }}
</a>
</h2>
<p class="card-text text-muted">
{{ post.excerpt|default(post.content|slice(0, 200)) }}...
</p>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
{{ post.createdAt|date('Y-m-d') }} ·
{{ post.category.name }}
</small>
<a href="{{ path('app_post_show', {slug: post.slug}) }}" class="btn btn-outline-primary btn-sm">
阅读更多
</a>
</div>
</div>
</article>
{% else %}
<p>暂无文章</p>
{% endfor %}
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">分类</div>
<div class="card-body">
<ul class="list-unstyled">
{% for category in categories %}
<li>
<a href="{{ path('app_category', {slug: category.slug}) }}">
{{ category.name }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
七、安全配置 #
7.1 security.yaml #
yaml
security:
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
password_hashers:
App\Entity\User:
algorithm: auto
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
default_target_path: admin_dashboard
logout:
path: app_logout
target: app_home
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/login, roles: PUBLIC_ACCESS }
八、总结 #
本章学习了:
- 博客系统功能设计
- 实体关系设计
- 控制器实现
- 表单创建
- 模板渲染
- 安全配置
下一章将学习 RESTful API实战。
最后更新:2026-03-28