博客系统实战 #

一、项目概述 #

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>&copy; {{ "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