Cheatsheet Symfony
Framework PHP robusto e maduro para aplicações web enterprise
Symfony
Instalação e Setup
Criar projecto
composer create-project symfony/skeleton meu-projeto cd meu-projeto symfony serve
symfony/skeleton cria um projecto mínimo. symfony serve inicia o servidor de desenvolvimento com HTTPS automático. Para projecto completo use symfony/website-skeleton.
Maker bundle
php bin/console make:controller HomeController php bin/console make:entity Post php bin/console make:form PostType php bin/console make:crud Post
MakerBundle gera código automaticamente. make:crud cria controller, templates e form de uma vez. Todos os comandos são interactivos e geram ficheiros prontos.
Instalar bundles
composer require symfony/mailer composer require symfony/security-bundle composer require doctrine/doctrine-bundle composer require symfony/maker-bundle --dev
composer require instala e configura bundles via Symfony Flex. As receitas criam ficheiros de config automaticamente. --dev instala apenas em desenvolvimento.
Symfony CLI
symfony new app --webapp symfony check:requirements symfony open:local symfony server:log
symfony new cria projecto com a CLI. --webapp inclui todos os pacotes web. check:requirements valida o ambiente. open:local abre no browser.
Estrutura do projecto
src/ Controller/ Entity/ Repository/ Service/ templates/ config/ packages/ routes.yaml services.yaml public/
src/ contém todo o código PHP. templates/ guarda os ficheiros Twig. config/packages/ tem a configuração por bundle. public/ é a raiz web.
Symfony Flex
composer recipes composer sync-recipes composer unpack api
Flex é o plugin que automatiza a instalação de pacotes. recipes lista as receitas aplicadas. unpack expande um pack nos seus componentes individuais para maior controlo.
Servidor de desenvolvimento
symfony serve -d symfony server:stop symfony server:status symfony serve --port=8080
-d executa em daemon (background). server:stop termina o servidor. O servidor suporta HTTPS automático e múltiplos projectos em portas diferentes.
Configuração .env
# .env APP_ENV=dev APP_SECRET=a1b2c3d4 DATABASE_URL="mysql://root:@127.0.0.1:3306/app" MAILER_DSN=smtp://localhost
.env define variáveis de ambiente. APP_ENV controla o ambiente (dev/prod/test). DATABASE_URL configura a ligação à BD. Nunca commite o .env.local.
Bin console
php bin/console list php bin/console debug:router php bin/console debug:container php bin/console about
bin/console é a consola de comandos. debug:router lista todas as rotas. debug:container mostra os serviços registados. about exibe info do projecto.
Configuração YAML
# config/packages/framework.yaml
framework:
secret: '%env(APP_SECRET)%'
session:
handler_id: null
csrf_protection: trueCada bundle tem o seu ficheiro em config/packages/. A sintaxe %env()% lê variáveis de ambiente. Configurações são fundidas automaticamente por ambiente.
Rotas e Controllers
Controller básico
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
class HomeController extends AbstractController
{
public function index(): Response
{
return new Response('Olá Symfony!');
}
}AbstractController fornece métodos helper como render(), json() e redirectToRoute(). Todo controller deve retornar um Response.
Tipos de resposta
return new Response('Texto HTML');
return $this->json(['ok' => true]);
return $this->redirectToRoute('app_home');
return $this->render('page.html.twig', $dados);
return new JsonResponse($data, 201);Response é a resposta base. json() retorna JSON com headers correctos. redirectToRoute() faz redirect 302. render() renderiza um template Twig.
Rota com prefixo
#[Route('/admin', name: 'admin_')]
class AdminController extends AbstractController
{
#[Route('/dashboard', name: 'dashboard')]
public function dashboard(): Response { }
// URL final: /admin/dashboard
}O #[Route] na classe define um prefixo para todas as rotas do controller. O name da classe é prefixado ao nome de cada método. Evita repetição de paths.
Rota com atributo
use Symfony\Component\Routing\Attribute\Route;
#[Route('/ola/{nome}', name: 'app_ola')]
public function ola(string $nome): Response
{
return $this->render('ola.html.twig', [
'nome' => $nome,
]);
}#[Route] define a rota com PHP 8 attributes. O name permite gerar URLs e redireccionar. O parâmetro {nome} é injectado automaticamente no método.
Request object
use Symfony\Component\HttpFoundation\Request;
public function store(Request $request): Response
{
$nome = $request->request->get('nome');
$query = $request->query->get('page', 1);
$todos = $request->request->all();
$ip = $request->getClientIp();
}Request é injectado automaticamente. request acede a dados POST. query acede a parâmetros GET. O segundo argumento de get() é o valor default.
Route naming e organização
// config/routes.yaml
controllers:
resource: ../src/Controller/
type: attribute
prefix: /{_locale}
requirements:
_locale: 'pt|en|fr'config/routes.yaml importa as rotas dos controllers. prefix adiciona um prefixo global. requirements restringe o _locale a valores válidos. Convenção: app_ para nomes.
Parâmetros de rota
#[Route('/user/{id}', requirements: ['id' => '\d+'])]
public function show(int $id): Response { }
#[Route('/blog/{slug}', defaults: ['page' => 1])]
public function post(string $slug, int $page): Response { }requirements valida o parâmetro com regex. \d+ aceita apenas números. defaults define valores opcionais. O type-hint int faz cast automático.
Gerar URLs
$url = $this->generateUrl('app_ola', ['nome' => 'Ana']);
// com RouterInterface injectado:
use Symfony\Component\Routing\RouterInterface;
$url = $router->generate('app_ola', ['nome' => 'Ana']);generateUrl() cria URLs a partir do nome da rota. Parâmetros extra viram query string. Use sempre nomes de rota em vez de URLs hardcoded para facilitar manutenção.
Response personalizado
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
return new BinaryFileResponse('/path/ficheiro.pdf');
$response = new StreamedResponse(function () {
echo 'streaming...';
});BinaryFileResponse envia ficheiros com headers correctos. StreamedResponse gera conteúdo progressivamente (útil para CSV grandes). Ambos extendem Response.
Métodos HTTP
#[Route('/posts', methods: ['GET'])]
public function list(): Response { }
#[Route('/posts', methods: ['POST'])]
public function create(Request $request): Response { }
#[Route('/posts/{id}', methods: ['PUT', 'PATCH'])]
public function update(int $id): Response { }methods restringe os verbos HTTP aceites. Rotas com o mesmo path mas métodos diferentes são resolvidas correctamente. Útil para APIs RESTful.
Redirecionamentos
return $this->redirectToRoute('app_home');
return $this->redirect('https://externo.pt');
return new RedirectResponse($url, 301);redirectToRoute() redirecciona para uma rota interna. redirect() aceita URL absoluto. RedirectResponse permite definir o código HTTP (301 permanente, 302 temporário).
Serviços e DI
Criar serviço
namespace App\Service;
class EmailService
{
public function enviar(string $para, string $msg): void
{
// lógica de envio
}
}Um serviço é uma classe PHP simples em src/Service/. O Symfony regista-o automaticamente no container. Não precisa de configuração manual com autowire activo.
Interface binding
# config/services.yaml
services:
App\Service\PaymentInterface:
alias: App\Service\StripePayment
// no código:
public function __construct(
private PaymentInterface $payment,
) {}alias liga uma interface à implementação concreta. Permite trocar implementações sem alterar o código consumidor. Essencial para Dependency Inversion e testabilidade.
Service decorator
#[AsDecorator(serviceName: 'app.notifier', priority: 10)]
class CachedNotifier implements NotifierInterface
{
public function __construct(
private NotifierInterface $inner,
) {}
public function notify(string $msg): void
{
// lógica extra + $this->inner->notify($msg);
}
}#[AsDecorator] envolve um serviço existente sem o modificar. O serviço original fica disponível como $inner. priority controla a ordem quando há múltiplos decorators.
Injecção automática
class PostController extends AbstractController
{
public function __construct(
private EmailService $email,
private LoggerInterface $logger,
) {}
public function index(): Response
{
$this->logger->info('Acedeu ao index');
}
}Constructor injection com promoted properties do PHP 8. O container resolve as dependências automaticamente via type-hint. LoggerInterface é injectado sem configuração extra.
Serviços com tag
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(event: 'kernel.request')]
class MeuListener
{
public function __invoke(RequestEvent $event): void
{
// executado em cada request
}
}#[AsEventListener] regista a classe como listener sem config YAML. O método __invoke é chamado quando o evento dispara. autoconfigure detecta o atributo automaticamente.
Compiler passes
// src/DependencyInjection/AppExtension.php
class AppExtension extends Extension
{
public function load(array $configs, ContainerBuilder $c): void
{
$loader = new YamlFileLoader($c, new FileLocator(__DIR__));
$loader->load('services.yaml');
}
}Extension permite a um bundle registar serviços programaticamente. CompilerPass modifica o container antes de compilar. Usado em bundles reutilizáveis para registar serviços condicionalmente.
Autowiring config
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude:
- '../src/Entity/'
- '../src/Kernel.php'autowire: true injecta dependências pelo type-hint. autoconfigure: true regista tags automaticamente. exclude impede o registo de entidades e do Kernel.
Serviço público
# config/services.yaml
services:
App\Service\LegacyService:
public: true
// aceder manualmente:
$service = $container->get(LegacyService::class);Por padrão os serviços são private (só via injection). public: true permite acesso directo pelo container. Use apenas para serviços legacy ou testes.
Parâmetros de config
# config/services.yaml
parameters:
app.nome: 'Meu Site'
app.max_items: 50
# no serviço:
use Symfony\Component\DependencyInjection\Attribute\Autowire;
public function __construct(
#[Autowire('%app.nome%')]
private string $nome,
) {}parameters define valores reutilizáveis. #[Autowire] injecta parâmetros ou valores específicos. A sintaxe %param% referencia parâmetros do container.
Lazy services
# config/services.yaml
services:
App\Service\HeavyService:
lazy: true
// ou com atributo:
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
#[AsDecorator('app.mailer')]
class LoggingMailer { }lazy: true cria um proxy que só instancia o serviço quando é realmente usado. Reduz memória e tempo de boot. Útil para serviços pesados raramente utilizados.
Doctrine ORM
Criar entidade
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 $titulo;
#[ORM\Column(type: 'text')]
private string $conteudo;
}#[ORM\Entity] marca a classe como entidade. #[ORM\Id] e #[ORM\GeneratedValue] definem a chave primária auto-incremental. repositoryClass liga ao repositório personalizado.
QueryBuilder
$qb = $this->createQueryBuilder('p')
->where('p.ativo = :ativo')
->andWhere('p.criado > :data')
->setParameter('ativo', true)
->setParameter('data', new \DateTime('-30 days'))
->orderBy('p.criado', 'DESC')
->setMaxResults(10);
$posts = $qb->getQuery()->getResult();createQueryBuilder() constrói consultas dinâmicas. setParameter() previne SQL injection. setMaxResults() limita resultados. Sempre preferível a DQL concatenada.
DQL
$dql = 'SELECT p, c FROM App\Entity\Post p
JOIN p.comments c
WHERE p.ativo = :ativo
ORDER BY p.criado DESC';
$query = $em->createQuery($dql)
->setParameter('ativo', true)
->setMaxResults(20);
$posts = $query->getResult();DQL é a linguagem de consultas orientada a objectos do Doctrine. JOIN carrega relações eagerly. getResult() retorna array de entidades. Use getOneOrNullResult() para single.
Tipos de coluna
#[ORM\Column(type: 'string', length: 100)] private string $nome; #[ORM\Column(type: 'integer')] private int $idade; #[ORM\Column(type: 'boolean', options: ['default' => false])] private bool $ativo; #[ORM\Column(type: 'datetime_immutable')] private \DateTimeImmutable $criado; #[ORM\Column(type: 'json')] private array $metadata = [];
type define o tipo SQL mapeado. options permite defaults e nullable. json serializa arrays automaticamente. datetime_immutable é preferível a datetime.
CRUD operações
$em = $this->entityManager; // Criar / Actualizar $em->persist($post); $em->flush(); // Apagar $em->remove($post); $em->flush(); // Ler $post = $em->find(Post::class, $id);
EntityManager gere o ciclo de vida das entidades. persist() marca para inserção/actualização. flush() executa as queries SQL. find() procura por chave primária.
Eventos de entidade
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
class Post
{
#[ORM\PrePersist]
public function onPrePersist(): void
{
$this->criado = new \DateTimeImmutable();
}
#[ORM\PreUpdate]
public function onPreUpdate(): void
{
$this->actualizado = new \DateTimeImmutable();
}
}#[ORM\HasLifecycleCallbacks] activa callbacks na entidade. #[ORM\PrePersist] executa antes de inserir. #[ORM\PreUpdate] executa antes de actualizar. Alternativa: usar Doctrine Events.
Migrações
php bin/console doctrine:database:create php bin/console make:migration php bin/console doctrine:migrations:migrate php bin/console doctrine:migrations:status php bin/console doctrine:schema:validate
make:migration gera a migração comparando entidades com a BD. migrate aplica migrações pendentes. schema:validate verifica consistência entre mapeamento e BD.
Relações ManyToOne
#[ORM\Entity]
class Comment
{
#[ORM\ManyToOne(inversedBy: 'comments')]
#[ORM\JoinColumn(nullable: false)]
private Post $post;
}
// no Post:
#[ORM\OneToMany(mappedBy: 'post', targetEntity: Comment::class)]
private Collection $comments;#[ORM\ManyToOne] define o lado dono da relação. inversedBy aponta para a propriedade no lado oposto. mappedBy no OneToMany indica que é o lado inverso.
Fixtures
use Doctrine\Bundle\FixturesBundle\Fixture;
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$post = new Post();
$post->setTitulo('Primeiro post');
$manager->persist($post);
$manager->flush();
}
}
// php bin/console doctrine:fixtures:loadFixtures populam a BD com dados de teste. ObjectManager funciona como o EntityManager. doctrine:fixtures:load limpa e recarrega tudo. Use --append para não limpar.
Repository
class PostRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Post::class);
}
public function findAtivos(): array
{
return $this->findBy(['ativo' => true], ['criado' => 'DESC']);
}
}ServiceEntityRepository é a base para repositórios. findBy() e findOneBy() são métodos mágicos herdados. Adicione métodos personalizados para consultas reutilizáveis.
Relações ManyToMany
#[ORM\Entity]
class Post
{
#[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'posts')]
#[ORM\JoinTable(name: 'post_tag')]
private Collection $tags;
public function __construct()
{
$this->tags = new ArrayCollection();
}
}#[ORM\ManyToMany] cria tabela intermédia. #[ORM\JoinTable] define o nome da tabela pivot. Inicialize colecções no construtor com ArrayCollection.
Forms e Validação
Criar form type
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
class PostType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('titulo')
->add('conteudo', TextareaType::class)
->add('ativo', CheckboxType::class, ['required' => false]);
}
}AbstractType é a base de todos os formulários. buildForm() define os campos. O tipo é inferido pelo nome da propriedade se omitido. required => false remove o atributo HTML required.
Tipos de campo
TextType::class TextareaType::class EmailType::class IntegerType::class ChoiceType::class EntityType::class DateTimeType::class CheckboxType::class FileType::class PasswordType::class
Cada tipo mapeia para um input HTML específico. ChoiceType cria select/radio/checkbox. EntityType carrega opções de uma entidade Doctrine. FileType gere uploads.
Formulários aninhados
class PostType extends AbstractType
{
public function buildForm(FormBuilderInterface $b, array $opts): void
{
$b->add('titulo')
->add('autor', AuthorType::class)
->add('tags', CollectionType::class, [
'entry_type' => TagType::class,
'allow_add' => true,
'by_reference' => false,
]);
}
}CollectionType gere listas de sub-formulários. allow_add permite adicionar itens via JS. by_reference => false força o uso de setters. entry_type define o tipo de cada item.
Usar no controller
$form = $this->createForm(PostType::class, $post);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($post);
$em->flush();
return $this->redirectToRoute('app_post_list');
}
return $this->render('post/form.html.twig', [
'form' => $form,
]);createForm() instancia o formulário ligado a uma entidade. handleRequest() processa a submissão. isSubmitted() e isValid() verificam o estado antes de persistir.
Formulário sem classe
$form = $this->createFormBuilder()
->add('nome', TextType::class)
->add('email', EmailType::class)
->add('enviar', SubmitType::class)
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$dados = $form->getData();
}createFormBuilder() cria formulários inline sem FormType. Ideal para formulários simples de pesquisa ou contacto. getData() retorna um array associativo com os valores.
CSRF em formulários
# config/packages/framework.yaml
framework:
csrf_protection: true
// validação manual:
if (!$this->isCsrfTokenValid('delete-item', $request->get('_token'))) {
throw new AccessDeniedException('Token inválido');
}csrf_protection activa tokens em todos os formulários. O token é renderizado automaticamente por form_end(). Para acções fora de forms use isCsrfTokenValid() manualmente.
Validação com constraints
use Symfony\Component\Validator\Constraints as Assert; #[Assert\NotBlank(message: 'O título é obrigatório')] #[Assert\Length(min: 5, max: 255)] private string $titulo; #[Assert\Email] #[Assert\NotBlank] private string $email; #[Assert\Range(min: 0, max: 100)] private int $idade;
Constraints validam dados da entidade. #[Assert\NotBlank] rejeita vazio. #[Assert\Length] limita caracteres. As constraints são verificadas automaticamente pelo form->isValid().
Eventos de formulário
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormEvent;
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$post = $event->getData();
$form = $event->getForm();
if ($post && $post->getId()) {
$form->add('criado', DateTimeType::class, ['disabled' => true]);
}
});FormEvents permite modificar campos dinamicamente. PRE_SET_DATA executa antes de popular dados. PRE_SUBMIT e POST_SUBMIT controlam a submissão. Útil para campos condicionais.
Renderizar no Twig
{{ form_start(form) }}
{{ form_row(form.titulo) }}
{{ form_row(form.conteudo) }}
{{ form_widget(form.ativo) }}
<button type="submit">Guardar</button>
{{ form_end(form) }}
{# renderizar tudo de uma vez: #}
{{ form(form) }}form_start() abre a tag form com CSRF. form_row() renderiza label + widget + erros. form_end() fecha e renderiza campos ocultos. form(form) é o atalho completo.
Upload de ficheiros
$builder->add('ficheiro', FileType::class, [
'constraints' => [
new File([
'maxSize' => '5M',
'mimeTypes' => ['application/pdf', 'image/png'],
]),
],
]);
// no controller:
$ficheiro = $form->get('ficheiro')->getData();
$novoNome = uniqid() . '.' . $ficheiro->guessExtension();
$ficheiro->move($this->getParameter('uploads_dir'), $novoNome);FileType gere uploads. A constraint File valida tamanho e MIME type. move() guarda o ficheiro no destino. Use getParameter() para o directório configurado.
Twig Templates
Variáveis e output
{{ nome }}
{{ post.titulo }}
{{ user.email|default('N/A') }}
{{ "Olá #{nome}" }}
{{ itens|length }}{{ }} imprime variáveis. Acesso a propriedades com ponto. |default fornece fallback para nulos. |length conta elementos. Interpolação com #{} dentro de strings.
Funções
{{ path('app_ola', {nome: 'Ana'}) }}
{{ url('app_ola', {nome: 'Ana'}) }}
{{ asset('css/app.css') }}
{{ csrf_token('meu_form') }}
{{ include('partials/menu.html.twig') }}
{{ dump(variavel) }}path() gera URL relativo. url() gera URL absoluto. asset() referencia ficheiros estáticos com versionamento. dump() mostra debug (só em dev).
Assets
<link rel="stylesheet" href="{{ asset('build/app.css') }}">
<script src="{{ asset('build/app.js') }}"></script>
{# com Webpack Encore: #}
{{ encore_entry_link_tags('app') }}
{{ encore_entry_script_tags('app') }}asset() gera URLs com cache-busting. Encore integra Webpack para compilar assets. encore_entry_link_tags() e encore_entry_script_tags() inserem tags automaticamente.
Controlo de fluxo
{% if user and user.isAdmin %}
<p>Admin</p>
{% elseif user %}
<p>Utilizador</p>
{% else %}
<p>Anónimo</p>
{% endif %}
{% for item in itens %}
<li>{{ loop.index }}. {{ item }}</li>
{% endfor %}{% if %} suporta condições com operadores lógicos. {% for %} itera arrays. loop.index dá a posição actual (1-based). loop.first e loop.last são booleanos úteis.
Include e macros
{% include 'partials/header.html.twig' %}
{% include 'partials/card.html.twig' with {titulo: 'X'} %}
{% macro input(nome, valor, tipo) %}
<input type="{{ tipo|default('text') }}"
name="{{ nome }}"
value="{{ valor }}">
{% endmacro %}
{% import _self as forms %}
{{ forms.input('email', '', 'email') }}{% include %} insere parciais com variáveis opcionais. {% macro %} define funções reutilizáveis. {% import %} importa macros de outro ficheiro. _self referencia macros do próprio template.
Debug e dump
{{ dump(variavel) }}
{{ dump() }} {# todas as variáveis #}
{% debug %}
{# na CLI: #}
php bin/console debug:twig
php bin/console lint:twig templates/dump() inspecciona variáveis (só em dev). debug:twig lista funções e filtros disponíveis. lint:twig valida sintaxe de todos os templates. Remova dumps antes de produção.
Herança de templates
{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
<body>
{% block conteudo %}{% endblock %}
{% block scripts %}{% endblock %}
</body>
</html>
{# templates/page.html.twig #}
{% extends 'base.html.twig' %}
{% block conteudo %}
<h1>Página</h1>
{% endblock %}{% extends %} herda de um layout base. {% block %} define secções sobrescrevíveis. parent() inclui o conteúdo do bloco pai. Cada template só pode ter um extends.
Componentes Twig
{# templates/components/alert.html.twig #}
<div class="alert alert-{{ tipo|default('info') }}">
{% block content %}{% endblock %}
</div>
{# uso: #}
{% component 'alert' with {tipo: 'danger'} %}
{% block content %}Erro grave!{% endblock %}
{% endcomponent %}Twig Components (bundle extra) criam componentes reutilizáveis com props. Sintaxe semelhante a Web Components. Requer symfony/ux-twig-component. Alternativa moderna a macros.
Filtros
{{ nome|upper }}
{{ texto|lower|truncate(50) }}
{{ data|date('d/m/Y') }}
{{ preco|number_format(2, ',', '.') }}
{{ html|raw }}
{{ lista|join(', ') }}|upper e |lower transformam case. |date formata datas. |raw desactiva o escaping HTML (cuidado com XSS). |number_format formata números. Filtros encadeiam com pipe.
Tradução (i18n)
{# no template: #}
{{ 'welcome.message'|trans({'%nome%': user.nome}) }}
{# translations/messages.pt.yaml: #}
welcome.message: 'Bem-vindo, %nome%!'
{# no controller: #}
$translator->trans('welcome.message', ['%nome%' => 'Ana']);|trans traduz strings com placeholders. Ficheiros em translations/ por locale. %placeholder% é substituído pelos parâmetros. trans() funciona também em PHP.
Segurança
Configurar security
# config/packages/security.yaml
security:
password_hashers:
App\Entity\User: 'auto'
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
main:
form_login:
login_path: app_login
check_path: app_loginpassword_hashers define o algoritmo de hash. providers indica como carregar utilizadores. firewalls protege áreas da aplicação. form_login activa autenticação por formulário.
CSRF protection
<input type="hidden" name="_token"
value="{{ csrf_token('delete-item') }}">
// validar no controller:
if (!$this->isCsrfTokenValid('delete-item', $request->get('_token'))) {
throw new AccessDeniedException('Token CSRF inválido');
}csrf_token() gera o token no template. isCsrfTokenValid() valida no controller. O primeiro argumento é o ID do token (deve ser único por acção). Protecção contra cross-site request forgery.
Logout
# config/packages/security.yaml
security:
firewalls:
main:
logout:
path: app_logout
target: app_home
// controller (vazio - Symfony trata):
#[Route('/logout', name: 'app_logout')]
public function logout(): void
{
throw new \LogicException('Interceptado pelo firewall');
}logout.path define a rota de logout. target é o redirect após logout. O controller nunca é executado — o firewall intercepta. A sessão é destruída automaticamente.
User entity
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
#[ORM\Entity]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
private string $email;
private string $password;
private array $roles = [];
public function getUserIdentifier(): string
{
return $this->email;
}
}UserInterface é obrigatório para autenticação. PasswordAuthenticatedUserInterface adiciona suporte a hash. getUserIdentifier() retorna o identificador único (email). getRoles() retorna papéis.
Voters personalizados
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class PostVoter extends Voter
{
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === 'EDIT' && $subject instanceof Post;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
return $subject->getAutor() === $user;
}
}Voter implementa lógica de autorização custom. supports() decide se o voter trata o atributo. voteOnAttribute() retorna true/false. Use com IsGranted passando o atributo e o subject.
API tokens
# config/packages/security.yaml
security:
firewalls:
api:
pattern: ^/api
stateless: true
custom_authenticators:
- App\Security\ApiTokenAuthenticator
// ou com LexikJWTAuthenticationBundle:
composer require lexik/jwt-authentication-bundlestateless: true desactiva sessões para APIs. custom_authenticators regista autenticadores próprios. LexikJWT é a solução padrão para JWT. Ideal para SPAs e apps mobile.
Proteger rotas
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
public function admin(): Response { }
#[IsGranted('ROLE_USER', statusCode: 403)]
public function profile(): Response { }
# ou no security.yaml:
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }#[IsGranted] protege métodos individuais. ROLE_ADMIN requer o papel admin. statusCode personaliza o erro. access_control protege por padrão de URL no YAML.
Password hashing
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
public function register(
Request $request,
UserPasswordHasherInterface $hasher,
): Response {
$user->setPassword(
$hasher->hashPassword($user, $plainPassword)
);
// verificar:
$valid = $hasher->isPasswordValid($user, $input);
}UserPasswordHasherInterface gere hashing seguro. hashPassword() cria hash com salt automático. isPasswordValid() compara input com hash guardado. Nunca guarde passwords em texto simples.
Obter utilizador
// no controller:
$user = $this->getUser();
$email = $user->getEmail();
// injectar Security:
use Symfony\Bundle\SecurityBundle\Security;
public function __construct(private Security $security) {}
$user = $this->security->getUser();
// no Twig:
{{ app.user.email }}
{% if is_granted('ROLE_ADMIN') %}getUser() retorna o utilizador autenticado ou null. Security injectado permite verificar em serviços. app.user acede no Twig. is_granted() verifica permissões.
Remember me
# config/packages/security.yaml
security:
firewalls:
main:
remember_me:
secret: '%kernel.secret%'
lifetime: 604800 # 1 semana
path: /
secure: trueremember_me mantém sessão com cookie persistente. lifetime define duração em segundos. secure: true envia cookie só por HTTPS. O utilizador precisa de checkbox no formulário de login.
Avançado e Boas Práticas
Cache
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
public function __construct(private CacheInterface $cache) {}
$dados = $this->cache->get('chave_unica', function (ItemInterface $item) {
$item->expiresAfter(3600);
return $this->dadosCaros();
});CacheInterface é injectado automaticamente. get() retorna do cache ou executa o callback. expiresAfter() define TTL em segundos. O cache é invalidado automaticamente ao expirar.
Filesystem
use Symfony\Component\Filesystem\Filesystem;
$fs = new Filesystem();
$fs->mkdir('/tmp/uploads');
$fs->copy('origem.txt', 'destino.txt', true);
$fs->remove('/tmp/antigo');
$fs->dumpFile('config.json', $jsonContent);
$fs->exists('ficheiro.txt');Filesystem abstracte operações de ficheiros. mkdir() cria directórios recursivamente. dumpFile() escreve atomicamente. remove() apaga ficheiros e pastas. Cross-platform e seguro.
Error handling
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
throw $this->createNotFoundException('Post não encontrado');
throw new AccessDeniedHttpException('Sem permissão');
// error controller custom:
# config/packages/framework.yaml
framework:
error_controller: App\Controller\ErrorController::showcreateNotFoundException() lança 404. AccessDeniedHttpException lança 403. O Symfony converte excepções em respostas HTTP. error_controller personaliza páginas de erro.
Serializer
use Symfony\Component\Serializer\SerializerInterface;
$json = $serializer->serialize($post, 'json', [
'groups' => ['post:read'],
]);
$post = $serializer->deserialize($json, Post::class, 'json');
// com Normalizer:
$dados = $serializer->normalize($post, 'json');SerializerInterface converte objectos para JSON/XML e vice-versa. groups controla que campos são serializados. normalize() retorna array. Essencial para APIs REST.
Logging
use Psr\Log\LoggerInterface;
public function __construct(private LoggerInterface $logger) {}
$this->logger->info('Utilizador {id} autenticado', ['id' => $user->getId()]);
$this->logger->warning('Tentativa falhada de {email}', ['email' => $email]);
$this->logger->error('Falha no pagamento', ['order' => $order->getId()]);LoggerInterface (PSR-3) é injectado automaticamente. Placeholders {chave} são substituídos pelo contexto. Níveis: debug, info, warning, error, critical. Logs em var/log/.
Boas práticas
// 1. Controllers finos - lógica em Services // 2. Type-hints em todo o lado // 3. DTOs para entrada/saída de dados // 4. Events para desacoplar módulos // 5. Config em YAML, não hardcoded
Mantenha controllers finos delegando para services. Use DTOs em vez de arrays. Prefira events para lógica transversal. Configure tudo em YAML com parameters. Teste com PHPUnit e WebTestCase.
Mailer
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
public function __construct(private MailerInterface $mailer) {}
$email = (new Email())
->from('noreply@site.pt')
->to('user@site.pt')
->subject('Bem-vindo!')
->html('<h1>Olá!</h1>')
->attachFromPath('/path/ficheiro.pdf');
$this->mailer->send($email);MailerInterface envia emails. Email constrói a mensagem com builder. html() define corpo HTML. attachFromPath() anexa ficheiros. Configure DSN em MAILER_DSN.
Profiler e Debug
// barra de debug (dev): /_profiler
php bin/console debug:router
php bin/console debug:container --show-arguments
php bin/console debug:autowiring
php bin/console profiler:info
// no código:
use Symfony\Component\Stopwatch\Stopwatch;
$stopwatch->start('operacao');
// ...
$event = $stopwatch->stop('operacao');O Profiler mostra queries, tempo e memória por request. debug:autowiring lista serviços injectáveis. Stopwatch mede tempo de operações. Indispensável para optimização.
HTTP Client
use Symfony\Contracts\HttpClient\HttpClientInterface;
public function __construct(private HttpClientInterface $client) {}
$response = $this->client->request('GET', 'https://api.exemplo.pt/dados', [
'headers' => ['Authorization' => 'Bearer token'],
'timeout' => 10,
]);
$statusCode = $response->getStatusCode();
$dados = $response->toArray();HttpClientInterface faz pedidos HTTP sem cURL. request() aceita método, URL e opções. toArray() faz parse JSON automático. Suporta async, retries e rate limiting nativo.
Rate limiting
# config/packages/rate_limiter.yaml
framework:
rate_limiter:
api:
policy: sliding_window
limit: 100
interval: '60 minutes'
// no controller:
use Symfony\Component\RateLimiter\RateLimiterFactory;
$limiter = $factory->create($request->getClientIp());
if (!$limiter->consume()->isAccepted()) {
return new Response('Too Many Requests', 429);
}rate_limiter limita pedidos por IP/utilizador. sliding_window é a política mais justa. consume() verifica e regista o pedido. Retorna 429 quando excede o limite. Essencial para APIs.
Eventos e Console
Event listeners
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
#[AsEventListener(event: 'kernel.request', priority: 10)]
class LocaleListener
{
public function __invoke(RequestEvent $event): void
{
$request = $event->getRequest();
$request->setLocale($request->getPreferredLanguage(['pt', 'en']));
}
}#[AsEventListener] regista sem config YAML. priority controla a ordem de execução (maior = primeiro). O método __invoke recebe o evento. kernel.request dispara antes do controller.
Input e Output
protected function configure(): void
{
$this->addArgument('ficheiro', InputArgument::REQUIRED)
->addOption('force', 'f', InputOption::VALUE_NONE);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$ficheiro = $input->getArgument('ficheiro');
$force = $input->getOption('force');
$output->writeln("<info>Feito!</info>");
}addArgument() define argumentos posicionais. addOption() define flags opcionais. getArgument() e getOption() leem valores. Tags como <info> colorem o output.
Failed messages
php bin/console messenger:failed:show
php bin/console messenger:failed:retry 1
php bin/console messenger:failed:remove 1
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
failed: 'doctrine://default?queue_name=failed'failure_transport guarda mensagens que falham após retries. failed:show lista falhas. failed:retry reprocessa. failed:remove descarta. Essencial para monitorização.
Event subscribers
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class LogSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
'kernel.request' => 'onRequest',
'kernel.response' => ['onResponse', -10],
];
}
public function onRequest(RequestEvent $e): void { }
public function onResponse(ResponseEvent $e): void { }
}EventSubscriberInterface permite ouvir múltiplos eventos numa classe. getSubscribedEvents() mapeia eventos a métodos. O array [método, prioridade] define ordem. Mais organizado que listeners separados.
Messenger (async)
// src/Message/SendEmailMessage.php
class SendEmailMessage
{
public function __construct(
public string $para,
public string $assunto,
) {}
}
// no controller:
$this->messageBus->dispatch(new SendEmailMessage('ana@site.pt', 'Olá'));Messenger envia mensagens para processamento assíncrono. O message é um DTO simples. dispatch() coloca na fila. Requer configuração de transport (Doctrine, RabbitMQ, Redis).
Dispatch events custom
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class OrderService
{
public function __construct(
private EventDispatcherInterface $dispatcher,
) {}
public function finalizar(Order $order): void
{
// lógica...
$this->dispatcher->dispatch(new OrderCompletedEvent($order));
}
}EventDispatcherInterface emite eventos custom. Crie uma classe evento que estenda Event. Listeners reagem ao evento sem acoplar lógica. Padrão observer para desacoplar módulos.
Kernel events
kernel.request // antes do controller kernel.controller // controller resolvido kernel.view // controller retornou não-Response kernel.response // antes de enviar resposta kernel.terminate // após enviar resposta kernel.exception // quando ocorre excepção
O ciclo de vida HTTP do Symfony emite eventos em cada fase. kernel.request para pré-processamento. kernel.exception para error handling. kernel.terminate para tarefas pós-resposta (emails, logs).
Message handlers
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class SendEmailHandler
{
public function __invoke(SendEmailMessage $message): void
{
// enviar email de forma assíncrona
$this->mailer->send($message->para, $message->assunto);
}
}
// php bin/console messenger:consume async -vv#[AsMessageHandler] regista o handler. O type-hint do __invoke determina que mensagem trata. messenger:consume processa a fila. -vv mostra logs detalhados.
Comandos console
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
#[AsCommand(name: 'app:limpar-cache', description: 'Limpa dados antigos')]
class LimparCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('Cache limpo!');
return Command::SUCCESS;
}
}#[AsCommand] regista o comando com nome e descrição. execute() contém a lógica. Command::SUCCESS retorna exit code 0. writeln() imprime no terminal.
Configurar transport
# config/packages/messenger.yaml
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
routing:
App\Message\SendEmailMessage: asynctransports define para onde vão as mensagens. routing mapeia classes a transports. retry_strategy reprocessa falhas com delay. DSN suporta Doctrine, AMQP e Redis.