23.3 Symfony de porta de entrada
TL;DR CRUD de tasks. Symfony 7.4, Twig, Doctrine, SQLite. Você não escreve o kernel. Escreve entity, form, controller e template. O resto o Flex monta.
Gerado por IA. Não existe no handbook original da CodeMate.
Conteúdo
- O recorte
- Como rodar
- O que o Flex já fez
- Entity
- Repository
- Form
- Controller
- Twig
- CSRF
- Na entrevista
- Recapitulando
- Exercícios práticos
O recorte
Lista, cria, marca feito, apaga. Sem auth. Sem API. O ponto é parar de ter medo do Symfony: rota no atributo, autowire, form, Twig, Doctrine.
| Método | Rota | Nome |
|---|---|---|
| GET+POST | / | task_index |
| POST | /{id}/toggle | task_toggle |
| POST | /{id}/delete | task_delete |
Como rodar
Não precisa clonar o repo. Baixe o zip.
unzip 03-symfony.zip
cd 03-symfony
composer install
php bin/console doctrine:schema:update --force
php -S localhost:8002 -t publicAbre http://localhost:8002. vendor/ não vai no zip.
SQLite: DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" no .env.
O que o Flex já fez
composer create-project symfony/skeleton + composer require twig doctrine form validator security-csrf.
Isso cria public/index.php, src/Kernel.php, config/. Você não reescreve. Você adiciona src/Entity, src/Controller, templates/.
Autowire em config/services.yaml: classe em src/ vira serviço. Por isso o controller pede TaskRepository no método e o Symfony injeta.
Entity
Atributo #[ORM\Entity]. Sem SQL na mão. schema:update cria a tabela.
src/Entity/Task.php
<?php
namespace App\Entity;
use App\Repository\TaskRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
// Entidade Doctrine. A tabela nasce no schema:update — sem SQL na mão.
#[ORM\Entity(repositoryClass: TaskRepository::class)]
class Task
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Título obrigatório.')]
#[Assert\Length(max: 180)]
private string $title = '';
#[ORM\Column]
private bool $done = false;
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): void
{
$this->title = $title;
}
public function isDone(): bool
{
return $this->done;
}
public function setDone(bool $done): void
{
$this->done = $done;
}
public function toggle(): void
{
$this->done = !$this->done;
}
}Repository
Query extra (allNewestFirst). find($id) já vem do pai — o param converter do controller usa.
src/Repository/TaskRepository.php
<?php
namespace App\Repository;
use App\Entity\Task;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
// Repository do Doctrine. find/persist vêm da classe pai.
/** @extends ServiceEntityRepository<Task> */
class TaskRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Task::class);
}
/** @return list<Task> */
public function allNewestFirst(): array
{
return $this->createQueryBuilder('t')
->orderBy('t.id', 'DESC')
->getQuery()
->getResult();
}
}Form
Amarrado na entidade (data_class). CSRF o form_end coloca. Título é o único campo visível.
src/Form/TaskType.php
<?php
namespace App\Form;
use App\Entity\Task;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
// Form amarrado na entidade. CSRF o Symfony coloca sozinho.
class TaskType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('title', TextType::class, [
'label' => 'Título',
'attr' => ['placeholder' => 'Comprar ração'],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Task::class,
]);
}
}Controller
#[Route] no método. POST válido → persist + flush + redirect (PRG). Task $task na rota {id} é param converter: não achou, 404.
src/Controller/TaskController.php
<?php
namespace App\Controller;
use App\Entity\Task;
use App\Form\TaskType;
use App\Repository\TaskRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
// Rotas no atributo. Sem routes.yaml extra. Autowire injeta o repository.
final class TaskController extends AbstractController
{
#[Route('/', name: 'task_index', methods: ['GET', 'POST'])]
public function index(
Request $request,
TaskRepository $tasks,
EntityManagerInterface $em,
): Response {
$task = new Task();
$form = $this->createForm(TaskType::class, $task);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($task);
$em->flush();
return $this->redirectToRoute('task_index');
}
return $this->render('task/index.html.twig', [
'tasks' => $tasks->allNewestFirst(),
'form' => $form,
]);
}
#[Route('/{id}/toggle', name: 'task_toggle', methods: ['POST'])]
public function toggle(Request $request, Task $task, EntityManagerInterface $em): Response
{
// Param converter: {id} vira Task. 404 se não achar.
$this->assertCsrf($request, $task);
$task->toggle();
$em->flush();
return $this->redirectToRoute('task_index');
}
#[Route('/{id}/delete', name: 'task_delete', methods: ['POST'])]
public function delete(Request $request, Task $task, EntityManagerInterface $em): Response
{
$this->assertCsrf($request, $task);
$em->remove($task);
$em->flush();
return $this->redirectToRoute('task_index');
}
private function assertCsrf(Request $request, Task $task): void
{
if (!$this->isCsrfTokenValid('task' . $task->getId(), (string) $request->request->get('_token'))) {
throw new BadRequestHttpException('CSRF inválido.');
}
}
}Twig
path('task_toggle', { id: task.id }) gera a URL. form_start / form_widget / form_end renderizam o form e o token CSRF.
templates/base.html.twig
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Tasks Symfony{% endblock %}</title>
<style>
body { font-family: sans-serif; max-width: 36rem; margin: 2.5rem auto; padding: 0 1rem; }
form.nova { display: flex; gap: 0.5rem; margin: 1rem 0 1.5rem; }
form.nova input { flex: 1; padding: 0.4rem; }
li { display: flex; align-items: center; gap: 0.5rem; margin: 0.4rem 0; }
li.done span { text-decoration: line-through; color: #666; }
button { padding: 0.35rem 0.7rem; }
.hint { color: #555; font-size: 0.9rem; }
</style>
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>templates/task/index.html.twig
{% extends 'base.html.twig' %}
{% block body %}
<h1>Tasks</h1>
<p class="hint">Symfony 7 + Twig + Doctrine + SQLite. Sem auth neste bolso.</p>
{{ form_start(form, { attr: { class: 'nova' } }) }}
{{ form_widget(form.title) }}
<button type="submit">Criar</button>
{{ form_end(form) }}
{% if tasks is empty %}
<p>Nenhuma task. Cria a primeira.</p>
{% else %}
<ul>
{% for task in tasks %}
<li class="{{ task.done ? 'done' : '' }}">
<span>{{ task.title }}</span>
<form method="post" action="{{ path('task_toggle', { id: task.id }) }}">
<input type="hidden" name="_token" value="{{ csrf_token('task' ~ task.id) }}">
<button type="submit">{{ task.done ? 'Reabrir' : 'Fechar' }}</button>
</form>
<form method="post" action="{{ path('task_delete', { id: task.id }) }}">
<input type="hidden" name="_token" value="{{ csrf_token('task' ~ task.id) }}">
<button type="submit">Apagar</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}CSRF
O recipe do Symfony 7.4 vem com CSRF stateless (sem campo hidden). Neste bolso voltamos ao clássico: campo + sessão. Pacote: symfony/security-csrf.
config/packages/csrf.yaml
# CSRF clássico (campo hidden + sessão). O recipe vem com stateless;
# neste bolso o token no form é o que você explica na entrevista.
framework:
csrf_protection: ~
form:
csrf_protection:
enabled: trueToggle e delete não passam pelo Form component — por isso csrf_token('task' ~ task.id) na mão + isCsrfTokenValid.
Na entrevista
"No Symfony eu não instancio o repository. O autowire injeta. Rota é atributo no controller. Form amarra na entity. Twig chama
path(). Doctrine faz persist/flush. CSRF do form o Symfony coloca; no POST solto eu coloco o token na mão."
Se puxarem Laravel: “Blade é o Twig. Eloquent é o Doctrine. Route::get é o #[Route]. O container é o mesmo papo de autowire.”
Recapitulando
- Flex monta o esqueleto. Você escreve entity, form, controller, twig
- Autowire: type-hint no método, o container injeta
- Param converter:
{id}viraTaskou 404 - CSRF: form_end no form;
csrf_tokennos POST soltos - SQLite de bolso. Sem Postgres neste projeto
Exercícios práticos
Exercício 1
Enunciado: Adicione um campo notas (texto opcional) na Task. Mostre na lista.
Solução
Coluna #[ORM\Column(type: 'text', nullable: true)] + getter/setter. No TaskType, TextareaType com required => false. No Twig, { task.notas }. Rode doctrine:schema:update --force.
Exercício 2
Enunciado: Por que toggle e delete são POST, não GET?
Solução
GET não muda estado. Bookmark / prefetch / crawler não pode apagar a task. CSRF em GET não tem o mesmo efeito. Entrevista: “mutação é POST (ou PATCH/DELETE). GET é leitura.”
Exercício 3
Enunciado: Tire o form_end e explique o que quebra.
Solução
O campo task[_token] some. O POST cai em CSRF inválido / form inválido. É o mesmo bug de quem renderiza só form_widget(form.title) e esquece o resto.
Parte do PHP/Laravel Interview Handbook — seção gerada por IA, só neste fork.