{{ message }}
{% endfor %}
{% for message in app.flashes('error') %}
{{ message }}
{% endfor %}
{% block body %}{% endblock %}
本教程基於經典 Symfony 1.x 教程「askeet」改編,展示 Symfony 表單處理、用戶認證、權限控制、Propel ORM 分頁、路由優化、代碼重構等核心技術。雖然 Symfony 版本已迭代至 6/7,但核心概念(表單組件、安全組件、分頁器、路由、MVC 分層)完全通用。
# config/routes.yaml
login:
path: /login
controller: App\Controller\UserController::login
logout:
path: /logout
controller: App\Controller\UserController::logout
// src/Form/LoginType.php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class LoginType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder
->add('nickname', TextType::class, [
'label' => '暱稱',
'constraints' => [new Assert\NotBlank(), new Assert\Length(max: 50)],
'attr' => ['autocomplete' => 'username'],
])
->add('password', PasswordType::class, [
'label' => '密碼',
'constraints' => [new Assert\NotBlank()],
'attr' => ['autocomplete' => 'current-password'],
])
->add('remember_me', CheckboxType::class, [
'label' => '記住我',
'required' => false,
])
->add('submit', SubmitType::class, ['label' => '登入']);
}
public function configureOptions(OptionsResolver $resolver) {
$resolver->setDefaults([
'csrf_protection' => true,
'csrf_field_name' => '_token',
]);
}
}
// src/Controller/UserController.php
namespace App\Controller;
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\Authentication\AuthenticationUtils;
use App\Form\LoginType;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
class UserController extends AbstractController {
public function login(Request $request, AuthenticationUtils $authUtils): Response {
// 獲取登入錯誤、上次用戶名
$error = $authUtils->getLastAuthenticationError();
$lastUsername = $authUtils->getLastUsername();
$form = $this->createForm(LoginType::class, [
'nickname' => $lastUsername,
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// 實際認證由 Security 組件攔截器處理(form_login)
// 這裡只處理表單驗證通過後的邏輯
// 真實項目中配置 security.yaml 的 form_login 自動處理認證
}
return $this->render('user/login.html.twig', [
'form' => $form->createView(),
'error' => $error,
]);
}
public function logout(): void {
// 由 security.yaml 的 logout 配置處理
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
}
# config/packages/security.yaml
security:
password_hashers:
App\Entity\User: 'auto'
providers:
app_user_provider:
entity:
class: App\Entity\User
property: nickname
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
form_login:
login_path: login
check_path: login
enable_csrf: true
username_parameter: nickname
password_parameter: password
logout:
path: logout
target: homepage
remember_me:
secret: '%kernel.secret%'
lifetime: 2592000 # 30 days
path: /
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/profile, roles: ROLE_USER }
{# templates/base.html.twig #}
{% block title %}askeet{% endblock %}
{{ encore_entry_link_tags('app') }}
{% for message in app.flashes('success') %}
{{ message }}
{% endfor %}
{% for message in app.flashes('error') %}
{{ message }}
{% endfor %}
{% block body %}{% endblock %}
// src/Repository/QuestionRepository.php
namespace App\Repository;
use App\Entity\Question;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\ORM\QueryBuilder;
class QuestionRepository extends ServiceEntityRepository {
public function __construct(ManagerRegistry $registry) {
parent::__construct($registry, Question::class);
}
public function getHomepagePager(int $page, int $limit = 20): Paginator {
$qb = $this->createQueryBuilder('q')
->leftJoin('q.author', 'u')->addSelect('u')
->orderBy('q.interestedUsersCount', 'DESC')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit);
return new Paginator($qb);
}
public function findByStrippedTitle(string $title): ?Question {
return $this->createQueryBuilder('q')
->where('q.strippedTitle = :title')
->setParameter('title', $title)
->getQuery()
->getOneOrNullResult();
}
}
// src/Controller/QuestionController.php
public function list(Request $request, QuestionRepository $questionRepo): Response {
$page = max(1, $request->query->getInt('page', 1));
$limit = $this->getParameter('app.pager.homepage_max') ?? 20;
$paginator = $questionRepo->getHomepagePager($page, $limit);
$totalItems = count($paginator);
$totalPages = (int)ceil($totalItems / $limit);
return $this->render('question/list.html.twig', [
'paginator' => $paginator,
'currentPage' => $page,
'totalPages' => $totalPages,
'totalItems' => $totalItems,
'limit' => $limit,
]);
}
{# templates/question/list.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
熱門問題
{% include 'question/_list.html.twig' with {questions: paginator} %}
{% endblock %}
{# templates/question/_list.html.twig #}
| 標題 | 作者 | 關注數 | 創建時間 |
|---|---|---|---|
| {{ question.title }} | {{ question.author.nickname }} | {{ question.interestedUsersCount }} | {{ question.createdAt|date('Y-m-d H:i') }} |
| 暫無問題 | |||
# config/routes.yaml
question_list:
path: /questions
controller: App\Controller\QuestionController::list
defaults: { page: 1 }
methods: [GET]
question_list_paginated:
path: /questions/page/{page}
controller: App\Controller\QuestionController::list
requirements:
page: '\d+'
methods: [GET]
question_show:
path: /question/{strippedTitle}
controller: App\Controller\QuestionController::show
requirements:
strippedTitle: '[a-z0-9-]+'
methods: [GET]
# 為了 SEO 友好,也可以保留 /questions/page/2 格式
// src/Service/QuestionService.php
namespace App\Service;
use App\Entity\Question;
use App\Repository\QuestionRepository;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\Pagination\PaginationInterface;
use Knp\Component\Pager\PaginatorInterface;
class QuestionService {
public function __construct(
private QuestionRepository $questionRepo,
private EntityManagerInterface $em,
private PaginatorInterface $paginator
) {}
public function getHomepageQuestions(int $page, int $limit = 20): PaginationInterface {
$query = $this->questionRepo->getHomepageQueryBuilder();
return $this->paginator->paginate($query, $page, $limit);
}
public function publishQuestion(string $title, string $content, User $author): Question {
$question = new Question();
$question->setTitle($title);
$question->setContent($content);
$question->setAuthor($author);
$question->setStrippedTitle($this->generateStrippedTitle($title));
$this->em->persist($question);
$this->em->flush();
return $question;
}
private function generateStrippedTitle(string $title): string {
// 簡單 slugify,實際用 slugger 服務
return strtolower(trim(preg_replace('/[^a-z0-9]+/i', '-', $title), '-'));
}
}
public function list(Request $request, QuestionService $questionService): Response {
$page = max(1, $request->query->getInt('page', 1));
$pagination = $questionService->getHomepageQuestions($page);
return $this->render('question/list.html.twig', [
'pagination' => $pagination,
]);
}
| Propel (舊) | Doctrine (現代) | 說明 |
|---|---|---|
| sfPropelPager | KnpPaginator / Doctrine Paginator | 分頁器 |
| Peer 類靜態方法 | Repository 實例方法 | 查詢封裝 |
| Criteria 對象 | QueryBuilder / DQL | 查詢構建 |
| doSelectJoinUser() | leftJoin + addSelect | 關聯查詢 |
| 配置文件 schema.yml | PHP 實體註解/屬性 | 映射定義 |
Symfony 表單與分頁實戰涵蓋了登入認證、CSRF 保護、記住我、權限控制、Twig 模板繼承、分頁查詢、路由優化、Repository 封裝、服務層分離、KnpPaginator 集成等企業級開發核心技能。現代 Symfony (6/7) 配合 Doctrine ORM、Security 組件、Form 組件、Twig、Routing、DependencyInjection,提供了類型安全、聲明式配置、自動配線、測試友好的開發體驗。核心架構原則:控制器瘦、模型富、服務封裝業務、表單處理輸入、模板只負責展示、路由語義化、分頁可配置。