Skip to content

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

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étodoRotaNome
GET+POST/task_index
POST/{id}/toggletask_toggle
POST/{id}/deletetask_delete

Como rodar

Não precisa clonar o repo. Baixe o zip.

bash
unzip 03-symfony.zip
cd 03-symfony
composer install
php bin/console doctrine:schema:update --force
php -S localhost:8002 -t public

Abre 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
<?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
<?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
<?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
<?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

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

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

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: true

Toggle 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} vira Task ou 404
  • CSRF: form_end no form; csrf_token nos 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.

Publicado sob a licença MIT